Skip to content

fix(ios): keep Rive views rendering through native-stack close transitions - #358

Open
mfazekas wants to merge 1 commit into
mainfrom
claude/issue-356-reproduction-95d546
Open

fix(ios): keep Rive views rendering through native-stack close transitions#358
mfazekas wants to merge 1 commit into
mainfrom
claude/issue-356-reproduction-95d546

Conversation

@mfazekas

@mfazekas mfazekas commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Fixes #356.

On iOS the Rive content could disappear part-way through a native-stack close transition, leaving an empty box sliding out while the rest of the screen stayed intact.

React unmounts a native-stack screen while its content is still on screen, and react-native-screens snapshots the outgoing screen in that same runloop turn (setViewToSnapshot, taken with snapshotViewAfterScreenUpdates:NO, so it reuses the last composited frame). Our dispose() ran detach() about 2 ms after unmount, which removes the Metal-backed RiveUIView and frees its drawable — roughly 150 ms before the transition ends. When that teardown landed before the snapshot settled, the snapshot captured an empty box and the whole close transition showed it.

The fix holds the teardown in a small helper (ios/DeferredTeardown.swift) and runs it after two display frames, or immediately when the app enters the background, whichever comes first. Frames rather than milliseconds because the thing being raced is itself frame-driven, so it stays correct on 120 Hz ProMotion. The background path matters because CADisplayLink does not tick in the background, and that is also when you most want the GPU resources released. Nothing about what gets released changed, only when, and there is no new public API.

Only the new iOS backend is changed.

Reproducing

The reproducer page is included in this PR: example/src/reproducers/Issue356NativeStackBack.tsx, listed in the example app as "Issue #356 native-stack back". Tap "Open Rive screen", then "Go back" while the animation is moving. The blue control box is the reference — whatever happens to the Rive tiles has to happen to it too. Six tiles because the failure is intermittent, so a grid catches it sooner.

By default it only shows on about 7% of pops, which is too rare to work with. To make it reliable (~67%), force react-native-screens to use its custom animator for the pop with a long duration. The pop otherwise ignores animationDuration, because by the time it runs the outgoing screen's props are already gone and it falls back to the system animator. Apply this to example/ with patch -p1, then pod install:

--- a/node_modules/react-native-screens/ios/RNSScreenStack.mm
+++ b/node_modules/react-native-screens/ios/RNSScreenStack.mm
@@ -864,6 +864,10 @@
                                                fromViewController:(UIViewController *)fromVC
                                                  toViewController:(UIViewController *)toVC
 {
+  // #356 repro only: always use the custom animator, so the pop honours
+  // transitionDuration below instead of falling back to the system animation.
+  return [[RNSScreenStackAnimator alloc] initWithOperation:operation];
+
   RNSScreenView *screen;
   if (operation == UINavigationControllerOperationPush) {
     screen = ((RNSScreen *)toVC).screenView;
--- a/node_modules/react-native-screens/ios/RNSScreenStackAnimator.mm
+++ b/node_modules/react-native-screens/ios/RNSScreenStackAnimator.mm
@@ -58,6 +58,9 @@
     screen = ((RNSScreen *)fromViewController).screenView;
   }
 
+  // #356 repro only: stretch the transition so the failure is easy to see.
+  return 2.0;
+
   if (screen != nil && screen.stackAnimation == RNSScreenStackAnimationNone) {
     return 0.0;
   }
@@ -83,6 +86,15 @@
   } else if (_operation == UINavigationControllerOperationPop) {
     screen = ((RNSScreen *)fromViewController).screenView;
   }
+
+  // #356 repro only: the popped screen's props are already gone here, so pick
+  // an animation explicitly instead of falling through to the default.
+  [self animateTransitionWithStackAnimation:RNSScreenStackAnimationSimplePush
+                              shadowEnabled:YES
+                          transitionContext:transitionContext
+                                       toVC:toViewController
+                                     fromVC:fromViewController];
+  return;
 
   if (screen != nil) {
     if ([screen.reactSuperview isKindOfClass:[RNSScreenStackView class]] &&

Verification

Measured by driving the pop and capturing at 60 fps, counting frames where the tiles are empty while the screen is still in place:

  • dispose at unmount (before): 8 of 12 pops blanked, 6-8 frames each
  • deferred 1 frame: 6 of 12, 1 frame each
  • deferred 2 frames: 0 of 12
  • final helper, including the background path: 0 of 12, then 0 of 6 after cleanup

Backgrounding during the wait was checked separately by widening the window to 4 s: all six pending teardowns flushed on didEnterBackground rather than being stranded.

Two findings worth recording. A Skia canvas placed in the same screen never blanked, so snapshotViewAfterScreenUpdates: captures CAMetalLayer content perfectly well — the problem was only ever that we freed ours too early. And the outgoing screen really is that frozen snapshot: registered on the tile border it is pixel-identical across 14 frames.

No automated test. The symptom needs frame-level capture during a transition plus the patched react-native-screens to be deterministic, and a harness test that just pops a screen would pass with or without the fix.

…tions

React unmounts a native-stack screen while its content is still on screen,
and react-native-screens snapshots the outgoing screen in that same runloop
turn. Tearing the Metal-backed Rive view down synchronously in dispose()
frees its drawable before that snapshot is taken, so the screen slides away
with an empty box (#356).

Hold the teardown for two display frames, or until the app backgrounds —
whichever comes first. Frames rather than milliseconds because the race is
frame-driven, so it stays correct at 120 Hz; the background path matters
because CADisplayLink doesn't tick there and the work would be stranded.

Measured on a forced-slow-pop harness: 8/12 pops blanked before, 0/12 after.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR addresses an iOS-only rendering issue where Metal-backed Rive content could disappear during React Navigation native-stack “pop” transitions by deferring native teardown until the outgoing screen snapshot has settled.

Changes:

  • Introduces an iOS DeferredTeardown helper to delay teardown by two display frames (or flush immediately on background).
  • Switches the experimental iOS backend’s dispose() path to use deferred detachment (detachWhenNotVisible) instead of immediate detach().
  • Adds an example reproducer screen for Issue #356 and the needed @react-navigation/native-stack dependency.

Reviewed changes

Copilot reviewed 5 out of 6 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
yarn.lock Locks the added example dependency (@react-navigation/native-stack).
ios/new/RiveReactNativeView.swift Adds DeferredTeardown usage and a detachWhenNotVisible() teardown path.
ios/new/HybridRiveView.swift Routes dispose() teardown through detachWhenNotVisible() on main.
ios/DeferredTeardown.swift New helper that delays teardown by display frames with a background flush.
example/src/reproducers/Issue356NativeStackBack.tsx Adds a native-stack reproducer screen for Issue #356.
example/package.json Adds @react-navigation/native-stack to support the reproducer.

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

Comment on lines +31 to +50
// Nothing on screen to protect, and no frames are coming.
if UIApplication.shared.applicationState == .background {
flush()
return
}

remainingFrames = Self.framesToWait
let link = CADisplayLink(target: self, selector: #selector(tick))
link.add(to: .main, forMode: .common)
self.link = link

backgroundObserver = NotificationCenter.default.addObserver(
forName: UIApplication.didEnterBackgroundNotification,
object: nil,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
self?.flush()
}
}
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.

Rive view disappears before iOS native-stack transition finishes

2 participants