Fix AnimatePresence stuck exit when motion child unmounts mid-exit#3707
Fix AnimatePresence stuck exit when motion child unmounts mid-exit#3707mattgperry wants to merge 1 commit intomainfrom
Conversation
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 <noreply@anthropic.com>
Greptile SummaryThis PR fixes a stuck-exit bug in
Confidence Score: 4/5Safe to merge — the fix is logically correct and the regression test directly covers the reported scenario. The The core logic change in Important Files Changed
Sequence DiagramsequenceDiagram
participant AP as AnimatePresence
participant PC as PresenceChild
participant MC as motion.div (child)
AP->>PC: "isPresent=false (exit triggered)"
Note over PC: latest.current.isPresent=false
PC->>MC: "context isPresent=false, exit animation starts"
Note over MC: Internal state change, motion.div unmounts mid-exit
MC-->>PC: register() cleanup fires
Note over PC: presenceChildren.delete(id), size=0, !isPresent=true
PC->>AP: onExitComplete() via latest.current
Note over AP: exitingComponents guard prevents double-fire
AP-->>PC: PresenceChild removed from tree
Reviews (1): Last reviewed commit: "Fire onExitComplete when last motion chi..." | Re-trigger Greptile |
| register: (childId: string) => { | ||
| presenceChildren.set(childId, false) | ||
| return () => presenceChildren.delete(childId) | ||
| return () => { | ||
| presenceChildren.delete(childId) | ||
| if ( | ||
| !latest.current.isPresent && | ||
| !presenceChildren.size | ||
| ) { | ||
| latest.current.onExitComplete?.() | ||
| } | ||
| } | ||
| }, |
There was a problem hiding this comment.
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.
Summary
AnimatePresencewrapper unmounts on a render after its exit was already triggered,PresenceChildwould never callonExitComplete, leaving the wrapper stuck in the tree.useEffectonly handled the case where the children map was already empty whenisPresentflipped — it never re-ran after a later motion-child unmount.registercleanup now also checks the latestisPresent(via a ref to avoid the closure capture) and firesonExitCompletewhen the last registered child unmounts during exit.Bug
Issue #3243: when a child of
AnimatePresencetriggers exit and the onlymotioncomponent inside it unmounts on the next render (e.g. because of internal state), the child stayed rendered indefinitely and showed its post-unmount UI.Test
Added a regression test that reproduces the reporter's scenario: render a child with an internal
useStatetoggle that swaps<motion.div>for a non-motion element after exit is triggered. Without the fix the wrapper persists; with the fix the child is removed.Fixes #3243
Test plan
yarn buildyarn test(794 passed, 7 skipped)🤖 Generated with Claude Code