Add cancelsJSResponder prop to the gesture handlers - #4094
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a new preventRecognizers configuration option to gesture handlers to control whether GH activation cancels React Native JS responders (with native iOS/Android plumbing + an example to demonstrate behavior).
Changes:
- Added
preventRecognizersto shared gesture config types/whitelists so it can flow from JS to native. - Implemented native behavior gates on iOS (root recognizer) and Android (orchestrator → root helper interception) with default
true. - Added docs + a new common-app example to showcase responder cancellation differences.
Reviewed changes
Copilot reviewed 13 out of 14 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/react-native-gesture-handler/src/v3/types/ConfigTypes.ts | Adds preventRecognizers to common gesture config typing. |
| packages/react-native-gesture-handler/src/v3/hooks/utils/propsWhiteList.ts | Allows the new config prop to pass through native config filtering. |
| packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx | Minor formatting-only change near detector initialization. |
| packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/utils.ts | Adds preventRecognizers to allowed prop list for detector plumbing. |
| packages/react-native-gesture-handler/apple/RNRootViewGestureRecognizer.m | iOS: only trigger RN responder cancellation when preventRecognizers is enabled. |
| packages/react-native-gesture-handler/apple/RNGestureHandler.mm | iOS: default + config parsing for preventRecognizers. |
| packages/react-native-gesture-handler/apple/RNGestureHandler.h | iOS: exposes preventRecognizers on the handler. |
| packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt | Android: intercept/cancel RN touch stream when requested by orchestrator. |
| packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt | Android: emits request/release callbacks based on handler activation + finish. |
| packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt | Android: stores/reads preventRecognizers from config with default true. |
| packages/docs-gesture-handler/docs/fundamentals/root-view.mdx | Doc formatting cleanup (indentation/trailing whitespace). |
| packages/docs-gesture-handler/docs/fundamentals/gesture-detector.mdx | Adds docs for preventRecognizers (but currently includes a broken tag and placement concerns). |
| apps/common-app/src/new_api/tests/rnResponderCancellation/index.tsx | New example screen demonstrating RN responder cancellation vs. concurrent handling. |
| apps/common-app/src/new_api/index.tsx | Registers the new example in the app’s examples list. |
Comments suppressed due to low confidence (1)
packages/docs-gesture-handler/docs/fundamentals/gesture-detector.mdx:206
- The closing tag for
HeaderWithBadgeswas changed to</BadHeaderWithBadgesges>, which is not a valid component/tag and will break MDX rendering/build. Replace it with the correct</HeaderWithBadges>closing tag.
<HeaderWithBadges platforms={['web']}>
### touchAction
</HeaderWithBadges>
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
@copilot review? |
Here's the review of the current state of the PR: Overall: The implementation is solid. All 3 previously flagged issues are resolved. Here's a brief summary per area: Android:
iOS:
TypeScript:
Docs: No issues found in the current state. |
preventRecognizers prop to the gesture handlerspreventRecognizers prop to the gesture handlers
j-piasecki
left a comment
There was a problem hiding this comment.
Can you update the title and description with the final prop name?
Also, I don't have an iOS device on hand, so can you check the following scenario?
There are two handlers and js recognizer on the same view. Gesture A with cancelsJSRecognizer: false, and a config that prevents it from activating (like minimumDistance: 100000 on pan). Gesture B has cancelsJSRecognizer: false and requires A to fail before it activates (useExclusiveGestures(A, B)), but should try to activate almost immediately (like pan with minimumDistance: 10). Now, in that scenario, does B trying to activate but getting blocked on A, cancel the JS recognizer, or not?
I've checked Android and there it works as expected (until A fails, the JS recognizer is not interrupted).
preventRecognizers prop to the gesture handlerscancelsJSResponder prop to the gesture handlers
This reverts commit fff3181.
…etector.tsx Co-authored-by: Jakub Piasecki <jakub.piasecki@swmansion.com>
m-bert
left a comment
There was a problem hiding this comment.
Seems like @j-piasecki caught everything
3a3d592 to
9f24c36
Compare
…ct-native-gesture-handler into prevent-recognizers
Not sure if you meant |
…t incorrect JS responder cancellation PR #4094 moved the ACTION_CANCEL dispatch from RootViewGestureHandler.onCancel() into the orchestrator's makeActive() method, gated by handler.cancelsJSResponder. However, when requestDisallowInterceptTouchEvent triggers tryCancelAllHandlers(), the RootViewGestureHandler itself activates through makeActive(), and since cancelsJSResponder defaults to true, it incorrectly dispatches ACTION_CANCEL to the root view — cancelling the very native view (e.g. ScrollView) that requested the touch lock. Setting cancelsJSResponder=false on the RootViewGestureHandler prevents this spurious cancellation while preserving the correct behavior for user gesture handlers.
## Description Fixes #4295 On Android, 3.x cancels the React Native JS responder whenever any native view calls `requestDisallowInterceptTouchEvent` during a touch. Two common scenarios break because of this (neither involves any RNGH gesture): - **RN `Pressable`/`Touchable*` wrapping a `TextInput`** — `ReactEditText` unconditionally calls `parent.requestDisallowInterceptTouchEvent(true)` on every `ACTION_DOWN`, even when `editable={false}` and `pointerEvents="none"` (`pointerEvents` isn't enforced in native Android dispatch for `ReactEditText`, only in RN's JS hit test). Taps landing on the input dispatch `topTouchCancel` and the press never completes (#4295 — this is what breaks Gluestack UI's Select). - **Any default-config `PanResponder`** — on grant, RN's `JSResponderHandler.setJSResponder` blocks native parents via `requestDisallowInterceptTouchEvent(true)` (`onShouldBlockNativeResponder` defaults to `true` on Android). That call bounces back through the RNGH root view, which then cancels the responder that was just granted: every drag logs `onPanResponderGrant` → `onPanResponderTerminate`. **Root cause:** #4094 moved the JS-responder cancellation from `RootViewGestureHandler.onCancel` into `GestureHandlerOrchestrator.makeActive`, gated on the new `cancelsJSResponder` flag — which defaults to `true` for every handler, **including the internal `RootViewGestureHandler`**. That handler activates inside `tryCancelAllHandlers()` (the `requestDisallowInterceptTouchEvent` path), so a mechanism that previously only cancelled RNGH's own handlers now also kills the JS responder. The fix opts the internal root handler out of JS-responder cancellation. This restores: - **v2 semantics** — root-handler activation cancelled other RNGH handlers but never dispatched a JS-responder cancel; handlers activating still cancel it through their own flag (incidentally, this also restores the behavior of v2's removed `handleSetJSResponder`: a JS responder taking over with `blockNativeResponder` cancels RNGH handlers, not itself), - **stock RN semantics** — `ReactSurfaceView.requestDisallowInterceptTouchEvent` deliberately keeps the JS touch stream alive; RN core only cancels the responder via explicit `onChildStartedNativeGesture` calls (e.g. `ScrollView`, `SwipeRefreshLayout` — those paths are unaffected by this change), - **iOS parity** — `RNRootViewGestureRecognizer` only notifies the delegate when the *preventing* recognizer maps to an RNGH handler with `cancelsJSResponder` set; the root recognizer itself never triggers the cancellation, so iOS never had this bug. The flag cannot be reverted accidentally: `resetConfig()` only runs through `Factory.setConfig` (the JS config path), and the root handler is constructed directly with a negative tag and never configured from JS. The "block release" timing is keyed on `gestureHandlers.isEmpty()`, not on the flag, so it is also unaffected. ## Test plan <details><summary>Repro 1 — #4295 (TextInput inside RN Pressable)</summary> <p> ```jsx <GestureHandlerRootView style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}> <Pressable onPress={() => setCount((c) => c + 1)} style={{ borderWidth: 1, padding: 10 }}> <TextInput value="Tap me" editable={false} pointerEvents="none" /> <Text>Icon area (works)</Text> </Pressable> <Text>Press count: {count}</Text> </GestureHandlerRootView> ``` </p> </details> <details><summary>Repro 2 — default PanResponder terminated on grant</summary> <p> ```jsx const responder = PanResponder.create({ // default onShouldBlockNativeResponder (true on Android) — do not override onStartShouldSetPanResponder: () => true, onPanResponderGrant: () => log('grant'), onPanResponderRelease: () => log('release'), onPanResponderTerminate: () => log('TERMINATE'), }); <GestureHandlerRootView style={{ flex: 1 }}> <View {...responder.panHandlers} style={{ width: 250, height: 180 }} /> </GestureHandlerRootView> ``` </p> </details> Verified on Android emulator (Pixel 7 Pro, API 34) with `basic-example` (RN 0.86, Fabric): - Repro 1 — before: taps on the `TextInput` area never fire `onPress` (count stays 0); taps on the sibling `Text` work. After: all taps fire (3/3 on the input area, sibling still works). - Repro 2 — before: every drag logs `grant` → `TERMINATE`. After: `grant` → `release`. - iOS (iPhone 17 Pro simulator, same RN version): Repro 1 works correctly with and without this change — confirms iOS is unaffected and this stays Android-only. - `RN responder cancellation` test example (added in #4094), Single handler tab, both toggle states — behavior unchanged by this PR: - `cancelsJSResponder: true`: `onResponderGrant` → moves → `onResponderTerminate` → `GH pan ACTIVE` → finalize (success), - `cancelsJSResponder: false`: responder keeps receiving moves, ends with `onResponderRelease`, pan still activates and finalizes with success.
Description
Adds a per-gesture
cancelsJSResponder?: booleanprop on both platforms to the v3 handler hooks. It defaults totrueto preserve the behavior of cancelling RN JS responder system.On
Androidit moves the root-view dispatch offonCancelonto new orchestrator callbacksonCancelJSResponderRequestedandonCancelJSResponderReleased. The orchestrator only files the "released" callback when the last active handler withcancelsJSResponder: truefinishes.on
iOStheRNRootViewGestureRecognizerchecks the handler prop before notifying the delegate.Test plan
Added new test example with multiple handlers that might or might not cancel the JS responder.