fix(ios): tear down the Rive view when Fabric drops it, not on JS unmount - #358
Open
mfazekas wants to merge 4 commits into
Open
fix(ios): tear down the Rive view when Fabric drops it, not on JS unmount#358mfazekas wants to merge 4 commits into
mfazekas wants to merge 4 commits into
Conversation
There was a problem hiding this comment.
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
DeferredTeardownhelper 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 immediatedetach(). - Adds an example reproducer screen for Issue #356 and the needed
@react-navigation/native-stackdependency.
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.
dskuza
approved these changes
Aug 6, 2026
dskuza
left a comment
There was a problem hiding this comment.
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
force-pushed
the
claude/issue-356-reproduction-95d546
branch
from
August 7, 2026 05:41
c79be53 to
e0f83e9
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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'suseEffectcleanup runs in React's commit phase, before the mounting instructions reach native views — sodispose()can fire a whole mounting transaction before anything happens natively. react-native-screens captures the outgoing screen inside that transaction (unmountChildComponentView→setViewToSnapshot), andsnapshotView(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
MTKViewbehaves identically to a plainUIViewthere, 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 callsdispose(). 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.swiftis 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).
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 toexample/withpatch -p1, thenpod install: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.