diff --git a/packages/framer-motion/src/components/AnimatePresence/PresenceChild.tsx b/packages/framer-motion/src/components/AnimatePresence/PresenceChild.tsx index 8befa0690c..a358dc9de0 100644 --- a/packages/framer-motion/src/components/AnimatePresence/PresenceChild.tsx +++ b/packages/framer-motion/src/components/AnimatePresence/PresenceChild.tsx @@ -1,13 +1,14 @@ "use client" import * as React from "react" -import { useId, useMemo } from "react" +import { useId, useMemo, useRef } from "react" import { PresenceContext, type PresenceContextProps, } from "../../context/PresenceContext" import { VariantLabels } from "../../motion/types" import { useConstant } from "../../utils/use-constant" +import { useIsomorphicLayoutEffect } from "../../utils/use-isomorphic-effect" import { PopChild } from "./PopChild" interface PresenceChildProps { @@ -38,6 +39,15 @@ export const PresenceChild = ({ const presenceChildren = useConstant(newChildrenMap) const id = useId() + // Written in a layout effect (not render) so discarded concurrent + // renders can't leave the refs pointing at uncommitted state. + const isPresentRef = useRef(isPresent) + const onExitCompleteRef = useRef(onExitComplete) + useIsomorphicLayoutEffect(() => { + isPresentRef.current = isPresent + onExitCompleteRef.current = onExitComplete + }) + let isReusedContext = true let context = useMemo((): PresenceContextProps => { isReusedContext = false @@ -57,7 +67,12 @@ export const PresenceChild = ({ }, register: (childId: string) => { presenceChildren.set(childId, false) - return () => presenceChildren.delete(childId) + return () => { + presenceChildren.delete(childId) + !isPresentRef.current && + !presenceChildren.size && + onExitCompleteRef.current?.() + } }, } }, [isPresent, presenceChildren, onExitComplete]) diff --git a/packages/framer-motion/src/components/AnimatePresence/__tests__/AnimatePresence.test.tsx b/packages/framer-motion/src/components/AnimatePresence/__tests__/AnimatePresence.test.tsx index 29a357b154..2cf43ad2da 100644 --- a/packages/framer-motion/src/components/AnimatePresence/__tests__/AnimatePresence.test.tsx +++ b/packages/framer-motion/src/components/AnimatePresence/__tests__/AnimatePresence.test.tsx @@ -1801,4 +1801,59 @@ describe("AnimatePresence with custom components", () => { // Child should have been removed after exit animation completes expect(container.childElementCount).toBe(0) }) + + test("Removes child when motion components inside unmount during exit (#3243)", async () => { + /** + * Reproduction for #3243: when an exit animation has been + * triggered for a child of AnimatePresence and the only motion + * component inside that child then unmounts on a subsequent + * render, the exit state should not get stuck. With no motion + * components left to drive the exit animation, PresenceChild + * should detect all children have unregistered and call + * onExitComplete. + */ + let setChildOpen: ((open: boolean) => void) | null = null + + const Child = () => { + const [isOpen, setIsOpen] = React.useState(true) + setChildOpen = setIsOpen + return isOpen ? ( + + ) : ( +
closed
+ ) + } + + const Component = ({ isVisible }: { isVisible: boolean }) => ( + + {isVisible && } + + ) + + const { container, rerender } = render() + + // Step 1: trigger exit. Child still renders motion.div, so + // PresenceChild flips isPresent=false while a motion child is + // still registered. + rerender() + + // Step 2: child internally re-renders without the motion + // component. Without the fix, PresenceChild never detects that + // all motion children have unregistered, so onExitComplete is + // never fired and the wrapper stays in the DOM forever. + await act(async () => { + setChildOpen!(false) + }) + + await act(async () => { + await nextFrame() + await nextFrame() + }) + + expect(container.childElementCount).toBe(0) + }) })