Skip to content

[iOS] Fix JS touches being cancelled when a ScrollView wrapped in a native gesture starts scrolling - #4574

Closed
JoonDong2 wants to merge 2 commits into
software-mansion:mainfrom
JoonDong2:fix/ios-native-scroll-cancels-js-touches
Closed

JoonDong2 wants to merge 2 commits into
software-mansion:mainfrom
JoonDong2:fix/ios-native-scroll-cancels-js-touches

Conversation

@JoonDong2

Copy link
Copy Markdown

Description

When a ScrollView is wrapped in a GestureDetector with a native gesture (useNativeGesture, or Gesture.Native() in the legacy API), every React Native touch is cancelled the moment the scroll view starts scrolling. A JS responder inside the scroll content, such as a PanResponder-based pager tracking a horizontal drag, receives onResponderTerminate mid-gesture even though its onResponderTerminationRequest returns false. In our app this showed up as a horizontal pager snapping back to the previous page whenever the finger drifted a few points vertically during a slow swipe.

When a handler activates, -[RNRootViewGestureRecognizer canBePreventedByGestureRecognizer:] calls -[RNGestureHandlerManager gestureRecognizer:didActivateInViewWithTouchHandler:], which disables and re-enables RCTSurfaceTouchHandler to cancel in-JS recognizers. That method intentionally skips scroll recognizers so that scroll events keep going through React Native's responder system, but it detects them with [gestureRecognizer.view isKindOfClass:[RNGHScrollView class]]. A native handler bound to a ScrollView attaches its RNDummyGestureRecognizer to the component view that wraps the platform scroll view (RCTScrollViewComponentView), so the check misses it and the touches are cancelled. The scroll view's own pan recognizer activates at the same moment and is skipped, because its view is the UIScrollView.

This PR also skips the cancellation when the activated recognizer is a native handler's RNDummyGestureRecognizer bound to a scroll view, found with retrieveScrollView: (the same lookup RNNativeViewGestureHandler uses). That recognizer only mirrors the state of the scroll view's pan recognizer, so its activation is the same event as the scroll starting, and it is now treated the same way as the scroll view's own pan recognizer.

The check is limited to RNDummyGestureRecognizer on purpose. retrieveScrollView: looks only at the view, and the legacy GestureDetector attaches every handler to its child view, so a Gesture.Pan() wrapping a ScrollView is attached to the same component view. Without the class check, that Pan would also stop cancelling JS responders. Activations of other handlers (pan, tap, ...) still cancel JS touches as before.

Whether UIKit consults the root recognizer for that activation varies between app launches: in some launches every such drag is cancelled, in others none are. That made it look device-specific to us at first (it was first reported on an iPad).

The same check exists on 2.x (isKindOfClass:[UIScrollView class]), so this may be worth backporting to v2-stable. We are running the equivalent change as a patch on 2.30.0.

This only touches iOS. I have not checked whether the same setup cancels JS touches on Android, where the cancellation goes through onChildStartedNativeGesture and has no scroll exception.

Test plan

Reproduction (v3 API):

import { useMemo } from 'react';
import { PanResponder, ScrollView, StyleSheet, Text, View } from 'react-native';
import {
  GestureDetector,
  GestureHandlerRootView,
  useNativeGesture,
} from 'react-native-gesture-handler';

export default function MinimalRepro() {
  const nativeGesture = useNativeGesture();

  const panResponder = useMemo(
    () =>
      PanResponder.create({
        // Claim horizontal drags, like a pager does
        onMoveShouldSetPanResponderCapture: (_, { dx, dy }) =>
          Math.abs(dx) > 20 && Math.abs(dx) > Math.abs(dy),
        // ...and never hand the touch back
        onPanResponderTerminationRequest: () => false,
        onPanResponderGrant: () => console.log('grant'),
        onPanResponderRelease: (_, { dx, dy }) =>
          console.log('release', JSON.stringify({ dx, dy })),
        onPanResponderTerminate: (_, { dx, dy }) =>
          console.log('TERMINATE', JSON.stringify({ dx, dy })),
      }),
    []
  );

  return (
    <GestureHandlerRootView style={styles.flex}>
      <GestureDetector gesture={nativeGesture}>
        <ScrollView>
          <View style={styles.header} />
          <View style={styles.pannable} {...panResponder.panHandlers}>
            <Text>
              Drag horizontally, then let the finger drift vertically
            </Text>
          </View>
        </ScrollView>
      </GestureDetector>
    </GestureHandlerRootView>
  );
}

