Skip to content

Fix AnimatePresence stuck when state changes too fast#3625

Merged
mattgperry merged 1 commit intomainfrom
worktree-fix-issue-3141
Mar 12, 2026
Merged

Fix AnimatePresence stuck when state changes too fast#3625
mattgperry merged 1 commit intomainfrom
worktree-fix-issue-3141

Conversation

@mattgperry
Copy link
Collaborator

Summary

  • Fixes AnimatePresence mode="wait" getting permanently stuck showing a stale exiting child when keys change rapidly (e.g., loading/loaded pattern with rapid state changes)
  • Root cause: DOMKeyframesResolver uses async keyframe resolution (scheduled for the next animation frame). If AsyncMotionValueAnimation.stop() is called before keyframes resolve, the _finished promise is never resolved — neither the cancelled keyframe resolver nor the non-existent inner animation will call notifyFinished(). This leaves the exit animation promise chain hanging forever, preventing onExitComplete from firing.
  • Fix: call notifyFinished() in AsyncMotionValueAnimation.stop() to resolve the _finished promise, allowing exit completion chains to proceed even when animations are interrupted during the async keyframe resolution window

Fixes #3141

Test plan

  • Added unit tests reproducing the rapid key alternation pattern from the issue (loading/loaded with useEffect flipping state)
  • Added Cypress E2E tests for both mount-time rapid switching and click-based rapid switching
  • All existing AnimatePresence tests pass (45/45)
  • Cypress tests pass on both React 18 and React 19
  • yarn build succeeds
  • yarn test passes (pre-existing useAnimation test failures unrelated to this change)

🤖 Generated with Claude Code

@greptile-apps
Copy link

greptile-apps bot commented Mar 9, 2026

Greptile Summary

This PR fixes a race condition in AnimatePresence mode="wait" where rapidly alternating keys could permanently freeze the component, leaving a stale exiting child in the DOM. The root cause was that DOMKeyframesResolver resolves keyframes asynchronously (on the next animation frame), and if AsyncMotionValueAnimation.stop() was called during that window, neither the cancelled resolver nor the non-existent inner animation would ever call notifyFinished() — leaving _finished hanging forever and blocking the onExitComplete promise chain.

Key changes:

  • AsyncMotionValueAnimation.stop() — adds a notifyFinished() call after cancelling the keyframe resolver, ensuring _finished is always resolved on stop regardless of whether keyframe resolution has completed. This is safe because Promise.resolve() is idempotent (double-resolution is a no-op).
  • Unit tests — two new Jest tests in the AnimatePresence suite: one reproducing the useEffect-driven loading/loaded pattern from issue [BUG] AnimatedPresence stuck when state changes too fast. #3141, and one simplified rapid-rerender scenario.
  • Cypress E2E tests — covers mount-time rapid key cycling (run 5× for intermittency) and click-triggered rapid switching with opacity verification.
  • Dev test page — a visual repro matching the original issue report.

The fix is minimal, well-targeted, and follows the same pattern already used in JSAnimation and NativeAnimation. Tests thoroughly cover the bug scenario.

Confidence Score: 5/5

  • Safe to merge — the fix is a single well-scoped line with no API surface changes, follows an existing pattern in the codebase, and is thoroughly tested.
  • The fix directly addresses the described root cause with a minimal, targeted change. Promise.resolve() is idempotent so no double-resolution risk. Both unit tests and E2E tests thoroughly cover the bug scenario (rapid key alternation in mode="wait"). The same pattern (notifyFinished() on stop) is already used in JSAnimation and NativeAnimation, indicating this is an accepted practice in the codebase. No unaddressed concerns remain.
  • No files require special attention. All changes are focused, well-tested, and follow established patterns.

Sequence Diagram

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

Last reviewed commit: c52c959

@greptile-apps
Copy link

greptile-apps bot commented Mar 9, 2026

Greptile Summary

This PR successfully fixes a real issue: AnimatePresence mode="wait" was getting permanently stuck showing stale exiting children when keys change faster than async keyframe resolution completes (e.g., loading/loaded patterns with rapid state changes).

Root cause: DOMKeyframesResolver schedules keyframe resolution asynchronously (next animation frame). When stop() is called before that frame fires, keyframeResolver.cancel() removes it from the resolution queue. Since onKeyframesResolved() never fires, this._animation never gets created, and the animation.finished.then(() => notifyFinished()) chain never sets up. The _finished promise — which AnimatePresence exit chains await — remains permanently unresolved, keeping exiting children in the DOM.

Fix: Adding this.notifyFinished() at the end of stop() (line 289) resolves the _finished promise even when stop() interrupts async keyframe resolution. Calling Promise.resolve() on an already-resolved Promise is a safe JavaScript no-op, so this handles both the pre-resolution case (primary bug) and the post-resolution case (when stop() interrupts a running animation) without side-effects.

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

  • The fix is minimal, well-reasoned, and safe to merge; the one-line addition to stop() correctly resolves a blocking bug with no risk of double-firing side-effects.
  • The core fix (adding notifyFinished() to stop()) is correct and addresses a real, reproducible bug. The PR includes comprehensive unit and E2E tests that validate the fix. Calling resolve() on an already-resolved Promise is a safe JavaScript no-op, eliminating any risk of unexpected side-effects. The code change is minimal and well-targeted. The only reason this is not 5/5 is that there's a pre-existing analogous gap in the cancel() method (it doesn't call notifyFinished() either), but that is a separate pre-existing issue outside this PR's scope.
  • No files require special attention.

Sequence Diagram

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

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>
@mattgperry mattgperry force-pushed the worktree-fix-issue-3141 branch from facedd8 to 10427ae Compare March 9, 2026 15:40
@mattgperry mattgperry merged commit ed64e5f into main Mar 12, 2026
5 checks passed
@mattgperry mattgperry deleted the worktree-fix-issue-3141 branch March 12, 2026 05:42
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] AnimatedPresence stuck when state changes too fast.

1 participant