fix(Android): pin a dismissal snapshot so pop exit animations keep showing screen content - #4570
Conversation
…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
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review. 📝 WalkthroughWalkthroughChangesThe Android Fabric flow captures screen pixels before deletion and coordinates removal transitions with Fabric dismissal flow
Estimated code review effort: 4 (Complex) | ~45 minutes Merge Risk: 🔵 Low · up to 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
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
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. Comment |
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
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.backgroundColorcolor 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.
notifyScreenRemovedschedulesstartRemovalTransition()withscreen.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:
Winning pop, batch executed by RN's own vsync callback, 11-15 ms later, posted runnable ran first:
Any UI-thread
scheduleMountItemcaller 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'sLayoutAnimationsProxyrebuilds the whole list). Marking retention earlier is not enough either: we verified frame-by-frame thatstartViewTransitionretention 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 withPixelCopyand pinned asscreen.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 matchingRemove+Deletepair in the transaction. A plainRemoveis a reorder, and a false positive would pin a permanent overlay on a live screen. Also matchesRNSModalScreen.NativeProxy.kt: queues dismissed tags; atwillMountItemspins the snapshot and starts the removal transition synchronously.ScreenDismissSnapshot.kt(new): thePixelCopycapture. 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 theUIManagerListener.Test4570.tsx(new, issue-tests): repro screen; pop the Detail screen while its Reanimated spinner animates.Before & after - visual documentation
Test4570in 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
Blank example app (Expo SDK 57)
Screen transition video slowed 10x
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 whetherstartRemovalTransitionran before or after the children were deleted.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.Co-Authored-By: Claude noreply@anthropic.com
https://claude.ai/code/session_01Du1cXLUY1ye11EiHKbQNkW