Skip to content

fix(Android): pin a dismissal snapshot so pop exit animations keep showing screen content - #4570

Open
Nodonisko wants to merge 2 commits into
software-mansion:mainfrom
Nodonisko:fix/android-fabric-pop-empty-screen
Open

fix(Android): pin a dismissal snapshot so pop exit animations keep showing screen content#4570
Nodonisko wants to merge 2 commits into
software-mansion:mainfrom
Nodonisko:fix/android-fabric-pop-empty-screen

Conversation

@Nodonisko

@Nodonisko Nodonisko commented Aug 30, 2026

Copy link
Copy Markdown

Description

Human description

On Android, when you pop screen where some Reanimated animations runs, it can result in race condition when it causes screen content Views to be removed before pop animations finishes so it result in flash of contentStyle.backgroundColor color during transition.

AI disclosure: I used Claude to debug and fix issues with my oversight, and fix was tested in RNS example app, blank Expo app and also in production app. Also attaching screenshots and videos as proof.

AI description

Root cause. notifyScreenRemoved schedules startRemovalTransition() with screen.post, and the transaction that deletes the screen's children races that runnable. Which side wins depends on who executes the batch. We instrumented both outcomes in a production app:

Losing pop, batch executed synchronously inside Reanimated's frame callback, before the posted runnable:

FabricUIManager.scheduleMountItem            <- executes immediately on the UI thread
  at com.swmansion.reanimated.NativeProxy.performOperations
  at com.swmansion.reanimated.NodesManager.onAnimationFrame

Winning pop, batch executed by RN's own vsync callback, 11-15 ms later, posted runnable ran first:

FabricUIManager$DispatchUIFrameCallback.doFrameGuarded
  at android.view.Choreographer$CallbackRecord.run

Any UI-thread scheduleMountItem caller triggers the losing path; Reanimated with pending operations at commit time is the ubiquitous real-world one. This is why the flash is invisible in bare example apps but consistent in apps with running animations.