const styles = StyleSheet.create({
  flex: { flex: 1 },
  header: { height: 300, backgroundColor: '#ddd' },
  pannable: { height: 1600, backgroundColor: '#9cf', padding: 24 },
});
  1. Drag horizontally on the blue area so that the PanResponder takes the touch.
  2. While dragging, let the finger drift about 10 pt vertically so the ScrollView starts scrolling.
  3. Lift the finger.

Before: in affected launches onPanResponderTerminate fires as soon as the ScrollView starts scrolling. After: onPanResponderRelease fires on finger-up.

Results with 4 drifted drags per launch, on iOS 26.2 simulators with React Native 0.85.3 (new architecture), Xcode 26.2 and 3.3.0 (the changed file is identical on main and 3.3.0):

Setup Without this change With this change
useNativeGesture, iPad Pro 13-inch (M5) every drag cancelled in 2 of 4 launches 5 launches, all 20 drags released
useNativeGesture, iPhone 17 every drag cancelled in 1 of 4 launches 6 launches, all 24 drags released*
Gesture.Native() (legacy API), iPad Pro 13-inch (M5) every drag cancelled in 3 of 6 launches 6 launches, all 24 drags released

* Measured with an earlier version of this change that did not have the RNDummyGestureRecognizer check. That check does not affect this path, since the activated recognizer here is the RNDummyGestureRecognizer.

Also checked with the change applied:

  • A legacy Gesture.Pan() wrapping a ScrollView directly, with a PanResponder inside that takes the touch on touch down and refuses termination: a horizontal drag still terminates the responder when the Pan activates (all 4 drags in each of 2 launches), the same as without the change. With usePanGesture the Pan is attached to the detector view, and the responder is terminated with or without the change.
  • A Pressable inside the same ScrollView still has its press cancelled when a scroll starts on it (onPressIn → onPressOut, no onPress), now through React Native's responder negotiation. A plain tap still fires onPress.
  • In the app where we hit this (2.30.0, Gesture.Native() on a list plus a Gesture.Pan() using simultaneousWithExternalGesture), the earlier version of the equivalent patch took cancelled swipes from 6 of 13 launches to none in 8 launches. The final version builds there.

I did not run the basic-example build locally; the change is a single file under apple/ that compiled in the 3.3.0 test app.

🤖 Generated with Claude Code

…ative gesture starts scrolling

A native handler bound to a ScrollView attaches its RNDummyGestureRecognizer
to the component view that wraps the platform scroll view, so the scroll
recognizer exception in gestureRecognizer:didActivateInViewWithTouchHandler:
misses it and every JS responder is cancelled when scrolling starts. Skip the
cancellation for that recognizer too, since it only mirrors the scroll view's
pan recognizer. The class check keeps other handlers attached to the same view
(for example a legacy Gesture.Pan() wrapping a ScrollView) cancelling JS
responders as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 6, 2026 16:45
@coderabbitai

coderabbitai Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: e848e164-a411-4d42-9718-193bdb4355a5
📥 Commits

Reviewing files that changed from the base of the PR and between ab44f1e and 92063e7.

📒 Files selected for processing (1)
  • packages/react-native-gesture-handler/apple/RNGestureHandlerManager.mm

Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 7 remain after this review.


📝 Summary

Summary by CodeRabbit

  • Bug Fixes
    • Improved gesture handling on non-macOS platforms so activating a gesture associated with a scroll view no longer cancels its touches.

Walkthrough

On non-macOS platforms, activation now skips touch cancellation when a dummy recognizer belongs to a gesture handler whose view resolves to a scroll view. The existing direct scroll-view check remains unchanged.

Changes

Scroll-view activation check

Layer / File(s) Summary
Dummy recognizer scroll-view check
packages/react-native-gesture-handler/apple/RNGestureHandlerManager.mm
On non-macOS platforms, activation returns without cancelling root-view touches when a dummy recognizer’s handler resolves its view to a scroll view.

