From 56fd790ed1b73254b5626aa24ab5f7210e4783ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Fri, 18 Sep 2026 10:11:31 +0200 Subject: [PATCH 1/7] Mark JS responder events for virtual and intercepting detectors --- .../src/__tests__/api_v3.test.tsx | 162 +++++++++++++++++- .../InterceptingGestureDetector.tsx | 6 + .../VirtualDetector/VirtualDetector.tsx | 12 +- .../src/v3/detectors/VirtualDetector/Wrap.tsx | 34 +++- .../src/v3/hooks/useJSResponderHandler.ts | 33 +++- 5 files changed, 234 insertions(+), 13 deletions(-) diff --git a/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx b/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx index 14e83c7750..61ddeb3007 100644 --- a/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx +++ b/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx @@ -11,10 +11,15 @@ import GestureHandlerRootView from '../components/GestureHandlerRootView'; import { fireGestureHandler, getByGestureTestId } from '../jestUtils'; import { State } from '../State'; import { Pressable, RectButton, ScrollView, Touchable } from '../v3/components'; -import { GestureDetector } from '../v3/detectors'; +import { + GestureDetector, + InterceptingGestureDetector, + VirtualGestureDetector, +} from '../v3/detectors'; import { useSimultaneousGestures } from '../v3/hooks'; import { useLongPressGesture, + useNativeGesture, usePanGesture, useTapGesture, } from '../v3/hooks/gestures'; @@ -189,14 +194,24 @@ describe('[API v3] Components', () => { ); }; - const getNativeDetector = ( + // Detector host views in document order (outer first). + const getNativeDetectors = ( views: ReturnType['UNSAFE_getAllByType'] ) => { - return views(View).find( + return views(View).filter( ({ props }) => props.handlerTags && props.onStartShouldSetResponder ); }; + // The innermost detector: the GH ScrollView is a detector too, and it is + // rendered above the example's one. + const getNativeDetector = ( + views: ReturnType['UNSAFE_getAllByType'] + ) => { + const detectors = getNativeDetectors(views); + return detectors[detectors.length - 1]; + }; + const TapGestureDetectorExample = () => { const tap = useTapGesture({ disableReanimated: true }); @@ -217,6 +232,46 @@ describe('[API v3] Components', () => { ); }; + const VirtualTapDetectorExample = () => { + const tap = useTapGesture({ disableReanimated: true }); + + return ( + + + + ); + }; + + const VirtualPanDetectorExample = () => { + const pan = usePanGesture({ disableReanimated: true }); + + return ( + + + + ); + }; + + const InterceptingTapDetectorExample = () => { + const tap = useTapGesture({ disableReanimated: true }); + + return ( + + + + ); + }; + + const InterceptingNativeDetectorExample = () => { + const native = useNativeGesture({ disableReanimated: true }); + + return ( + + + + ); + }; + const SimultaneousGestureDetectorExample = () => { const tap = useTapGesture({ disableReanimated: true }); const pan = usePanGesture({ disableReanimated: true }); @@ -336,6 +391,107 @@ describe('[API v3] Components', () => { expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe(true); }); + test('handles responder event passed through VirtualDetector for supported gestures', async () => { + const { UNSAFE_getAllByType } = render( + + + + + + ); + + await act(flushImmediate); + + const scrollViewResponder = getScrollViewResponder(UNSAFE_getAllByType); + const virtualChild = screen.getByTestId('virtual-child'); + + expect(scrollViewResponder).toBeDefined(); + expect( + scrollViewResponder?.props.onStartShouldSetResponderCapture() + ).toBe(false); + expect(virtualChild.props.onStartShouldSetResponder()).toBe(false); + expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe(true); + }); + + test('does not handle responder event passed through VirtualDetector for unsupported gestures', async () => { + const { UNSAFE_getAllByType } = render( + + + + + + ); + + await act(flushImmediate); + + const scrollViewResponder = getScrollViewResponder(UNSAFE_getAllByType); + const virtualChild = screen.getByTestId('virtual-child'); + + expect(scrollViewResponder).toBeDefined(); + expect( + scrollViewResponder?.props.onStartShouldSetResponderCapture() + ).toBe(false); + expect(virtualChild.props.onStartShouldSetResponder()).toBe(false); + expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe( + false + ); + }); + + test('handles responder event passed through InterceptingGestureDetector for supported gestures', async () => { + const { UNSAFE_getAllByType } = render( + + + + + + ); + + await act(flushImmediate); + + const detectors = getNativeDetectors(UNSAFE_getAllByType); + const scrollViewResponder = getScrollViewResponder(UNSAFE_getAllByType); + // The GH ScrollView is the outer intercepting detector, the example is the inner one. + const interceptingDetector = detectors[detectors.length - 1]; + + expect(interceptingDetector).toBeDefined(); + expect(scrollViewResponder).toBeDefined(); + expect( + scrollViewResponder?.props.onStartShouldSetResponderCapture() + ).toBe(false); + expect(interceptingDetector?.props.onStartShouldSetResponder()).toBe( + false + ); + expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe(true); + }); + + test('does not handle responder event passed through InterceptingGestureDetector for native gestures', async () => { + const { UNSAFE_getAllByType } = render( + + + + + + ); + + await act(flushImmediate); + + const detectors = getNativeDetectors(UNSAFE_getAllByType); + const scrollViewResponder = getScrollViewResponder(UNSAFE_getAllByType); + const interceptingDetector = detectors[detectors.length - 1]; + + expect(interceptingDetector).toBeDefined(); + expect(scrollViewResponder).toBeDefined(); + expect( + scrollViewResponder?.props.onStartShouldSetResponderCapture() + ).toBe(false); + expect(interceptingDetector?.props.onStartShouldSetResponder()).toBe( + false + ); + expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe( + false + ); + }); + test('does not handle responder event passed through NativeDetector for unsupported gestures', async () => { const { UNSAFE_getAllByType } = render( diff --git a/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/InterceptingGestureDetector.tsx b/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/InterceptingGestureDetector.tsx index 667775b030..8e4e2a63cf 100644 --- a/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/InterceptingGestureDetector.tsx +++ b/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/InterceptingGestureDetector.tsx @@ -7,6 +7,7 @@ import type { } from '../../../handlers/gestureHandlerCommon'; import { Reanimated } from '../../../handlers/gestures/reanimatedWrapper'; import { tagMessage } from '../../../utils'; +import { useJSResponderHandler } from '../../hooks/useJSResponderHandler'; import { isComposedGesture } from '../../hooks/utils/relationUtils'; import type { DetectorCallbacks, @@ -54,6 +55,10 @@ export function InterceptingGestureDetector< TExtendedHandlerData >) { useEnsureGestureHandlerRootView(); + const { handleStartShouldSetResponder } = useJSResponderHandler( + gesture, + false + ); const [virtualChildren, setVirtualChildren] = useState>( () => new Set() @@ -298,6 +303,7 @@ export function InterceptingGestureDetector< : undefined } handlerTags={handlerTags} + onStartShouldSetResponder={handleStartShouldSetResponder} style={nativeDetectorStyles.detector} virtualChildren={strippedVirtualChildren} moduleId={globalThis._RNGH_MODULE_ID}> diff --git a/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/VirtualDetector.tsx b/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/VirtualDetector.tsx index 00ee8dd18b..193879ffb5 100644 --- a/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/VirtualDetector.tsx +++ b/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/VirtualDetector.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { findNodeHandle, Platform } from 'react-native'; import { tagMessage } from '../../../utils'; +import { useJSResponderHandler } from '../../hooks/useJSResponderHandler'; import { isComposedGesture } from '../../hooks/utils/relationUtils'; import type { DetectorCallbacks, VirtualChild } from '../../types'; import type { VirtualDetectorProps } from '../common'; @@ -37,6 +38,9 @@ export function VirtualDetector< const { register, unregister, setMode } = useRequiredInterceptingDetectorContext(); + const { handleStartShouldSetResponder } = useJSResponderHandler( + props.gesture + ); const viewRef = useRef(null); const [viewTag, setViewTag] = useState(-1); @@ -114,5 +118,11 @@ export function VirtualDetector< useGestureRelationsUpdater(props.gesture); - return {props.children}; + return ( + + {props.children} + + ); } diff --git a/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/Wrap.tsx b/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/Wrap.tsx index 14d0313ee8..486e669ce5 100644 --- a/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/Wrap.tsx +++ b/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/Wrap.tsx @@ -1,5 +1,6 @@ import type { PropsWithChildren } from 'react'; import React, { useCallback } from 'react'; +import type { GestureResponderEvent } from 'react-native'; import type { WrapRef } from '../../../hostInstance'; import { assignRef, preferHostInstance } from '../../../hostInstance'; @@ -7,9 +8,16 @@ import { tagMessage } from '../../../utils'; export type { WrapRef }; -export type WrapProps = PropsWithChildren<{ ref?: WrapRef }>; +export type WrapProps = PropsWithChildren<{ + ref?: WrapRef; + onStartShouldSetResponder?: (event: GestureResponderEvent) => boolean; +}>; -export const Wrap: React.FunctionComponent = ({ ref, children }) => { +export const Wrap: React.FunctionComponent = ({ + ref, + children, + onStartShouldSetResponder, +}) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any let child: any; try { @@ -53,9 +61,29 @@ export const Wrap: React.FunctionComponent = ({ ref, children }) => { [childRef, ref] ); + const childOnStartShouldSetResponder = + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + child.props.onStartShouldSetResponder as + | ((event: GestureResponderEvent) => boolean) + | undefined; + + // Marks the responder event for the scroll view interop, then defers to the + // child's own handler. + const handleStartShouldSetResponder = useCallback( + (event: GestureResponderEvent) => { + onStartShouldSetResponder?.(event); + return childOnStartShouldSetResponder?.(event) ?? false; + }, + [onStartShouldSetResponder, childOnStartShouldSetResponder] + ); + return React.cloneElement( child, - { collapsable: false, ref: attachRef }, + { + collapsable: false, + ref: attachRef, + onStartShouldSetResponder: handleStartShouldSetResponder, + }, // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access child.props.children ); diff --git a/packages/react-native-gesture-handler/src/v3/hooks/useJSResponderHandler.ts b/packages/react-native-gesture-handler/src/v3/hooks/useJSResponderHandler.ts index 204b2d1dc7..e8f6170990 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/useJSResponderHandler.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/useJSResponderHandler.ts @@ -19,18 +19,24 @@ function isSupportedGesture< TConfig, THandlerData, TExtendedHandlerData extends THandlerData, ->(gesture: Gesture): boolean { +>( + gesture: Gesture, + allowNative: boolean +): boolean { if (isComposedGesture(gesture)) { - return gesture.gestures.some(isSupportedGesture); + return gesture.gestures.some((child) => + isSupportedGesture(child, allowNative) + ); } switch (gesture.type) { case SingleGestureName.Tap: case SingleGestureName.LongPress: case SingleGestureName.Fling: - case SingleGestureName.Native: case SingleGestureName.Hover: return true; + case SingleGestureName.Native: + return allowNative; default: return false; } @@ -40,7 +46,14 @@ export function useJSResponderHandler< TConfig, THandlerData, TExtendedHandlerData extends THandlerData, ->(gesture: Gesture) { +>( + gesture: Gesture | undefined, + // Whether a Native gesture counts as handling the tap. True for buttons and + // other wrapped controls. InterceptingGestureDetector passes false, since + // ScrollView and FlatList are intercepting detectors with a Native gesture + // and a touch on a scroller should not keep the keyboard open. + allowNative = true +) { const jsResponderContext = use(JSResponderContext); const [enabledSharedValueRevision, setEnabledSharedValueRevision] = useState(0); @@ -51,6 +64,10 @@ export function useJSResponderHandler< } useEffect(() => { + if (gesture === undefined) { + return; + } + const enabledSharedValues = getEnabledSharedValues(gesture); if (Worklets === undefined || enabledSharedValues.length === 0) { @@ -107,8 +124,12 @@ export function useJSResponderHandler< const shouldHandleJSResponderEvent = useCallback(() => { void enabledSharedValueRevision; - return isGestureEnabled(gesture) && isSupportedGesture(gesture); - }, [enabledSharedValueRevision, gesture]); + return ( + gesture !== undefined && + isGestureEnabled(gesture) && + isSupportedGesture(gesture, allowNative) + ); + }, [enabledSharedValueRevision, gesture, allowNative]); const handleStartShouldSetResponder = useCallback(() => { if (shouldHandleJSResponderEvent()) { From 70217926c30baa8fe5032cc347c59d3aaf015469 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Mon, 21 Sep 2026 12:48:38 +0200 Subject: [PATCH 2/7] CodeRabbit review --- .../src/v3/hooks/useJSResponderHandler.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/react-native-gesture-handler/src/v3/hooks/useJSResponderHandler.ts b/packages/react-native-gesture-handler/src/v3/hooks/useJSResponderHandler.ts index e8f6170990..9158f543d4 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/useJSResponderHandler.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/useJSResponderHandler.ts @@ -6,7 +6,7 @@ import { updateResponderEventValue, } from '../scrollViewInterop'; import { type Gesture, type SharedValue, SingleGestureName } from '../types'; -import { isComposedGesture, isGestureEnabled } from './utils'; +import { isComposedGesture, maybeUnpackValue } from './utils'; import { getEnabledSharedValues, SHARED_VALUE_OFFSET, @@ -29,6 +29,10 @@ function isSupportedGesture< ); } + if (maybeUnpackValue(gesture.config.enabled) === false) { + return false; + } + switch (gesture.type) { case SingleGestureName.Tap: case SingleGestureName.LongPress: @@ -124,11 +128,7 @@ export function useJSResponderHandler< const shouldHandleJSResponderEvent = useCallback(() => { void enabledSharedValueRevision; - return ( - gesture !== undefined && - isGestureEnabled(gesture) && - isSupportedGesture(gesture, allowNative) - ); + return gesture !== undefined && isSupportedGesture(gesture, allowNative); }, [enabledSharedValueRevision, gesture, allowNative]); const handleStartShouldSetResponder = useCallback(() => { From bea2e052b6fc199764a29f533c4c48c7aa0dd12e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Tue, 27 Oct 2026 12:43:21 +0100 Subject: [PATCH 3/7] Use capture phase callbacks --- .../src/__tests__/api_v3.test.tsx | 4 ++-- .../VirtualDetector/VirtualDetector.tsx | 2 +- .../src/v3/detectors/VirtualDetector/Wrap.tsx | 22 +++++++++---------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx b/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx index 61ddeb3007..e106c5bab2 100644 --- a/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx +++ b/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx @@ -409,7 +409,7 @@ describe('[API v3] Components', () => { expect( scrollViewResponder?.props.onStartShouldSetResponderCapture() ).toBe(false); - expect(virtualChild.props.onStartShouldSetResponder()).toBe(false); + expect(virtualChild.props.onStartShouldSetResponderCapture()).toBe(false); expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe(true); }); @@ -431,7 +431,7 @@ describe('[API v3] Components', () => { expect( scrollViewResponder?.props.onStartShouldSetResponderCapture() ).toBe(false); - expect(virtualChild.props.onStartShouldSetResponder()).toBe(false); + expect(virtualChild.props.onStartShouldSetResponderCapture()).toBe(false); expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe( false ); diff --git a/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/VirtualDetector.tsx b/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/VirtualDetector.tsx index 6f052e5158..b6628a3592 100644 --- a/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/VirtualDetector.tsx +++ b/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/VirtualDetector.tsx @@ -158,7 +158,7 @@ export function VirtualDetector< return ( + onStartShouldSetResponderCapture={handleStartShouldSetResponder}> {props.children} ); diff --git a/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/Wrap.tsx b/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/Wrap.tsx index 486e669ce5..e80f252dff 100644 --- a/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/Wrap.tsx +++ b/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/Wrap.tsx @@ -10,13 +10,13 @@ export type { WrapRef }; export type WrapProps = PropsWithChildren<{ ref?: WrapRef; - onStartShouldSetResponder?: (event: GestureResponderEvent) => boolean; + onStartShouldSetResponderCapture?: (event: GestureResponderEvent) => boolean; }>; export const Wrap: React.FunctionComponent = ({ ref, children, - onStartShouldSetResponder, + onStartShouldSetResponderCapture, }) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any let child: any; @@ -61,20 +61,20 @@ export const Wrap: React.FunctionComponent = ({ [childRef, ref] ); - const childOnStartShouldSetResponder = + const childOnStartShouldSetResponderCapture = // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - child.props.onStartShouldSetResponder as + child.props.onStartShouldSetResponderCapture as | ((event: GestureResponderEvent) => boolean) | undefined; - // Marks the responder event for the scroll view interop, then defers to the - // child's own handler. - const handleStartShouldSetResponder = useCallback( + // Marks in the capture phase: RN Text replaces its own press handling with + // any passed `onStartShouldSetResponder`, which would kill its `onPress`. + const handleStartShouldSetResponderCapture = useCallback( (event: GestureResponderEvent) => { - onStartShouldSetResponder?.(event); - return childOnStartShouldSetResponder?.(event) ?? false; + onStartShouldSetResponderCapture?.(event); + return childOnStartShouldSetResponderCapture?.(event) ?? false; }, - [onStartShouldSetResponder, childOnStartShouldSetResponder] + [onStartShouldSetResponderCapture, childOnStartShouldSetResponderCapture] ); return React.cloneElement( @@ -82,7 +82,7 @@ export const Wrap: React.FunctionComponent = ({ { collapsable: false, ref: attachRef, - onStartShouldSetResponder: handleStartShouldSetResponder, + onStartShouldSetResponderCapture: handleStartShouldSetResponderCapture, }, // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access child.props.children From 0f06905e94989e035c0652ea6be8042522fbc5e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Mon, 2 Nov 2026 14:00:00 +0100 Subject: [PATCH 4/7] Don't mark taps through RefreshControl and ScrollView's own Native gesture --- .../src/__tests__/api_v3.test.tsx | 57 ++++++++++++++++++- .../src/v3/components/GestureComponents.tsx | 7 ++- .../v3/components/GestureComponents.web.tsx | 3 +- .../src/v3/createNativeWrapper.tsx | 27 ++++++--- .../InterceptingGestureDetector.tsx | 34 ++++++++++- .../VirtualDetector/VirtualDetector.tsx | 26 ++++++++- .../src/v3/hooks/useJSResponderHandler.ts | 20 +++---- 7 files changed, 145 insertions(+), 29 deletions(-) diff --git a/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx b/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx index 15332730dc..6a67b78f67 100644 --- a/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx +++ b/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx @@ -10,7 +10,13 @@ import { Keyboard, TextInput, View } from 'react-native'; import GestureHandlerRootView from '../components/GestureHandlerRootView'; import { fireGestureHandler, getByGestureTestId } from '../jestUtils'; import { State } from '../State'; -import { Pressable, RectButton, ScrollView, Touchable } from '../v3/components'; +import { + Pressable, + RectButton, + RefreshControl, + ScrollView, + Touchable, +} from '../v3/components'; import { GestureDetector, InterceptingGestureDetector, @@ -498,7 +504,7 @@ describe('[API v3] Components', () => { expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe(true); }); - test('does not handle responder event passed through InterceptingGestureDetector for native gestures', async () => { + test('handles responder event passed through InterceptingGestureDetector for native gestures', async () => { const { UNSAFE_getAllByType } = render( @@ -521,6 +527,24 @@ describe('[API v3] Components', () => { expect(interceptingDetector?.props.onStartShouldSetResponder()).toBe( false ); + expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe(true); + }); + + test('does not handle responder event passed through the native gesture of ScrollView itself', async () => { + const { UNSAFE_getAllByType } = render( + + + + ); + + await act(flushImmediate); + + const [scrollViewDetector] = getNativeDetectors(UNSAFE_getAllByType); + const scrollViewResponder = getScrollViewResponder(UNSAFE_getAllByType); + + expect(scrollViewDetector).toBeDefined(); + expect(scrollViewResponder).toBeDefined(); + expect(scrollViewDetector?.props.onStartShouldSetResponder()).toBe(false); expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe( false ); @@ -645,6 +669,35 @@ describe('[API v3] Components', () => { false ); }); + + test('does not handle responder event passed through RefreshControl', async () => { + const { UNSAFE_root, UNSAFE_getAllByType } = render( + + } + /> + + ); + + await act(flushImmediate); + + const scrollViewResponder = getScrollViewResponder(UNSAFE_getAllByType); + // The native mock drops the props, so look at the element RN renders + const refreshControl = UNSAFE_root.find( + ({ props }) => + props.refreshing !== undefined && + props.onStartShouldSetResponderCapture + ); + + expect(scrollViewResponder).toBeDefined(); + expect(refreshControl.props.onStartShouldSetResponderCapture()).toBe( + false + ); + expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe( + false + ); + }); }); describe('keyboardShouldPersistTaps="never" drop', () => { diff --git a/packages/react-native-gesture-handler/src/v3/components/GestureComponents.tsx b/packages/react-native-gesture-handler/src/v3/components/GestureComponents.tsx index bce5014531..099af84843 100644 --- a/packages/react-native-gesture-handler/src/v3/components/GestureComponents.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/GestureComponents.tsx @@ -35,7 +35,9 @@ export const RefreshControl: React.ComponentType< disallowInterruption: true, shouldCancelWhenOutside: false, }, - GestureDetectorType.Virtual + GestureDetectorType.Virtual, + // On Android it is the parent of the whole list, so it must not mark taps + false ); // eslint-disable-next-line @typescript-eslint/no-redeclare @@ -50,7 +52,8 @@ const GHScrollView = createNativeWrapper< disallowInterruption: true, shouldCancelWhenOutside: false, }, - GestureDetectorType.Intercepting + GestureDetectorType.Intercepting, + false ); export const ScrollView = ( diff --git a/packages/react-native-gesture-handler/src/v3/components/GestureComponents.web.tsx b/packages/react-native-gesture-handler/src/v3/components/GestureComponents.web.tsx index 7fa1cc9c11..9229b9018b 100644 --- a/packages/react-native-gesture-handler/src/v3/components/GestureComponents.web.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/GestureComponents.web.tsx @@ -16,7 +16,8 @@ export const ScrollView = createNativeWrapper( { disallowInterruption: false, }, - GestureDetectorType.Intercepting + GestureDetectorType.Intercepting, + false ); export const Switch = createNativeWrapper(RNSwitch, { diff --git a/packages/react-native-gesture-handler/src/v3/createNativeWrapper.tsx b/packages/react-native-gesture-handler/src/v3/createNativeWrapper.tsx index 60ba4641be..c42f056f62 100644 --- a/packages/react-native-gesture-handler/src/v3/createNativeWrapper.tsx +++ b/packages/react-native-gesture-handler/src/v3/createNativeWrapper.tsx @@ -1,8 +1,9 @@ import React, { useEffect } from 'react'; -import { GestureDetectorType, InterceptingGestureDetector } from './detectors'; +import { GestureDetectorType } from './detectors'; import { NativeDetector } from './detectors/NativeDetector'; -import { VirtualDetector } from './detectors/VirtualDetector/VirtualDetector'; +import { InterceptingGestureDetectorBase } from './detectors/VirtualDetector/InterceptingGestureDetector'; +import { VirtualDetectorBase } from './detectors/VirtualDetector/VirtualDetector'; import { useNativeGesture } from './hooks/gestures'; import type { NativeGestureConfig } from './hooks/gestures/native/NativeTypes'; import { NativeWrapperProps } from './hooks/utils'; @@ -16,7 +17,9 @@ export default function createNativeWrapper( config: Readonly< Omit, keyof WrapperSpecificProperties> > = {}, - detectorType: GestureDetectorType = GestureDetectorType.Native + detectorType: GestureDetectorType = GestureDetectorType.Native, + // Ignored by the Native detector, see `useJSResponderHandler` + countNativeAsHandled = true ) { const ComponentWrapper = ( props: TProps & NativeWrapperProperties @@ -57,15 +60,23 @@ export default function createNativeWrapper( onGestureUpdate_CAN_CAUSE_INFINITE_RERENDER?.(native); }, [native, onGestureUpdate_CAN_CAUSE_INFINITE_RERENDER]); + if (detectorType === GestureDetectorType.Native) { + return ( + + + + ); + } + const DetectorComponent = detectorType === GestureDetectorType.Intercepting - ? InterceptingGestureDetector - : detectorType === GestureDetectorType.Virtual - ? VirtualDetector - : NativeDetector; + ? InterceptingGestureDetectorBase + : VirtualDetectorBase; return ( - + ); diff --git a/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/InterceptingGestureDetector.tsx b/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/InterceptingGestureDetector.tsx index 2298dc4cda..777cb4ea03 100644 --- a/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/InterceptingGestureDetector.tsx +++ b/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/InterceptingGestureDetector.tsx @@ -40,7 +40,20 @@ interface StrippedVirtualChildren { enableContextMenu?: boolean; } -export function InterceptingGestureDetector< +type InterceptingGestureDetectorBaseProps< + TConfig, + THandlerData, + TExtendedHandlerData extends THandlerData, +> = InterceptingGestureDetectorProps< + TConfig, + THandlerData, + TExtendedHandlerData +> & { + countNativeAsHandled?: boolean; +}; + +// Internal: `countNativeAsHandled` is not part of the public detector API. +export function InterceptingGestureDetectorBase< TConfig, THandlerData, TExtendedHandlerData extends THandlerData, @@ -50,7 +63,8 @@ export function InterceptingGestureDetector< touchAction, userSelect, enableContextMenu, -}: InterceptingGestureDetectorProps< + countNativeAsHandled, +}: InterceptingGestureDetectorBaseProps< TConfig, THandlerData, TExtendedHandlerData @@ -58,7 +72,7 @@ export function InterceptingGestureDetector< useEnsureGestureHandlerRootView(); const { handleStartShouldSetResponder } = useJSResponderHandler( gesture, - false + countNativeAsHandled ); const [virtualChildren, setVirtualChildren] = useState>( @@ -313,3 +327,17 @@ export function InterceptingGestureDetector< ); } + +export function InterceptingGestureDetector< + TConfig, + THandlerData, + TExtendedHandlerData extends THandlerData, +>( + props: InterceptingGestureDetectorProps< + TConfig, + THandlerData, + TExtendedHandlerData + > +) { + return ; +} diff --git a/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/VirtualDetector.tsx b/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/VirtualDetector.tsx index b6628a3592..6b3ea3e0cb 100644 --- a/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/VirtualDetector.tsx +++ b/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/VirtualDetector.tsx @@ -46,11 +46,22 @@ function useRequiredInterceptingDetectorContext() { return context; } -export function VirtualDetector< +type VirtualDetectorBaseProps< TConfig, THandlerData, TExtendedHandlerData extends THandlerData, ->(props: VirtualDetectorProps) { +> = VirtualDetectorProps & { + countNativeAsHandled?: boolean; +}; + +// Internal: `countNativeAsHandled` is not part of the public detector API. +export function VirtualDetectorBase< + TConfig, + THandlerData, + TExtendedHandlerData extends THandlerData, +>( + props: VirtualDetectorBaseProps +) { // Don't memoize virtual detectors to be able to listen to changes in children // TODO: replace with MutationObserver when it rolls out in React Native 'use no memo'; @@ -58,7 +69,8 @@ export function VirtualDetector< const { register, unregister, setMode } = useRequiredInterceptingDetectorContext(); const { handleStartShouldSetResponder } = useJSResponderHandler( - props.gesture + props.gesture, + props.countNativeAsHandled ); const viewRef = useRef(null); @@ -163,3 +175,11 @@ export function VirtualDetector< ); } + +export function VirtualDetector< + TConfig, + THandlerData, + TExtendedHandlerData extends THandlerData, +>(props: VirtualDetectorProps) { + return ; +} diff --git a/packages/react-native-gesture-handler/src/v3/hooks/useJSResponderHandler.ts b/packages/react-native-gesture-handler/src/v3/hooks/useJSResponderHandler.ts index 9158f543d4..0230c3bdd8 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/useJSResponderHandler.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/useJSResponderHandler.ts @@ -21,11 +21,11 @@ function isSupportedGesture< TExtendedHandlerData extends THandlerData, >( gesture: Gesture, - allowNative: boolean + countNativeAsHandled: boolean ): boolean { if (isComposedGesture(gesture)) { return gesture.gestures.some((child) => - isSupportedGesture(child, allowNative) + isSupportedGesture(child, countNativeAsHandled) ); } @@ -40,7 +40,7 @@ function isSupportedGesture< case SingleGestureName.Hover: return true; case SingleGestureName.Native: - return allowNative; + return countNativeAsHandled; default: return false; } @@ -52,11 +52,9 @@ export function useJSResponderHandler< TExtendedHandlerData extends THandlerData, >( gesture: Gesture | undefined, - // Whether a Native gesture counts as handling the tap. True for buttons and - // other wrapped controls. InterceptingGestureDetector passes false, since - // ScrollView and FlatList are intercepting detectors with a Native gesture - // and a touch on a scroller should not keep the keyboard open. - allowNative = true + // Whether a tap on a Native gesture counts as handled. Scrollers and other + // non-tap-targets turn it off so touching them doesn't keep the keyboard open. + countNativeAsHandled = true ) { const jsResponderContext = use(JSResponderContext); const [enabledSharedValueRevision, setEnabledSharedValueRevision] = @@ -128,8 +126,10 @@ export function useJSResponderHandler< const shouldHandleJSResponderEvent = useCallback(() => { void enabledSharedValueRevision; - return gesture !== undefined && isSupportedGesture(gesture, allowNative); - }, [enabledSharedValueRevision, gesture, allowNative]); + return ( + gesture !== undefined && isSupportedGesture(gesture, countNativeAsHandled) + ); + }, [enabledSharedValueRevision, gesture, countNativeAsHandled]); const handleStartShouldSetResponder = useCallback(() => { if (shouldHandleJSResponderEvent()) { From a1df4ccc2431b60e891269d96a8d80c8e89884c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Mon, 2 Nov 2026 14:06:11 +0100 Subject: [PATCH 5/7] Remove unused isGestureEnabled --- .../src/v3/hooks/utils/configUtils.ts | 15 --------------- .../src/v3/hooks/utils/index.ts | 1 - 2 files changed, 16 deletions(-) diff --git a/packages/react-native-gesture-handler/src/v3/hooks/utils/configUtils.ts b/packages/react-native-gesture-handler/src/v3/hooks/utils/configUtils.ts index 3f54c74114..425fcd272b 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/utils/configUtils.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/utils/configUtils.ts @@ -7,7 +7,6 @@ import { isTestEnv, tagMessage } from '../../../utils'; import type { BaseGestureConfig, ExcludeInternalConfigProps, - Gesture, SingleGestureName, } from '../../types'; import { isNativeAnimatedEvent, shouldHandleTouchEvents } from './eventUtils'; @@ -19,20 +18,6 @@ import { PropsWhiteLists, } from './propsWhiteList'; import { hasWorkletEventHandlers, maybeUnpackValue } from './reanimatedUtils'; -import { isComposedGesture } from './relationUtils'; - -export function isGestureEnabled< - TConfig, - THandlerData, - TExtendedHandlerData extends THandlerData, ->(gesture: Gesture): boolean { - if (isComposedGesture(gesture)) { - // For composed gestures, we need to check if at least one of the composed gestures is enabled - return gesture.gestures.some(isGestureEnabled); - } - - return maybeUnpackValue(gesture.config.enabled) !== false; -} export function resolveInternalConfigProps< TConfig extends object, diff --git a/packages/react-native-gesture-handler/src/v3/hooks/utils/index.ts b/packages/react-native-gesture-handler/src/v3/hooks/utils/index.ts index e3abff4319..1a40919f6f 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/utils/index.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/utils/index.ts @@ -1,5 +1,4 @@ export { - isGestureEnabled, prepareConfigForNativeSide, useClonedAndRemappedConfig, } from './configUtils'; From f6443f55829654abce950193266bce338ce9952a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Tue, 3 Nov 2026 11:12:43 +0100 Subject: [PATCH 6/7] Restore false for InterceptingGestureDetector native gestures --- .../src/__tests__/api_v3.test.tsx | 6 ++-- .../src/v3/components/GestureComponents.tsx | 3 +- .../v3/components/GestureComponents.web.tsx | 3 +- .../src/v3/createNativeWrapper.tsx | 21 ++++++----- .../InterceptingGestureDetector.tsx | 36 +++---------------- 5 files changed, 21 insertions(+), 48 deletions(-) diff --git a/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx b/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx index 6a67b78f67..7c25457787 100644 --- a/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx +++ b/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx @@ -504,7 +504,7 @@ describe('[API v3] Components', () => { expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe(true); }); - test('handles responder event passed through InterceptingGestureDetector for native gestures', async () => { + test('does not handle responder event passed through InterceptingGestureDetector for native gestures', async () => { const { UNSAFE_getAllByType } = render( @@ -527,7 +527,9 @@ describe('[API v3] Components', () => { expect(interceptingDetector?.props.onStartShouldSetResponder()).toBe( false ); - expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe(true); + expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe( + false + ); }); test('does not handle responder event passed through the native gesture of ScrollView itself', async () => { diff --git a/packages/react-native-gesture-handler/src/v3/components/GestureComponents.tsx b/packages/react-native-gesture-handler/src/v3/components/GestureComponents.tsx index 099af84843..1193a77d45 100644 --- a/packages/react-native-gesture-handler/src/v3/components/GestureComponents.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/GestureComponents.tsx @@ -52,8 +52,7 @@ const GHScrollView = createNativeWrapper< disallowInterruption: true, shouldCancelWhenOutside: false, }, - GestureDetectorType.Intercepting, - false + GestureDetectorType.Intercepting ); export const ScrollView = ( diff --git a/packages/react-native-gesture-handler/src/v3/components/GestureComponents.web.tsx b/packages/react-native-gesture-handler/src/v3/components/GestureComponents.web.tsx index 9229b9018b..7fa1cc9c11 100644 --- a/packages/react-native-gesture-handler/src/v3/components/GestureComponents.web.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/GestureComponents.web.tsx @@ -16,8 +16,7 @@ export const ScrollView = createNativeWrapper( { disallowInterruption: false, }, - GestureDetectorType.Intercepting, - false + GestureDetectorType.Intercepting ); export const Switch = createNativeWrapper(RNSwitch, { diff --git a/packages/react-native-gesture-handler/src/v3/createNativeWrapper.tsx b/packages/react-native-gesture-handler/src/v3/createNativeWrapper.tsx index c42f056f62..07d74ffbf9 100644 --- a/packages/react-native-gesture-handler/src/v3/createNativeWrapper.tsx +++ b/packages/react-native-gesture-handler/src/v3/createNativeWrapper.tsx @@ -1,8 +1,7 @@ import React, { useEffect } from 'react'; -import { GestureDetectorType } from './detectors'; +import { GestureDetectorType, InterceptingGestureDetector } from './detectors'; import { NativeDetector } from './detectors/NativeDetector'; -import { InterceptingGestureDetectorBase } from './detectors/VirtualDetector/InterceptingGestureDetector'; import { VirtualDetectorBase } from './detectors/VirtualDetector/VirtualDetector'; import { useNativeGesture } from './hooks/gestures'; import type { NativeGestureConfig } from './hooks/gestures/native/NativeTypes'; @@ -18,7 +17,7 @@ export default function createNativeWrapper( Omit, keyof WrapperSpecificProperties> > = {}, detectorType: GestureDetectorType = GestureDetectorType.Native, - // Ignored by the Native detector, see `useJSResponderHandler` + // Virtual detector only, see `useJSResponderHandler` countNativeAsHandled = true ) { const ComponentWrapper = ( @@ -60,23 +59,23 @@ export default function createNativeWrapper( onGestureUpdate_CAN_CAUSE_INFINITE_RERENDER?.(native); }, [native, onGestureUpdate_CAN_CAUSE_INFINITE_RERENDER]); - if (detectorType === GestureDetectorType.Native) { + if (detectorType === GestureDetectorType.Virtual) { return ( - + - + ); } const DetectorComponent = detectorType === GestureDetectorType.Intercepting - ? InterceptingGestureDetectorBase - : VirtualDetectorBase; + ? InterceptingGestureDetector + : NativeDetector; return ( - + ); diff --git a/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/InterceptingGestureDetector.tsx b/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/InterceptingGestureDetector.tsx index 777cb4ea03..eb8ecefa00 100644 --- a/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/InterceptingGestureDetector.tsx +++ b/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/InterceptingGestureDetector.tsx @@ -40,20 +40,7 @@ interface StrippedVirtualChildren { enableContextMenu?: boolean; } -type InterceptingGestureDetectorBaseProps< - TConfig, - THandlerData, - TExtendedHandlerData extends THandlerData, -> = InterceptingGestureDetectorProps< - TConfig, - THandlerData, - TExtendedHandlerData -> & { - countNativeAsHandled?: boolean; -}; - -// Internal: `countNativeAsHandled` is not part of the public detector API. -export function InterceptingGestureDetectorBase< +export function InterceptingGestureDetector< TConfig, THandlerData, TExtendedHandlerData extends THandlerData, @@ -63,16 +50,17 @@ export function InterceptingGestureDetectorBase< touchAction, userSelect, enableContextMenu, - countNativeAsHandled, -}: InterceptingGestureDetectorBaseProps< +}: InterceptingGestureDetectorProps< TConfig, THandlerData, TExtendedHandlerData >) { useEnsureGestureHandlerRootView(); + // A Native gesture here belongs to the container hosting virtual children + // (a scroller, a pager), not to a tap target const { handleStartShouldSetResponder } = useJSResponderHandler( gesture, - countNativeAsHandled + false ); const [virtualChildren, setVirtualChildren] = useState>( @@ -327,17 +315,3 @@ export function InterceptingGestureDetectorBase< ); } - -export function InterceptingGestureDetector< - TConfig, - THandlerData, - TExtendedHandlerData extends THandlerData, ->( - props: InterceptingGestureDetectorProps< - TConfig, - THandlerData, - TExtendedHandlerData - > -) { - return ; -} From 81d25bbec4670322a5695ae01c07f84fedd3da91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Tue, 3 Nov 2026 11:43:03 +0100 Subject: [PATCH 7/7] Move RefreshControl tap opt-out to an internal gesture config flag --- .../src/__tests__/api_v3.test.tsx | 40 +++++++++++++++++++ .../src/v3/createNativeWrapper.tsx | 26 ++++++------ .../VirtualDetector/VirtualDetector.tsx | 26 ++---------- .../src/v3/hooks/useJSResponderHandler.ts | 4 +- .../src/v3/hooks/utils/propsWhiteList.ts | 1 + .../src/v3/types/ConfigTypes.ts | 3 ++ 6 files changed, 63 insertions(+), 37 deletions(-) diff --git a/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx b/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx index 7c25457787..cf9a3b8d24 100644 --- a/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx +++ b/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx @@ -258,6 +258,16 @@ describe('[API v3] Components', () => { ); }; + const VirtualNativeDetectorExample = () => { + const native = useNativeGesture({ disableReanimated: true }); + + return ( + + + + ); + }; + const InterceptingTapDetectorExample = () => { const tap = useTapGesture({ disableReanimated: true }); @@ -453,6 +463,28 @@ describe('[API v3] Components', () => { expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe(true); }); + test('handles responder event passed through VirtualDetector for native gestures', async () => { + const { UNSAFE_getAllByType } = render( + + + + + + ); + + await act(flushImmediate); + + const scrollViewResponder = getScrollViewResponder(UNSAFE_getAllByType); + const virtualChild = screen.getByTestId('virtual-child'); + + expect(scrollViewResponder).toBeDefined(); + expect( + scrollViewResponder?.props.onStartShouldSetResponderCapture() + ).toBe(false); + expect(virtualChild.props.onStartShouldSetResponderCapture()).toBe(false); + expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe(true); + }); + test('does not handle responder event passed through VirtualDetector for unsupported gestures', async () => { const { UNSAFE_getAllByType } = render( @@ -673,6 +705,9 @@ describe('[API v3] Components', () => { }); test('does not handle responder event passed through RefreshControl', async () => { + const warn = jest + .spyOn(console, 'warn') + .mockImplementation(() => undefined); const { UNSAFE_root, UNSAFE_getAllByType } = render( { expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe( false ); + // The opt-out is an internal config prop, it must not reach the native side + expect(warn).not.toHaveBeenCalledWith( + expect.stringContaining('countNativeAsHandled') + ); + warn.mockRestore(); }); }); diff --git a/packages/react-native-gesture-handler/src/v3/createNativeWrapper.tsx b/packages/react-native-gesture-handler/src/v3/createNativeWrapper.tsx index 07d74ffbf9..d7da826ad9 100644 --- a/packages/react-native-gesture-handler/src/v3/createNativeWrapper.tsx +++ b/packages/react-native-gesture-handler/src/v3/createNativeWrapper.tsx @@ -2,10 +2,11 @@ import React, { useEffect } from 'react'; import { GestureDetectorType, InterceptingGestureDetector } from './detectors'; import { NativeDetector } from './detectors/NativeDetector'; -import { VirtualDetectorBase } from './detectors/VirtualDetector/VirtualDetector'; +import { VirtualDetector } from './detectors/VirtualDetector/VirtualDetector'; import { useNativeGesture } from './hooks/gestures'; import type { NativeGestureConfig } from './hooks/gestures/native/NativeTypes'; import { NativeWrapperProps } from './hooks/utils'; +import type { InternalConfigProps } from './types'; import type { NativeWrapperProperties, WrapperSpecificProperties, @@ -17,7 +18,8 @@ export default function createNativeWrapper( Omit, keyof WrapperSpecificProperties> > = {}, detectorType: GestureDetectorType = GestureDetectorType.Native, - // Virtual detector only, see `useJSResponderHandler` + // Whether a tap on the wrapped component counts as handled, see + // `useJSResponderHandler` countNativeAsHandled = true ) { const ComponentWrapper = ( @@ -53,26 +55,24 @@ export default function createNativeWrapper( // detector, whatever the caller passed. gestureHandlerProps.disableReanimated = true; + if (!countNativeAsHandled) { + ( + gestureHandlerProps as InternalConfigProps + ).countNativeAsHandled = false; + } + const native = useNativeGesture(gestureHandlerProps); useEffect(() => { onGestureUpdate_CAN_CAUSE_INFINITE_RERENDER?.(native); }, [native, onGestureUpdate_CAN_CAUSE_INFINITE_RERENDER]); - if (detectorType === GestureDetectorType.Virtual) { - return ( - - - - ); - } - const DetectorComponent = detectorType === GestureDetectorType.Intercepting ? InterceptingGestureDetector - : NativeDetector; + : detectorType === GestureDetectorType.Virtual + ? VirtualDetector + : NativeDetector; return ( diff --git a/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/VirtualDetector.tsx b/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/VirtualDetector.tsx index 6b3ea3e0cb..b6628a3592 100644 --- a/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/VirtualDetector.tsx +++ b/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/VirtualDetector.tsx @@ -46,22 +46,11 @@ function useRequiredInterceptingDetectorContext() { return context; } -type VirtualDetectorBaseProps< - TConfig, - THandlerData, - TExtendedHandlerData extends THandlerData, -> = VirtualDetectorProps & { - countNativeAsHandled?: boolean; -}; - -// Internal: `countNativeAsHandled` is not part of the public detector API. -export function VirtualDetectorBase< +export function VirtualDetector< TConfig, THandlerData, TExtendedHandlerData extends THandlerData, ->( - props: VirtualDetectorBaseProps -) { +>(props: VirtualDetectorProps) { // Don't memoize virtual detectors to be able to listen to changes in children // TODO: replace with MutationObserver when it rolls out in React Native 'use no memo'; @@ -69,8 +58,7 @@ export function VirtualDetectorBase< const { register, unregister, setMode } = useRequiredInterceptingDetectorContext(); const { handleStartShouldSetResponder } = useJSResponderHandler( - props.gesture, - props.countNativeAsHandled + props.gesture ); const viewRef = useRef(null); @@ -175,11 +163,3 @@ export function VirtualDetectorBase< ); } - -export function VirtualDetector< - TConfig, - THandlerData, - TExtendedHandlerData extends THandlerData, ->(props: VirtualDetectorProps) { - return ; -} diff --git a/packages/react-native-gesture-handler/src/v3/hooks/useJSResponderHandler.ts b/packages/react-native-gesture-handler/src/v3/hooks/useJSResponderHandler.ts index 0230c3bdd8..223bdeb4e7 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/useJSResponderHandler.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/useJSResponderHandler.ts @@ -40,7 +40,9 @@ function isSupportedGesture< case SingleGestureName.Hover: return true; case SingleGestureName.Native: - return countNativeAsHandled; + return ( + countNativeAsHandled && gesture.config.countNativeAsHandled !== false + ); default: return false; } diff --git a/packages/react-native-gesture-handler/src/v3/hooks/utils/propsWhiteList.ts b/packages/react-native-gesture-handler/src/v3/hooks/utils/propsWhiteList.ts index ad3c07a131..6ffa590628 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/utils/propsWhiteList.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/utils/propsWhiteList.ts @@ -71,6 +71,7 @@ export const PropsToFilter = new Set< 'changeEventCalculator', 'disableReanimated', 'shouldUseReanimatedDetector', + 'countNativeAsHandled', 'useAnimated', 'runOnJS', diff --git a/packages/react-native-gesture-handler/src/v3/types/ConfigTypes.ts b/packages/react-native-gesture-handler/src/v3/types/ConfigTypes.ts index 6415743183..28fa9d5be5 100644 --- a/packages/react-native-gesture-handler/src/v3/types/ConfigTypes.ts +++ b/packages/react-native-gesture-handler/src/v3/types/ConfigTypes.ts @@ -63,6 +63,9 @@ export type InternalConfigProps = { fillInDefaultValues?: | ((event: GestureEvent) => void) | undefined; + // Set to false by native wrappers of components that are not tap targets, + // see `useJSResponderHandler` + countNativeAsHandled?: boolean | undefined; }; export type CommonGestureConfig = {