Skip to content

[iOS] Fix js responder cancelation in modals - #4306

Merged
m-bert merged 3 commits into
mainfrom
@mbert/modal-responder
Jul 9, 2026
Merged

[iOS] Fix js responder cancelation in modals#4306
m-bert merged 3 commits into
mainfrom
@mbert/modal-responder

Conversation

@m-bert

@m-bert m-bert commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

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

Tested on the following code:
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 https://github.com/software-mansion/react-native-gesture-handler/issues/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',
  },
});

Copilot AI review requested due to automatic review settings July 8, 2026 10:36

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes an iOS responder-cancellation edge case when gestures activate inside react-native-screens routes presented as formSheet/modal, ensuring underlying RN Pressable/Touchable* touches are properly cancelled.

Changes:

  • Stop the root-recognizer registration walk-up at a modally-presented RNSScreenView, so the root recognizer is attached to a view that will move with UIKit reparenting.
  • Retry root attachment once on the next main run loop turn if the ancestor chain is temporarily incomplete during Fabric mounting.
  • When cancelling JS responder touches, fall back to using an RNSScreenView’s touchHandler if it’s not directly present in the view’s recognizers.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/react-native-gesture-handler/apple/RNGestureHandlerManager.mm Outdated
Comment thread packages/react-native-gesture-handler/apple/RNGestureHandlerManager.mm Outdated
@m-bert
m-bert merged commit 5b70c4b into main Jul 9, 2026
3 checks passed
@m-bert
m-bert deleted the @mbert/modal-responder branch July 9, 2026 08:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Gesture activation doesn't cancel the JS responder inside react-native-screens modally-presented screens (formSheet/modal)

4 participants