Skip to content

cherry-pick(4.3-stable): don't resurrect cancelled layout animations (#9660)#9738

Merged
MatiPl01 merged 2 commits into
4.3-stablefrom
@matipl01/4.3-stable/cherry-pick-#9660
Jul 2, 2026
Merged

cherry-pick(4.3-stable): don't resurrect cancelled layout animations (#9660)#9738
MatiPl01 merged 2 commits into
4.3-stablefrom
@matipl01/4.3-stable/cherry-pick-#9660

Conversation

@MatiPl01

Copy link
Copy Markdown
Member

Summary

Cherry-picking for Reanimated 4.3.2 release

Cherry-pick notes

Minor conflict resolution in maybeCancelAnimation: the Android pendingStarts_ invalidation block was prepended while keeping 4.3-stable's existing layoutAnimations_.contains(tag) lookup (main had already refactored that line to an iterator form in an unrelated change). The new InterruptedExitingExample was registered using 4.3-stable's plain-import style in examples/index.ts. All other hunks applied as-is.

… ran (#9660)

Closes #7493

Fixes another cause of #7493 (`RetryableMountingLayerException: Unable
to find viewState
for tag X. Surface stopped: false`) that reproduces on `main` (despite
the fix from #8083).
If new reports come in after this lands, we can reopen.

The bug is Android-specific. On Android, Fabric uses a push model:
`pullTransaction` can run on
the JS thread, which is why starting a layout animation (entering,
exiting or layout) can't
happen inline — `pullTransaction` schedules a start lambda onto the UI
thread, and only that
lambda creates the `layoutAnimations_` entry. (On iOS pulls happen on
the main thread, so the
start and any later cancellation are naturally ordered and none of this
can race.)

That asynchrony leaves a gap: if the view is removed before the start
lambda runs — easy when
the UI thread is stalled, since the removal is processed by another
`pullTransaction` on the JS
thread — `maybeCancelAnimation` finds no entry to erase and silently
does nothing. The lambda
then runs anyway and starts the animation for a view whose
`Remove`+`Delete` are already sitting
in Android's mount-item queue. The first progress update is emitted
while the view still *looks*
mounted (`preserveMountedTags` checks the host view registry, and the
`Delete` hasn't been
executed yet — so it passes, correctly), and the next synchronous mount
drains the queue in
FIFO order: `Delete` first, then the fresh `Update`. `getViewState`
throws.

Depending on timing this surfaces in two ways (both reproduced, both
gone with this fix): the
mounting-layer exception above, or — if no mount happens while the view
still resolves as
mounted — a zombie animation that finishes ~a second later and trips
`react_native_assert(parent && "Parent node is nullptr")` in
`handleRemovals`, because its node
was already flushed out of the tree once.

Captured from the repro on a Pixel 5 emulator (Android's JS and UI
threads; exiting variant):

| | JS thread | UI thread |
|---|---|---|
| 1 | `Remove(item)` pulled: item has `exiting` → Remove/Delete
withheld, `startExitingAnimation` schedules lambda **L** on the UI
thread. No `layoutAnimations_` entry exists yet. | stalled (GC / heavy
mount / busy frame) |
| 2 | screen unmount pulled with `shouldAnimate=false`
(react-native-screens pop / `skipExiting`): `endAnimationsRecursively`
force-ends the item. `maybeCancelAnimation` → **no entry → no-op**.
`Remove`+`Delete(item)` go into the queued mount batch. | still stalled
|
| 3 | | **L runs**: `createLayoutAnimation` creates the entry in
`layoutAnimations_`; the exiting config was never cleared on the
force-end path, so the animation **starts** and writes its first frame
into the update map. |
| 4 | | a pull emits `Update(item)` — `preserveMountedTags` passes, the
`Delete` is still queued. |
| 5 | | an event-driven `performOperations` (Android mounts
synchronously from event handling) → FIFO drain executes the queued
**`Delete(item)`**, then the **`Update(item)`** → 💥 `Unable to find
viewState for tag X` |

The entering variant has the same shape but is defused today by an
accident: the plain-removal
path calls `clearLayoutAnimationConfig`, so the resurrected start hits
the missing-config
early-return. The force-end paths don't clear configs, so the exiting
variant has no such luck.
Rather than rely on that side effect, this fixes the actual problem — a
cancellation that can
race the start and lose.

Android-only (`#ifdef ANDROID`, like the rest of the thread-related
special-casing in these
files). Both proxies (`_Legacy` and `_Experimental`) get the same
treatment, with the shared
bits in `LayoutAnimationsProxyCommon.h`:

- when an animation start is scheduled, the tag's `pendingStarts_` count
is bumped and the
  current *handle* is captured into the lambda;
- `maybeCancelAnimation` bumps the handle, invalidating starts still in
flight for that tag;
- a start lambda whose captured handle went stale bails out instead of
re-creating the animation
(`consumeIsCancelled`; the exiting lambda also clears the animation
config on that path,
  mirroring the non-animated removal path).

New example: **[LA] Interrupted exiting animation (#7493)** in
`apps/common-app`. It needs no
library modifications: each cycle blocks the UI thread for ~600ms with a
busy-wait worklet
(standing in for a real main-thread stall), unmounts the exiting item
while blocked, then force-ends it via a
`skipExiting` wrapper unmount in a second commit (two separate commits
matter — React's
automatic batching would otherwise fold both unmounts into one
transaction, which takes a
harmless path).

On an API 35 emulator: without the fix the example dies within a few
seconds of being
opened — the mounting-layer exception when scrolling, the
`handleRemovals` assert when left
alone. With the fix it runs indefinitely.

- The underlying push-model transaction ordering in react-native is only
fully solved by the
pull model (Props 2.0); together with #8083 this removes the known ways
reanimated itself
  emits an `Update` for a view whose `Delete` is already committed.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@MatiPl01 MatiPl01 force-pushed the @matipl01/4.3-stable/cherry-pick-#9660 branch from cc3ce94 to 8d4ff2f Compare June 23, 2026 13:10
@MatiPl01 MatiPl01 merged commit b79dbb4 into 4.3-stable Jul 2, 2026
21 checks passed
@MatiPl01 MatiPl01 deleted the @matipl01/4.3-stable/cherry-pick-#9660 branch July 2, 2026 16:20
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.

3 participants