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 ? (
+