Skip to content

Fix crash from withheld exiting layout animations in experimental proxy - #10073

Merged
tomekzaw merged 2 commits into
mainfrom
@tomekzaw/fix-withheld-exiting-experimental-proxy
Jul 30, 2026
Merged

tomekzaw merged 2 commits into
mainfrom
@tomekzaw/fix-withheld-exiting-experimental-proxy

Conversation

@tomekzaw

Copy link
Copy Markdown
Member

Summary

Closes #9822.

On iOS release builds with ENABLE_SHARED_ELEMENT_TRANSITIONS: true (the default on main), the Suspense + Layout Animation Crash example crashes within ~1–2 seconds with EXC_BAD_ACCESS inside LayoutAnimationsProxy_Experimental::updateLightTree. This is the experimental-proxy counterpart of #9820/#9821: when React re-creates a tag whose exiting removal the proxy is still withholding (a re-suspending Suspense boundary deletes a subtree and later re-creates it with the same fiber tags), the proxy's bookkeeping is contradicted and corrupts both the light tree and the mounting layer's view registry.

Unlike the legacy proxy — whose nodeForTag_ tracks every node of a withheld subtree, so the #9821 reconcile could catch any re-created tag — the experimental proxy only kept ANIMATING roots in lightNodes_. Two whole classes of withheld views were invisible to any reconcile:

  • WAITING interior nodes (e.g. FlatList cell containers kept alive under an exiting item) — tracked in neither lightNodes_ nor deadNodes,
  • settled DEAD nodes — already moved to deadNodes and erased from lightNodes_, but still mounted until the end-of-transaction cleanup, which runs after an incoming Create would have re-registered the tag.

A Create for such a tag passed straight through against a still-registered old view. Diagnosing with a mutation-stream replay showed the resulting "double Create" (an iOS RCTComponentViewRegistry overwrite) as the single divergence source; the wrong view then got recycled by the old node's deferred Delete, and subsequent transactions faulted on null light nodes / stale indices.

The fix mirrors #9821, adapted to the experimental proxy's bookkeeping:

  • reconcileContradictedRemovals runs at the start of pullTransaction: an incoming Create/Insert targeting a withheld tag — found in lightNodes_ (any non-live state) or in deadNodes — flushes that node's withheld Remove/Delete immediately, before the tag is re-registered, mirroring the deadNodes cleanup in handleRemovals.
  • WAITING subtree members are now registered in lightNodes_ (both the kept-subview branch of startAnimationsRecursively and the kept-root branch of handleRemovals), so the reconcile can find them — matching the legacy proxy's per-node bookkeeping.
  • endAnimationsRecursively and maybeDropAncestors erase the deleted node's lightNodes_ entry (guarded so a re-registered live node is never touched). This also fixes a pre-existing stale-entry leak for animating descendants force-ended together with an exiting ancestor.
  • endLayoutAnimation replaces an unchecked lightNodes_[tag] lookup with a guarded find — the old code would default-insert a null node and dereference it in release, where the assert is compiled out.

Test plan

Uses the Suspense + Layout Animation Crash example added in #9821, with ENABLE_SHARED_ELEMENT_TRANSITIONS left at its default (true):

  1. build fabric-example for iOS in Release,
  2. open the example and tap Start stress.

Before this change the app crashes within ~1–2 seconds; three consecutive runs on main produced the exact stack reported in #9822 (SIGSEGV in updateLightTreepullTransactionRCTMountingManager performTransaction). After it, the same stress ran through hundreds of mode switches (several minutes) with no crash, and a replay of ~525k logged mutations from an instrumented run showed no registry overwrites and no index divergence between the emitted mutations and the mounted hierarchy.

🤖 Generated with Claude Code

When React re-creates a tag whose exiting removal is still withheld
(e.g. a Suspense boundary re-suspending over animating views), the
experimental proxy either overwrote or missed the stale bookkeeping,
letting the old still-mounted view corrupt the mounting layer.

Port of the reconcile fix from #9821 to LayoutAnimationsProxy_Experimental,
extended to its bookkeeping model: reconcile contradicted removals for
nodes tracked in lightNodes_ and deadNodes, register WAITING subtree
members in lightNodes_ so the reconcile can find them, erase lightNodes_
entries when withheld nodes are deleted, and guard the endLayoutAnimation
lookup against a release-mode null deref.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 6491f3e7-98b8-47a0-8be5-0c93495f8c71

