Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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?.()
}
},
Comment on lines 68 to 76
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Cleanup can double-fire onExitComplete in the normal exit path

When a motion child completes its exit animation naturally, context.onExitComplete marks the entry true and fires the outer onExitComplete. AnimatePresence then calls setRenderedChildren, which eventually unmounts the PresenceChild — and with it, the motion child. That unmount triggers this cleanup: presenceChildren.delete(id) leaves size=0, !isPresent is still true, so latest.current.onExitComplete?.() is called a second time. This is currently safe only because AnimatePresence.onExit guards against re-entry via exitingComponents.current.has(key). If PresenceChild is ever used with an onExitComplete that is not idempotent, the double-fire will surface. Adding an early-return when the entry was already marked complete (presenceChildren used to hold true for this id before deletion) would make the invariant explicit and not depend on the caller's guard.

}
}, [isPresent, presenceChildren, onExitComplete])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ? (
<motion.div
data-testid="motion"
exit={{ opacity: 0 }}
transition={{ duration: 10 }}
/>
) : (
<div data-testid="closed">closed</div>
)
}

const Component = ({ isVisible }: { isVisible: boolean }) => (
<AnimatePresence>
{isVisible && <Child key="child" />}
</AnimatePresence>
)

const { container, rerender } = render(<Component isVisible />)

// Step 1: trigger exit. Child still renders motion.div, so
// PresenceChild flips isPresent=false while a motion child is
// still registered.
rerender(<Component isVisible={false} />)

// 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)
})
})