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/2] 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/2] 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(() => {