fix(ios): keep Rive views rendering through native-stack close transitions - #358
Open
mfazekas wants to merge 1 commit into
Open
fix(ios): keep Rive views rendering through native-stack close transitions#358mfazekas wants to merge 1 commit into
mfazekas wants to merge 1 commit into
Conversation
…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.
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.
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() | ||
| } | ||
| } |
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.
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 withsnapshotViewAfterScreenUpdates:NO, so it reuses the last composited frame). Ourdispose()randetach()about 2 ms after unmount, which removes the Metal-backedRiveUIViewand 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 becauseCADisplayLinkdoes 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 toexample/withpatch -p1, thenpod install: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:
Backgrounding during the wait was checked separately by widening the window to 4 s: all six pending teardowns flushed on
didEnterBackgroundrather than being stranded.Two findings worth recording. A Skia canvas placed in the same screen never blanked, so
snapshotViewAfterScreenUpdates:capturesCAMetalLayercontent 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.