From 33c19b7aac1eb54840cac2259a0a36ee38cd2cc3 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Sat, 9 May 2026 06:18:00 +0200 Subject: [PATCH 1/3] Fire onExitComplete when last motion child unmounts mid-exit PresenceChild only checked for an empty children map when isPresent flipped, so if a motion child unmounted on a later render its exit completion went undetected and the wrapper stayed in the tree. Fixes #3243 Co-Authored-By: Claude Opus 4.7 --- .../AnimatePresence/PresenceChild.tsx | 22 +++++++- .../__tests__/AnimatePresence.test.tsx | 55 +++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/packages/framer-motion/src/components/AnimatePresence/PresenceChild.tsx b/packages/framer-motion/src/components/AnimatePresence/PresenceChild.tsx index 8befa0690c..c23de01036 100644 --- a/packages/framer-motion/src/components/AnimatePresence/PresenceChild.tsx +++ b/packages/framer-motion/src/components/AnimatePresence/PresenceChild.tsx @@ -1,7 +1,7 @@ "use client" import * as React from "react" -import { useId, useMemo } from "react" +import { useId, useMemo, useRef } from "react" import { PresenceContext, type PresenceContextProps, @@ -38,6 +38,16 @@ export const PresenceChild = ({ const presenceChildren = useConstant(newChildrenMap) const id = useId() + /** + * Track the latest `isPresent` and `onExitComplete` so the cleanup + * returned from `register` can read up-to-date values. The cleanup + * fires whenever a motion child unmounts; if it unmounts during exit + * and was the last registered child, we need to fire `onExitComplete` + * here because the parent useEffect won't re-run. + */ + const latest = useRef({ isPresent, onExitComplete }) + latest.current = { isPresent, onExitComplete } + let isReusedContext = true let context = useMemo((): PresenceContextProps => { isReusedContext = false @@ -57,7 +67,15 @@ export const PresenceChild = ({ }, register: (childId: string) => { presenceChildren.set(childId, false) - return () => presenceChildren.delete(childId) + return () => { + presenceChildren.delete(childId) + if ( + !latest.current.isPresent && + !presenceChildren.size + ) { + latest.current.onExitComplete?.() + } + } }, } }, [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) + }) }) From e0605fa7e116c0259430bcc1cec7a31f00437d69 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Mon, 11 May 2026 13:20:54 +0200 Subject: [PATCH 2/3] Write PresenceChild latest-ref in layout effect for concurrent safety Setting ref.current during render would persist values from discarded concurrent renders. Move the writes into a layout effect so the ref only reflects committed state. Layout phase runs before the motion child's passive useEffect cleanup, so the cleanup still observes the latest isPresent when it reads the ref. Co-Authored-By: Claude Opus 4.7 --- .../AnimatePresence/PresenceChild.tsx | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/framer-motion/src/components/AnimatePresence/PresenceChild.tsx b/packages/framer-motion/src/components/AnimatePresence/PresenceChild.tsx index c23de01036..3d98328e27 100644 --- a/packages/framer-motion/src/components/AnimatePresence/PresenceChild.tsx +++ b/packages/framer-motion/src/components/AnimatePresence/PresenceChild.tsx @@ -8,6 +8,7 @@ import { } 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 { @@ -39,14 +40,18 @@ export const PresenceChild = ({ const id = useId() /** - * Track the latest `isPresent` and `onExitComplete` so the cleanup - * returned from `register` can read up-to-date values. The cleanup - * fires whenever a motion child unmounts; if it unmounts during exit - * and was the last registered child, we need to fire `onExitComplete` - * here because the parent useEffect won't re-run. + * Track the latest committed `isPresent` and `onExitComplete` so the + * cleanup returned from `register` can read up-to-date values when a + * motion child unmounts mid-exit. The ref is written in a layout + * effect — not during render — so discarded concurrent renders never + * leave it pointing at uncommitted state. */ - const latest = useRef({ isPresent, onExitComplete }) - latest.current = { isPresent, onExitComplete } + const isPresentRef = useRef(isPresent) + const onExitCompleteRef = useRef(onExitComplete) + useIsomorphicLayoutEffect(() => { + isPresentRef.current = isPresent + onExitCompleteRef.current = onExitComplete + }) let isReusedContext = true let context = useMemo((): PresenceContextProps => { @@ -70,10 +75,10 @@ export const PresenceChild = ({ return () => { presenceChildren.delete(childId) if ( - !latest.current.isPresent && + !isPresentRef.current && !presenceChildren.size ) { - latest.current.onExitComplete?.() + onExitCompleteRef.current?.() } } }, From a90110d1f9167ac0684724ede88f0a746bfe9510 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Tue, 12 May 2026 11:21:34 +0200 Subject: [PATCH 3/3] Tighten PresenceChild register cleanup Shrink the layout-effect comment and inline the size-check into the existing && idiom used by the sibling useEffect. Co-Authored-By: Claude Opus 4.7 --- .../components/AnimatePresence/PresenceChild.tsx | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/packages/framer-motion/src/components/AnimatePresence/PresenceChild.tsx b/packages/framer-motion/src/components/AnimatePresence/PresenceChild.tsx index 3d98328e27..a358dc9de0 100644 --- a/packages/framer-motion/src/components/AnimatePresence/PresenceChild.tsx +++ b/packages/framer-motion/src/components/AnimatePresence/PresenceChild.tsx @@ -39,13 +39,8 @@ export const PresenceChild = ({ const presenceChildren = useConstant(newChildrenMap) const id = useId() - /** - * Track the latest committed `isPresent` and `onExitComplete` so the - * cleanup returned from `register` can read up-to-date values when a - * motion child unmounts mid-exit. The ref is written in a layout - * effect — not during render — so discarded concurrent renders never - * leave it pointing at uncommitted state. - */ + // 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(() => { @@ -74,12 +69,9 @@ export const PresenceChild = ({ presenceChildren.set(childId, false) return () => { presenceChildren.delete(childId) - if ( - !isPresentRef.current && - !presenceChildren.size - ) { + !isPresentRef.current && + !presenceChildren.size && onExitCompleteRef.current?.() - } } }, }