cherry-pick(4.3-stable): don't resurrect cancelled layout animations (#9660)#9738
Merged
Merged
Conversation
… 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>
cc3ce94 to
8d4ff2f
Compare
piaskowyk
approved these changes
Jun 26, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Cherry-picking for Reanimated 4.3.2 release
Cherry-pick notes
Minor conflict resolution in
maybeCancelAnimation: the AndroidpendingStarts_invalidation block was prepended while keeping 4.3-stable's existinglayoutAnimations_.contains(tag)lookup (main had already refactored that line to an iterator form in an unrelated change). The newInterruptedExitingExamplewas registered using 4.3-stable's plain-import style inexamples/index.ts. All other hunks applied as-is.