Suggested reviewers: j-piasecki

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 92063

Wrapped iOS ScrollViews retain JS responder behavior when scrolling starts. The relevant recognizer lookups were verified, and no actionable merge risk remains.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the iOS fix for JS touches being cancelled when a ScrollView wrapped in a native gesture starts scrolling.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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.

Copilot review overview

🟡 Changes recommended

The new exception unintentionally changes macOS responder behavior and should be restricted to iOS.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)
What changed in this PR

Fixes iOS JS responder cancellation when a native gesture wraps a scrolling ScrollView.

Changes:

  • Detects native dummy recognizers bound to scroll views.
  • Avoids cancelling JS touches when scrolling activates.
File Description
packages/​react-native-gesture-handler/​apple/​RNGestureHandlerManager.mm Adds the scroll-view native-handler cancellation exception.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +383 to +387
RNGestureHandler *handler = [RNGestureHandler findGestureHandlerByRecognizer:gestureRecognizer];
if ([gestureRecognizer isKindOfClass:[RNDummyGestureRecognizer class]] && handler != nil &&
[handler retrieveScrollView:gestureRecognizer.view] != nil) {
return;
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch, thanks. On macOS RNDummyGestureRecognizer begins on mouseDown: and never mirrors the scroll view, so the exception would have suppressed the cancellation on every press. Guarded it with #if !TARGET_OS_OSX in 92063e7, so macOS keeps its existing behavior.

On macOS RNDummyGestureRecognizer begins on mouse down instead of mirroring
the scroll view's pan recognizer, so skipping the cancellation there would
suppress it for every press inside a wrapped ScrollView.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@JoonDong2
JoonDong2 requested a balanced review from Copilot October 8, 2026 00:38

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@m-bert

m-bert commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator

Hi @JoonDong2! Thank you for pointing this out!

Have you tried using cancelsJSResponder prop?

const nativeGesture = useNativeGesture({ cancelsJSResponder: false });

@JoonDong2

Copy link
Copy Markdown
Author

Thanks @m-bert! You're right, useNativeGesture({ cancelsJSResponder: false }) avoids it on 3.x. RNRootViewGestureRecognizer only calls didActivateInViewWithTouchHandler: when the handler has cancelsJSResponder set, so the dummy recognizer never resets the touch handler. I should have mentioned that in the description.

I still think the default is worth fixing, for a few reasons:

  1. gestureRecognizer:didActivateInViewWithTouchHandler: already intends to skip scroll activations, but it detects them by the view class. The native handler's RNDummyGestureRecognizer only mirrors the scroll view's pan recognizer, so its activation is the same "scroll started" event. It just sits on the component view, so the check misses it. As a result, wrapping a ScrollView in a GestureDetector only to declare relations (e.g. simultaneousWithExternalGesture) changes how JS responders behave compared to a bare ScrollView.
  2. With the default, whether it happens changes between app launches, which makes the option hard to discover. We first thought it was iPad-only.
  3. The option isn't reachable everywhere the bug is: the legacy Gesture.Native() builder has no cancelsJSResponder method, and 2.x doesn't have the option at all, although it has the same isKindOfClass:[UIScrollView class] check.

One downside of the current change: with it, an explicit cancelsJSResponder: true on a scroll-bound native gesture would be ignored. If you'd prefer, I can change the approach so that the option stays respected, e.g. default cancelsJSResponder to false only for native handlers bound to a scroll view, or skip the cancellation here only when the user didn't set the option explicitly. Happy to go with whichever fits the library better.

@m-bert

m-bert commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator

Hi @JoonDong2, thanks for the detailed investigation and for confirming that cancelsJSResponder helps.

We've discussed this internally and decided to keep the current behaviour. Cancelling JS responders when a handler activates is the intended default, and cancelsJSResponder: false is the designed opt-out for cases like this one.

Changing this default would also be a behaviour change for existing apps that rely on the cancellation, so it is not something we would take outside of a major release.

I'm going to close this PR, but thank you again for the report and the thorough write-up.

@m-bert m-bert closed this Oct 12, 2026
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.

3 participants