📥 Commits

Reviewing files that changed from the base of the PR and between fcd31e1 and 066e79d.

📒 Files selected for processing (1)
  • packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.cpp
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.cpp

📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes
    • Fixed layout animations when views are rapidly removed and re-created, including cases where pending exits are contradicted by new inserts.
    • Prevented stale exit/removal logic from deleting views that were already reinserted.
    • Hardened exit animation finalization to avoid incorrect node lookups that could leave inconsistent state.
    • Improved reliability and cleanup for complex nested view transitions during fast layout changes.

Walkthrough

The experimental layout animation proxy reconciles re-created tags with withheld removals during mounting transactions and keeps lightNodes_ mappings synchronized across removal, animation, and ancestor cleanup paths.

Changes

Layout removal reconciliation

Layer / File(s) Summary
Contradicted removal reconciliation
packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.*
pullTransaction reconciles Create and Insert mutations against withheld or dead nodes, removes contradicted nodes from their parents, and ends subtree animations before ancestor cleanup.
Light-node lifecycle cleanup
packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.cpp
Removal and animation paths register waiting nodes, erase matching stale mappings, and safely handle missing entries when ending layout animations.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related issues

  • #9402 — Covers related mutation reconciliation and stale-node cleanup in the layout animation proxy.

Suggested reviewers: bartlomiejbloniarz

Sequence Diagram(s)

sequenceDiagram
  participant ReactMounting
  participant LayoutAnimationsProxy_Experimental
  participant lightNodes_
  participant deadNodes
  participant LightNodeParent
  ReactMounting->>LayoutAnimationsProxy_Experimental: pullTransaction(mutations)
  LayoutAnimationsProxy_Experimental->>lightNodes_: reconcile Create/Insert tags
  LayoutAnimationsProxy_Experimental->>deadNodes: check DEAD nodes
  LayoutAnimationsProxy_Experimental->>LightNodeParent: remove contradicted node
  LayoutAnimationsProxy_Experimental->>LayoutAnimationsProxy_Experimental: end subtree animations and drop ancestors
  LayoutAnimationsProxy_Experimental-->>ReactMounting: filtered mutations
Loading
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main change: fixing a crash in the experimental proxy caused by withheld exiting layout animations.
Description check ✅ Passed The description matches the reported crash scenario and the implemented fix, so it is clearly related to the PR.
Linked Issues check ✅ Passed The changes implement the #9822 fix by reconciling contradicted removals and hardening light-node bookkeeping in the experimental proxy.
Out of Scope Changes check ✅ Passed The diff stays focused on the experimental layout-animation crash fix and related bookkeeping changes.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch @tomekzaw/fix-withheld-exiting-experimental-proxy

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.

🧹 Nitpick comments (1)
packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.cpp (1)

123-172: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Consider regression test coverage for this crash path.

This is a targeted fix for a hard-to-hit, timing-dependent native crash (Suspense re-suspension + exiting layout animations + shared element transitions). No test exercising reconcileContradictedRemovals/the recreate-while-withheld scenario is included in the reviewed files. If there's an existing native/mounting-layer test harness for this proxy, a regression test would materially reduce risk of silent reintroduction.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.cpp`
around lines 123 - 172, Add a regression test using the existing native or
mounting-layer harness for LayoutAnimationsProxy_Experimental and exercise
reconcileContradictedRemovals when a tag is recreated while its removal is
withheld, including the exiting/shared-transition timing path. Assert the stale
removal is flushed and the recreated view remains correctly registered without
crashing.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In
`@packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.cpp`:
- Around line 123-172: Add a regression test using the existing native or
mounting-layer harness for LayoutAnimationsProxy_Experimental and exercise
reconcileContradictedRemovals when a tag is recreated while its removal is
withheld, including the exiting/shared-transition timing path. Assert the stale
removal is flushed and the recreated view remains correctly registered without
crashing.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 31024595-4185-4564-8145-e498872f0c94

📥 Commits

Reviewing files that changed from the base of the PR and between cf5f300 and fcd31e1.

📒 Files selected for processing (2)
  • packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.cpp
  • packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.h

