Fix AnimatePresence stuck when state changes too fast#3625
Conversation
Greptile SummaryThis PR fixes a race condition in Key changes:
The fix is minimal, well-targeted, and follows the same pattern already used in Confidence Score: 5/5
Sequence DiagramsequenceDiagram
participant AP as AnimatePresence
participant AMVA as AsyncMotionValueAnimation
participant KR as KeyframeResolver (async)
participant WP as WithPromise._finished
AP->>AMVA: new AsyncMotionValueAnimation(exitAnimation)
AMVA->>KR: scheduleResolve() (next frame)
AP->>AMVA: .then(onExitComplete)
Note over AMVA,WP: _animation is null → registers on _finished
Note over AP: Rapid state change — new key arrives
AP->>AMVA: stop()
alt BEFORE FIX (bug)
AMVA->>KR: cancel()
Note over KR: resolver cancelled, callback never fires
Note over AMVA: _animation never created
Note over WP: _finished ❌ NEVER RESOLVED
Note over AP: onExitComplete hangs forever<br/>→ stale child stuck in DOM
else AFTER FIX (this PR)
AMVA->>KR: cancel()
AMVA->>WP: notifyFinished() ✅
WP-->>AP: _finished resolves
AP->>AP: onExitComplete fires
Note over AP: new child renders correctly
end
Last reviewed commit: c52c959 |
Greptile SummaryThis PR successfully fixes a real issue: Root cause: Fix: Adding Testing: New unit tests reproduce the rapid key alternation pattern from issue #3141 (loading/loaded with useEffect). Cypress E2E tests cover both mount-time and click-based rapid switching, with comprehensive validation that the content shows the correct final key and animations complete properly. All 45 existing AnimatePresence tests pass; new tests also pass on React 18 and 19. Confidence Score: 4/5
Sequence DiagramsequenceDiagram
participant AP as AnimatePresence
participant AMVA as AsyncMotionValueAnimation
participant KR as KeyframeResolver (async)
participant WP as WithPromise (_finished)
Note over AP,WP: Bug scenario (before fix) — stop() called before keyframes resolve
AP->>AMVA: new AsyncMotionValueAnimation()
AMVA->>KR: scheduleResolve() → defers to next frame
AMVA->>WP: _finished = new Promise (unresolved)
AP->>AMVA: stop() [rapid key change]
AMVA->>KR: cancel() → removed from toResolve queue
Note over KR: onKeyframesResolved() NEVER fires
Note over WP: _finished stays UNRESOLVED forever
Note over AP: onExitComplete never fires → child stuck in DOM
Note over AP,WP: After fix — notifyFinished() called in stop()
AP->>AMVA: new AsyncMotionValueAnimation()
AMVA->>KR: scheduleResolve() → defers to next frame
AMVA->>WP: _finished = new Promise (unresolved)
AP->>AMVA: stop() [rapid key change]
AMVA->>KR: cancel() → removed from toResolve queue
AMVA->>WP: notifyFinished() → _finished resolves ✓
WP-->>AP: Promise chain unblocked
AP->>AP: onExitComplete fires → new child mounts ✓
Last reviewed commit: c52c959 |
When rapid key changes cause AnimatePresence to return null (to force a re-render), the exiting child is unmounted and remounted. On remount, ExitAnimationFeature.mount() calls onExitComplete before AnimatePresence's layout effect populates the exitComplete map. This prematurely adds the key to exitingComponents, causing the actual exit completion callback to be blocked by the exitingComponents guard. Fix: only add to exitingComponents after confirming exitComplete has the key. This ensures the early onExitComplete call (when exitComplete isn't populated yet) is a no-op, while the actual exit completion proceeds correctly. Fixes #3141 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
facedd8 to
10427ae
Compare
Summary
AnimatePresence mode="wait"getting permanently stuck showing a stale exiting child when keys change rapidly (e.g., loading/loaded pattern with rapid state changes)DOMKeyframesResolveruses async keyframe resolution (scheduled for the next animation frame). IfAsyncMotionValueAnimation.stop()is called before keyframes resolve, the_finishedpromise is never resolved — neither the cancelled keyframe resolver nor the non-existent inner animation will callnotifyFinished(). This leaves the exit animation promise chain hanging forever, preventingonExitCompletefrom firing.notifyFinished()inAsyncMotionValueAnimation.stop()to resolve the_finishedpromise, allowing exit completion chains to proceed even when animations are interrupted during the async keyframe resolution windowFixes #3141
Test plan
useEffectflipping state)yarn buildsucceedsyarn testpasses (pre-existinguseAnimationtest failures unrelated to this change)🤖 Generated with Claude Code