Fix AnimatePresence remounting present children inside React.StrictMode (#3746)#3752
Fix AnimatePresence remounting present children inside React.StrictMode (#3746)#3752mattgperry wants to merge 2 commits into
Conversation
Greptile SummaryThis PR fixes a StrictMode-specific re-mount bug in
Confidence Score: 5/5Safe 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
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]
Reviews (1): Last reviewed commit: "Fix AnimatePresence remounting present c..." | Re-trigger Greptile |
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>
c10193e to
f532675
Compare
Fixes #3746.
The bug
AnimatePresencespliced exiting children into the new children array at their index within the previously rendered children:Those are two unrelated index spaces. Rendering
[a, persist, b]and switching to[c, d, persist]produced:The exiting
blands between the two entering children, andpersistis pushed to the end. On React 19 that reorder remountspersist, 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, samesplice.Tests
Cypress —
animate-presence-strict-datasetreproduces 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:
mounts.persist= 3, expected 2)mounts.persistis 2 when correct — StrictMode double-invokes effects on initial mount only, so a genuine remount later adds exactly 1.Jest —
Exiting children don't reorder present children (#3746)asserts the ordering invariant (an exiting child stays after the present child it followed). Fails on main (bat index 2,persistat 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 failedyarn lint— no new errors (the 52 pre-existingdev/reacterrors are unchanged)