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..6c23595d --- /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, 6, 7, 8, 9, 10, 11]; + +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: 110, + 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/new/HybridRiveView.swift b/ios/new/HybridRiveView.swift index c87091d5..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?.detach() - } + // 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 bd2c3baf..0f805216 100644 --- a/ios/new/RiveReactNativeView.swift +++ b/ios/new/RiveReactNativeView.swift @@ -285,6 +285,26 @@ class RiveReactNativeView: UIView { pendingBindInstance = nil } + /// 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) + // 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() + } + } + /// 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