Commit 5b70c4b
authored
[iOS] Fix js responder cancelation in modals (#4306)
## Description
On iOS, when a gesture activates inside a `react-native-screens` route
presented as `formSheet` or `modal`, the in-flight touch of a core RN
`Pressable`/`Touchable` underneath is not cancelled — the press
completes and `onPress` fires on release, alongside the gesture.
In this PR registration walk-up now also stops at a modally-presented
`RNSScreenView`, so the root recognizer is attached to the screen view
and travels with it when `UIKit` reparents it. When the walk-up
dead-ends at `nil`, registration is retried once on the next run loop
turn, when the mounting transaction has finished and the hierarchy is
connected.
Fixes #4305
## Test plan
<details>
<summary>Tested on the following code:</summary>
```tsx
import { useNavigation } from '@react-navigation/native';
import {
createNativeStackNavigator,
type NativeStackNavigationProp,
} from '@react-navigation/native-stack';
import React, { useState } from 'react';
import { Button, Pressable, StyleSheet, Text, View } from 'react-native';
import { GestureDetector, usePanGesture } from 'react-native-gesture-handler';
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
} from 'react-native-reanimated';
// Repro for #4305
//
// Ported to the v3 API (usePanGesture) with explicit `cancelsJSResponder`.
//
// iOS: when a Pan gesture activates inside a react-native-screens native-stack
// route presented as `formSheet` (or `modal`), the in-flight JS-responder touch
// of a core RN Pressable underneath is NOT cancelled — on release `onPress`
// fires alongside the gesture. On a push route the pan activation cancels the
// press as expected.
//
// How to test (iOS):
// 1. Swipe the row horizontally on the home screen — the press counter must
// NOT increment (control).
// 2. Open the push route, swipe the row — counter must NOT increment.
// 3. Open the formSheet / modal route, swipe the row — BUG: the counter
// increments on release.
function Row({ label }: { label: string }) {
const [pressCount, setPressCount] = useState(0);
const translateX = useSharedValue(0);
const pan = usePanGesture({
activeOffsetX: [-12, 12],
failOffsetY: [-12, 12],
cancelsJSResponder: true,
onUpdate: (e) => {
'worklet';
translateX.value = Math.min(0, e.translationX);
},
onFinalize: () => {
'worklet';
translateX.value = withSpring(0);
},
});
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }],
}));
return (
<View style={styles.rowContainer}>
<Text style={styles.caption}>{label}</Text>
<GestureDetector gesture={pan}>
<Animated.View style={animatedStyle}>
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => {
console.log(`onPress fired (${label})`);
setPressCount((c) => c + 1);
}}>
<Text style={styles.rowText}>Swipe me left</Text>
<Text style={styles.counter}>
onPress fired: {pressCount} {pressCount > 0 ? '❌' : ''}
</Text>
</Pressable>
</Animated.View>
</GestureDetector>
</View>
);
}
type StackParamList = {
home: undefined;
push: undefined;
sheet: undefined;
modal: undefined;
};
function HomeScreen() {
const navigation = useNavigation<NativeStackNavigationProp<StackParamList>>();
return (
<View style={styles.screen}>
<Row label="home screen (control — swipe must not press)" />
<Button
title="Open push route"
onPress={() => navigation.navigate('push')}
/>
<Button
title="Open formSheet route"
onPress={() => navigation.navigate('sheet')}
/>
<Button
title="Open modal route"
onPress={() => navigation.navigate('modal')}
/>
</View>
);
}
function PushScreen() {
return (
<View style={styles.screen}>
<Row label="push route (expected: swipe must not press)" />
</View>
);
}
function SheetScreen() {
return (
<View style={styles.sheet}>
<Row label="formSheet (bug: onPress fires after swipe)" />
<Text style={styles.hint}>Swipe down to dismiss</Text>
</View>
);
}
function ModalScreen() {
return (
<View style={styles.sheet}>
<Row label="modal (bug: onPress fires after swipe)" />
<Text style={styles.hint}>Swipe down to dismiss</Text>
</View>
);
}
const Stack = createNativeStackNavigator<StackParamList>();
export default function EmptyExample() {
return (
<Stack.Navigator>
<Stack.Screen
name="home"
component={HomeScreen}
options={{ headerShown: false }}
/>
<Stack.Screen
name="push"
component={PushScreen}
options={{ title: 'Push route' }}
/>
<Stack.Screen
name="sheet"
component={SheetScreen}
options={{
presentation: 'formSheet',
sheetAllowedDetents: [0.5],
headerShown: false,
}}
/>
<Stack.Screen
name="modal"
component={ModalScreen}
options={{ presentation: 'modal', headerShown: false }}
/>
</Stack.Navigator>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
justifyContent: 'center',
gap: 16,
padding: 20,
backgroundColor: '#f5f5f7',
},
sheet: {
flex: 1,
justifyContent: 'center',
gap: 16,
padding: 20,
},
rowContainer: {
gap: 6,
},
caption: {
fontSize: 13,
color: '#555',
},
row: {
backgroundColor: '#a78bfa',
borderRadius: 12,
padding: 20,
gap: 4,
},
rowPressed: {
backgroundColor: '#7c5cd6',
},
rowText: {
fontSize: 17,
fontWeight: '600',
color: '#1a1a2e',
},
counter: {
fontSize: 14,
color: '#1a1a2e',
},
hint: {
textAlign: 'center',
color: '#888',
},
});
```
</details>1 parent c8d66b4 commit 5b70c4b
1 file changed
Lines changed: 57 additions & 5 deletions
Lines changed: 57 additions & 5 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
276 | 276 | | |
277 | 277 | | |
278 | 278 | | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
279 | 305 | | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
280 | 311 | | |
281 | 312 | | |
282 | 313 | | |
283 | 314 | | |
284 | | - | |
285 | | - | |
286 | 315 | | |
287 | 316 | | |
288 | 317 | | |
289 | | - | |
290 | | - | |
291 | | - | |
| 318 | + | |
| 319 | + | |
292 | 320 | | |
293 | 321 | | |
294 | 322 | | |
| |||
299 | 327 | | |
300 | 328 | | |
301 | 329 | | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
302 | 341 | | |
303 | 342 | | |
304 | 343 | | |
| |||
347 | 386 | | |
348 | 387 | | |
349 | 388 | | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
350 | 402 | | |
351 | 403 | | |
352 | 404 | | |
| |||
0 commit comments