Skip to content

fix(LayoutAnimations): don't crash when a view hidden under LayoutAnimation is shown again - #10916

Merged
bartlomiejbloniarz merged 1 commit into
mainfrom
bartlomiejbloniarz/fix-layout-animation-recreated-live-view
Oct 22, 2026
Merged

bartlomiejbloniarz merged 1 commit into
mainfrom
bartlomiejbloniarz/fix-layout-animation-recreated-live-view

Conversation

@bartlomiejbloniarz

@bartlomiejbloniarz bartlomiejbloniarz commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

Summary

On iOS, React Native hides a subtree (display: none, react-freeze, <Activity mode="hidden">) by removing its host views from the mounted tree. Showing it again creates the same tags. When the hide runs under LayoutAnimation with a delete config, React Native's LayoutAnimationKeyFrameManager defers the Remove and Delete. If the subtree is shown again before that animation ends, the deferred Remove and Delete arrive in the same transaction as the Create of the same tag.

reconcileContradictedRemovals only handled a Create of a node whose removal the proxy withholds. Here the node is still live, so updateLightTree started the view's Reanimated exiting animation and kept the node. The following Create then aborted on LightNode already exists in debug builds. In release builds, the proxy overwrote the node while the view stayed mounted.

Now reconcileContradictedRemovals also handles a live node that the same transaction deletes and creates again. As for a withheld node, the view stays mounted: the Create becomes an Update of the mounted view and the Delete is dropped. The proxy then sees a move, so the exiting animation does not start and the view keeps its layout animation configs. Showing the view again does not run its entering animation, because the view is moved rather than inserted. A Create of a live tag that the transaction does not delete still asserts.

Example

W is a wrapper that gets display: none, and B is an Animated.View with exiting inside it. Each diagram shows React's host tree, the proxy's light tree and the mounted native views at one point in time.

  1. Before hiding, all three trees match.
flowchart LR
    subgraph react["React host tree"]
        direction TB
        r1[Root] --> rw1[W] --> rb1[B]
    end
    subgraph light["Light tree"]
        direction TB
        l1[Root] --> lw1["W · LIVE"] --> lb1["B · LIVE"]
    end
    subgraph native["Native views"]
        direction TB
        n1[Root] --> nw1[W] --> nb1[B]
    end
    react ~~~ light ~~~ native
Loading
  1. React hides W in a commit under LayoutAnimation.configureNext. React's diff removes and deletes W and B. LayoutAnimationKeyFrameManager holds those mutations back and fades both views out, so the proxy only receives opacity Updates. The light tree and the native views still hold W and B.
flowchart LR
    subgraph react["React host tree"]
        direction TB
        r2[Root]
    end
    subgraph light["Light tree"]
        direction TB
        l2[Root] --> lw2["W · LIVE"] --> lb2["B · LIVE"]
    end
    subgraph native["Native views"]
        direction TB
        n2[Root] --> nw2["W · fading out"] --> nb2["B · fading out"]
    end
    react ~~~ light ~~~ native
Loading
  1. React shows W again before the fade ends. Its diff creates W and B with the same tags. Their Creates conflict with the running fade, so LayoutAnimationKeyFrameManager sends the held-back Removes and Deletes in the same transaction: Remove, Delete, Create and Insert of W and B.

Before this change, the proxy handled the Removes and Deletes as a removal of W. It started B's exiting animation and kept both nodes in the light tree while B animated. The Create of W then found its tag still in the light tree.

flowchart LR
    subgraph react["React host tree"]
        direction TB
        r3[Root] --> rw3["W (same tag)"] --> rb3["B (same tag)"]
    end
    subgraph light["Light tree after Remove and Delete"]
        direction TB
        l3[Root] --> lw3["W · WAITING for B"] --> lb3["B · ANIMATING exiting"]
        c3["Create W: LightNode already exists"]
    end
    subgraph native["Native views"]
        direction TB
        n3[Root] --> nw3[W] --> nb3[B]
    end
    react ~~~ light ~~~ native
    classDef exiting stroke-dasharray: 5 5
    classDef failure fill:#f8d7da,stroke:#c0392b,color:#7b1d1d
    class lw3,lb3 exiting
    class c3 failure
Loading

Now the proxy turns each Create into an Update of the mounted view and drops the Delete before it updates the light tree. What remains is a Remove and an Insert of the same views, which the proxy handles as a move. No exiting animation starts, and the native views stay the same.

