Skip to content

fix(ios): tear down the Rive view when Fabric drops it, not on JS unmount - #358

Open
mfazekas wants to merge 4 commits into
mainfrom
claude/issue-356-reproduction-95d546
Open

fix(ios): tear down the Rive view when Fabric drops it, not on JS unmount#358
mfazekas wants to merge 4 commits 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.

What actually happens

RiveView's useEffect cleanup runs in React's commit phase, before the mounting instructions reach native views — so dispose() can fire a whole mounting transaction before anything happens natively. react-native-screens captures the outgoing screen inside that transaction (unmountChildComponentViewsetViewToSnapshot), and snapshotView(afterScreenUpdates: false) reuses the last composited frame. If a render-server composite lands in the gap between our teardown and that capture, the capture contains our half-torn-down view — and that snapshot is what slides out for the rest of the transition.

This was verified in isolation, without React Native, in a ~130-line UIKit app: removing a subview and snapshotting in the same runloop turn still captures it; two frames later the same snapshot comes back empty. An MTKView behaves identically to a plain UIView there, so this is not about Metal being uncapturable.

It also explains the two things that looked odd: plain RN views never show this, because Fabric's own unmount runs inside the same transaction as the capture, leaving no room for a composite; and Android was never affected, because its teardown already runs from onDropViewInstance.

The fix

Tear down when Fabric drops the view (willMove(toSuperview:)) instead of when the JS effect cleanup calls dispose(). That puts teardown in the same transaction as the capture, so there is no gap. No timers, no frame counting, no background special case — DeferredTeardown.swift is deleted, and the change is a net −75 lines.

Only the new iOS backend changes. dispose() becomes a no-op there; legacy keeps its current behaviour.

The properly-named hook for this is Nitro's onDropView, but it is unusable here: it needs nitro ≥ 0.35.1 and RN ≥ 0.82, and below 0.82 nitro compiles the call out entirely (#1267), so it would silently do nothing on the versions this package supports. There's a comment in the code marking it as the intended replacement once the RN floor rises.

Verification

300-run campaign, arms interleaved run-by-run so drift cannot masquerade as an effect. Every run proves from the app's own log which teardown path executed, that the recording is valid, and that a transition was actually captured; anything failing those checks is voided and retried rather than counted (final void count: zero).

teardown driven by valid runs failures rate
JS effect cleanup (before) 194 53 27.3%
Fabric unmount (after) 195 0 0%

Fisher exact, two-sided: p = 3.7e-18.

The failure signal is unambiguous: during the campaign the teardown painted the view green, so a "failure" means our own teardown pixels are present in react-native-screens' capture — not an inference from missing artwork.

Reparenting was checked separately, since the hook fires on view removal rather than on unmount: reordering siblings triggers no teardown and leaves content untouched; moving a view across parents does tear down (a genuine remount) and it renders again afterwards; and a screen that is covered by another rather than popped keeps its views attached, so nothing is torn down while it is still in the stack.

Reproducing

The reproducer page is included: example/src/reproducers/Issue356NativeStackBack.tsx, listed as "Issue #356 native-stack back". Open the Rive screen, then go back while the animation is moving. The blue control box is the reference — whatever happens to the tiles must happen to it too.

At default settings it only shows on a small fraction of pops. To make it frequent, force react-native-screens to use its custom animator with a long duration for the pop — it otherwise ignores animationDuration, because by the time the pop runs the outgoing screen's props are already gone. Apply 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 the
+  // 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 pop 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]] &&

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

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 thread ios/DeferredTeardown.swift Outdated
@mfazekas mfazekas changed the title fix(ios): keep Rive views rendering through native-stack close transitions fix(ios): tear down the Rive view when Fabric drops it, not on JS unmount Aug 5, 2026

@dskuza dskuza 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.

Awesome, I much prefer this implementation over the original!

…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.
The ordering was already safe — schedule() is @mainactor and runs
synchronously on the main thread, and didEnterBackgroundNotification is
delivered on the main run loop, so it cannot arrive between the two — but
registering first means nobody has to derive that to review the code.
…ount

The JS effect cleanup runs in React's commit phase, before the mounting
instructions reach native views — potentially a whole transaction before
anything happens natively. react-native-screens captures the outgoing screen
inside that transaction, and `snapshotView(afterScreenUpdates:)` reuses the
last composited frame, so if a render-server composite lands in the gap the
capture contains our half-torn-down view and the screen slides away empty
(#356).

Moving teardown to `willMove(toSuperview:)` puts it inside the same mounting
transaction as the capture, leaving no room for a composite in between —
which is why plain RN views never showed this, and why Android, whose
teardown already runs from onDropViewInstance, was never affected.

Replaces the two-frame CADisplayLink deferral: no timers, no frame counting,
no background special case.

Measured on a 300-run campaign with the arms interleaved run-by-run, each run
verifying which teardown path executed and that a transition was captured:
53 failures in 194 runs before, 0 in 195 after (Fisher exact p = 3.7e-18).
The unmount hook only ran detach() when riveUIView existed, so a view whose
configure failed — which never creates one — was torn down without settling
its awaitViewReady() waiters, and the promise hung forever.

detach() is what resumes those continuations, so it has to run whenever the
view is dropped, configured or not. Caught by load-error.harness.tsx.
@mfazekas
mfazekas force-pushed the claude/issue-356-reproduction-95d546 branch from c79be53 to e0f83e9 Compare August 7, 2026 05:41
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

3 participants