Why not just fix the ordering? Reordering or withholding mutations does not survive other MountingOverrideDelegates (Reanimated's LayoutAnimationsProxy rebuilds the whole list). Marking retention earlier is not enough either: we verified frame-by-frame that startViewTransition retention keeps the content alive only 2-3 frames through a Reanimated-rebuilt transaction. The only artifact the rebuild cannot touch is a bitmap, which is the approach iOS already takes: snapshot the dismissed screen.

At UIManagerListener.willMountItems, the last point guaranteed to run on the UI thread before the deleting batch, the screen's window rect is captured with PixelCopy and pinned as screen.foreground; the exit animation then shows the real content and the bitmap dies with the fragment view. startRemovalTransition() is also called there synchronously, so the existing retention no longer depends on the race and covers the first frames if the snapshot bails out.

Changes

  • cpp/legacy/RNSScreenRemovalListener.cpp: notify only screens with a matching Remove + Delete pair in the transaction. A plain Remove is a reorder, and a false positive would pin a permanent overlay on a live screen. Also matches RNSModalScreen.
  • NativeProxy.kt: queues dismissed tags; at willMountItems pins the snapshot and starts the removal transition synchronously.
  • ScreenDismissSnapshot.kt (new): the PixelCopy capture. Guards: API >= 26, attached and non-zero size, top screen only, transparent modals skipped, clipped rects skipped, OOM/interruption bail out, 64 ms deadline. Every guard falls back to current behavior.
  • ScreensModule.kt: registers/unregisters the UIManagerListener.
  • Test4570.tsx (new, issue-tests): repro screen; pop the Detail screen while its Reanimated spinner animates.

Before & after - visual documentation

Test4570 in this repo's example app, both pops with a log-verified lost race (retention ran after the children were already deleted), 12 consecutive frames @ 60 fps:

Before / after

RNS example app

rns-example-board-before-after

Blank example app (Expo SDK 57)

rns-whiteflash-board-before-after

Screen transition video slowed 10x

Before After
rns-example-before-slow0.1x.mp4
rns-example-after-slow0.1x.mp4

Test plan

  • Test4570 (this repo, instrumented run): on stock, pops that lose the retention race play the exit animation on an empty background-colored screen; with this PR the content stays visible through the whole exit. Race outcomes were verified per pop by logging whether startRemovalTransition ran before or after the children were deleted.
  • Production app validation: before, 40-100% of pops lost the race depending on load, each losing pop flashed; after, every losing pop keeps its content. The PixelCopy deadline bail (1 of 7 pops under heavy load) was reproduced in logs; the synchronous retention marking covers it.
  • Reproduction without a build: open a fresh create-expo-app (expo-router, two screens) in Expo Go and pop; every pop flashes on stock, because the Expo Go host keeps Reanimated busy. The same JS as a standalone debug build almost never flashes, matching the dispatch analysis above.
  • Existing examples (Simple Native Stack and friends): pops are frame-for-frame identical before and after this change.

Co-Authored-By: Claude noreply@anthropic.com

https://claude.ai/code/session_01Du1cXLUY1ye11EiHKbQNkW

…n Fabric

Fabric's differ dismantles a removed subtree bottom-up and the whole
transaction executes in one mount batch, which can run before the posted
startRemovalTransition marks the children with startViewTransition. The
pop exit animation then plays on an empty, background-colored shell.

Take the approach iOS already uses: snapshot the dismissed screen. Queue
the tag in notifyScreenRemoved and, at UIManagerListener.willMountItems
(the last moment the content is intact and presented), PixelCopy the
screen's window rect and pin it as screen.foreground for the exit
animation. The bitmap dies with the fragment view.

The C++ removal listener now notifies only screens with a matching
Remove+Delete pair in the transaction: a plain Remove is a reorder, and
a false positive would pin a permanent overlay on a live screen.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du1cXLUY1ye11EiHKbQNkW
@coderabbitai

coderabbitai Bot commented Aug 30, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2c6af866-3d09-4039-9291-ad9105185c15

📥 Commits

Reviewing files that changed from the base of the PR and between 043ab95 and e486238.

📒 Files selected for processing (2)
  • apps/src/tests/issue-tests/Test4570.tsx
  • apps/src/tests/issue-tests/index.ts

Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review.


📝 Walkthrough

Walkthrough

Changes

The Android Fabric flow captures screen pixels before deletion and coordinates removal transitions with UIManagerListener. Native removal callbacks require matching screen Delete and Remove mutations. Fabric listener registration is idempotent and is removed during invalidation. A new issue test reproduces the animated pop flash.

Fabric dismissal flow

Layer / File(s) Summary
Native removal detection
cpp/legacy/RNSScreenRemovalListener.cpp
Removal callbacks now cover RNSScreen and RNSModalScreen only when matching Delete mutations exist.
Fabric listener coordination
android/src/fabric/java/.../NativeProxy.kt, android/src/main/java/.../ScreensModule.kt
NativeProxy tracks screens awaiting snapshots during mounting. ScreensModule removes and re-adds the Fabric listener to prevent duplicate registrations.
Dismissal snapshot capture
android/src/main/java/.../legacy/ScreenDismissSnapshot.kt
PixelCopy captures valid window pixels and applies them as a screen foreground drawable before child removal.
Dismissal flash reproduction
apps/src/tests/issue-tests/Test4570.tsx, apps/src/tests/issue-tests/index.ts
The issue test adds an animated native stack flow and exports it through the issue-tests index.

Estimated code review effort: 4 (Complex) | ~45 minutes

Merge Risk: 🔵 Low · up to e4862

The Android change keeps dismissed screen content visible during pop animations by coordinating a temporary snapshot with native screen removal. It is mergeable with explicit owner awareness that interruption and cleanup behavior in the native lifecycle should receive follow-up validation.

Sequence Diagram(s)

sequenceDiagram
  participant RNSScreenRemovalListener
  participant NativeProxy
  participant UIManager
  participant ScreenDismissSnapshot
  RNSScreenRemovalListener->>NativeProxy: notifyScreenRemoved(screenTag)
  NativeProxy->>NativeProxy: Queue screen tag
  UIManager->>NativeProxy: willMountItems()
  NativeProxy->>ScreenDismissSnapshot: pinDismissSnapshot(screen)
  ScreenDismissSnapshot->>ScreenDismissSnapshot: Copy window pixels with PixelCopy
  NativeProxy->>NativeProxy: Start removal transition
  UIManager->>UIManager: Execute mount items
Loading

Suggested reviewers: t0maboro

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 16 functions across 6 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the primary Android fix: pinning dismissal snapshots to preserve screen content during pop exit animations.
Description check ✅ Passed The description directly explains the Android Fabric race condition, the snapshot-based fix, implementation changes, safeguards, and validation results.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
  • Fix all pre-merge checks with AI

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@Nodonisko
Nodonisko marked this pull request as draft August 30, 2026 17:49
Pop the Detail screen while its Reanimated spinner animates: Reanimated's
pending operations flush the deleting mount batch synchronously on the UI
thread, which used to beat the posted startRemovalTransition and play the
exit animation on an empty screen.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du1cXLUY1ye11EiHKbQNkW
@Nodonisko
Nodonisko marked this pull request as ready for review August 31, 2026 07:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant