Skip to content

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

Open
mattgperry wants to merge 2 commits into
mainfrom
fix-3746-animatepresence-strictmode-remount
Open

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

Conversation

@mattgperry

@mattgperry mattgperry commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

Fixes #3746.

The bug

AnimatePresence spliced exiting children into the new children array at their index within the previously rendered children:

for (let i = 0; i < renderedChildren.length; i++) {
    if (!presentKeys.includes(key)) {
        nextChildren.splice(i, 0, child)   // `i` indexes renderedChildren…
    }                                      // …but nextChildren is presentChildren
}

Those are two unrelated index spaces. Rendering [a, persist, b] and switching to [c, d, persist] produced:

["a", "c", "b", "d", "persist"]

The exiting b lands between the two entering children, and persist is pushed to the end. On React 19 that reorder remounts persist, which replays its enter animation — the reported "disappears and reappears, and its width animates from 0".

The fix

Reinsert each exiting child directly after the child it previously followed, giving ["a", "c", "d", "persist", "b"]. One extra variable, same single pass, same splice.

Tests

Cypressanimate-presence-strict-dataset reproduces the report: a bar chart with two datasets sharing a key, under StrictMode. It samples the persisting bar's width every frame across the switch and asserts it never collapses, and that its mount count never grows.

This is the real regression gate, and it is React-19-specific:

main this PR
React 18 passes passes
React 19 fails 4/4 (mounts.persist = 3, expected 2) passes 3/3

mounts.persist is 2 when correct — StrictMode double-invokes effects on initial mount only, so a genuine remount later adds exactly 1.

JestExiting children don't reorder present children (#3746) asserts the ordering invariant (an exiting child stays after the present child it followed). Fails on main (b at index 2, persist at index 4), passes here. Note JSDOM does not reproduce the remount itself — the persisting child is never remounted there on either React version — so this test guards the ordering, not the symptom.

Verification

  • npx jest --config packages/framer-motion/jest.config.json — 800 passed, 0 failed
  • Cypress presence/layout sweep (19 specs, 39 tests) — passes on React 18 and React 19
  • yarn lint — no new errors (the 52 pre-existing dev/react errors are unchanged)

@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

mattgperry and others added 2 commits July 27, 2026 11:06
Exiting children were spliced into the new children array at their index
within the previously rendered children. Those are two different index
spaces, so exiting children could be interleaved with entering children
and push present children to new positions, remounting them (#3746).

Reinsert each exiting child directly after the child it previously
followed instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mattgperry
mattgperry force-pushed the fix-3746-animatepresence-strictmode-remount branch from c10193e to f532675 Compare July 27, 2026 10:20
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