Fix AnimatePresence remounting present children inside React.StrictMode (#3746)#3752
Fix AnimatePresence remounting present children inside React.StrictMode (#3746)#3752mattgperry wants to merge 1 commit into
Conversation
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 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 |
Bug
When
AnimatePresenceis used insideReact.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. Theirinitialvalues are re-applied as if they were freshly entering (e.g.widthanimates from 0). The behaviour is non‑deterministic and only reproduces in the browser — removingStrictMode"fixes" it.Fixes #3746.
Cause
When children change,
AnimatePresencerebuilds its rendered list by taking the new present children and splicing each exiting child back in at its old absolute index:The old index is taken from
renderedChildrenbut applied to a list built frompresentChildren, 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], movingpersistfrom 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 synchronousact()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]—persiststays ahead of the exitingb, so it is preserved and animates its change instead of remounting.Tests
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]).strict-mode-animate-presence-switch): reproduces the report — a list of bars inStrictMode, switching datasets, asserting the persistent bar is never remounted (mount count stays at the StrictMode double‑mount) and keeps its width. Fails onmainunder React 19, passes with the fix.Verified:
strict-mode-animate-presence-switchCypress spec passes on React 18 and React 19 (failed on React 19 before the fix).framer-motionsuite: 800 client + 49 SSR tests pass.🤖 Generated with Claude Code