From 21b9f69cae2443701caa8d8d13bf5298a8324686 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/2] 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 78e2d067612627127e3be7373db6427c03a637f9 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/2] 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.