Skip to content

Don't resurrect layout animations cancelled before their start lambda ran#9660

Open
bartlomiejbloniarz wants to merge 3 commits into
mainfrom
dont-resurrect-cancelled-la-starts
Open

Don't resurrect layout animations cancelled before their start lambda ran#9660
bartlomiejbloniarz wants to merge 3 commits into
mainfrom
dont-resurrect-cancelled-la-starts

Conversation

@bartlomiejbloniarz

@bartlomiejbloniarz bartlomiejbloniarz commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Closes #7493

Summary

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.

The interleaving

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. maybeCancelAnimationno 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.

The fix

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).

Reproduction & testing

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.

Notes

  • The underlying push-model transaction ordering in react-native is only fully solved by the
    pull model (Props 2.0); together with [LA] Add check if view is mounted on Android  #8083 this removes the known ways reanimated itself
    emits an Update for a view whose Delete is already committed.

… ran

On Android, pullTransaction can run on the JS thread, so animation starts
are scheduled onto the UI thread. If the view is removed before the
scheduled start runs, maybeCancelAnimation finds no layoutAnimations_
entry to erase and the cancellation is lost - the stale start then
re-creates the animation for a view whose Remove+Delete are already in
the mount-item queue, and its updates race the queued Delete
("Unable to find viewState for tag X", #7493).

Pending starts now capture a per-tag handle that cancellations
invalidate; a stale start bails out instead of resurrecting the
animation. Includes a repro example (interrupted exiting animation).

Closes #7493

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

Occasional crash on Android when entering animation

1 participant