flowchart LR
    subgraph react["React host tree"]
        direction TB
        r4[Root] --> rw4["W (same tag)"] --> rb4["B (same tag)"]
    end
    subgraph light["Light tree"]
        direction TB
        l4[Root] --> lw4["W · LIVE"] --> lb4["B · LIVE, keeps its exiting config"]
    end
    subgraph native["Native views"]
        direction TB
        n4[Root] --> nw4["W · same view, opacity 1"] --> nb4["B · same view, opacity 1"]
    end
    react ~~~ light ~~~ native
Loading

Test plan

  • New runtime test suite layout animations hidden under LayoutAnimation. It hides an Animated.View with exiting under LayoutAnimation, shows it again while the animation runs, and checks that the view keeps its tag, becomes opaque again and still runs its exiting animation when it unmounts. It covers display: none, react-freeze and Activity.
  • On main, all three cases abort on LightNode already exists in an iOS DebugRuntimeTests build.
  • With this change, the whole reanimated runtime test suite passes on iOS and Android.

Changelog

  • I added a changelog fragment with yarn changelog:add for each changed package, or this PR does not change react-native-reanimated or react-native-worklets.

…ation removes and re-creates in one transaction

On iOS, React Native hides a `display: none` subtree (also react-freeze and
`<Activity mode="hidden">`) by taking its host views out of the mounted tree:
the differ emits Remove+Delete, and showing it again emits Create+Insert with
the same tags.

With a LayoutAnimation delete config, LayoutAnimationKeyFrameManager runs
before the proxy and defers each Remove+Delete as a keyframe. When the subtree
is shown again before the keyframe ends, the Create conflicts with it, so the
deferred Remove+Delete are flushed into the same transaction, ahead of the
Create+Insert.

reconcileContradictedRemovals only handled a Create of a node whose removal
the proxy already withholds. Here the node is still LIVE, so updateLightTree
processed the Remove+Delete, started the view's Reanimated exiting animation
and kept the node mapped. The following Create then hit the "LightNode already
exists" assert in debug builds. In release builds, the node was overwritten
while its view stayed mounted.

reconcileContradictedRemovals now handles both cases of React re-creating a
tag that the light tree still holds. Both keep the mounted view: the Create
becomes an Update of it and the Delete is dropped, because Android mounts the
Deletes of a transaction after its Creates. A withheld node is torn down as
before. A live node is converted only when the same transaction deletes it,
and the conversion happens in place in React's mutations, so updateLightTree
sees a plain move (Remove+Insert) and the exiting config is not consumed. A
Create of a live tag that the transaction does not delete still hits the
assert.

Because the live view only moves, showing it again does not run its
Reanimated entering animation, unlike a withheld view, which is inserted
again. React Native's LayoutAnimation already handles the view's opacity in
that case.
@coderabbitai

coderabbitai Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

📝 Walkthrough

The change updates LayoutAnimationsProxy reconciliation when a view is recreated in a transaction that also deletes its tag. It adds runtime tests for hiding and restoring views with three container types during a layout animation. The tests check the view tag, opacity, and native mounting state.

Priority: ➖ Normal

Change: Bug fix · Severity of issue fixed: Medium

Merge Risk: 🟡 Moderate · up to 3d6e3

Showing a view again during its removal animation can take an incorrect native removal path. Preserve the Delete needed for non-LIVE bookkeeping before merging.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title clearly and concisely describes the primary fix: preventing crashes when a view hidden under LayoutAnimation is shown again.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Description check Passed The pull request description directly explains the LayoutAnimation reconciliation fix, the affected iOS failure, the implementation, and the added runtime tests. It matches the changeset.
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: e2c58650-79da-4266-bc16-60093ebd880c
📥 Commits

Reviewing files that changed from the base of the PR and between 1d2b213 and 3d6e39c.

📒 Files selected for processing: 5
  • apps/common-app/runtime-tests/reanimated/suites.ts
  • apps/common-app/runtime-tests/reanimated/tests/layoutAnimations/hiddenUnderLayoutAnimation.test.tsx
  • packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy.cpp
  • packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy.h
  • packages/react-native-reanimated/changelog/layout-animation-recreated-live-view.fix.md

@bartlomiejbloniarz
bartlomiejbloniarz marked this pull request as ready for review October 20, 2026 14:59
@bartlomiejbloniarz
bartlomiejbloniarz merged commit 26651a4 into main Oct 22, 2026
40 checks passed
@bartlomiejbloniarz
bartlomiejbloniarz deleted the bartlomiejbloniarz/fix-layout-animation-recreated-live-view branch October 22, 2026 12:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants