Repository navigation
[iOS] Fix JS touches being cancelled when a ScrollView wrapped in a native gesture starts scrolling - #4574
[iOS] Fix JS touches being cancelled when a ScrollView wrapped in a native gesture starts scrolling#4574JoonDong2 wants to merge 2 commits into
Conversation
…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>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configuration
📒 Files selected for processing (1)
Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 7 remain after this review. 📝 SummarySummary by CodeRabbit
WalkthroughOn 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. ChangesScroll-view activation check
Suggested reviewers: Priority: ➖ Normal Merge Risk: ⚪ Minimal · up to 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)
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. Comment |
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The new exception unintentionally changes macOS responder behavior and should be restricted to iOS.
Review effort: Balanced
Findings: 1
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.
| RNGestureHandler *handler = [RNGestureHandler findGestureHandlerByRecognizer:gestureRecognizer]; | ||
| if ([gestureRecognizer isKindOfClass:[RNDummyGestureRecognizer class]] && handler != nil && | ||
| [handler retrieveScrollView:gestureRecognizer.view] != nil) { | ||
| return; | ||
| } |
There was a problem hiding this comment.
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>
|
Hi @JoonDong2! Thank you for pointing this out! Have you tried using const nativeGesture = useNativeGesture({ cancelsJSResponder: false }); |
|
Thanks @m-bert! You're right, I still think the default is worth fixing, for a few reasons:
One downside of the current change: with it, an explicit |
|
Hi @JoonDong2, thanks for the detailed investigation and for confirming that We've discussed this internally and decided to keep the current behaviour. Cancelling JS responders when a handler activates is the intended default, and 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. |

Description
When a
ScrollViewis wrapped in aGestureDetectorwith a native gesture (useNativeGesture, orGesture.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 aPanResponder-based pager tracking a horizontal drag, receivesonResponderTerminatemid-gesture even though itsonResponderTerminationRequestreturnsfalse. 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-enablesRCTSurfaceTouchHandlerto 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 aScrollViewattaches itsRNDummyGestureRecognizerto 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 theUIScrollView.This PR also skips the cancellation when the activated recognizer is a native handler's
RNDummyGestureRecognizerbound to a scroll view, found withretrieveScrollView:(the same lookupRNNativeViewGestureHandleruses). 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
RNDummyGestureRecognizeron purpose.retrieveScrollView:looks only at the view, and the legacyGestureDetectorattaches every handler to its child view, so aGesture.Pan()wrapping aScrollViewis 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 tov2-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
onChildStartedNativeGestureand has no scroll exception.Test plan
Reproduction (v3 API):
PanRespondertakes the touch.ScrollViewstarts scrolling.Before: in affected launches
onPanResponderTerminatefires as soon as theScrollViewstarts scrolling. After:onPanResponderReleasefires 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
mainand 3.3.0):useNativeGesture, iPad Pro 13-inch (M5)useNativeGesture, iPhone 17Gesture.Native()(legacy API), iPad Pro 13-inch (M5)* Measured with an earlier version of this change that did not have the
RNDummyGestureRecognizercheck. That check does not affect this path, since the activated recognizer here is theRNDummyGestureRecognizer.Also checked with the change applied:
Gesture.Pan()wrapping aScrollViewdirectly, with aPanResponderinside 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. WithusePanGesturethe Pan is attached to the detector view, and the responder is terminated with or without the change.Pressableinside the sameScrollViewstill has its press cancelled when a scroll starts on it (onPressIn→onPressOut, noonPress), now through React Native's responder negotiation. A plain tap still firesonPress.Gesture.Native()on a list plus aGesture.Pan()usingsimultaneousWithExternalGesture), 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-examplebuild locally; the change is a single file underapple/that compiled in the 3.3.0 test app.🤖 Generated with Claude Code