Skip to content

Fix AnimatePresence stuck exit when motion child unmounts mid-exit#3707

Open
mattgperry wants to merge 1 commit intomainfrom
worktree-fix-issue-3243
Open

Fix AnimatePresence stuck exit when motion child unmounts mid-exit#3707
mattgperry wants to merge 1 commit intomainfrom
worktree-fix-issue-3243

Conversation

@mattgperry
Copy link
Copy Markdown
Collaborator

Summary

  • When a motion child inside an AnimatePresence wrapper unmounts on a render after its exit was already triggered, PresenceChild would never call onExitComplete, leaving the wrapper stuck in the tree.
  • The existing useEffect only handled the case where the children map was already empty when isPresent flipped — it never re-ran after a later motion-child unmount.
  • The register cleanup now also checks the latest isPresent (via a ref to avoid the closure capture) and fires onExitComplete when the last registered child unmounts during exit.

Bug

Issue #3243: when a child of AnimatePresence triggers exit and the only motion component 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 useState toggle 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 build
  • yarn test (794 passed, 7 skipped)

🤖 Generated with Claude Code

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-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 9, 2026

Greptile Summary

This PR fixes a stuck-exit bug in AnimatePresence (#3243): when a child's only motion component unmounts mid-exit (e.g. due to internal state), PresenceChild never fired onExitComplete, leaving the wrapper permanently in the DOM. The fix stores the latest isPresent/onExitComplete in a ref and checks them inside the register cleanup — so when the last motion child deregisters during exit, onExitComplete fires immediately instead of waiting for a useEffect that will never re-run.

  • PresenceChild.tsx: Adds a latest ref updated synchronously on every render; the register cleanup now fires onExitComplete when the map empties while isPresent is false.
  • AnimatePresence.test.tsx: Adds a regression test mirroring the reporter's scenario — a child with internal state that swaps motion.div for a plain element after exit is triggered.

Confidence Score: 4/5

Safe to merge — the fix is logically correct and the regression test directly covers the reported scenario.

The latest ref pattern is correctly applied and handles the stuck-exit case well. The register cleanup can call onExitComplete a second time in the normal animation-completion flow, but this is currently harmless because AnimatePresence.onExit has an exitingComponents guard that short-circuits re-entry.

The core logic change in PresenceChild.tsx — specifically the interaction between the new cleanup path, the existing useEffect at line 100-105, and the context.onExitComplete path.

Important Files Changed

Filename Overview
packages/framer-motion/src/components/AnimatePresence/PresenceChild.tsx Adds a latest ref pattern to allow the register cleanup to detect and handle the stuck-exit edge case; double-firing is prevented by AnimatePresence's exitingComponents guard.
packages/framer-motion/src/components/AnimatePresence/tests/AnimatePresence.test.tsx Adds a focused regression test that precisely replicates the #3243 scenario; test structure and assertions are correct.

Sequence Diagram

sequenceDiagram
    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
Loading

Reviews (1): Last reviewed commit: "Fire onExitComplete when last motion chi..." | Re-trigger Greptile

Comment on lines 68 to 79
register: (childId: string) => {
presenceChildren.set(childId, false)
return () => presenceChildren.delete(childId)
return () => {
presenceChildren.delete(childId)
if (
!latest.current.isPresent &&
!presenceChildren.size
) {
latest.current.onExitComplete?.()
}
}
},
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] AnimatePresence fails to detect motion component unmount on next render after exit animation

1 participant