Skip to content

refactor(LayoutAnimations): stop taking over UIManagerAnimationDelegate#9901

Draft
bartlomiejbloniarz wants to merge 2 commits into
mainfrom
remove-animation-delegate
Draft

refactor(LayoutAnimations): stop taking over UIManagerAnimationDelegate#9901
bartlomiejbloniarz wants to merge 2 commits into
mainfrom
remove-animation-delegate

Conversation

@bartlomiejbloniarz

@bartlomiejbloniarz bartlomiejbloniarz commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Summary

We register LayoutAnimationsProxy_Legacy as the UIManagerAnimationDelegate only to receive stopSurface — the other three delegate methods are no-ops. Occupying that slot overwrites the LayoutAnimationDriver that RN installs there, which silently kills LayoutAnimation.configureNext for the whole app.

The proxy now detects teardown itself: it registers a commit hook and marks a surface in surfacesToRemove_ whenever a commit's root has no children — which is what SurfaceHandler::stopcommitEmptyTree produces, and lands before that transaction reaches pullTransaction. The pull consumes the mark only on a transaction that actually removes children of the root, so pulls emitted for animation frames (which run with empty mutations) can't consume it early. App-level null renders don't trigger the mark (the AppContainer view stays under the root), so only real teardown and display-mode suspend commit an empty root — verified empirically and by enumerating every commitEmptyTree caller.

The check deliberately doesn't read the ShadowTreeRegistry: pullTransaction/commit hooks can run under the registry's shared lock, and re-locking there deadlocks with a pending writer (#8579 is that exact bug elsewhere).

Freeing the slot restores configureNext to stock RN behavior per platform: it animates on iOS (enableLayoutAnimationsOnIOS defaults to true) and on Android when the app enables enableLayoutAnimationsOnAndroid (off by default in RN core — without it configureNext snaps in stock RN too). The MountingCoordinator chains RN's driver and our override delegate; the driver's own stopSurface flushes its withheld removals into the teardown transaction, so the consume condition always holds there.

uiManager moves from the Android-only proxy constructor args to a shared one; the commit hook registration needs it on both platforms.

Test plan

fabric-example with ENABLE_SHARED_ELEMENT_TRANSITIONS=false (legacy proxy), iOS sim + Android emulator (with enableLayoutAnimationsOnAndroid force-enabled on Android):

  • configureNext animates: video frame measurements show full easeInEaseOut interpolation on both platforms (iOS: 119 frames, 180→720px; Android: 158→275→388→468→514→630px)
  • configureNext delete animations (transitions that remove views): the driver replays withheld Remove/Delete together with a final keyframe update; our reordering used to mount that update after the delete and abort (found in the Bluesky app, deterministic). Fixed in the second commit; covered by the bench's remove-views button on both platforms
  • Reanimated entering/exiting/layout animations unchanged
  • Reload teardown: exit animations skipped (single-frame removal), clean registry teardown, animations work on the fresh surface
  • Adversarial pass: reloads fired at 0/60/150/350ms into exit animations, mount/unmount storms, concurrent reload requests, background/foreground with exits in flight, LogBox surface churn, configureNext mid-flight at teardown — 11/11 teardowns skipped exits with zero false positives on live surfaces, no crashes/ANRs/ghost views
  • Rendering null at the app root: exits animate normally (surface alive), UI recovers

Manual test bench: remove-animation-delegate-testbench branch (flip ENABLE_SHARED_ELEMENT_TRANSITIONS to false in apps/fabric-example/package.json locally, then build).

Reanimated registered LayoutAnimationsProxy_Legacy as the
UIManagerAnimationDelegate only to receive stopSurface - the other
three delegate methods were no-ops. Occupying the delegate slot
overwrites the LayoutAnimationDriver that React Native installs there,
which silently disables LayoutAnimation.configureNext for the whole
app.

The proxy now detects surface teardown itself: it registers a commit
hook and marks a surface in surfacesToRemove_ whenever a commit's root
has no children, which is what SurfaceHandler::stop's commitEmptyTree
produces. The mark lands before the teardown transaction reaches
pullTransaction, so the consumer side is unchanged. App-level empty
renders don't trigger this - rendering null from the app still leaves
the AppContainer view under the root, so only genuine teardown (and
display-mode suspend) commits an empty root.

The teardown check deliberately avoids reading the ShadowTreeRegistry:
pullTransaction and commit hooks can run under the registry's shared
lock, and re-locking it there can deadlock with a pending writer (#8579
is that exact bug in another spot).

The delegate slot stays with React Native, so configureNext works next
to Reanimated again, with both mounting override delegates chained by
the MountingCoordinator.

uiManager moves from the Android-only constructor parameters of the
layout animation proxies to a shared one, since the commit hook
registration needs it on both platforms.
…me transaction

With the animation delegate freed, RN's LayoutAnimationDriver runs next to
the reanimated override delegate. For configureNext delete animations the
driver withholds Remove/Delete mutations and replays them after the
animation, in the same pull as the final keyframe update for those views.
React's differ never pairs an update with a delete for one tag, but the
driver does - and since we emit removals before updates, the update reached
the mounting layer after its view was already unregistered, aborting with
RCTComponentViewRegistry: Attempt to query unregistered component
(reproduced in the Bluesky app on any configureNext that removes views).

Updates targeting views deleted in the same transaction are dropped - the
views are gone this frame either way.
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.

1 participant