From 86e5f3f652a10bc1bb90bc27e623274c95b0ad69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Tue, 11 Aug 2026 10:32:02 +0200 Subject: [PATCH 01/10] Pressable with Touchable --- .../src/__tests__/api_v3.test.tsx | 12 +- .../src/v3/components/Pressable.tsx | 477 ++---------------- .../v3/components/PressableWithTouchable.tsx | 239 +++++++++ .../src/v3/components/StatefulPressable.tsx | 453 +++++++++++++++++ 4 files changed, 729 insertions(+), 452 deletions(-) create mode 100644 packages/react-native-gesture-handler/src/v3/components/PressableWithTouchable.tsx create mode 100644 packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx 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 26010ddd4b..6ba37d84ce 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 @@ -256,14 +256,18 @@ describe('[API v3] Components', () => { await act(flushImmediate); - const nativeDetector = getNativeDetector(UNSAFE_getAllByType); const scrollViewResponder = getScrollViewResponder(UNSAFE_getAllByType); + const button = screen.getByTestId('pressable'); + // Default Pressable delegates to the native-button Touchable, which marks + // the tap as RNGH-handled via the button's capture handler. expect(scrollViewResponder).toBeDefined(); expect( scrollViewResponder?.props.onStartShouldSetResponderCapture() ).toBe(false); - expect(nativeDetector?.props.onStartShouldSetResponder()).toBe(false); + // The button marks the tap as RNGH-handled without claiming it... + expect(button.props.onStartShouldSetResponderCapture()).toBe(false); + // ...so the logical responder claims it and the mark is consumed. expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe(true); expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe( false @@ -281,13 +285,13 @@ describe('[API v3] Components', () => { await act(flushImmediate); - const nativeDetector = getNativeDetector(UNSAFE_getAllByType); const scrollViewResponder = getScrollViewResponder(UNSAFE_getAllByType); + const button = screen.getByTestId('pressable'); // Outside of 'handled' mode the logical responder view is not rendered // at all — the responder event can never be claimed on behalf of RNGH. expect(scrollViewResponder).toBeUndefined(); - expect(nativeDetector?.props.onStartShouldSetResponder()).toBe(false); + expect(button.props.onStartShouldSetResponderCapture()).toBe(false); }); test('handles responder event passed through NativeDetector for keyboardShouldPersistTaps handled', async () => { 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 7938d8629d..1c978a9596 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx @@ -1,452 +1,33 @@ -import React, { - use, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; -import type { - Insets, - LayoutChangeEvent, - StyleProp, - ViewStyle, -} from 'react-native'; -import { Platform } from 'react-native'; - -import type { - PressableDimensions, - PressableEvent, - PressableProps, -} from '../../components/Pressable/PressableProps'; -import { - getStatesConfig, - StateMachineEvent, -} from '../../components/Pressable/stateDefinitions'; -import { PressableStateMachine } from '../../components/Pressable/StateMachine'; -import { - addInsets, - gestureToPressableEvent, - gestureTouchToPressableEvent, - isTouchWithinInset, - numberAsInset, - viewCenterToPressableEvent, -} from '../../components/Pressable/utils'; -import { getTVProps } from '../../components/utils'; -import { PressabilityDebugView } from '../../handlers/PressabilityDebugView'; -import { useIsScreenReaderEnabled } from '../../useIsScreenReaderEnabled'; -import { INT32_MAX, isTestEnv } from '../../utils'; -import { GestureDetector } from '../detectors'; -import { - useHoverGesture, - useLongPressGesture, - useNativeGesture, - useSimultaneousGestures, -} from '../hooks'; -import { - isKeyboardDismissingTap, - JSResponderContext, -} from '../scrollViewInterop'; -import { PureNativeButton } from './GestureButtons'; - -const DEFAULT_LONG_PRESS_DURATION = 500; -const IS_TEST_ENV = isTestEnv(); - +import React, { useRef } from 'react'; + +import type { PressableProps } from '../../components/Pressable/PressableProps'; +import PressableWithTouchable from './PressableWithTouchable'; +import StatefulPressable from './StatefulPressable'; + +/** + * `Pressable` dispatches between two implementations: + * + * - {@link StatefulPressable} — the state-machine engine, used whenever any of + * the `simultaneousWith` / `requireToFail` / `block` relation props is passed, + * since coordinating the press with an external gesture needs the composed + * gesture recognizers. + * - {@link PressableWithTouchable} — the simpler engine built on the native + * button `Touchable`, used for everything else (the common case). + * + * The choice is made once, at mount, so conditionally adding or removing a + * relation prop later cannot swap engines mid-life and lose the current press. + */ const Pressable = (props: PressableProps) => { - const { - testOnly_pressed, - hitSlop, - pressRetentionOffset, - delayHoverIn, - delayHoverOut, - delayLongPress, - unstable_pressDelay, - onHoverIn, - onHoverOut, - onPress, - onPressIn, - onPressOut, - onLongPress, - onLayout, - style, - children, - android_disableSound, - android_ripple, - disabled, - accessible, - simultaneousWith, - requireToFail, - block, - ref, - ...remainingProps - } = props; - - const [pressedState, setPressedState] = useState(testOnly_pressed ?? false); - - const longPressTimeoutRef = useRef(null); - const pressDelayTimeoutRef = useRef(null); - const isOnPressAllowed = useRef(true); - const jsResponderContext = use(JSResponderContext); - const isCurrentlyPressed = useRef(false); - const dimensions = useRef({ - width: 0, - 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' - ? numberAsInset(hitSlop) - : (hitSlop ?? numberAsInset(0)), - [hitSlop] - ); - const normalizedPressRetentionOffset: Insets = useMemo( - () => - typeof pressRetentionOffset === 'number' - ? numberAsInset(pressRetentionOffset) - : (pressRetentionOffset ?? {}), - [pressRetentionOffset] - ); - const appliedHitSlop = addInsets( - normalizedHitSlop, - normalizedPressRetentionOffset - ); - - const cancelLongPress = useCallback(() => { - if (longPressTimeoutRef.current) { - clearTimeout(longPressTimeoutRef.current); - longPressTimeoutRef.current = null; - isOnPressAllowed.current = true; - } - }, []); - - const cancelDelayedPress = useCallback(() => { - if (pressDelayTimeoutRef.current) { - clearTimeout(pressDelayTimeoutRef.current); - pressDelayTimeoutRef.current = null; - } - }, []); - - const startLongPress = useCallback( - (event: PressableEvent) => { - if (onLongPress) { - cancelLongPress(); - longPressTimeoutRef.current = setTimeout(() => { - isOnPressAllowed.current = false; - onLongPress(event); - }, delayLongPress ?? DEFAULT_LONG_PRESS_DURATION); - } - }, - [onLongPress, cancelLongPress, delayLongPress] - ); - const innerHandlePressIn = useCallback( - (event: PressableEvent) => { - onPressIn?.(event); - startLongPress(event); - setPressedState(true); - if (pressDelayTimeoutRef.current) { - clearTimeout(pressDelayTimeoutRef.current); - pressDelayTimeoutRef.current = null; - } - }, - [onPressIn, startLongPress] - ); - - 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 ( - !skipBoundsCheck && - !isTouchWithinInset( - dimensions.current, - normalizedHitSlop, - event.nativeEvent.changedTouches.at(-1) - ) - ) { - // Ignoring pressIn within pressRetentionOffset - return; - } - - isCurrentlyPressed.current = true; - if (unstable_pressDelay) { - pressDelayTimeoutRef.current = setTimeout(() => { - innerHandlePressIn(event); - }, unstable_pressDelay); - } else { - innerHandlePressIn(event); - } - }, - [innerHandlePressIn, normalizedHitSlop, unstable_pressDelay] - ); - - const handlePressOut = useCallback( - (event: PressableEvent, success: boolean = true) => { - if (!isCurrentlyPressed.current) { - // Some prop configurations may lead to handlePressOut being called mutliple times. - return; - } - - isCurrentlyPressed.current = false; - - if (pressDelayTimeoutRef.current) { - innerHandlePressIn(event); - } - - onPressOut?.(event); - - if (isOnPressAllowed.current && success) { - onPress?.(event); - } - - handleFinalize(); - }, - [handleFinalize, innerHandlePressIn, onPress, onPressOut] - ); - - const stateMachine = useMemo(() => new PressableStateMachine(), []); - const isScreenReaderEnabled = useIsScreenReaderEnabled(); - - useEffect(() => { - const configuration = getStatesConfig( - handlePressIn, - handlePressOut, - isScreenReaderEnabled - ); - stateMachine.setStates(configuration); - }, [handlePressIn, handlePressOut, stateMachine, isScreenReaderEnabled]); - - const hoverInTimeout = useRef(null); - const hoverOutTimeout = useRef(null); - - const hoverGesture = useHoverGesture({ - manualActivation: true, // Prevents Hover blocking Native gesture on web - cancelsTouchesInView: false, - onBegin: (event) => { - if (hoverOutTimeout.current) { - clearTimeout(hoverOutTimeout.current); - } - if (delayHoverIn) { - hoverInTimeout.current = setTimeout( - () => onHoverIn?.(gestureToPressableEvent(event)), - delayHoverIn - ); - return; - } - onHoverIn?.(gestureToPressableEvent(event)); - }, - onFinalize: (event) => { - if (hoverInTimeout.current) { - clearTimeout(hoverInTimeout.current); - } - if (delayHoverOut) { - hoverOutTimeout.current = setTimeout( - () => onHoverOut?.(gestureToPressableEvent(event)), - delayHoverOut - ); - return; - } - onHoverOut?.(gestureToPressableEvent(event)); - }, - enabled: disabled !== true, - disableReanimated: true, - simultaneousWith, - block, - requireToFail, - hitSlop: appliedHitSlop, - }); - - const pressAndTouchGesture = useLongPressGesture({ - minDuration: Platform.OS === 'web' ? 0 : INT32_MAX, // Long press handles finalize on web, thus it must activate right away - 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, - pressableEvent - ); - }, - onTouchesUp: () => { - if (Platform.OS === 'android' && !isScreenReaderEnabled) { - // Prevents potential soft-locks - stateMachine.reset(); - handleFinalize(); - } - }, - onTouchesCancel: (event) => { - const pressableEvent = gestureTouchToPressableEvent(event); - stateMachine.reset(); - handlePressOut(pressableEvent, false); - }, - onFinalize: (event) => { - if (Platform.OS !== 'web') { - return; - } - - stateMachine.handleEvent( - event.canceled ? StateMachineEvent.CANCEL : StateMachineEvent.FINALIZE - ); - - handleFinalize(); - }, - enabled: disabled !== true, - disableReanimated: true, - simultaneousWith: simultaneousWith, - block: block, - requireToFail: requireToFail, - hitSlop: appliedHitSlop, - }); - - // RNButton is placed inside ButtonGesture to enable Android's ripple and to capture non-propagating events - const buttonGesture = useNativeGesture({ - onTouchesCancel: (event) => { - if (Platform.OS !== 'macos' && Platform.OS !== 'web') { - // On MacOS cancel occurs in middle of gesture - // On Web cancel occurs on mouse move, which is unwanted - const pressableEvent = gestureTouchToPressableEvent(event); - stateMachine.reset(); - handlePressOut(pressableEvent, false); - } - }, - 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 - // receives LONG_PRESS_TOUCHES_DOWN here, so bypass it and drive the press handlers directly. - // A focus-driven press has no coordinates, so skip the hit-slop bounds check entirely. - handlePressIn(viewCenterToPressableEvent(dimensions.current), true); - return; - } - if (Platform.OS === 'android' && isScreenReaderEnabled) { - stateMachine.handleEvent( - StateMachineEvent.NATIVE_BEGIN, - viewCenterToPressableEvent(dimensions.current) - ); - return; - } - stateMachine.handleEvent(StateMachineEvent.NATIVE_BEGIN); - }, - onActivate: () => { - if (!Platform.isTV && Platform.OS !== 'android') { - stateMachine.handleEvent(StateMachineEvent.NATIVE_START); - } - }, - onFinalize: (event) => { - // On Web we use LongPress.onFinalize instead of Native.onFinalize, - // as Native cancels on mouse move, and LongPress does not. - if (Platform.OS === 'web') { - return; - } - - if (Platform.isTV) { - handlePressOut( - viewCenterToPressableEvent(dimensions.current), - !event.canceled - ); - handleFinalize(); - return; - } - - stateMachine.handleEvent( - event.canceled ? StateMachineEvent.CANCEL : StateMachineEvent.FINALIZE - ); - - handleFinalize(); - }, - enabled: disabled !== true, - disableReanimated: true, - simultaneousWith, - block, - requireToFail, - hitSlop: appliedHitSlop, - shouldActivateOnStart: Platform.OS === 'web', - }); - - const gesture = useSimultaneousGestures( - buttonGesture, - pressAndTouchGesture, - hoverGesture - ); - - // `cursor: 'pointer'` on `RNButton` crashes iOS - const pointerStyle: StyleProp = - Platform.OS === 'web' ? { cursor: 'pointer' } : {}; - - const styleProp = - typeof style === 'function' ? style({ pressed: pressedState }) : style; - - const childrenProp = - typeof children === 'function' - ? children({ pressed: pressedState }) - : children; - - const rippleColor = useMemo(() => { - const defaultRippleColor = android_ripple ? undefined : 'transparent'; - return android_ripple?.color ?? defaultRippleColor; - }, [android_ripple]); - - const setDimensions = useCallback( - (event: LayoutChangeEvent) => { - onLayout?.(event); - dimensions.current = event.nativeEvent.layout; - }, - [onLayout] - ); - - const tvProps = getTVProps(remainingProps); - - return ( - - >} - {...tvProps} - onLayout={setDimensions} - accessible={accessible !== false} - hitSlop={appliedHitSlop} - enabled={disabled !== true} - touchSoundDisabled={android_disableSound ?? undefined} - rippleColor={rippleColor} - rippleRadius={android_ripple?.radius ?? undefined} - style={[pointerStyle, styleProp]} - testOnly_onPress={IS_TEST_ENV ? onPress : undefined} - testOnly_onPressIn={IS_TEST_ENV ? onPressIn : undefined} - testOnly_onPressOut={IS_TEST_ENV ? onPressOut : undefined} - testOnly_onLongPress={IS_TEST_ENV ? onLongPress : undefined}> - {childrenProp} - {__DEV__ ? ( - - ) : null} - - + const usesRelations = useRef( + props.simultaneousWith != null || + props.requireToFail != null || + props.block != null + ).current; + + return usesRelations ? ( + + ) : ( + ); }; diff --git a/packages/react-native-gesture-handler/src/v3/components/PressableWithTouchable.tsx b/packages/react-native-gesture-handler/src/v3/components/PressableWithTouchable.tsx new file mode 100644 index 0000000000..5d151cf79a --- /dev/null +++ b/packages/react-native-gesture-handler/src/v3/components/PressableWithTouchable.tsx @@ -0,0 +1,239 @@ +import React, { useEffect, useRef, useState } from 'react'; +import type { Insets } from 'react-native'; + +import type { ButtonEvent } from '../../components/GestureHandlerButton'; +import type { + InnerPressableEvent, + PressableProps, +} from '../../components/Pressable/PressableProps'; +import { addInsets, numberAsInset } from '../../components/Pressable/utils'; +import { PressabilityDebugView } from '../../handlers/PressabilityDebugView'; +import { Touchable } from './Touchable/Touchable'; + +// The native button reports coordinates on every press/hover event, so a +// `ButtonEvent` carries everything a `PressableEvent` exposes. There is no touch +// list at this layer, so `touches`/`changedTouches` mirror the single point +// (matching the other Pressable event converters). +function buttonToPressableEvent(event: ButtonEvent) { + const timestamp = Date.now(); + const inner: InnerPressableEvent = { + identifier: 0, + locationX: event.x, + locationY: event.y, + pageX: event.absoluteX, + pageY: event.absoluteY, + target: 0, + timestamp, + touches: [], + changedTouches: [], + force: undefined, + }; + + return { + nativeEvent: { + ...inner, + touches: [inner], + changedTouches: [inner], + }, + }; +} + +function normalizeInset(value: Insets | number | null | undefined): Insets { + return typeof value === 'number' + ? numberAsInset(value) + : (value ?? numberAsInset(0)); +} + +type Timers = { + press: ReturnType | null; + hoverIn: ReturnType | null; + hoverOut: ReturnType | null; +}; + +const PressableWithTouchable = (props: PressableProps) => { + const { + testOnly_pressed, + hitSlop, + pressRetentionOffset, + delayHoverIn, + delayHoverOut, + delayLongPress, + unstable_pressDelay, + onHoverIn, + onHoverOut, + onPress, + onPressIn, + onPressOut, + onLongPress, + onLayout, + style, + children, + android_disableSound, + android_ripple, + disabled, + accessible, + ref, + ...rest + } = props; + + // Pull the props that must not reach Touchable: `cancelable` / + // `dimensionsAfterResize` are unsupported here, and the relation props are + // handled by the wrapper (which routes them to the stateful implementation). + // + /* eslint-disable @typescript-eslint/no-unused-vars */ + const { + cancelable, + dimensionsAfterResize, + simultaneousWith, + requireToFail, + block, + ...remainingProps + } = rest; + /* eslint-enable @typescript-eslint/no-unused-vars */ + + const [pressed, setPressed] = useState(testOnly_pressed ?? false); + const timers = useRef({ press: null, hoverIn: null, hoverOut: null }); + + // Clear any pending timers on unmount so a delayed callback never fires into + // a torn-down component. + useEffect( + () => () => { + const pending = timers.current; + + if (pending.press) { + clearTimeout(pending.press); + } + + if (pending.hoverIn) { + clearTimeout(pending.hoverIn); + } + + if (pending.hoverOut) { + clearTimeout(pending.hoverOut); + } + }, + [] + ); + + // RN's ripple config allows `null` on every field; Touchable's excludes it. + // Normalize `null` → `undefined` so the types line up. + const androidRipple = android_ripple + ? { + color: android_ripple.color ?? undefined, + borderless: android_ripple.borderless ?? undefined, + radius: android_ripple.radius ?? undefined, + foreground: android_ripple.foreground ?? undefined, + } + : undefined; + + const appliedHitSlop = addInsets( + normalizeInset(hitSlop), + normalizeInset(pressRetentionOffset) + ); + + const firePressIn = (event: ButtonEvent) => { + setPressed(true); + onPressIn?.(buttonToPressableEvent(event)); + }; + + const handlePressIn = (event: ButtonEvent) => { + if (unstable_pressDelay) { + timers.current.press = setTimeout(() => { + timers.current.press = null; + firePressIn(event); + }, unstable_pressDelay); + return; + } + firePressIn(event); + }; + + const handlePressOut = (event: ButtonEvent) => { + // If the touch is released before `unstable_pressDelay` elapses, RN still + // emits the deferred `onPressIn` before `onPressOut` — flush it now. + if (timers.current.press) { + clearTimeout(timers.current.press); + timers.current.press = null; + firePressIn(event); + } + + setPressed(false); + onPressOut?.(buttonToPressableEvent(event)); + }; + + const handleHoverIn = onHoverIn + ? (event: ButtonEvent) => { + if (timers.current.hoverOut) { + clearTimeout(timers.current.hoverOut); + timers.current.hoverOut = null; + } + + if (delayHoverIn) { + timers.current.hoverIn = setTimeout(() => { + timers.current.hoverIn = null; + onHoverIn(buttonToPressableEvent(event)); + }, delayHoverIn); + return; + } + + onHoverIn(buttonToPressableEvent(event)); + } + : undefined; + + const handleHoverOut = onHoverOut + ? (event: ButtonEvent) => { + if (timers.current.hoverIn) { + clearTimeout(timers.current.hoverIn); + timers.current.hoverIn = null; + } + + if (delayHoverOut) { + timers.current.hoverOut = setTimeout(() => { + timers.current.hoverOut = null; + onHoverOut(buttonToPressableEvent(event)); + }, delayHoverOut); + return; + } + + onHoverOut(buttonToPressableEvent(event)); + } + : undefined; + + const resolvedStyle = + typeof style === 'function' ? style({ pressed }) : style; + + const resolvedChildren = + typeof children === 'function' ? children({ pressed }) : children; + + return ( + onPress(buttonToPressableEvent(event)) : undefined + } + onLongPress={ + onLongPress + ? (event) => onLongPress(buttonToPressableEvent(event)) + : undefined + } + onHoverIn={handleHoverIn} + onHoverOut={handleHoverOut}> + {resolvedChildren} + {__DEV__ ? ( + + ) : null} + + ); +}; + +export default PressableWithTouchable; diff --git a/packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx b/packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx new file mode 100644 index 0000000000..7938d8629d --- /dev/null +++ b/packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx @@ -0,0 +1,453 @@ +import React, { + use, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import type { + Insets, + LayoutChangeEvent, + StyleProp, + ViewStyle, +} from 'react-native'; +import { Platform } from 'react-native'; + +import type { + PressableDimensions, + PressableEvent, + PressableProps, +} from '../../components/Pressable/PressableProps'; +import { + getStatesConfig, + StateMachineEvent, +} from '../../components/Pressable/stateDefinitions'; +import { PressableStateMachine } from '../../components/Pressable/StateMachine'; +import { + addInsets, + gestureToPressableEvent, + gestureTouchToPressableEvent, + isTouchWithinInset, + numberAsInset, + viewCenterToPressableEvent, +} from '../../components/Pressable/utils'; +import { getTVProps } from '../../components/utils'; +import { PressabilityDebugView } from '../../handlers/PressabilityDebugView'; +import { useIsScreenReaderEnabled } from '../../useIsScreenReaderEnabled'; +import { INT32_MAX, isTestEnv } from '../../utils'; +import { GestureDetector } from '../detectors'; +import { + useHoverGesture, + useLongPressGesture, + useNativeGesture, + useSimultaneousGestures, +} from '../hooks'; +import { + isKeyboardDismissingTap, + JSResponderContext, +} from '../scrollViewInterop'; +import { PureNativeButton } from './GestureButtons'; + +const DEFAULT_LONG_PRESS_DURATION = 500; +const IS_TEST_ENV = isTestEnv(); + +const Pressable = (props: PressableProps) => { + const { + testOnly_pressed, + hitSlop, + pressRetentionOffset, + delayHoverIn, + delayHoverOut, + delayLongPress, + unstable_pressDelay, + onHoverIn, + onHoverOut, + onPress, + onPressIn, + onPressOut, + onLongPress, + onLayout, + style, + children, + android_disableSound, + android_ripple, + disabled, + accessible, + simultaneousWith, + requireToFail, + block, + ref, + ...remainingProps + } = props; + + const [pressedState, setPressedState] = useState(testOnly_pressed ?? false); + + const longPressTimeoutRef = useRef(null); + const pressDelayTimeoutRef = useRef(null); + const isOnPressAllowed = useRef(true); + const jsResponderContext = use(JSResponderContext); + const isCurrentlyPressed = useRef(false); + const dimensions = useRef({ + width: 0, + 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' + ? numberAsInset(hitSlop) + : (hitSlop ?? numberAsInset(0)), + [hitSlop] + ); + const normalizedPressRetentionOffset: Insets = useMemo( + () => + typeof pressRetentionOffset === 'number' + ? numberAsInset(pressRetentionOffset) + : (pressRetentionOffset ?? {}), + [pressRetentionOffset] + ); + const appliedHitSlop = addInsets( + normalizedHitSlop, + normalizedPressRetentionOffset + ); + + const cancelLongPress = useCallback(() => { + if (longPressTimeoutRef.current) { + clearTimeout(longPressTimeoutRef.current); + longPressTimeoutRef.current = null; + isOnPressAllowed.current = true; + } + }, []); + + const cancelDelayedPress = useCallback(() => { + if (pressDelayTimeoutRef.current) { + clearTimeout(pressDelayTimeoutRef.current); + pressDelayTimeoutRef.current = null; + } + }, []); + + const startLongPress = useCallback( + (event: PressableEvent) => { + if (onLongPress) { + cancelLongPress(); + longPressTimeoutRef.current = setTimeout(() => { + isOnPressAllowed.current = false; + onLongPress(event); + }, delayLongPress ?? DEFAULT_LONG_PRESS_DURATION); + } + }, + [onLongPress, cancelLongPress, delayLongPress] + ); + const innerHandlePressIn = useCallback( + (event: PressableEvent) => { + onPressIn?.(event); + startLongPress(event); + setPressedState(true); + if (pressDelayTimeoutRef.current) { + clearTimeout(pressDelayTimeoutRef.current); + pressDelayTimeoutRef.current = null; + } + }, + [onPressIn, startLongPress] + ); + + 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 ( + !skipBoundsCheck && + !isTouchWithinInset( + dimensions.current, + normalizedHitSlop, + event.nativeEvent.changedTouches.at(-1) + ) + ) { + // Ignoring pressIn within pressRetentionOffset + return; + } + + isCurrentlyPressed.current = true; + if (unstable_pressDelay) { + pressDelayTimeoutRef.current = setTimeout(() => { + innerHandlePressIn(event); + }, unstable_pressDelay); + } else { + innerHandlePressIn(event); + } + }, + [innerHandlePressIn, normalizedHitSlop, unstable_pressDelay] + ); + + const handlePressOut = useCallback( + (event: PressableEvent, success: boolean = true) => { + if (!isCurrentlyPressed.current) { + // Some prop configurations may lead to handlePressOut being called mutliple times. + return; + } + + isCurrentlyPressed.current = false; + + if (pressDelayTimeoutRef.current) { + innerHandlePressIn(event); + } + + onPressOut?.(event); + + if (isOnPressAllowed.current && success) { + onPress?.(event); + } + + handleFinalize(); + }, + [handleFinalize, innerHandlePressIn, onPress, onPressOut] + ); + + const stateMachine = useMemo(() => new PressableStateMachine(), []); + const isScreenReaderEnabled = useIsScreenReaderEnabled(); + + useEffect(() => { + const configuration = getStatesConfig( + handlePressIn, + handlePressOut, + isScreenReaderEnabled + ); + stateMachine.setStates(configuration); + }, [handlePressIn, handlePressOut, stateMachine, isScreenReaderEnabled]); + + const hoverInTimeout = useRef(null); + const hoverOutTimeout = useRef(null); + + const hoverGesture = useHoverGesture({ + manualActivation: true, // Prevents Hover blocking Native gesture on web + cancelsTouchesInView: false, + onBegin: (event) => { + if (hoverOutTimeout.current) { + clearTimeout(hoverOutTimeout.current); + } + if (delayHoverIn) { + hoverInTimeout.current = setTimeout( + () => onHoverIn?.(gestureToPressableEvent(event)), + delayHoverIn + ); + return; + } + onHoverIn?.(gestureToPressableEvent(event)); + }, + onFinalize: (event) => { + if (hoverInTimeout.current) { + clearTimeout(hoverInTimeout.current); + } + if (delayHoverOut) { + hoverOutTimeout.current = setTimeout( + () => onHoverOut?.(gestureToPressableEvent(event)), + delayHoverOut + ); + return; + } + onHoverOut?.(gestureToPressableEvent(event)); + }, + enabled: disabled !== true, + disableReanimated: true, + simultaneousWith, + block, + requireToFail, + hitSlop: appliedHitSlop, + }); + + const pressAndTouchGesture = useLongPressGesture({ + minDuration: Platform.OS === 'web' ? 0 : INT32_MAX, // Long press handles finalize on web, thus it must activate right away + 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, + pressableEvent + ); + }, + onTouchesUp: () => { + if (Platform.OS === 'android' && !isScreenReaderEnabled) { + // Prevents potential soft-locks + stateMachine.reset(); + handleFinalize(); + } + }, + onTouchesCancel: (event) => { + const pressableEvent = gestureTouchToPressableEvent(event); + stateMachine.reset(); + handlePressOut(pressableEvent, false); + }, + onFinalize: (event) => { + if (Platform.OS !== 'web') { + return; + } + + stateMachine.handleEvent( + event.canceled ? StateMachineEvent.CANCEL : StateMachineEvent.FINALIZE + ); + + handleFinalize(); + }, + enabled: disabled !== true, + disableReanimated: true, + simultaneousWith: simultaneousWith, + block: block, + requireToFail: requireToFail, + hitSlop: appliedHitSlop, + }); + + // RNButton is placed inside ButtonGesture to enable Android's ripple and to capture non-propagating events + const buttonGesture = useNativeGesture({ + onTouchesCancel: (event) => { + if (Platform.OS !== 'macos' && Platform.OS !== 'web') { + // On MacOS cancel occurs in middle of gesture + // On Web cancel occurs on mouse move, which is unwanted + const pressableEvent = gestureTouchToPressableEvent(event); + stateMachine.reset(); + handlePressOut(pressableEvent, false); + } + }, + 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 + // receives LONG_PRESS_TOUCHES_DOWN here, so bypass it and drive the press handlers directly. + // A focus-driven press has no coordinates, so skip the hit-slop bounds check entirely. + handlePressIn(viewCenterToPressableEvent(dimensions.current), true); + return; + } + if (Platform.OS === 'android' && isScreenReaderEnabled) { + stateMachine.handleEvent( + StateMachineEvent.NATIVE_BEGIN, + viewCenterToPressableEvent(dimensions.current) + ); + return; + } + stateMachine.handleEvent(StateMachineEvent.NATIVE_BEGIN); + }, + onActivate: () => { + if (!Platform.isTV && Platform.OS !== 'android') { + stateMachine.handleEvent(StateMachineEvent.NATIVE_START); + } + }, + onFinalize: (event) => { + // On Web we use LongPress.onFinalize instead of Native.onFinalize, + // as Native cancels on mouse move, and LongPress does not. + if (Platform.OS === 'web') { + return; + } + + if (Platform.isTV) { + handlePressOut( + viewCenterToPressableEvent(dimensions.current), + !event.canceled + ); + handleFinalize(); + return; + } + + stateMachine.handleEvent( + event.canceled ? StateMachineEvent.CANCEL : StateMachineEvent.FINALIZE + ); + + handleFinalize(); + }, + enabled: disabled !== true, + disableReanimated: true, + simultaneousWith, + block, + requireToFail, + hitSlop: appliedHitSlop, + shouldActivateOnStart: Platform.OS === 'web', + }); + + const gesture = useSimultaneousGestures( + buttonGesture, + pressAndTouchGesture, + hoverGesture + ); + + // `cursor: 'pointer'` on `RNButton` crashes iOS + const pointerStyle: StyleProp = + Platform.OS === 'web' ? { cursor: 'pointer' } : {}; + + const styleProp = + typeof style === 'function' ? style({ pressed: pressedState }) : style; + + const childrenProp = + typeof children === 'function' + ? children({ pressed: pressedState }) + : children; + + const rippleColor = useMemo(() => { + const defaultRippleColor = android_ripple ? undefined : 'transparent'; + return android_ripple?.color ?? defaultRippleColor; + }, [android_ripple]); + + const setDimensions = useCallback( + (event: LayoutChangeEvent) => { + onLayout?.(event); + dimensions.current = event.nativeEvent.layout; + }, + [onLayout] + ); + + const tvProps = getTVProps(remainingProps); + + return ( + + >} + {...tvProps} + onLayout={setDimensions} + accessible={accessible !== false} + hitSlop={appliedHitSlop} + enabled={disabled !== true} + touchSoundDisabled={android_disableSound ?? undefined} + rippleColor={rippleColor} + rippleRadius={android_ripple?.radius ?? undefined} + style={[pointerStyle, styleProp]} + testOnly_onPress={IS_TEST_ENV ? onPress : undefined} + testOnly_onPressIn={IS_TEST_ENV ? onPressIn : undefined} + testOnly_onPressOut={IS_TEST_ENV ? onPressOut : undefined} + testOnly_onLongPress={IS_TEST_ENV ? onLongPress : undefined}> + {childrenProp} + {__DEV__ ? ( + + ) : null} + + + ); +}; + +export default Pressable; From 78a90700820329b9e875012caa7ff2fd4c2744ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Tue, 11 Aug 2026 10:46:23 +0200 Subject: [PATCH 02/10] unvibe comments --- .../src/__tests__/api_v3.test.tsx | 4 ---- 1 file changed, 4 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 6ba37d84ce..ccfd163fdb 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 @@ -259,15 +259,11 @@ describe('[API v3] Components', () => { const scrollViewResponder = getScrollViewResponder(UNSAFE_getAllByType); const button = screen.getByTestId('pressable'); - // Default Pressable delegates to the native-button Touchable, which marks - // the tap as RNGH-handled via the button's capture handler. expect(scrollViewResponder).toBeDefined(); expect( scrollViewResponder?.props.onStartShouldSetResponderCapture() ).toBe(false); - // The button marks the tap as RNGH-handled without claiming it... expect(button.props.onStartShouldSetResponderCapture()).toBe(false); - // ...so the logical responder claims it and the mark is consumed. expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe(true); expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe( false From 72a1242f3788588b98bb0386a03890f10a60b17b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Tue, 11 Aug 2026 11:43:22 +0200 Subject: [PATCH 03/10] typo --- .../src/v3/components/StatefulPressable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx b/packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx index 7938d8629d..05626ff2da 100644 --- a/packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx @@ -198,7 +198,7 @@ const Pressable = (props: PressableProps) => { const handlePressOut = useCallback( (event: PressableEvent, success: boolean = true) => { if (!isCurrentlyPressed.current) { - // Some prop configurations may lead to handlePressOut being called mutliple times. + // Some prop configurations may lead to handlePressOut being called multiple times. return; } From 05639e322d605058fbde8399bf0af7f561433788 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Tue, 11 Aug 2026 12:08:34 +0200 Subject: [PATCH 04/10] Review + retention offset --- .../v3/components/PressableWithTouchable.tsx | 100 +++++++++++++----- 1 file changed, 73 insertions(+), 27 deletions(-) diff --git a/packages/react-native-gesture-handler/src/v3/components/PressableWithTouchable.tsx b/packages/react-native-gesture-handler/src/v3/components/PressableWithTouchable.tsx index 5d151cf79a..5daba56f1f 100644 --- a/packages/react-native-gesture-handler/src/v3/components/PressableWithTouchable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/PressableWithTouchable.tsx @@ -1,33 +1,38 @@ import React, { useEffect, useRef, useState } from 'react'; -import type { Insets } from 'react-native'; +import type { Insets, LayoutChangeEvent } from 'react-native'; import type { ButtonEvent } from '../../components/GestureHandlerButton'; import type { InnerPressableEvent, + PressableDimensions, + PressableEvent, PressableProps, } from '../../components/Pressable/PressableProps'; -import { addInsets, numberAsInset } from '../../components/Pressable/utils'; +import { + addInsets, + isTouchWithinInset, + numberAsInset, +} from '../../components/Pressable/utils'; import { PressabilityDebugView } from '../../handlers/PressabilityDebugView'; import { Touchable } from './Touchable/Touchable'; -// The native button reports coordinates on every press/hover event, so a -// `ButtonEvent` carries everything a `PressableEvent` exposes. There is no touch -// list at this layer, so `touches`/`changedTouches` mirror the single point -// (matching the other Pressable event converters). -function buttonToPressableEvent(event: ButtonEvent) { - const timestamp = Date.now(); - const inner: InnerPressableEvent = { +function buttonToInner(event: ButtonEvent): InnerPressableEvent { + return { identifier: 0, locationX: event.x, locationY: event.y, pageX: event.absoluteX, pageY: event.absoluteY, target: 0, - timestamp, + timestamp: Date.now(), touches: [], changedTouches: [], force: undefined, }; +} + +function buttonToPressableEvent(event: ButtonEvent) { + const inner = buttonToInner(event); return { nativeEvent: { @@ -38,6 +43,9 @@ function buttonToPressableEvent(event: ButtonEvent) { }; } +// RN's Pressable default. Touchable's own default is 600ms +const DEFAULT_LONG_PRESS_DURATION = 500; + function normalizeInset(value: Insets | number | null | undefined): Insets { return typeof value === 'number' ? numberAsInset(value) @@ -76,9 +84,8 @@ const PressableWithTouchable = (props: PressableProps) => { ...rest } = props; - // Pull the props that must not reach Touchable: `cancelable` / - // `dimensionsAfterResize` are unsupported here, and the relation props are - // handled by the wrapper (which routes them to the stateful implementation). + // Drop props Touchable doesn't take: `cancelable`/`dimensionsAfterResize` are + // unsupported; the relation props are handled by the wrapper. // /* eslint-disable @typescript-eslint/no-unused-vars */ const { @@ -93,9 +100,11 @@ const PressableWithTouchable = (props: PressableProps) => { const [pressed, setPressed] = useState(testOnly_pressed ?? false); const timers = useRef({ press: null, hoverIn: null, hoverOut: null }); + const dimensions = useRef({ width: 0, height: 0 }); + + // Whether the in-progress press activated within hitSlop (see handlePressIn). + const isActive = useRef(false); - // Clear any pending timers on unmount so a delayed callback never fires into - // a torn-down component. useEffect( () => () => { const pending = timers.current; @@ -126,8 +135,12 @@ const PressableWithTouchable = (props: PressableProps) => { } : undefined; + // Activation is gated to `normalizedHitSlop` (see handlePressIn); the native + // button gets the wider `appliedHitSlop` so an active press is retained out + // to hitSlop + pressRetentionOffset before it cancels. + const normalizedHitSlop = normalizeInset(hitSlop); const appliedHitSlop = addInsets( - normalizeInset(hitSlop), + normalizedHitSlop, normalizeInset(pressRetentionOffset) ); @@ -137,17 +150,40 @@ const PressableWithTouchable = (props: PressableProps) => { }; const handlePressIn = (event: ButtonEvent) => { + // A down in the retention-only zone is held by the button but isn't a press. + isActive.current = isTouchWithinInset( + dimensions.current, + normalizedHitSlop, + buttonToInner(event) + ); + + if (!isActive.current) { + return; + } + if (unstable_pressDelay) { + // Drop a still-pending timer so a re-entrant press can't double-fire. + if (timers.current.press) { + clearTimeout(timers.current.press); + } + timers.current.press = setTimeout(() => { timers.current.press = null; firePressIn(event); }, unstable_pressDelay); return; } + firePressIn(event); }; const handlePressOut = (event: ButtonEvent) => { + // Not cleared here: onPress fires after onPressOut and must stay suppressed + // too; isActive resets on the next press-in. + if (!isActive.current) { + return; + } + // If the touch is released before `unstable_pressDelay` elapses, RN still // emits the deferred `onPressIn` before `onPressOut` — flush it now. if (timers.current.press) { @@ -160,6 +196,22 @@ const PressableWithTouchable = (props: PressableProps) => { onPressOut?.(buttonToPressableEvent(event)); }; + const makeActiveHandler = ( + handler: ((event: PressableEvent) => void) | null | undefined + ) => + handler + ? (event: ButtonEvent) => { + if (isActive.current) { + handler(buttonToPressableEvent(event)); + } + } + : undefined; + + const handleLayout = (event: LayoutChangeEvent) => { + onLayout?.(event); + dimensions.current = event.nativeEvent.layout; + }; + const handleHoverIn = onHoverIn ? (event: ButtonEvent) => { if (timers.current.hoverOut) { @@ -211,26 +263,20 @@ const PressableWithTouchable = (props: PressableProps) => { accessible={accessible !== false} disabled={disabled === true} hitSlop={appliedHitSlop} - onLayout={onLayout} + onLayout={handleLayout} androidRipple={androidRipple} touchSoundDisabled={android_disableSound ?? undefined} - delayLongPress={delayLongPress ?? undefined} + delayLongPress={delayLongPress ?? DEFAULT_LONG_PRESS_DURATION} style={resolvedStyle} onPressIn={handlePressIn} onPressOut={handlePressOut} - onPress={ - onPress ? (event) => onPress(buttonToPressableEvent(event)) : undefined - } - onLongPress={ - onLongPress - ? (event) => onLongPress(buttonToPressableEvent(event)) - : undefined - } + onPress={makeActiveHandler(onPress)} + onLongPress={makeActiveHandler(onLongPress)} onHoverIn={handleHoverIn} onHoverOut={handleHoverOut}> {resolvedChildren} {__DEV__ ? ( - + ) : null} ); From 65280de0dc8bc3383fbe793dd8ad8902993eb7b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Tue, 11 Aug 2026 12:32:41 +0200 Subject: [PATCH 05/10] Delay --- .../v3/components/PressableWithTouchable.tsx | 82 +++++++++++-------- 1 file changed, 50 insertions(+), 32 deletions(-) diff --git a/packages/react-native-gesture-handler/src/v3/components/PressableWithTouchable.tsx b/packages/react-native-gesture-handler/src/v3/components/PressableWithTouchable.tsx index 5daba56f1f..bed566d7f2 100644 --- a/packages/react-native-gesture-handler/src/v3/components/PressableWithTouchable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/PressableWithTouchable.tsx @@ -5,7 +5,6 @@ import type { ButtonEvent } from '../../components/GestureHandlerButton'; import type { InnerPressableEvent, PressableDimensions, - PressableEvent, PressableProps, } from '../../components/Pressable/PressableProps'; import { @@ -16,6 +15,21 @@ import { import { PressabilityDebugView } from '../../handlers/PressabilityDebugView'; import { Touchable } from './Touchable/Touchable'; +// RN's Pressable default. Touchable's own default is 600ms +const DEFAULT_LONG_PRESS_DURATION = 500; + +type Timers = { + press: ReturnType | null; + hoverIn: ReturnType | null; + hoverOut: ReturnType | null; +}; + +function normalizeInset(value: Insets | number | null | undefined): Insets { + return typeof value === 'number' + ? numberAsInset(value) + : (value ?? numberAsInset(0)); +} + function buttonToInner(event: ButtonEvent): InnerPressableEvent { return { identifier: 0, @@ -43,21 +57,6 @@ function buttonToPressableEvent(event: ButtonEvent) { }; } -// RN's Pressable default. Touchable's own default is 600ms -const DEFAULT_LONG_PRESS_DURATION = 500; - -function normalizeInset(value: Insets | number | null | undefined): Insets { - return typeof value === 'number' - ? numberAsInset(value) - : (value ?? numberAsInset(0)); -} - -type Timers = { - press: ReturnType | null; - hoverIn: ReturnType | null; - hoverOut: ReturnType | null; -}; - const PressableWithTouchable = (props: PressableProps) => { const { testOnly_pressed, @@ -144,6 +143,17 @@ const PressableWithTouchable = (props: PressableProps) => { normalizeInset(pressRetentionOffset) ); + // Long press is measured from onPressIn, which `unstable_pressDelay` defers, + // so fold it in to match RN / StatefulPressable. + const resolvedDelayLongPress = + (delayLongPress ?? DEFAULT_LONG_PRESS_DURATION) + + (unstable_pressDelay ?? 0); + + const handleLayout = (event: LayoutChangeEvent) => { + onLayout?.(event); + dimensions.current = event.nativeEvent.layout; + }; + const firePressIn = (event: ButtonEvent) => { setPressed(true); onPressIn?.(buttonToPressableEvent(event)); @@ -196,21 +206,29 @@ const PressableWithTouchable = (props: PressableProps) => { onPressOut?.(buttonToPressableEvent(event)); }; - const makeActiveHandler = ( - handler: ((event: PressableEvent) => void) | null | undefined - ) => - handler - ? (event: ButtonEvent) => { - if (isActive.current) { - handler(buttonToPressableEvent(event)); - } + const handlePress = onPress + ? (event: ButtonEvent) => { + if (isActive.current) { + onPress(buttonToPressableEvent(event)); } - : undefined; + } + : undefined; - const handleLayout = (event: LayoutChangeEvent) => { - onLayout?.(event); - dimensions.current = event.nativeEvent.layout; - }; + const handleLongPress = onLongPress + ? (event: ButtonEvent) => { + if (!isActive.current) { + return; + } + // Flush the deferred onPressIn so it can't arrive after onLongPress + // (e.g. delayLongPress={0} makes the two timers coincide). + if (timers.current.press) { + clearTimeout(timers.current.press); + timers.current.press = null; + firePressIn(event); + } + onLongPress(buttonToPressableEvent(event)); + } + : undefined; const handleHoverIn = onHoverIn ? (event: ButtonEvent) => { @@ -266,12 +284,12 @@ const PressableWithTouchable = (props: PressableProps) => { onLayout={handleLayout} androidRipple={androidRipple} touchSoundDisabled={android_disableSound ?? undefined} - delayLongPress={delayLongPress ?? DEFAULT_LONG_PRESS_DURATION} + delayLongPress={resolvedDelayLongPress} style={resolvedStyle} onPressIn={handlePressIn} onPressOut={handlePressOut} - onPress={makeActiveHandler(onPress)} - onLongPress={makeActiveHandler(onLongPress)} + onPress={handlePress} + onLongPress={handleLongPress} onHoverIn={handleHoverIn} onHoverOut={handleHoverOut}> {resolvedChildren} From cbf3a63964f5d1a65cd77d10678f38bc0c1d2497 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Tue, 11 Aug 2026 12:50:55 +0200 Subject: [PATCH 06/10] Rename --- .../src/v3/components/StatefulPressable.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx b/packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx index 05626ff2da..cc06618652 100644 --- a/packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx @@ -52,7 +52,7 @@ import { PureNativeButton } from './GestureButtons'; const DEFAULT_LONG_PRESS_DURATION = 500; const IS_TEST_ENV = isTestEnv(); -const Pressable = (props: PressableProps) => { +const StatefulPressable = (props: PressableProps) => { const { testOnly_pressed, hitSlop, @@ -450,4 +450,4 @@ const Pressable = (props: PressableProps) => { ); }; -export default Pressable; +export default StatefulPressable; From 587cf4d8c6af57617d33258044dd9b1a8daa691b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Tue, 11 Aug 2026 15:48:30 +0200 Subject: [PATCH 07/10] Don't use ref --- .../src/v3/components/Pressable.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) 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 1c978a9596..3e56aebc44 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx @@ -1,4 +1,4 @@ -import React, { useRef } from 'react'; +import React from 'react'; import type { PressableProps } from '../../components/Pressable/PressableProps'; import PressableWithTouchable from './PressableWithTouchable'; @@ -14,15 +14,14 @@ import StatefulPressable from './StatefulPressable'; * - {@link PressableWithTouchable} — the simpler engine built on the native * button `Touchable`, used for everything else (the common case). * - * The choice is made once, at mount, so conditionally adding or removing a - * relation prop later cannot swap engines mid-life and lose the current press. + * The choice is re-evaluated each render: toggling a relation prop at runtime + * swaps engines, which remounts and drops any in-progress press. */ const Pressable = (props: PressableProps) => { - const usesRelations = useRef( + const usesRelations = props.simultaneousWith != null || - props.requireToFail != null || - props.block != null - ).current; + props.requireToFail != null || + props.block != null; return usesRelations ? ( From 17b6aec28a32d2fc0d21f4d8a0f6b7274a995dd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Thu, 13 Aug 2026 08:36:58 +0200 Subject: [PATCH 08/10] Hover delay cancellation --- .../v3/components/PressableWithTouchable.tsx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/react-native-gesture-handler/src/v3/components/PressableWithTouchable.tsx b/packages/react-native-gesture-handler/src/v3/components/PressableWithTouchable.tsx index bed566d7f2..846e1a03f7 100644 --- a/packages/react-native-gesture-handler/src/v3/components/PressableWithTouchable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/PressableWithTouchable.tsx @@ -230,13 +230,24 @@ const PressableWithTouchable = (props: PressableProps) => { } : undefined; - const handleHoverIn = onHoverIn + // Wire each handler whenever the opposite side can leave a pending timer, so a + // delayed hover callback is cancelled once the pointer leaves/re-enters. + const needsHoverIn = + onHoverIn != null || (onHoverOut != null && !!delayHoverOut); + const needsHoverOut = + onHoverOut != null || (onHoverIn != null && !!delayHoverIn); + + const handleHoverIn = needsHoverIn ? (event: ButtonEvent) => { if (timers.current.hoverOut) { clearTimeout(timers.current.hoverOut); timers.current.hoverOut = null; } + if (!onHoverIn) { + return; + } + if (delayHoverIn) { timers.current.hoverIn = setTimeout(() => { timers.current.hoverIn = null; @@ -249,13 +260,17 @@ const PressableWithTouchable = (props: PressableProps) => { } : undefined; - const handleHoverOut = onHoverOut + const handleHoverOut = needsHoverOut ? (event: ButtonEvent) => { if (timers.current.hoverIn) { clearTimeout(timers.current.hoverIn); timers.current.hoverIn = null; } + if (!onHoverOut) { + return; + } + if (delayHoverOut) { timers.current.hoverOut = setTimeout(() => { timers.current.hoverOut = null; From dbfca04295471ad9661a4b7fab7c1ec7e55bb179 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Thu, 13 Aug 2026 08:47:12 +0200 Subject: [PATCH 09/10] cursor --- .../src/v3/components/PressableWithTouchable.tsx | 3 ++- .../src/v3/components/pointerStyle.ts | 5 +++++ .../src/v3/components/pointerStyle.web.ts | 6 ++++++ 3 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 packages/react-native-gesture-handler/src/v3/components/pointerStyle.ts create mode 100644 packages/react-native-gesture-handler/src/v3/components/pointerStyle.web.ts diff --git a/packages/react-native-gesture-handler/src/v3/components/PressableWithTouchable.tsx b/packages/react-native-gesture-handler/src/v3/components/PressableWithTouchable.tsx index 846e1a03f7..e80c389670 100644 --- a/packages/react-native-gesture-handler/src/v3/components/PressableWithTouchable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/PressableWithTouchable.tsx @@ -13,6 +13,7 @@ import { numberAsInset, } from '../../components/Pressable/utils'; import { PressabilityDebugView } from '../../handlers/PressabilityDebugView'; +import { pointerStyle } from './pointerStyle'; import { Touchable } from './Touchable/Touchable'; // RN's Pressable default. Touchable's own default is 600ms @@ -300,7 +301,7 @@ const PressableWithTouchable = (props: PressableProps) => { androidRipple={androidRipple} touchSoundDisabled={android_disableSound ?? undefined} delayLongPress={resolvedDelayLongPress} - style={resolvedStyle} + style={[pointerStyle, resolvedStyle]} onPressIn={handlePressIn} onPressOut={handlePressOut} onPress={handlePress} diff --git a/packages/react-native-gesture-handler/src/v3/components/pointerStyle.ts b/packages/react-native-gesture-handler/src/v3/components/pointerStyle.ts new file mode 100644 index 0000000000..f9e2c15950 --- /dev/null +++ b/packages/react-native-gesture-handler/src/v3/components/pointerStyle.ts @@ -0,0 +1,5 @@ +import type { ViewStyle } from 'react-native'; + +// A pointer cursor only applies on the web (see the `.web` counterpart); other +// platforms contribute nothing. +export const pointerStyle: ViewStyle = {}; diff --git a/packages/react-native-gesture-handler/src/v3/components/pointerStyle.web.ts b/packages/react-native-gesture-handler/src/v3/components/pointerStyle.web.ts new file mode 100644 index 0000000000..8d3a12c1a7 --- /dev/null +++ b/packages/react-native-gesture-handler/src/v3/components/pointerStyle.web.ts @@ -0,0 +1,6 @@ +import type { ViewStyle } from 'react-native'; + +// react-native-web sets `cursor: 'pointer'` on its interactive components +// (Pressable, TouchableOpacity, …) but not on `View`, which the native button +// renders — so the Touchable-based Pressable adds it here to match RN Pressable. +export const pointerStyle: ViewStyle = { cursor: 'pointer' }; From 90a50071ff148c5164fcf1a4da8f578b2360bd57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Thu, 13 Aug 2026 09:34:50 +0200 Subject: [PATCH 10/10] Clear timeouts --- .../src/v3/components/StatefulPressable.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx b/packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx index cc06618652..9d95512cb1 100644 --- a/packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx @@ -234,6 +234,24 @@ const StatefulPressable = (props: PressableProps) => { const hoverInTimeout = useRef(null); const hoverOutTimeout = useRef(null); + useEffect( + () => () => { + if (longPressTimeoutRef.current) { + clearTimeout(longPressTimeoutRef.current); + } + if (pressDelayTimeoutRef.current) { + clearTimeout(pressDelayTimeoutRef.current); + } + if (hoverInTimeout.current) { + clearTimeout(hoverInTimeout.current); + } + if (hoverOutTimeout.current) { + clearTimeout(hoverOutTimeout.current); + } + }, + [] + ); + const hoverGesture = useHoverGesture({ manualActivation: true, // Prevents Hover blocking Native gesture on web cancelsTouchesInView: false,