Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
171 changes: 171 additions & 0 deletions example/src/reproducers/Issue356NativeStackBack.tsx
Original file line number Diff line number Diff line change
@@ -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<ParamList>();
const TILES = [0, 1, 2, 3, 4, 5];

function StartScreen({
navigation,
}: {
navigation: NativeStackNavigationProp<ParamList, 'Issue356Start'>;
}) {
return (
<View style={styles.container}>
<Text style={styles.title}>Issue #356</Text>
<Text style={styles.subtitle}>
Open the next screen, then go back while the animation is moving and
watch the outgoing screen slide away.
</Text>
<Pressable
style={styles.button}
onPress={() => navigation.navigate('Issue356Animation')}
>
<Text style={styles.buttonText}>Open Rive screen</Text>
</Pressable>
</View>
);
}

function AnimationScreen({
navigation,
}: {
navigation: NativeStackNavigationProp<ParamList, 'Issue356Animation'>;
}) {
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 (
<View style={styles.container}>
<View style={styles.grid}>
{TILES.map((i) => (
<View key={i} style={styles.frame}>
{riveFile ? (
<RiveView file={riveFile} fit={Fit.Contain} style={styles.rive} />
) : null}
</View>
))}
</View>
<View style={styles.control}>
<Animated.View
style={[styles.marker, { transform: [{ translateX: markerX }] }]}
/>
<Text style={styles.controlText}>control</Text>
</View>
<Pressable style={styles.button} onPress={() => navigation.goBack()}>
<Text style={styles.buttonText}>Go back</Text>
</Pressable>
</View>
);
}

export default function Issue356NativeStackBack() {
return (
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="Issue356Start" component={StartScreen} />
<Stack.Screen name="Issue356Animation" component={AnimationScreen} />
</Stack.Navigator>
);
}

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' },
});
81 changes: 81 additions & 0 deletions ios/DeferredTeardown.swift
Original file line number Diff line number Diff line change
@@ -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

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
}
}
}
2 changes: 1 addition & 1 deletion ios/new/HybridRiveView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}

Expand Down
10 changes: 10 additions & 0 deletions ios/new/RiveReactNativeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
private var pendingBindInstance: ViewModelInstance?
private var viewReadyContinuations: [CheckedContinuation<Bool, Never>] = []
private var isViewReady = false
private let deferredTeardown = DeferredTeardown()
private var configTask: Task<Void, Never>?
private var settledTask: Task<Void, Never>?
private var stopNotifyTask: Task<Void, Never>?
Expand Down Expand Up @@ -104,7 +105,7 @@
// Probe for a default ViewModel first. If the artboard has none,
// the SDK would fire an error event — skip auto-binding silently instead.
do {
let _ = try await config.file.getDefaultViewModelInfo(for: artboard)

Check warning on line 108 in ios/new/RiveReactNativeView.swift

View workflow job for this annotation

GitHub Actions / lint-swift

Prefer `_ = foo()` over `let _ = foo()` when discarding a result from a function (redundant_discardable_let)
dataBind = .auto
} catch {
dataBind = .none
Expand Down Expand Up @@ -273,6 +274,7 @@

private func cleanup() {
dispatchPrecondition(condition: .onQueue(.main))
deferredTeardown.cancel()
configTask?.cancel()
configTask = nil
settledTask?.cancel()
Expand All @@ -285,6 +287,14 @@
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)
Expand Down
1 change: 1 addition & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading