From 5d3e7e5d475e85c2b72d748a95c0466e38a6ac66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Wed, 29 Jul 2026 09:15:20 +0200 Subject: [PATCH 1/4] fix(ios): keep Rive views rendering through native-stack close transitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- example/package.json | 1 + .../reproducers/Issue356NativeStackBack.tsx | 171 ++++++++++++++++++ ios/DeferredTeardown.swift | 81 +++++++++ ios/new/HybridRiveView.swift | 2 +- ios/new/RiveReactNativeView.swift | 10 + yarn.lock | 1 + 6 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 example/src/reproducers/Issue356NativeStackBack.tsx create mode 100644 ios/DeferredTeardown.swift diff --git a/example/package.json b/example/package.json index 0e3c0050..ab2d34a2 100644 --- a/example/package.json +++ b/example/package.json @@ -17,6 +17,7 @@ "@react-native-async-storage/async-storage": "^2.1.2", "@react-native-picker/picker": "^2.11.4", "@react-navigation/native": "^7.1.9", + "@react-navigation/native-stack": "^7.3.16", "@react-navigation/stack": "^7.3.2", "react": "19.1.0", "react-native": "0.80.3", diff --git a/example/src/reproducers/Issue356NativeStackBack.tsx b/example/src/reproducers/Issue356NativeStackBack.tsx new file mode 100644 index 00000000..0192fccb --- /dev/null +++ b/example/src/reproducers/Issue356NativeStackBack.tsx @@ -0,0 +1,171 @@ +import { useEffect, useRef } from 'react'; +import { + View, + Text, + StyleSheet, + Pressable, + Animated, + Easing, +} from 'react-native'; +import { + createNativeStackNavigator, + type NativeStackNavigationProp, +} from '@react-navigation/native-stack'; +import { RiveView, useRiveFile, Fit } from '@rive-app/react-native'; +import { type Metadata } from '../shared/metadata'; + +/** + * Reproducer for issue #356: the Rive content disappeared part-way through an + * iOS native-stack close transition, while the rest of the outgoing screen kept + * sliding out. + * + * Open the 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. Several tiles because the failure was intermittent + * (~7% of pops), so a grid catches it sooner. + */ + +type ParamList = { + Issue356Start: undefined; + Issue356Animation: undefined; +}; + +const Stack = createNativeStackNavigator(); +const TILES = [0, 1, 2, 3, 4, 5]; + +function StartScreen({ + navigation, +}: { + navigation: NativeStackNavigationProp; +}) { + return ( + + Issue #356 + + Open the next screen, then go back while the animation is moving and + watch the outgoing screen slide away. + + navigation.navigate('Issue356Animation')} + > + Open Rive screen + + + ); +} + +function AnimationScreen({ + navigation, +}: { + navigation: NativeStackNavigationProp; +}) { + const { riveFile } = useRiveFile(require('../../assets/rive/rewards.riv')); + const markerX = useRef(new Animated.Value(0)).current; + + useEffect(() => { + const loop = Animated.loop( + Animated.sequence([ + Animated.timing(markerX, { + toValue: 260, + duration: 900, + easing: Easing.linear, + useNativeDriver: true, + }), + Animated.timing(markerX, { + toValue: 0, + duration: 900, + easing: Easing.linear, + useNativeDriver: true, + }), + ]) + ); + loop.start(); + return () => loop.stop(); + }, [markerX]); + + return ( + + + {TILES.map((i) => ( + + {riveFile ? ( + + ) : null} + + ))} + + + + control + + navigation.goBack()}> + Go back + + + ); +} + +export default function Issue356NativeStackBack() { + return ( + + + + + ); +} + +Issue356NativeStackBack.metadata = { + name: 'Issue #356 native-stack back', + description: + 'Rive content must stay visible until the iOS native-stack close transition finishes', +} satisfies Metadata; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#fff', + padding: 20, + justifyContent: 'center', + }, + title: { fontSize: 24, fontWeight: 'bold', marginBottom: 10 }, + subtitle: { fontSize: 15, color: '#666', marginBottom: 20 }, + grid: { flexDirection: 'row', flexWrap: 'wrap' }, + frame: { + width: '33%', + height: 150, + backgroundColor: '#ffd9d9', + borderWidth: 2, + borderColor: '#d33', + }, + rive: { flex: 1 }, + control: { + marginTop: 12, + height: 80, + backgroundColor: '#d9e8ff', + borderWidth: 2, + borderColor: '#36c', + alignItems: 'center', + justifyContent: 'center', + }, + controlText: { color: '#36c', fontWeight: 'bold' }, + marker: { + position: 'absolute', + left: 8, + top: 8, + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: '#0a0', + }, + button: { + marginTop: 20, + backgroundColor: '#323232', + paddingVertical: 14, + borderRadius: 8, + alignItems: 'center', + }, + buttonText: { color: '#fff', fontSize: 16, fontWeight: 'bold' }, +}); diff --git a/ios/DeferredTeardown.swift b/ios/DeferredTeardown.swift new file mode 100644 index 00000000..247f978f --- /dev/null +++ b/ios/DeferredTeardown.swift @@ -0,0 +1,81 @@ +import UIKit + +/// Runs teardown a couple of display frames after it was requested, or right +/// away if the app stops being visible first. +/// +/// React unmounts a native-stack screen while its content is still on screen — +/// react-native-screens snapshots the outgoing screen in the same runloop turn +/// as the unmount — so releasing a Metal-backed view synchronously empties the +/// box the user is still looking at for the rest of the close transition +/// (issue #356). Two frames is what it takes for that snapshot to have settled; +/// frames rather than milliseconds because the thing being raced is itself +/// frame-driven, so this stays correct at 120 Hz. +/// +/// CADisplayLink does not tick in the background, so backgrounding has to run +/// the pending work instead of stranding it — which is also when we most want +/// the GPU resources released. +@MainActor +final class DeferredTeardown { + private static let framesToWait = 2 + + private var link: CADisplayLink? + private var remainingFrames = 0 + private var pendingWork: (() -> Void)? + private var backgroundObserver: NSObjectProtocol? + + /// Schedules `work`. Ignored if work is already pending. + func schedule(_ work: @escaping () -> Void) { + guard pendingWork == nil else { return } + pendingWork = work + + // 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() + } + } + } + + /// Runs any pending work immediately. + func flush() { + let work = pendingWork + stop() + work?() + } + + /// Drops any pending work without running it. + func cancel() { + stop() + } + + @objc private func tick() { + remainingFrames -= 1 + guard remainingFrames <= 0 else { return } + flush() + } + + private func stop() { + pendingWork = nil + remainingFrames = 0 + link?.invalidate() + link = nil + if let observer = backgroundObserver { + NotificationCenter.default.removeObserver(observer) + backgroundObserver = nil + } + } +} diff --git a/ios/new/HybridRiveView.swift b/ios/new/HybridRiveView.swift index c87091d5..fa8e7055 100644 --- a/ios/new/HybridRiveView.swift +++ b/ios/new/HybridRiveView.swift @@ -183,7 +183,7 @@ class HybridRiveView: HybridRiveViewSpec { // must run on main (mirrors the legacy backend's dispose()). let riveView = view as? RiveReactNativeView DispatchQueue.main.async { - riveView?.detach() + riveView?.detachWhenNotVisible() } } diff --git a/ios/new/RiveReactNativeView.swift b/ios/new/RiveReactNativeView.swift index bd2c3baf..58837431 100644 --- a/ios/new/RiveReactNativeView.swift +++ b/ios/new/RiveReactNativeView.swift @@ -27,6 +27,7 @@ class RiveReactNativeView: UIView { private var pendingBindInstance: ViewModelInstance? private var viewReadyContinuations: [CheckedContinuation] = [] private var isViewReady = false + private let deferredTeardown = DeferredTeardown() private var configTask: Task? private var settledTask: Task? private var stopNotifyTask: Task? @@ -273,6 +274,7 @@ class RiveReactNativeView: UIView { private func cleanup() { dispatchPrecondition(condition: .onQueue(.main)) + deferredTeardown.cancel() configTask?.cancel() configTask = nil settledTask?.cancel() @@ -285,6 +287,14 @@ class RiveReactNativeView: UIView { pendingBindInstance = nil } + /// Teardown, held back until it can't be seen. See `DeferredTeardown`. + func detachWhenNotVisible() { + dispatchPrecondition(condition: .onQueue(.main)) + deferredTeardown.schedule { [weak self] in + self?.detach() + } + } + /// Final teardown, called from HybridRiveView.dispose() (always on main). /// Unlike the reload-path cleanup(), this also settles any pending /// awaitViewReady() callers so their promises (which retain this view) diff --git a/yarn.lock b/yarn.lock index 3a1d07da..710f4a0d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19439,6 +19439,7 @@ __metadata: "@react-native/metro-config": 0.80.3 "@react-native/typescript-config": 0.80.3 "@react-navigation/native": ^7.1.9 + "@react-navigation/native-stack": ^7.3.16 "@react-navigation/stack": ^7.3.2 "@types/deep-equal": ^1.0.4 "@types/react": ^19.0.0 From 5c2b9d452ce6c9acf148da5c7dbcad5994163a9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Thu, 30 Jul 2026 09:19:08 +0200 Subject: [PATCH 2/4] fix(ios): register the background observer before checking app state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ios/DeferredTeardown.swift | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/ios/DeferredTeardown.swift b/ios/DeferredTeardown.swift index 247f978f..8d22f8c9 100644 --- a/ios/DeferredTeardown.swift +++ b/ios/DeferredTeardown.swift @@ -28,6 +28,16 @@ final class DeferredTeardown { guard pendingWork == nil else { return } pendingWork = work + backgroundObserver = NotificationCenter.default.addObserver( + forName: UIApplication.didEnterBackgroundNotification, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.flush() + } + } + // Nothing on screen to protect, and no frames are coming. if UIApplication.shared.applicationState == .background { flush() @@ -38,16 +48,6 @@ final class DeferredTeardown { 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() - } - } } /// Runs any pending work immediately. From be3446b1a0bd5dff5a5e4159a3c6edb334a2a34d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Wed, 5 Aug 2026 16:16:25 +0200 Subject: [PATCH 3/4] fix(ios): tear down the Rive view when Fabric drops it, not on JS unmount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../reproducers/Issue356NativeStackBack.tsx | 4 +- ios/DeferredTeardown.swift | 81 ------------------- ios/new/HybridRiveView.swift | 10 +-- ios/new/RiveReactNativeView.swift | 22 +++-- 4 files changed, 21 insertions(+), 96 deletions(-) delete mode 100644 ios/DeferredTeardown.swift diff --git a/example/src/reproducers/Issue356NativeStackBack.tsx b/example/src/reproducers/Issue356NativeStackBack.tsx index 0192fccb..6c23595d 100644 --- a/example/src/reproducers/Issue356NativeStackBack.tsx +++ b/example/src/reproducers/Issue356NativeStackBack.tsx @@ -31,7 +31,7 @@ type ParamList = { }; const Stack = createNativeStackNavigator(); -const TILES = [0, 1, 2, 3, 4, 5]; +const TILES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; function StartScreen({ navigation, @@ -135,7 +135,7 @@ const styles = StyleSheet.create({ grid: { flexDirection: 'row', flexWrap: 'wrap' }, frame: { width: '33%', - height: 150, + height: 110, backgroundColor: '#ffd9d9', borderWidth: 2, borderColor: '#d33', diff --git a/ios/DeferredTeardown.swift b/ios/DeferredTeardown.swift deleted file mode 100644 index 8d22f8c9..00000000 --- a/ios/DeferredTeardown.swift +++ /dev/null @@ -1,81 +0,0 @@ -import UIKit - -/// Runs teardown a couple of display frames after it was requested, or right -/// away if the app stops being visible first. -/// -/// React unmounts a native-stack screen while its content is still on screen — -/// react-native-screens snapshots the outgoing screen in the same runloop turn -/// as the unmount — so releasing a Metal-backed view synchronously empties the -/// box the user is still looking at for the rest of the close transition -/// (issue #356). Two frames is what it takes for that snapshot to have settled; -/// frames rather than milliseconds because the thing being raced is itself -/// frame-driven, so this stays correct at 120 Hz. -/// -/// CADisplayLink does not tick in the background, so backgrounding has to run -/// the pending work instead of stranding it — which is also when we most want -/// the GPU resources released. -@MainActor -final class DeferredTeardown { - private static let framesToWait = 2 - - private var link: CADisplayLink? - private var remainingFrames = 0 - private var pendingWork: (() -> Void)? - private var backgroundObserver: NSObjectProtocol? - - /// Schedules `work`. Ignored if work is already pending. - func schedule(_ work: @escaping () -> Void) { - guard pendingWork == nil else { return } - pendingWork = work - - backgroundObserver = NotificationCenter.default.addObserver( - forName: UIApplication.didEnterBackgroundNotification, - object: nil, - queue: .main - ) { [weak self] _ in - MainActor.assumeIsolated { - self?.flush() - } - } - - // 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 - } - - /// Runs any pending work immediately. - func flush() { - let work = pendingWork - stop() - work?() - } - - /// Drops any pending work without running it. - func cancel() { - stop() - } - - @objc private func tick() { - remainingFrames -= 1 - guard remainingFrames <= 0 else { return } - flush() - } - - private func stop() { - pendingWork = nil - remainingFrames = 0 - link?.invalidate() - link = nil - if let observer = backgroundObserver { - NotificationCenter.default.removeObserver(observer) - backgroundObserver = nil - } - } -} diff --git a/ios/new/HybridRiveView.swift b/ios/new/HybridRiveView.swift index fa8e7055..dde9f4f4 100644 --- a/ios/new/HybridRiveView.swift +++ b/ios/new/HybridRiveView.swift @@ -179,12 +179,10 @@ class HybridRiveView: HybridRiveViewSpec { // MARK: Lifecycle func dispose() { - // Nitro finalizes HybridObjects on the JS/GC thread; the view's teardown - // must run on main (mirrors the legacy backend's dispose()). - let riveView = view as? RiveReactNativeView - DispatchQueue.main.async { - riveView?.detachWhenNotVisible() - } + // Deliberately empty: the view tears itself down when Fabric drops it (see + // RiveReactNativeView.willMove(toSuperview:)). Doing it here instead would + // run in React's commit phase, ahead of the mounting transaction, which is + // what caused #356. } // MARK: Views diff --git a/ios/new/RiveReactNativeView.swift b/ios/new/RiveReactNativeView.swift index 58837431..1a31af09 100644 --- a/ios/new/RiveReactNativeView.swift +++ b/ios/new/RiveReactNativeView.swift @@ -27,7 +27,6 @@ class RiveReactNativeView: UIView { private var pendingBindInstance: ViewModelInstance? private var viewReadyContinuations: [CheckedContinuation] = [] private var isViewReady = false - private let deferredTeardown = DeferredTeardown() private var configTask: Task? private var settledTask: Task? private var stopNotifyTask: Task? @@ -274,7 +273,6 @@ class RiveReactNativeView: UIView { private func cleanup() { dispatchPrecondition(condition: .onQueue(.main)) - deferredTeardown.cancel() configTask?.cancel() configTask = nil settledTask?.cancel() @@ -287,11 +285,21 @@ class RiveReactNativeView: UIView { pendingBindInstance = nil } - /// Teardown, held back until it can't be seen. See `DeferredTeardown`. - func detachWhenNotVisible() { - dispatchPrecondition(condition: .onQueue(.main)) - deferredTeardown.schedule { [weak self] in - self?.detach() + /// Teardown runs when Fabric drops the view, not when the JS effect cleanup + /// calls dispose(). + /// + /// The effect cleanup fires in React's commit phase, before the mounting + /// instructions reach native views — potentially a whole transaction early. + /// react-native-screens captures the outgoing screen during that transaction + /// (`unmountChildComponentView`), and a render-server composite landing in the + /// gap bakes our half-torn-down view into that capture, so the screen slides + /// away empty (#356). Fabric's own unmount happens inside the same transaction + /// as the capture, leaving no room for one — which is why plain RN views never + /// show this. + override func willMove(toSuperview newSuperview: UIView?) { + super.willMove(toSuperview: newSuperview) + if newSuperview == nil, riveUIView != nil { + detach() } } From e0f83e9c7af8cf4e039a0ced9e9d8c79bcd8fba4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Thu, 6 Aug 2026 11:46:54 +0200 Subject: [PATCH 4/4] fix(ios): settle awaitViewReady() waiters when configure failed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ios/new/RiveReactNativeView.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ios/new/RiveReactNativeView.swift b/ios/new/RiveReactNativeView.swift index 1a31af09..0f805216 100644 --- a/ios/new/RiveReactNativeView.swift +++ b/ios/new/RiveReactNativeView.swift @@ -298,7 +298,9 @@ class RiveReactNativeView: UIView { /// show this. override func willMove(toSuperview newSuperview: UIView?) { super.willMove(toSuperview: newSuperview) - if newSuperview == nil, riveUIView != nil { + // Unconditional: a view whose configure failed has no riveUIView but can + // still have awaitViewReady() waiters, and detach() is what settles them. + if newSuperview == nil { detach() } }