Skip to content

fix: prevent exiting views from getting stuck under heavy load on Android#9621

Open
bartlomiejbloniarz wants to merge 4 commits into
mainfrom
bartlomiejbloniarz/la-pending-cleanup-v2
Open

fix: prevent exiting views from getting stuck under heavy load on Android#9621
bartlomiejbloniarz wants to merge 4 commits into
mainfrom
bartlomiejbloniarz/la-pending-cleanup-v2

Conversation

@bartlomiejbloniarz

@bartlomiejbloniarz bartlomiejbloniarz commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #9170.

On Android an exiting Animated.View could stay stuck on screen after it was unmounted, under heavy/spiky UI-thread load.

When a layout animation finishes, its entry in layoutAnimations_ isn't erased right away — the tag is queued and the entry is erased during the next pullTransaction (so the final frame can still be flushed). That deferred erase races with tag reuse because the steps run on two different threads: endLayoutAnimation and the scheduleOnUI'd createLayoutAnimation run on the UI thread, while pullTransaction (and the cleanup at the top of it) runs on the mounting thread — which on Android is usually the JS thread, during React commits. The recursive mutex serializes each critical section but doesn't order them, and the re-create isn't done inside pullTransaction, so under load:

  1. [UI thread] endLayoutAnimation(T) — animation ends, T is queued for cleanup.
  2. [UI thread] startExitingAnimation's scheduled job runs createLayoutAnimation(T)layoutAnimations_[T] is re-created for the reused tag.
  3. [JS thread] pullTransaction runs the cleanup, which erases the queued tags → wipes the freshly re-created layoutAnimations_[T].
  4. [UI thread] endLayoutAnimation(T, shouldRemove=true)layoutAnimations_ no longer contains T, so it returns early, the node is never marked DEAD, and the view stays on screen forever.

On iOS pullTransaction always runs on the UI thread, so it's all one thread. scheduleOnUI called from pullTransaction runs createLayoutAnimation inline, after the cleanup, within the same pullTransaction — so step 2 can't slip in before step 3, and the window never opens.

Ideally, once pull model / branching is made default we can remove the threading weridness around this.

The fix makes count (the number of in-flight animations for a view) the source of truth for whether an entry has settled: endLayoutAnimation decrements it to 0, and the cleanup re-checks it before erasing, so a tag that was re-animated in the meantime is back at count >= 1 (the start paths do count + 1) and is left alone. Read sites that can run before the cleanup in a transaction now also check that the entry isn't settled; sites that run after it don't need to — the cleanup holds the mutex for the whole transaction, so no settled entry can exist there.

Test plan

Added an [LA] Exiting tag reuse stress example: a fixed grid that mounts/unmounts views in the same slots with ZoomIn/ZoomOut, plus periodic spikes of short-lived views to load the UI thread.

  1. Run on Android (Fabric), with ENABLE_SHARED_ELEMENT_TRANSITIONS both off (Legacy proxy) and on (Experimental proxy).
  2. Open the example (it starts automatically), let it run through a few spikes, tap Stop.
  3. Before: views remain stuck on screen after Stop. After: the grid clears completely.

Copilot AI 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.

Pull request overview

Fixes an Android-only race where deferred cleanup of finished layout animations could erase a newly re-created layoutAnimations_[tag] entry after tag reuse under UI-thread load, preventing exiting views from being marked dead and leaving them stuck on-screen. The change makes “settled” status derive from the per-tag in-flight animation count, and re-checks that status at cleanup/read sites to avoid wiping re-animated tags.

Changes:

  • Replace “finished tag queue” cleanup with a “maybe-settled tag set” and only erase entries that are still settled (count == 0) at cleanup time.
  • Update multiple read sites (legacy/experimental/shared transitions) to treat “settled” entries as effectively absent.
  • Add an example app stress test to reproduce/tag-reuse under spiky load.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/SharedTransitions.cpp Avoids using container tags backed by missing/settled layout-animation entries.
packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.h Introduces LayoutAnimation::isSettled() and replaces the finished-tag vector with an unordered_set.
packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.cpp Updates opacity restoration logic to iterate the new “maybe settled” tag set.
packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.cpp Uses settled-aware cleanup and settled-aware checks across legacy layout animation handling.
packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.cpp Uses settled-aware cleanup and settled-aware checks across experimental layout animation handling.
apps/common-app/src/apps/reanimated/examples/LayoutAnimations/ExitingTagReuseStressExample.tsx Adds a stress-test example to validate exiting-tag reuse under load.
apps/common-app/src/apps/reanimated/examples/index.ts Registers the new example in the common app examples list.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

…roid

The deferred cleanup of finished layout animations could race with tag
reuse and erase a freshly re-created entry, leaving the exiting view
stuck on screen. Use the in-flight animation count as the source of
truth for whether an entry has settled and re-check it before erasing.

Copilot AI 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.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.

After the cleanup loop in pullTransaction no entry can be settled (the
mutex is held for the whole transaction, so no end callback runs and
nothing creates a count==0 entry inline). The isSettled() guards at
those read sites were dead code, so revert them to the original checks.

Copilot AI 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.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Copilot AI 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.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Copilot AI 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.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.

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.

[Reanimated 4][Android] Animated.View with exiting animation gets stuck on screen on unmount

2 participants