Skip to content

Commit ab4464d

Browse files
authored
fix(iOS, Stack v4): Fix reattaching screens which have preventNativeDismiss set (#3886)
## Description When a user triggers a native back gesture on iOS, UIKit automatically removes the screen from its parent. For screens where `preventNativeDismiss` is set to true, the screen might be reattached to the hierarchy, what's causing state desynchronization between JS and native, because natively we're filtering out this screen from `viewControllers` that was introduced in: #3584 Note: I intentionally added this check only to 'card' presentation. For modals, `preventNativeDismiss` is fired before detaching the modal from the hierarchy, so the issue with reattaching isn't reproducible. Closes: #3885 ## Changes - excluded screens that have `preventNativeDismiss` option set from our solution for preventing reattaching screens that were removed from the hierarchy ## Before & after - visual documentation | Before | After | | --- | --- | | <video src="https://github.com/user-attachments/assets/3571ff56-e3be-4ea1-ad60-f57974efa59f" /> | <video src="https://github.com/user-attachments/assets/79a79c29-504e-4657-bbf9-ff0e079a6490" /> | ## Test plan Added Test3885, performed regression testing on Test2559 ## Checklist - [ ] Included code example that can be used to test this change. - [ ] For visual changes, included screenshots / GIFs / recordings documenting the change. - [ ] For API changes, updated relevant public types. - [ ] Ensured that CI passes
1 parent e7ef00c commit ab4464d

3 files changed

Lines changed: 97 additions & 1 deletion

File tree

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import * as React from 'react';
2+
import { View, Text, StyleSheet, Alert, Pressable } from 'react-native';
3+
import {
4+
NavigationContainer,
5+
ParamListBase,
6+
usePreventRemove,
7+
} from '@react-navigation/native';
8+
import {
9+
createNativeStackNavigator,
10+
NativeStackNavigationProp,
11+
} from '@react-navigation/native-stack';
12+
import Colors from '@apps/shared/styling/Colors';
13+
14+
type NavigationProps = {
15+
navigation: NativeStackNavigationProp<ParamListBase>;
16+
};
17+
18+
function HomeScreen({ navigation }: NavigationProps) {
19+
return (
20+
<View style={styles.container}>
21+
<Text>Home Screen</Text>
22+
23+
<Pressable onPress={() => navigation.navigate('Login')}>
24+
<Text style={styles.linkText}>Go to Login</Text>
25+
</Pressable>
26+
</View>
27+
);
28+
}
29+
30+
function LoginScreen({ navigation }: NavigationProps) {
31+
const hasUnsavedChanges = true;
32+
33+
usePreventRemove(hasUnsavedChanges, ({ data }) => {
34+
Alert.alert('Unsaved changes', 'Discard and exit?', [
35+
{ text: 'No', style: 'cancel' },
36+
{
37+
text: 'Yes',
38+
style: 'destructive',
39+
onPress: () => navigation.dispatch(data.action),
40+
},
41+
]);
42+
});
43+
44+
return (
45+
<View style={styles.container}>
46+
<Text>Login Screen</Text>
47+
<Pressable onPress={() => navigation.goBack()}>
48+
<Text style={styles.linkText}>Try to go back</Text>
49+
</Pressable>
50+
</View>
51+
);
52+
}
53+
54+
const Stack = createNativeStackNavigator();
55+
56+
export default function App() {
57+
return (
58+
<NavigationContainer>
59+
<Stack.Navigator>
60+
<Stack.Screen
61+
name="Home"
62+
component={HomeScreen}
63+
options={{ title: 'Home' }}
64+
/>
65+
<Stack.Screen
66+
name="Login"
67+
component={LoginScreen}
68+
options={{ title: 'Login' }}
69+
/>
70+
</Stack.Navigator>
71+
</NavigationContainer>
72+
);
73+
}
74+
75+
const styles = StyleSheet.create({
76+
container: {
77+
flex: 1,
78+
alignItems: 'center',
79+
justifyContent: 'center',
80+
gap: 20,
81+
},
82+
linkText: {
83+
color: Colors.BlueDark100,
84+
fontSize: 16,
85+
fontWeight: '600',
86+
},
87+
});

apps/src/tests/issue-tests/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ export { default as Test3816 } from './Test3816';
190190
export { default as Test3833 } from './Test3833';
191191
export { default as Test3835 } from './Test3835';
192192
export { default as Test3867 } from './Test3867';
193+
export { default as Test3885 } from './Test3885';
193194
export { default as TestScreenAnimation } from './TestScreenAnimation';
194195
// The following test was meant to demo the "go back" gesture using Reanimated
195196
// but the associated PR in react-navigation is currently put on hold

ios/RNSScreenStack.mm

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -703,7 +703,15 @@ - (void)updateContainer
703703
/// quickly. Since view recycling is disabled, once we detect that a screen has been removed from the view
704704
/// hierarchy, it won't be reused. This allows us to safely filter out dismissed screens from screens coming
705705
/// from JS state via `controllers`.
706-
if (_iosPreventReattachmentOfDismissedScreens && screen.controller.isRemovedFromParent) {
706+
///
707+
/// Note: screens with `preventNativeDismiss` are intentionally excluded from this guard.
708+
/// When `preventNativeDismiss` is set and the user triggers a native back gesture, UIKit removes
709+
/// the screen from its parent. We then need to reattach it so that the `preventNativeDismiss`
710+
/// callback fires correctly on the JS side. This breaks the general assumption that a screen
711+
/// removed from the hierarchy will never be reattached.
712+
/// See: https://github.com/software-mansion/react-native-screens/issues/3885
713+
if (_iosPreventReattachmentOfDismissedScreens && screen.controller.isRemovedFromParent &&
714+
!screen.preventNativeDismiss) {
707715
continue;
708716
}
709717
[pushControllers addObject:screen.controller];

0 commit comments

Comments
 (0)