@tomekzaw
tomekzaw requested review from pawicao and removed request for bartlomiejbloniarz July 27, 2026 15:36
@tomekzaw
tomekzaw added this pull request to the merge queue Jul 30, 2026
Merged via the queue into main with commit 7225372 Jul 30, 2026
13 checks passed
@tomekzaw
tomekzaw deleted the @tomekzaw/fix-withheld-exiting-experimental-proxy branch July 30, 2026 13:06
tjzel added a commit that referenced this pull request Aug 7, 2026
…xy (#10073)

## Summary

Closes #9822.

On iOS release builds with `ENABLE_SHARED_ELEMENT_TRANSITIONS: true`
(the default on `main`), the Suspense + Layout Animation Crash example
crashes within ~1–2 seconds with `EXC_BAD_ACCESS` inside
`LayoutAnimationsProxy_Experimental::updateLightTree`. This is the
experimental-proxy counterpart of #9820/#9821: when React re-creates a
tag whose exiting removal the proxy is still withholding (a
re-suspending Suspense boundary deletes a subtree and later re-creates
it with the same fiber tags), the proxy's bookkeeping is contradicted
and corrupts both the light tree and the mounting layer's view registry.

Unlike the legacy proxy — whose `nodeForTag_` tracks every node of a
withheld subtree, so the #9821 reconcile could catch any re-created tag
— the experimental proxy only kept `ANIMATING` roots in `lightNodes_`.
Two whole classes of withheld views were invisible to any reconcile:

- **`WAITING` interior nodes** (e.g. FlatList cell containers kept alive
under an exiting item) — tracked in neither `lightNodes_` nor
`deadNodes`,
- **settled `DEAD` nodes** — already moved to `deadNodes` and erased
from `lightNodes_`, but still mounted until the end-of-transaction
cleanup, which runs *after* an incoming `Create` would have
re-registered the tag.

A `Create` for such a tag passed straight through against a
still-registered old view. Diagnosing with a mutation-stream replay
showed the resulting "double Create" (an iOS `RCTComponentViewRegistry`
overwrite) as the single divergence source; the wrong view then got
recycled by the old node's deferred `Delete`, and subsequent
transactions faulted on null light nodes / stale indices.

The fix mirrors #9821, adapted to the experimental proxy's bookkeeping:

- `reconcileContradictedRemovals` runs at the start of
`pullTransaction`: an incoming `Create`/`Insert` targeting a withheld
tag — found in `lightNodes_` (any non-live state) or in `deadNodes` —
flushes that node's withheld Remove/Delete immediately, before the tag
is re-registered, mirroring the `deadNodes` cleanup in `handleRemovals`.
- `WAITING` subtree members are now registered in `lightNodes_` (both
the kept-subview branch of `startAnimationsRecursively` and the
kept-root branch of `handleRemovals`), so the reconcile can find them —
matching the legacy proxy's per-node bookkeeping.
- `endAnimationsRecursively` and `maybeDropAncestors` erase the deleted
node's `lightNodes_` entry (guarded so a re-registered live node is
never touched). This also fixes a pre-existing stale-entry leak for
animating descendants force-ended together with an exiting ancestor.
- `endLayoutAnimation` replaces an unchecked `lightNodes_[tag]` lookup
with a guarded `find` — the old code would default-insert a null node
and dereference it in release, where the assert is compiled out.

## Test plan

Uses the Suspense + Layout Animation Crash example added in #9821, with
`ENABLE_SHARED_ELEMENT_TRANSITIONS` left at its default (`true`):

1. build `fabric-example` for iOS in Release,
2. open the example and tap Start stress.

Before this change the app crashes within ~1–2 seconds; three
consecutive runs on `main` produced the exact stack reported in #9822
(`SIGSEGV` in `updateLightTree` ← `pullTransaction` ←
`RCTMountingManager performTransaction`). After it, the same stress ran
through hundreds of mode switches (several minutes) with no crash, and a
replay of ~525k logged mutations from an instrumented run showed no
registry overwrites and no index divergence between the emitted
mutations and the mounted hierarchy.

(cherry picked from commit 7225372)
Co-authored-by: Tomasz Żelawski <tzelawski@gmail.com>
tjzel added a commit that referenced this pull request Aug 25, 2026
…tions in experimental proxy (#10073) (#10207)

Co-authored-by: Tomasz Zawadzki <tomekzawadzki98@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants