Skip to content

Fix AnimatePresence remounting present children inside React.StrictMode (#3746)#3752

Open
mattgperry wants to merge 1 commit into
mainfrom
fix-3746-animatepresence-strictmode-remount
Open

Fix AnimatePresence remounting present children inside React.StrictMode (#3746)#3752
mattgperry wants to merge 1 commit into
mainfrom
fix-3746-animatepresence-strictmode-remount

Conversation

@mattgperry

Copy link
Copy Markdown
Collaborator

Bug

When AnimatePresence is used inside React.StrictMode, switching between two datasets where some keys persist (exist in both the before and after state) causes the persistent items to disappear and reappear instead of animating. Their initial values are re-applied as if they were freshly entering (e.g. width animates from 0). The behaviour is non‑deterministic and only reproduces in the browser — removing StrictMode "fixes" it.

Fixes #3746.

Cause

When children change, AnimatePresence rebuilds its rendered list by taking the new present children and splicing each exiting child back in at its old absolute index:

let nextChildren = [...presentChildren]
for (let i = 0; i < renderedChildren.length; i++) {
    if (!presentKeys.includes(getChildKey(renderedChildren[i]))) {
        nextChildren.splice(i, 0, child) // old index into a re-ordered array
    }
}

The old index is taken from renderedChildren but applied to a list built from presentChildren, which may have a different length and order. As a result an exiting child can be inserted before a child that is still present, reordering it.

Example — [a, persist, b] → present [c, d, persist] produced [a, c, b, d, persist], moving persist from index 1 to index 4.

React reconciles keyed children by identity, so a repositioned key is a move. Under React 19 concurrent rendering + StrictMode, the moved subtree's effects are re-run (unmount → mount), which remounts the still‑present child and replays its enter animation. JSDOM's synchronous act() rendering doesn't surface this, which is why it only reproduces in the browser.

Fix

Reinsert each exiting child immediately after the present child it followed in the previous render (or at the start if it had no present sibling before it). Present children keep a stable position and are never reordered behind an exiting sibling, so React no longer treats them as moved.

For the example above the rendered list is now [a, c, d, persist, b]persist stays ahead of the exiting b, so it is preserved and animates its change instead of remounting.

Tests

  • Unit (AnimatePresence.test.tsx): asserts the rendered order keeps a present child ahead of an exiting sibling that followed it. Fails on the old splice logic ([a, c, b, d, persist]), passes with the fix ([a, c, d, persist, b]).
  • E2E (strict-mode-animate-presence-switch): reproduces the report — a list of bars in StrictMode, switching datasets, asserting the persistent bar is never remounted (mount count stays at the StrictMode double‑mount) and keeps its width. Fails on main under React 19, passes with the fix.

Verified:

  • strict-mode-animate-presence-switch Cypress spec passes on React 18 and React 19 (failed on React 19 before the fix).
  • Full framer-motion suite: 800 client + 49 SSR tests pass.

🤖 Generated with Claude Code

When switching datasets in AnimatePresence, exiting children were
re-inserted into the rendered list at their old absolute index. When the
present children had changed order/length, this could shift a still-present
child to a new position. React treats a repositioned key as a move and —
particularly under React.StrictMode in development — re-runs the moved
subtree's effects, remounting it. The remounted child replays its enter
animation (e.g. width from 0) and visually disappears/reappears instead of
animating the change.

Reinsert each exiting child immediately after the present child it followed
in the previous render (or at the start if it had none), so present children
keep a stable position and are never reordered behind an exiting sibling.

Fixes #3746

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Jun 12, 2026

Copy link
Copy Markdown

Greptile Summary

This PR fixes a StrictMode-specific re-mount bug in AnimatePresence where persistent children (keys present in both the old and new dataset) were incorrectly reordered behind exiting siblings, causing React to treat them as moved nodes and re-run their effects — replaying enter animations instead of animating the change.

  • Root cause fix (index.tsx): The old splice(i, 0, child) used the exiting child's absolute index from renderedChildren as an insertion point into the rebuilt nextChildren list, which had different length and order — pushing present keys to new positions. The replacement two-pass algorithm groups each exiting child under its nearest preceding present sibling via a trailingExits Map, so present children are never reordered behind an exiting sibling.
  • Unit test (AnimatePresence.test.tsx): Adds a test asserting the rendered DOM order after switching [a, persist, b][c, d, persist] is [a, c, d, persist, b] (the old code produced [a, c, b, d, persist], moving persist).
  • E2E + dev harness: Adds a Cypress spec and dev test that reproduces the full browser-side scenario in StrictMode — two dataset switches, asserting the persisting bar's width never collapses and its mount count never exceeds the StrictMode double-mount baseline.

Confidence Score: 5/5

Safe to merge; the change is narrowly scoped to the child-ordering logic inside the diff branch of AnimatePresence and is backed by a new unit test plus a Cypress E2E spec on both React 18 and 19.

The algorithm replacement is logically correct across all edge cases (leading exits, trailing exits, multiple ongoing exit waves, and round-trip dataset switches), the new O(n) Map-based approach is strictly more correct and more efficient than the old O(n²) splice, and the existing 800-test suite continues to pass.

No files require special attention. The unit test in AnimatePresence.test.tsx only asserts one switch direction, but the E2E spec covers the full round-trip that triggered the original bug report.

Important Files Changed

Filename Overview
packages/framer-motion/src/components/AnimatePresence/index.tsx Core fix: replaces the absolute-index splice with a two-pass "trailing exits" algorithm that groups each exiting child under its nearest preceding present sibling, preserving present-child DOM positions across renders.
packages/framer-motion/src/components/AnimatePresence/tests/AnimatePresence.test.tsx New unit test verifies rendered order after a single A→B switch; only covers one direction of the dataset round-trip (the E2E test handles B→A).
packages/framer-motion/cypress/integration/strict-mode-animate-presence-switch.ts New E2E spec reproduces the #3746 scenario end-to-end: two dataset switches in StrictMode, asserting no re-mount of the persisting element and width stays at 200 after each switch.
dev/react/src/tests/strict-mode-animate-presence-switch.tsx New dev test harness for the E2E spec: renders two fixed datasets inside StrictMode and exposes a window.mountCounts map so Cypress can assert re-mount counts.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[presentChildren !== diffedChildren] --> B[nextChildren = copy of presentChildren]
    B --> C[First pass: iterate renderedChildren]
    C --> D{key in presentKeys?}
    D -- Yes --> E[prevPresentKey = key]
    D -- No --> F[exitingChildren.push child\ntrailingExits.get prevPresentKey .push child]
    E --> C
    F --> C
    C --> G{exitingChildren.length > 0?}
    G -- No --> H[nextChildren unchanged\nno DOM reorder]
    G -- Yes --> I[merged = trailingExits.get null — leading exits]
    I --> J[Second pass: iterate nextChildren]
    J --> K[merged.push present child]
    K --> L[merged.push trailingExits for that key]
    L --> J
    J --> M[nextChildren = merged]
    M --> N{mode === wait?}
    N -- Yes --> O[nextChildren = exitingChildren only]
    N -- No --> P[setRenderedChildren nextChildren\nsetDiffedChildren presentChildren]
    O --> P
    P --> Q[return null — trigger re-render]
Loading

Reviews (1): Last reviewed commit: "Fix AnimatePresence remounting present c..." | Re-trigger Greptile

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 enter/exit tracking broken inside React.StrictMode

1 participant