From 5aeb9de252df4845f18fb005f8e349435bd1aae6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Tue, 23 Jun 2026 12:10:31 +0200 Subject: [PATCH 1/2] Do not call onPress on buttons while dismissing keyboard --- .../tests/keyboardShouldPersistTaps/index.tsx | 12 ++- .../src/__tests__/api_v3.test.tsx | 12 +-- .../src/v3/components/Pressable.tsx | 36 +++++---- .../ScrollViewResponderInterceptor.tsx | 76 +++++++++++++++++-- .../src/v3/components/Touchable/Touchable.tsx | 41 ++++++++-- 5 files changed, 145 insertions(+), 32 deletions(-) diff --git a/apps/common-app/src/new_api/tests/keyboardShouldPersistTaps/index.tsx b/apps/common-app/src/new_api/tests/keyboardShouldPersistTaps/index.tsx index 9fa8e0c1d5..dfc4692298 100644 --- a/apps/common-app/src/new_api/tests/keyboardShouldPersistTaps/index.tsx +++ b/apps/common-app/src/new_api/tests/keyboardShouldPersistTaps/index.tsx @@ -13,6 +13,7 @@ import { Pressable as RNGHPressable, ScrollView as RNGHScrollView, TextInput as RNGHTextInput, + Touchable as RNGHTouchable, useTapGesture, } from 'react-native-gesture-handler'; @@ -20,13 +21,14 @@ import type { FeedbackHandle } from '../../../common'; import { COLORS, Feedback, InfoSection } from '../../../common'; type Mode = 'never' | 'handled' | 'always'; -type Example = 'pressable' | 'tap'; +type Example = 'pressable' | 'touchable' | 'tap'; const MODES: Mode[] = ['never', 'handled', 'always']; -const EXAMPLES: Example[] = ['pressable', 'tap']; +const EXAMPLES: Example[] = ['pressable', 'touchable', 'tap']; const EXAMPLE_LABELS: Record = { pressable: 'GH Pressable', + touchable: 'GH Touchable', tap: 'useTapGesture', }; @@ -105,6 +107,12 @@ export default function KeyboardShouldPersistTapsExample() { onPress={() => report('GH Pressable onPress')}> Press me + ) : example === 'touchable' ? ( + report('GH Touchable onPress')}> + Press me + ) : ( report('useTapGesture onActivate')} 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 c7ce5958bf..9941fd5a7a 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 @@ -133,7 +133,7 @@ describe('[API v3] Components', () => { describe('ScrollView', () => { test('handles responder event passed through Pressable for keyboardShouldPersistTaps handled', async () => { - const { getByTestId, UNSAFE_getAllByType } = render( + const { UNSAFE_getAllByType } = render( @@ -143,14 +143,14 @@ describe('[API v3] Components', () => { await act(flushImmediate); - const pressable = getByTestId('pressable'); + const nativeDetector = getNativeDetector(UNSAFE_getAllByType); const scrollViewResponder = getScrollViewResponder(UNSAFE_getAllByType); expect(scrollViewResponder).toBeDefined(); expect( scrollViewResponder?.props.onStartShouldSetResponderCapture() ).toBe(false); - expect(pressable.props.onStartShouldSetResponder()).toBe(false); + expect(nativeDetector?.props.onStartShouldSetResponder()).toBe(false); expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe(true); expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe( false @@ -158,7 +158,7 @@ describe('[API v3] Components', () => { }); test('does not handle responder event passed through Pressable without keyboardShouldPersistTaps handled', async () => { - const { getByTestId, UNSAFE_getAllByType } = render( + const { UNSAFE_getAllByType } = render( @@ -168,14 +168,14 @@ describe('[API v3] Components', () => { await act(flushImmediate); - const pressable = getByTestId('pressable'); + const nativeDetector = getNativeDetector(UNSAFE_getAllByType); const scrollViewResponder = getScrollViewResponder(UNSAFE_getAllByType); expect(scrollViewResponder).toBeDefined(); expect( scrollViewResponder?.props.onStartShouldSetResponderCapture() ).toBe(false); - expect(pressable.props.onStartShouldSetResponder()).toBe(false); + expect(nativeDetector?.props.onStartShouldSetResponder()).toBe(false); expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe( false ); diff --git a/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx b/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx index 6d5f09de89..e872809bca 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx @@ -45,8 +45,8 @@ import { } from '../hooks'; import { PureNativeButton } from './GestureButtons'; import { + isKeyboardDismissingTap, JSResponderContext, - updateResponderEventValue, } from './ScrollViewResponderInterceptor'; const DEFAULT_LONG_PRESS_DURATION = 500; @@ -92,6 +92,11 @@ const Pressable = (props: PressableProps) => { height: 0, }); + // When the touch that begins a press is the one dismissing the keyboard + // (keyboardShouldPersistTaps="never"), the press is swallowed to match RN's + // touchables. + const dropKeyboardTapRef = useRef(null); + const normalizedHitSlop: Insets = useMemo( () => typeof hitSlop === 'number' @@ -153,11 +158,16 @@ const Pressable = (props: PressableProps) => { const handleFinalize = useCallback(() => { isCurrentlyPressed.current = false; + dropKeyboardTapRef.current = null; cancelLongPress(); cancelDelayedPress(); setPressedState(false); }, [cancelDelayedPress, cancelLongPress]); + const captureKeyboardDismiss = useCallback(() => { + dropKeyboardTapRef.current ??= isKeyboardDismissingTap(jsResponderContext); + }, [jsResponderContext]); + const handlePressIn = useCallback( (event: PressableEvent, skipBoundsCheck = false) => { if ( @@ -265,6 +275,12 @@ const Pressable = (props: PressableProps) => { maxDistance: INT32_MAX, // Stops long press from cancelling on touch move cancelsTouchesInView: false, onTouchesDown: (event) => { + captureKeyboardDismiss(); + + if (dropKeyboardTapRef.current) { + return; + } + const pressableEvent = gestureTouchToPressableEvent(event); stateMachine.handleEvent( StateMachineEvent.LONG_PRESS_TOUCHES_DOWN, @@ -314,6 +330,12 @@ const Pressable = (props: PressableProps) => { } }, onBegin: () => { + captureKeyboardDismiss(); + + if (dropKeyboardTapRef.current) { + return; + } + if (Platform.isTV) { // tvOS drives this native gesture from the focus-engine Select press. // The press state machine is touch-based and never @@ -398,23 +420,11 @@ const Pressable = (props: PressableProps) => { [onLayout] ); - // Let RN components higher in the tree handle JS responder negotiation. - // RNGH ScrollView uses this marker to preserve keyboardShouldPersistTaps='handled' - // when there are no RN responder components between it and this Pressable. - const handleStartShouldSetResponder = useCallback(() => { - if (!disabled) { - updateResponderEventValue(jsResponderContext, true); - } - - return false; - }, [disabled, jsResponderContext]); - const tvProps = getTVProps(remainingProps); return ( 1 || Keyboard?.addListener == null) { + return; + } + + const setVisible = () => { + isSoftKeyboardVisible = true; + }; + const setHidden = () => { + isSoftKeyboardVisible = false; + }; + + keyboardTrackerSubscriptions = [ + Keyboard.addListener('keyboardDidShow', setVisible), + Keyboard.addListener('keyboardWillShow', setVisible), + Keyboard.addListener('keyboardDidHide', setHidden), + ]; +} + +function unsubscribeFromKeyboardVisibility() { + keyboardTrackerRefCount--; + + if (keyboardTrackerRefCount > 0) { + return; + } + + for (const subscription of keyboardTrackerSubscriptions) { + subscription.remove(); + } + keyboardTrackerSubscriptions = []; + isSoftKeyboardVisible = false; +} export type JSResponderContextValue = { isRNGHResponderEvent: React.MutableRefObject; + keyboardShouldPersistTaps: KeyboardShouldPersistTaps; }; export const JSResponderContext = @@ -21,6 +69,19 @@ export function updateResponderEventValue( } } +export function isKeyboardDismissingTap( + jsResponderContext: JSResponderContextValue | null | undefined +): boolean { + if (jsResponderContext == null) { + return false; + } + + const mode = jsResponderContext.keyboardShouldPersistTaps; + const keyboardNeverPersistTaps = !mode || mode === 'never'; + + return keyboardNeverPersistTaps && isSoftKeyboardVisible; +} + type ScrollViewResponderInterceptorProps = PropsWithChildren<{ keyboardShouldPersistTaps?: RNScrollViewProps['keyboardShouldPersistTaps']; }>; @@ -31,10 +92,15 @@ const ScrollViewResponderInterceptor = ({ }: ScrollViewResponderInterceptorProps) => { const isRNGHResponderEvent = useRef(false); const contextValue = useMemo( - () => ({ isRNGHResponderEvent }), - [isRNGHResponderEvent] + () => ({ isRNGHResponderEvent, keyboardShouldPersistTaps }), + [isRNGHResponderEvent, keyboardShouldPersistTaps] ); + useEffect(() => { + subscribeToKeyboardVisibility(); + return () => unsubscribeFromKeyboardVisibility(); + }, []); + const resetRNGHResponderEvent = useCallback(() => { isRNGHResponderEvent.current = false; return false; diff --git a/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.tsx b/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.tsx index bd5e19e9cd..867923b8b1 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.tsx @@ -1,10 +1,14 @@ -import React, { useCallback, useRef } from 'react'; +import React, { use, useCallback, useRef } from 'react'; import { Platform } from 'react-native'; import GestureHandlerButton from '../../../components/GestureHandlerButton'; import { getTVProps } from '../../../components/utils'; import { NativeDetector } from '../../detectors/NativeDetector'; import { useNativeGesture } from '../../hooks'; +import { + isKeyboardDismissingTap, + JSResponderContext, +} from '../ScrollViewResponderInterceptor'; import type { AnimationDuration, CallbackEventType, @@ -101,6 +105,19 @@ export const Touchable = (props: TouchableProps) => { undefined ); + // Swallow the tap that dismisses the keyboard in + // keyboardShouldPersistTaps="never", matching RN's touchables. + const jsResponderContext = use(JSResponderContext); + const dropKeyboardTapRef = useRef(null); + + const captureKeyboardDismiss = useCallback(() => { + dropKeyboardTapRef.current ??= isKeyboardDismissingTap(jsResponderContext); + }, [jsResponderContext]); + + const resetKeyboardDismiss = useCallback(() => { + dropKeyboardTapRef.current = null; + }, []); + const wrappedLongPress = useCallback(() => { longPressDetected.current = true; onLongPress?.(); @@ -119,7 +136,9 @@ export const Touchable = (props: TouchableProps) => { const onBegin = useCallback( (e: CallbackEventType) => { - if (!e.pointerInside) { + captureKeyboardDismiss(); + + if (!e.pointerInside || dropKeyboardTapRef.current) { pointerState.current = PointerState.OUTSIDE; return; } @@ -129,7 +148,7 @@ export const Touchable = (props: TouchableProps) => { pointerState.current = PointerState.INSIDE; }, - [startLongPressTimer, onPressIn] + [captureKeyboardDismiss, startLongPressTimer, onPressIn] ); const onActivate = useCallback((e: CallbackEventType) => { @@ -141,11 +160,19 @@ export const Touchable = (props: TouchableProps) => { const onFinalize = useCallback( (e: EndCallbackEventType) => { - if (pointerState.current === PointerState.INSIDE) { + if ( + !dropKeyboardTapRef.current && + pointerState.current === PointerState.INSIDE + ) { onPressOut?.(e); } - if (!e.canceled && !longPressDetected.current && e.pointerInside) { + if ( + !dropKeyboardTapRef.current && + !e.canceled && + !longPressDetected.current && + e.pointerInside + ) { onPress?.(e); } @@ -155,8 +182,10 @@ export const Touchable = (props: TouchableProps) => { clearTimeout(longPressTimeout.current); longPressTimeout.current = undefined; } + + resetKeyboardDismiss(); }, - [onPressOut, onPress] + [resetKeyboardDismiss, onPressOut, onPress] ); const onUpdate = useCallback( From 3dba8abc979b5e7dfa7a9d8cffb4a3e82ab32975 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Tue, 23 Jun 2026 12:38:11 +0200 Subject: [PATCH 2/2] Tests --- .../src/__tests__/api_v3.test.tsx | 122 +++++++++++++++++- 1 file changed, 121 insertions(+), 1 deletion(-) 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 9941fd5a7a..db6ba57c8f 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 @@ -1,11 +1,15 @@ import { render, renderHook } from '@testing-library/react-native'; import { act } from 'react'; -import { View } from 'react-native'; +import { Keyboard, 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 { + isKeyboardDismissingTap, + type JSResponderContextValue, +} from '../v3/components/ScrollViewResponderInterceptor'; import { GestureDetector } from '../v3/detectors'; import { useSimultaneousGestures } from '../v3/hooks'; import { usePanGesture, useTapGesture } from '../v3/hooks/gestures'; @@ -278,6 +282,122 @@ describe('[API v3] Components', () => { }); }); + describe('keyboardShouldPersistTaps="never" drop', () => { + // The keyboard-visibility tracker subscribes via Keyboard.addListener when a + // ScrollView mounts. We spy on it to grab the captured `keyboardDidShow` + // handler and invoke it, simulating the soft keyboard being open. + const showSoftKeyboard = (addListenerSpy: jest.SpyInstance) => { + const showCall = addListenerSpy.mock.calls.find( + (call) => call[0] === 'keyboardDidShow' + ); + act(() => { + showCall?.[1]?.({ endCoordinates: { height: 300 } }); + }); + }; + + const makeContext = ( + keyboardShouldPersistTaps: JSResponderContextValue['keyboardShouldPersistTaps'] + ): JSResponderContextValue => ({ + isRNGHResponderEvent: { current: false }, + keyboardShouldPersistTaps, + }); + + const fireTap = (testID: string) => + act(() => { + fireGestureHandler(getByGestureTestId(testID), [ + { state: State.BEGAN }, + { state: State.ACTIVE }, + { state: State.END }, + ]); + }); + + test('isKeyboardDismissingTap is true only in never mode while the soft keyboard is visible', async () => { + const addListenerSpy = jest.spyOn(Keyboard, 'addListener'); + + render( + + + + ); + await act(flushImmediate); + + // Keyboard not shown yet -> nothing to dismiss. + expect(isKeyboardDismissingTap(makeContext('never'))).toBe(false); + + showSoftKeyboard(addListenerSpy); + + // `never` (and its default, undefined) drops the tap; the others never do. + expect(isKeyboardDismissingTap(makeContext('never'))).toBe(true); + expect(isKeyboardDismissingTap(makeContext(undefined))).toBe(true); + expect(isKeyboardDismissingTap(makeContext('handled'))).toBe(false); + expect(isKeyboardDismissingTap(makeContext('always'))).toBe(false); + // Outside an RNGH ScrollView there is no context, so nothing is dropped. + expect(isKeyboardDismissingTap(null)).toBe(false); + + addListenerSpy.mockRestore(); + }); + + test('Touchable does NOT fire onPress on the keyboard-dismissing tap (never)', async () => { + const addListenerSpy = jest.spyOn(Keyboard, 'addListener'); + const onPress = jest.fn(); + + render( + + + + + + ); + await act(flushImmediate); + showSoftKeyboard(addListenerSpy); + + fireTap('touchable'); + + expect(onPress).not.toHaveBeenCalled(); + addListenerSpy.mockRestore(); + }); + + test('Touchable fires onPress in never mode when the keyboard is not visible', async () => { + const onPress = jest.fn(); + + render( + + + + + + ); + await act(flushImmediate); + + fireTap('touchable'); + + expect(onPress).toHaveBeenCalledTimes(1); + }); + + test.each(['handled', 'always'] as const)( + 'Touchable fires onPress in %s mode even while the keyboard is visible', + async (mode) => { + const addListenerSpy = jest.spyOn(Keyboard, 'addListener'); + const onPress = jest.fn(); + + render( + + + + + + ); + await act(flushImmediate); + showSoftKeyboard(addListenerSpy); + + fireTap('touchable'); + + expect(onPress).toHaveBeenCalledTimes(1); + addListenerSpy.mockRestore(); + } + ); + }); + describe('Touchable', () => { test('calls onPress on successful press', () => { const pressFn = jest.fn();