From 7648946f31011345885cb95fd1869dd2b72d96f7 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Fri, 24 Jul 2026 12:11:42 +0200 Subject: [PATCH 1/6] Implement button press state machine on web --- .../web/handlers/NativeViewGestureHandler.ts | 133 +++++++++++++++++- .../src/web/interfaces.ts | 2 + .../src/web/tools/ButtonEvents.ts | 20 +++ 3 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 packages/react-native-gesture-handler/src/web/tools/ButtonEvents.ts diff --git a/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts b/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts index 92738c14c8..4b1f58e594 100644 --- a/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts +++ b/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts @@ -1,6 +1,7 @@ import { Platform } from 'react-native'; import { type ActionType } from '../../ActionType'; +import type { ButtonEvent } from '../../specs/RNGestureHandlerButtonNativeComponent'; import { State } from '../../State'; import type { NativeHandlerData } from '../../v3/hooks/gestures/native/NativeTypes'; import type { HandlerData } from '../../v3/types'; @@ -17,6 +18,7 @@ import type { } from '../interfaces'; import { NativeGestureRole } from '../interfaces'; import type { GestureHandlerDelegate } from '../tools/GestureHandlerDelegate'; +import { ButtonEventName, dispatchButtonEvent } from '../tools/ButtonEvents'; import { dispatchGestureLifecycleEvent, GestureLifecycleEvent, @@ -39,6 +41,12 @@ export default class NativeViewGestureHandler extends GestureHandler { private lastActiveHandlerData: HandlerData | null = null; + private hasLongPressHandler = false; + private longPressDuration = -1; + private longPressDetected = false; + private lastEventWasInside = false; + private pendingLongPress: ReturnType | null = null; + public constructor( delegate: GestureHandlerDelegate ) { @@ -90,6 +98,12 @@ export default class NativeViewGestureHandler extends GestureHandler { if (config.yieldsToContinuousGestures !== undefined) { this.yieldsToContinuousGestures = config.yieldsToContinuousGestures; } + if (config.hasLongPressHandler !== undefined) { + this.hasLongPressHandler = config.hasLongPressHandler; + } + if (config.longPressDuration !== undefined) { + this.longPressDuration = config.longPressDuration; + } const view = this.delegate.view as HTMLElement; this.restoreViewStyles(view); @@ -165,9 +179,33 @@ export default class NativeViewGestureHandler extends GestureHandler { } } - protected override onPointerLeave(): void { + protected override onPointerLeave(event: AdaptedEvent): void { + this.tracker.track(event); + + if ( + this.role === NativeGestureRole.Button && + !this.shouldCancelWhenOutside && + this.lastEventWasInside + ) { + this.dispatchButtonEvent(ButtonEventName.PressOut); + this.clearLongPressTimer(); + } + if (this.state === State.BEGAN || this.state === State.ACTIVE) { - this.cancel(); + super.onPointerLeave(event); + } + } + + protected override onPointerEnter(event: AdaptedEvent): void { + this.tracker.track(event); + super.onPointerEnter(event); + + if ( + this.role === NativeGestureRole.Button && + (this.state === State.BEGAN || this.state === State.ACTIVE) && + !this.lastEventWasInside + ) { + this.dispatchButtonEvent(ButtonEventName.PressIn); } } @@ -234,6 +272,7 @@ export default class NativeViewGestureHandler extends GestureHandler { } public override detach(): void { + this.clearLongPressTimer(); super.detach(); this.role = null; } @@ -282,6 +321,88 @@ export default class NativeViewGestureHandler extends GestureHandler { ); } + protected override onStateChange(newState: State): void { + if (this.role !== NativeGestureRole.Button) { + return; + } + + if (newState === State.BEGAN) { + if (!this.getButtonEventData().pointerInside) { + return; + } + + this.dispatchButtonEvent(ButtonEventName.PressIn); + this.longPressDetected = false; + + if (this.hasLongPressHandler && this.longPressDuration >= 0) { + this.pendingLongPress = setTimeout(() => { + this.pendingLongPress = null; + this.longPressDetected = true; + this.dispatchButtonEvent(ButtonEventName.LongPress); + }, this.longPressDuration); + } + return; + } + + if ( + newState !== State.END && + newState !== State.FAILED && + newState !== State.CANCELLED + ) { + return; + } + + const endedInside = this.lastEventWasInside; + + if (endedInside) { + this.dispatchButtonEvent(ButtonEventName.PressOut); + } + this.clearLongPressTimer(); + + if (newState === State.END && !this.longPressDetected && endedInside) { + this.dispatchButtonEvent(ButtonEventName.Press); + } + + this.dispatchButtonEvent(ButtonEventName.InteractionFinished); + this.longPressDetected = false; + } + + private dispatchButtonEvent(name: ButtonEventName): void { + if (name === ButtonEventName.PressIn) { + this.lastEventWasInside = true; + } else if (name === ButtonEventName.PressOut) { + this.lastEventWasInside = false; + } + + dispatchButtonEvent( + this.delegate.view as HTMLElement | null, + name, + this.getButtonEventData() + ); + } + + private getButtonEventData(): ButtonEvent { + const absolute = this.tracker.getAbsoluteCoordsAverage(); + const relative = this.tracker.getRelativeCoordsAverage(); + + return { + pointerInside: this.delegate.isPointerInBounds(absolute), + x: relative.x, + y: relative.y, + absoluteX: absolute.x, + absoluteY: absolute.y, + numberOfPointers: this.tracker.trackedPointersCount, + pointerType: this.pointerType, + }; + } + + private clearLongPressTimer(): void { + if (this.pendingLongPress !== null) { + clearTimeout(this.pendingLongPress); + this.pendingLongPress = null; + } + } + protected override transformNativeEvent(): Record { const absolute = this.tracker.getAbsoluteCoordsAverage(); const relative = this.tracker.getRelativeCoordsAverage(); @@ -317,7 +438,15 @@ export default class NativeViewGestureHandler extends GestureHandler { } public override reset(): void { + this.clearLongPressTimer(); super.reset(); this.lastActiveHandlerData = null; + this.lastEventWasInside = false; + this.longPressDetected = false; + } + + public override onDestroy(): void { + this.clearLongPressTimer(); + super.onDestroy(); } } diff --git a/packages/react-native-gesture-handler/src/web/interfaces.ts b/packages/react-native-gesture-handler/src/web/interfaces.ts index 069ba0b1f1..626a2add2c 100644 --- a/packages/react-native-gesture-handler/src/web/interfaces.ts +++ b/packages/react-native-gesture-handler/src/web/interfaces.ts @@ -91,6 +91,8 @@ export interface Config extends Record { shouldActivateOnStart?: boolean; disallowInterruption?: boolean; yieldsToContinuousGestures?: boolean; + hasLongPressHandler?: boolean; + longPressDuration?: number; direction?: Directions; enableTrackpadTwoFingerGesture?: boolean; } diff --git a/packages/react-native-gesture-handler/src/web/tools/ButtonEvents.ts b/packages/react-native-gesture-handler/src/web/tools/ButtonEvents.ts new file mode 100644 index 0000000000..4cb58dc3ef --- /dev/null +++ b/packages/react-native-gesture-handler/src/web/tools/ButtonEvents.ts @@ -0,0 +1,20 @@ +import type { ButtonEvent } from '../../specs/RNGestureHandlerButtonNativeComponent'; + +export const ButtonEventName = { + Press: 'gh:buttonPress', + PressIn: 'gh:buttonPressIn', + PressOut: 'gh:buttonPressOut', + LongPress: 'gh:buttonLongPress', + InteractionFinished: 'gh:buttonInteractionFinished', +} as const; + +export type ButtonEventName = + (typeof ButtonEventName)[keyof typeof ButtonEventName]; + +export function dispatchButtonEvent( + view: HTMLElement | null | undefined, + name: ButtonEventName, + event: ButtonEvent +): void { + view?.dispatchEvent(new CustomEvent(name, { detail: event })); +} From 706850de1a648e0cc5fe6a00abb17c542e1c8b84 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Fri, 24 Jul 2026 12:12:45 +0200 Subject: [PATCH 2/6] Handle button press events on web --- .../components/GestureHandlerButton.web.tsx | 200 ++++++++++++------ 1 file changed, 132 insertions(+), 68 deletions(-) diff --git a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx index 6140bd2b68..e902e9f434 100644 --- a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx @@ -2,7 +2,9 @@ import * as React from 'react'; import type { ColorValue, NativeSyntheticEvent, ViewProps } from 'react-native'; import { View } from 'react-native'; +import type { ButtonEvent } from '../specs/RNGestureHandlerButtonNativeComponent'; import { NativeGestureRole } from '../web/interfaces'; +import { ButtonEventName } from '../web/tools/ButtonEvents'; import { GestureLifecycleEvent } from '../web/tools/GestureLifecycleEvents'; const prefersReducedMotion = (): boolean => @@ -28,6 +30,21 @@ type ButtonProps = ViewProps & { defaultScale?: number; defaultUnderlayOpacity?: number; underlayColor?: ColorValue; + onButtonPress?: + | ((event: NativeSyntheticEvent) => void) + | undefined; + onButtonPressIn?: + | ((event: NativeSyntheticEvent) => void) + | undefined; + onButtonPressOut?: + | ((event: NativeSyntheticEvent) => void) + | undefined; + onButtonLongPress?: + | ((event: NativeSyntheticEvent) => void) + | undefined; + onButtonInteractionFinished?: + | ((event: NativeSyntheticEvent) => void) + | undefined; }; export const ButtonComponent = ({ @@ -49,6 +66,11 @@ export const ButtonComponent = ({ defaultScale = 1, defaultUnderlayOpacity = 0, underlayColor, + onButtonPress, + onButtonPressIn, + onButtonPressOut, + onButtonLongPress, + onButtonInteractionFinished, style, children, ...rest @@ -119,73 +141,119 @@ export const ButtonComponent = ({ }; }, []); - const pressIn = React.useCallback( - (event: NativeSyntheticEvent) => { - if (!enabled || !gestureEnabledRef.current) { - return; - } + const pressIn = React.useCallback(() => { + if (!enabled || !gestureEnabledRef.current) { + return; + } - event.stopPropagation(); - if (pressOutTimer.current != null) { - clearTimeout(pressOutTimer.current); - pressOutTimer.current = null; - } - pressInTimestamp.current = performance.now(); - setCurrentDuration(tapAnimationInDuration); - setPressed(true); - }, - [enabled, tapAnimationInDuration] - ); + if (pressOutTimer.current != null) { + clearTimeout(pressOutTimer.current); + pressOutTimer.current = null; + } + pressInTimestamp.current = performance.now(); + setCurrentDuration(tapAnimationInDuration); + setPressed(true); + }, [enabled, tapAnimationInDuration]); - const pressOut = React.useCallback( - (event: NativeSyntheticEvent) => { - // Only release if a press-in was actually recorded — guards against - // stray pointer events and lets us complete the release cycle even if - // `enabled` flipped to false between press-in and press-out. - if (pressInTimestamp.current === 0 || !gestureEnabledRef.current) { - return; - } + const pressOut = React.useCallback(() => { + // Only release if a press-in was actually recorded — guards against + // stray pointer events and lets us complete the release cycle even if + // `enabled` flipped to false between press-in and press-out. + if (pressInTimestamp.current === 0 || !gestureEnabledRef.current) { + return; + } - event.stopPropagation(); - if (pressOutTimer.current != null) { - clearTimeout(pressOutTimer.current); - pressOutTimer.current = null; - } - const elapsed = performance.now() - pressInTimestamp.current; - pressInTimestamp.current = 0; + if (pressOutTimer.current != null) { + clearTimeout(pressOutTimer.current); + pressOutTimer.current = null; + } + const elapsed = performance.now() - pressInTimestamp.current; + pressInTimestamp.current = 0; - if (longPressDuration >= 0 && elapsed >= longPressDuration) { - // Long-press release — use the configured long-press out duration. - setCurrentDuration(longPressAnimationOutDuration); - setPressed(false); - } else if (elapsed >= tapAnimationInDuration) { - // Press-in animation fully finished - release with the configured out duration. - setCurrentDuration(tapAnimationOutDuration); - setPressed(false); - // elapsed * 2 to ensure there is at least half of the tapAnimationOutDuration left for the animation to play - } else if (elapsed * 2 >= tapAnimationOutDuration) { - setCurrentDuration(elapsed); - setPressed(false); - } else { - // Let the in-progress CSS press-in transition continue; schedule press-out after remaining time. - const remaining = tapAnimationInDuration - elapsed; - pressOutTimer.current = setTimeout( - () => { - pressOutTimer.current = null; - setCurrentDuration(tapAnimationOutDuration); - setPressed(false); - }, - prefersReducedMotion() ? 0 : remaining - ); - } - }, - [ - longPressDuration, - longPressAnimationOutDuration, - tapAnimationInDuration, - tapAnimationOutDuration, - ] - ); + if (longPressDuration >= 0 && elapsed >= longPressDuration) { + // Long-press release — use the configured long-press out duration. + setCurrentDuration(longPressAnimationOutDuration); + setPressed(false); + } else if (elapsed >= tapAnimationInDuration) { + // Press-in animation fully finished - release with the configured out duration. + setCurrentDuration(tapAnimationOutDuration); + setPressed(false); + // elapsed * 2 to ensure there is at least half of the tapAnimationOutDuration left for the animation to play + } else if (elapsed * 2 >= tapAnimationOutDuration) { + setCurrentDuration(elapsed); + setPressed(false); + } else { + // Let the in-progress CSS press-in transition continue; schedule press-out after remaining time. + const remaining = tapAnimationInDuration - elapsed; + pressOutTimer.current = setTimeout( + () => { + pressOutTimer.current = null; + setCurrentDuration(tapAnimationOutDuration); + setPressed(false); + }, + prefersReducedMotion() ? 0 : remaining + ); + } + }, [ + longPressDuration, + longPressAnimationOutDuration, + tapAnimationInDuration, + tapAnimationOutDuration, + ]); + + React.useEffect(() => { + const node = viewRef.current; + const wrapEvent = (event: Event): NativeSyntheticEvent => + ({ + nativeEvent: (event as CustomEvent).detail, + }) as NativeSyntheticEvent; + + const handlePress = (event: Event) => { + onButtonPress?.(wrapEvent(event)); + }; + const handlePressIn = (event: Event) => { + pressIn(); + onButtonPressIn?.(wrapEvent(event)); + }; + const handlePressOut = (event: Event) => { + pressOut(); + onButtonPressOut?.(wrapEvent(event)); + }; + const handleLongPress = (event: Event) => { + onButtonLongPress?.(wrapEvent(event)); + }; + const handleInteractionFinished = (event: Event) => { + onButtonInteractionFinished?.(wrapEvent(event)); + }; + + node?.addEventListener(ButtonEventName.Press, handlePress); + node?.addEventListener(ButtonEventName.PressIn, handlePressIn); + node?.addEventListener(ButtonEventName.PressOut, handlePressOut); + node?.addEventListener(ButtonEventName.LongPress, handleLongPress); + node?.addEventListener( + ButtonEventName.InteractionFinished, + handleInteractionFinished + ); + + return () => { + node?.removeEventListener(ButtonEventName.Press, handlePress); + node?.removeEventListener(ButtonEventName.PressIn, handlePressIn); + node?.removeEventListener(ButtonEventName.PressOut, handlePressOut); + node?.removeEventListener(ButtonEventName.LongPress, handleLongPress); + node?.removeEventListener( + ButtonEventName.InteractionFinished, + handleInteractionFinished + ); + }; + }, [ + onButtonInteractionFinished, + onButtonLongPress, + onButtonPress, + onButtonPressIn, + onButtonPressOut, + pressIn, + pressOut, + ]); const handlePointerEnter = React.useCallback( (event: NativeSyntheticEvent<{ pointerType?: string }>) => { @@ -203,7 +271,6 @@ export const ButtonComponent = ({ const handlePointerLeave = React.useCallback( (event: NativeSyntheticEvent<{ pointerType?: string }>) => { - pressOut(event); if (event.nativeEvent.pointerType === 'touch') { return; } @@ -212,7 +279,7 @@ export const ButtonComponent = ({ } setHovered(false); }, - [pressOut, pressed, hoverAnimationOutDuration] + [pressed, hoverAnimationOutDuration] ); // Mask hover at render rather than clearing the state. Avoids a state @@ -268,9 +335,6 @@ export const ButtonComponent = ({ }, ]} onPointerEnter={handlePointerEnter} - onPointerDown={pressIn} - onPointerUp={pressOut} - onPointerCancel={pressOut} onPointerLeave={handlePointerLeave}> {hasUnderlay && ( Date: Fri, 24 Jul 2026 12:13:43 +0200 Subject: [PATCH 3/6] Attach native gesture directly to button on web --- .../src/ActionType.ts | 1 + .../components/GestureHandlerButton.web.tsx | 242 +++++++++++++----- .../src/web/handlers/GestureHandler.test.ts | 44 ++++ .../src/web/handlers/GestureHandler.ts | 11 +- .../web/handlers/NativeViewGestureHandler.ts | 44 +--- .../src/web/tools/ButtonEvents.ts | 4 +- 6 files changed, 254 insertions(+), 92 deletions(-) create mode 100644 packages/react-native-gesture-handler/src/web/handlers/GestureHandler.test.ts diff --git a/packages/react-native-gesture-handler/src/ActionType.ts b/packages/react-native-gesture-handler/src/ActionType.ts index 07071aa2dd..ea4d5e38ec 100644 --- a/packages/react-native-gesture-handler/src/ActionType.ts +++ b/packages/react-native-gesture-handler/src/ActionType.ts @@ -1,4 +1,5 @@ export const ActionType = { + NONE: 0, REANIMATED_WORKLET: 1, NATIVE_ANIMATED_EVENT: 2, JS_FUNCTION_OLD_API: 3, diff --git a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx index e902e9f434..a6e277613c 100644 --- a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx @@ -2,7 +2,11 @@ import * as React from 'react'; import type { ColorValue, NativeSyntheticEvent, ViewProps } from 'react-native'; import { View } from 'react-native'; +import { ActionType } from '../ActionType'; +import RNGestureHandlerModule from '../RNGestureHandlerModule.web'; import type { ButtonEvent } from '../specs/RNGestureHandlerButtonNativeComponent'; +import { useIsomorphicLayoutEffect } from '../useIsomorphicLayoutEffect'; +import type { PropsRef } from '../web/interfaces'; import { NativeGestureRole } from '../web/interfaces'; import { ButtonEventName } from '../web/tools/ButtonEvents'; import { GestureLifecycleEvent } from '../web/tools/GestureLifecycleEvents'; @@ -11,6 +15,8 @@ const prefersReducedMotion = (): boolean => typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches; +const noopGestureEvent = () => undefined; + type ButtonProps = ViewProps & { ref?: React.Ref>; enabled?: boolean; @@ -30,6 +36,19 @@ type ButtonProps = ViewProps & { defaultScale?: number; defaultUnderlayOpacity?: number; underlayColor?: ColorValue; + hasLongPressHandler?: boolean; + moduleId?: number; + handlerTag?: number; + cancelOnLeave?: boolean; + gestureTestID?: string; + gestureHitSlop?: + | { + top?: number; + left?: number; + bottom?: number; + right?: number; + } + | undefined; onButtonPress?: | ((event: NativeSyntheticEvent) => void) | undefined; @@ -66,6 +85,12 @@ export const ButtonComponent = ({ defaultScale = 1, defaultUnderlayOpacity = 0, underlayColor, + hasLongPressHandler = false, + moduleId: _moduleId, + handlerTag, + cancelOnLeave = true, + gestureTestID, + gestureHitSlop, onButtonPress, onButtonPressIn, onButtonPressOut, @@ -91,6 +116,37 @@ export const ButtonComponent = ({ ); const gestureEnabledRef = React.useRef(true); const viewRef = React.useRef(null); + const gesturePropsRef = React.useRef({ + // Managed button handlers dispatch their events through DOM CustomEvents, + // so ActionType.NONE prevents these callbacks from being used. + // They remain present because the web module shares its attachment API + // with regular JS-driven gesture handlers. + onGestureHandlerEvent: noopGestureEvent, + onGestureHandlerStateChange: noopGestureEvent, + onGestureHandlerTouchEvent: noopGestureEvent, + }); + const managedGestureConfigRef = React.useRef({ + enabled, + shouldCancelWhenOutside: cancelOnLeave, + shouldActivateOnStart: false, + disallowInterruption: true, + yieldsToContinuousGestures: true, + testID: gestureTestID, + hitSlop: gestureHitSlop, + hasLongPressHandler, + longPressDuration, + }); + managedGestureConfigRef.current = { + enabled, + shouldCancelWhenOutside: cancelOnLeave, + shouldActivateOnStart: false, + disallowInterruption: true, + yieldsToContinuousGestures: true, + testID: gestureTestID, + hitSlop: gestureHitSlop, + hasLongPressHandler, + longPressDuration, + }; const setRef = React.useCallback( (node: React.ComponentRef | null) => { @@ -104,7 +160,7 @@ export const ButtonComponent = ({ [externalRef] ); - React.useEffect(() => { + useIsomorphicLayoutEffect(() => { const node = viewRef.current; const handleGestureBegan = () => { @@ -141,67 +197,86 @@ export const ButtonComponent = ({ }; }, []); - const pressIn = React.useCallback(() => { - if (!enabled || !gestureEnabledRef.current) { - return; - } + const pressIn = React.useCallback( + (event?: NativeSyntheticEvent) => { + const isManagedButtonEvent = event === undefined; - if (pressOutTimer.current != null) { - clearTimeout(pressOutTimer.current); - pressOutTimer.current = null; - } - pressInTimestamp.current = performance.now(); - setCurrentDuration(tapAnimationInDuration); - setPressed(true); - }, [enabled, tapAnimationInDuration]); - - const pressOut = React.useCallback(() => { - // Only release if a press-in was actually recorded — guards against - // stray pointer events and lets us complete the release cycle even if - // `enabled` flipped to false between press-in and press-out. - if (pressInTimestamp.current === 0 || !gestureEnabledRef.current) { - return; - } + if (!enabled || (!isManagedButtonEvent && !gestureEnabledRef.current)) { + return; + } - if (pressOutTimer.current != null) { - clearTimeout(pressOutTimer.current); - pressOutTimer.current = null; - } - const elapsed = performance.now() - pressInTimestamp.current; - pressInTimestamp.current = 0; + // Managed button events are emitted before the Began lifecycle event. + // Unmanaged buttons are still driven by their wrapping native gesture, + // so they must keep honoring its cancellation state. + if (isManagedButtonEvent) { + gestureEnabledRef.current = true; + } - if (longPressDuration >= 0 && elapsed >= longPressDuration) { - // Long-press release — use the configured long-press out duration. - setCurrentDuration(longPressAnimationOutDuration); - setPressed(false); - } else if (elapsed >= tapAnimationInDuration) { - // Press-in animation fully finished - release with the configured out duration. - setCurrentDuration(tapAnimationOutDuration); - setPressed(false); - // elapsed * 2 to ensure there is at least half of the tapAnimationOutDuration left for the animation to play - } else if (elapsed * 2 >= tapAnimationOutDuration) { - setCurrentDuration(elapsed); - setPressed(false); - } else { - // Let the in-progress CSS press-in transition continue; schedule press-out after remaining time. - const remaining = tapAnimationInDuration - elapsed; - pressOutTimer.current = setTimeout( - () => { - pressOutTimer.current = null; - setCurrentDuration(tapAnimationOutDuration); - setPressed(false); - }, - prefersReducedMotion() ? 0 : remaining - ); - } - }, [ - longPressDuration, - longPressAnimationOutDuration, - tapAnimationInDuration, - tapAnimationOutDuration, - ]); + event?.stopPropagation(); + + if (pressOutTimer.current != null) { + clearTimeout(pressOutTimer.current); + pressOutTimer.current = null; + } + pressInTimestamp.current = performance.now(); + setCurrentDuration(tapAnimationInDuration); + setPressed(true); + }, + [enabled, tapAnimationInDuration] + ); - React.useEffect(() => { + const pressOut = React.useCallback( + (event?: NativeSyntheticEvent) => { + // Only release if a press-in was actually recorded — guards against + // stray pointer events and lets us complete the release cycle even if + // `enabled` flipped to false between press-in and press-out. + if (pressInTimestamp.current === 0 || !gestureEnabledRef.current) { + return; + } + + event?.stopPropagation(); + + if (pressOutTimer.current != null) { + clearTimeout(pressOutTimer.current); + pressOutTimer.current = null; + } + const elapsed = performance.now() - pressInTimestamp.current; + pressInTimestamp.current = 0; + + if (longPressDuration >= 0 && elapsed >= longPressDuration) { + // Long-press release — use the configured long-press out duration. + setCurrentDuration(longPressAnimationOutDuration); + setPressed(false); + } else if (elapsed >= tapAnimationInDuration) { + // Press-in animation fully finished - release with the configured out duration. + setCurrentDuration(tapAnimationOutDuration); + setPressed(false); + // elapsed * 2 to ensure there is at least half of the tapAnimationOutDuration left for the animation to play + } else if (elapsed * 2 >= tapAnimationOutDuration) { + setCurrentDuration(elapsed); + setPressed(false); + } else { + // Let the in-progress CSS press-in transition continue; schedule press-out after remaining time. + const remaining = tapAnimationInDuration - elapsed; + pressOutTimer.current = setTimeout( + () => { + pressOutTimer.current = null; + setCurrentDuration(tapAnimationOutDuration); + setPressed(false); + }, + prefersReducedMotion() ? 0 : remaining + ); + } + }, + [ + longPressDuration, + longPressAnimationOutDuration, + tapAnimationInDuration, + tapAnimationOutDuration, + ] + ); + + useIsomorphicLayoutEffect(() => { const node = viewRef.current; const wrapEvent = (event: Event): NativeSyntheticEvent => ({ @@ -255,6 +330,49 @@ export const ButtonComponent = ({ pressOut, ]); + useIsomorphicLayoutEffect(() => { + const node = viewRef.current; + if (handlerTag === undefined || node === null) { + return; + } + + RNGestureHandlerModule.createGestureHandler( + 'NativeViewGestureHandler', + handlerTag, + managedGestureConfigRef.current + ); + RNGestureHandlerModule.attachGestureHandler( + handlerTag, + node, + ActionType.NONE, + gesturePropsRef + ); + + return () => { + RNGestureHandlerModule.detachGestureHandler(handlerTag); + RNGestureHandlerModule.dropGestureHandler(handlerTag); + }; + }, [handlerTag]); + + useIsomorphicLayoutEffect(() => { + if (handlerTag === undefined) { + return; + } + + RNGestureHandlerModule.setGestureHandlerConfig( + handlerTag, + managedGestureConfigRef.current + ); + }, [ + cancelOnLeave, + enabled, + gestureHitSlop, + gestureTestID, + handlerTag, + hasLongPressHandler, + longPressDuration, + ]); + const handlePointerEnter = React.useCallback( (event: NativeSyntheticEvent<{ pointerType?: string }>) => { if (!enabled || event.nativeEvent.pointerType === 'touch') { @@ -271,6 +389,9 @@ export const ButtonComponent = ({ const handlePointerLeave = React.useCallback( (event: NativeSyntheticEvent<{ pointerType?: string }>) => { + if (handlerTag === undefined) { + pressOut(event); + } if (event.nativeEvent.pointerType === 'touch') { return; } @@ -279,7 +400,7 @@ export const ButtonComponent = ({ } setHovered(false); }, - [pressed, hoverAnimationOutDuration] + [handlerTag, hoverAnimationOutDuration, pressOut, pressed] ); // Mask hover at render rather than clearing the state. Avoids a state @@ -335,6 +456,9 @@ export const ButtonComponent = ({ }, ]} onPointerEnter={handlePointerEnter} + onPointerDown={handlerTag === undefined ? pressIn : undefined} + onPointerUp={handlerTag === undefined ? pressOut : undefined} + onPointerCancel={handlerTag === undefined ? pressOut : undefined} onPointerLeave={handlePointerLeave}> {hasUnderlay && ( { + public registerListeners = jest.fn(); + public unregisterListeners = jest.fn(); + + protected mapEvent(): never { + throw new Error('Test event manager does not map events'); + } +} + +function createHandler(enabled: boolean) { + const delegate = { + onEnabledChange: jest.fn(), + } as unknown as GestureHandlerDelegate; + const handler = new TestGestureHandler(delegate); + + handler.setGestureConfig({ enabled }); + + return handler; +} + +describe('GestureHandler web event manager attachment', () => { + test('registers listeners for an initially enabled handler', () => { + const manager = new TestEventManager({}); + + createHandler(true).attachEventManager(manager); + + expect(manager.registerListeners).toHaveBeenCalledTimes(1); + }); + + test('does not register listeners for an initially disabled handler', () => { + const manager = new TestEventManager({}); + + createHandler(false).attachEventManager(manager); + + expect(manager.registerListeners).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react-native-gesture-handler/src/web/handlers/GestureHandler.ts b/packages/react-native-gesture-handler/src/web/handlers/GestureHandler.ts index b7888aee9a..50ed36b95f 100644 --- a/packages/react-native-gesture-handler/src/web/handlers/GestureHandler.ts +++ b/packages/react-native-gesture-handler/src/web/handlers/GestureHandler.ts @@ -144,7 +144,12 @@ export default abstract class GestureHandler implements IGestureHandler { manager.setOnPointerMoveOut(this.onPointerMoveOut.bind(this)); manager.setOnWheel(this.onWheel.bind(this)); - manager.registerListeners(); + // The initial config is applied before the handler is attached. Honor an + // initially disabled handler here because the delegate's enabled-change + // callback cannot update event managers until attachment has finished. + if (this.enabled) { + manager.registerListeners(); + } } // @@ -445,6 +450,10 @@ export default abstract class GestureHandler implements IGestureHandler { // public sendEvent = (newState: State, oldState: State): void => { + if (this.actionType === ActionType.NONE) { + return; + } + const { onGestureHandlerEvent, onGestureHandlerStateChange, diff --git a/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts b/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts index 4b1f58e594..9cd638d4e0 100644 --- a/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts +++ b/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts @@ -1,6 +1,6 @@ import { Platform } from 'react-native'; -import { type ActionType } from '../../ActionType'; +import { ActionType } from '../../ActionType'; import type { ButtonEvent } from '../../specs/RNGestureHandlerButtonNativeComponent'; import { State } from '../../State'; import type { NativeHandlerData } from '../../v3/hooks/gestures/native/NativeTypes'; @@ -17,8 +17,12 @@ import type { PropsRef, } from '../interfaces'; import { NativeGestureRole } from '../interfaces'; +import { + ButtonEventName, + type ButtonEventTypeName, + dispatchButtonEvent, +} from '../tools/ButtonEvents'; import type { GestureHandlerDelegate } from '../tools/GestureHandlerDelegate'; -import { ButtonEventName, dispatchButtonEvent } from '../tools/ButtonEvents'; import { dispatchGestureLifecycleEvent, GestureLifecycleEvent, @@ -179,33 +183,9 @@ export default class NativeViewGestureHandler extends GestureHandler { } } - protected override onPointerLeave(event: AdaptedEvent): void { - this.tracker.track(event); - - if ( - this.role === NativeGestureRole.Button && - !this.shouldCancelWhenOutside && - this.lastEventWasInside - ) { - this.dispatchButtonEvent(ButtonEventName.PressOut); - this.clearLongPressTimer(); - } - + protected override onPointerLeave(): void { if (this.state === State.BEGAN || this.state === State.ACTIVE) { - super.onPointerLeave(event); - } - } - - protected override onPointerEnter(event: AdaptedEvent): void { - this.tracker.track(event); - super.onPointerEnter(event); - - if ( - this.role === NativeGestureRole.Button && - (this.state === State.BEGAN || this.state === State.ACTIVE) && - !this.lastEventWasInside - ) { - this.dispatchButtonEvent(ButtonEventName.PressIn); + this.cancel(); } } @@ -296,6 +276,10 @@ export default class NativeViewGestureHandler extends GestureHandler { return this.role === NativeGestureRole.Button; } + private isManagedButton(): boolean { + return this.isButton() && this.actionType === ActionType.NONE; + } + public override shouldBeginWithRecordedHandlers( recorded: IGestureHandler[] ): boolean { @@ -322,7 +306,7 @@ export default class NativeViewGestureHandler extends GestureHandler { } protected override onStateChange(newState: State): void { - if (this.role !== NativeGestureRole.Button) { + if (!this.isManagedButton()) { return; } @@ -367,7 +351,7 @@ export default class NativeViewGestureHandler extends GestureHandler { this.longPressDetected = false; } - private dispatchButtonEvent(name: ButtonEventName): void { + private dispatchButtonEvent(name: ButtonEventTypeName): void { if (name === ButtonEventName.PressIn) { this.lastEventWasInside = true; } else if (name === ButtonEventName.PressOut) { diff --git a/packages/react-native-gesture-handler/src/web/tools/ButtonEvents.ts b/packages/react-native-gesture-handler/src/web/tools/ButtonEvents.ts index 4cb58dc3ef..6512c841df 100644 --- a/packages/react-native-gesture-handler/src/web/tools/ButtonEvents.ts +++ b/packages/react-native-gesture-handler/src/web/tools/ButtonEvents.ts @@ -8,12 +8,12 @@ export const ButtonEventName = { InteractionFinished: 'gh:buttonInteractionFinished', } as const; -export type ButtonEventName = +export type ButtonEventTypeName = (typeof ButtonEventName)[keyof typeof ButtonEventName]; export function dispatchButtonEvent( view: HTMLElement | null | undefined, - name: ButtonEventName, + name: ButtonEventTypeName, event: ButtonEvent ): void { view?.dispatchEvent(new CustomEvent(name, { detail: event })); From 0a705b0c254afd9bd91c5bfb658feb16d7b922a9 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Fri, 24 Jul 2026 12:14:26 +0200 Subject: [PATCH 4/6] Use the shared Touchable implementation on web --- .../v3/components/Touchable/Touchable.web.tsx | 274 ------------------ 1 file changed, 274 deletions(-) delete mode 100644 packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.web.tsx diff --git a/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.web.tsx b/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.web.tsx deleted file mode 100644 index 3552eb6b46..0000000000 --- a/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.web.tsx +++ /dev/null @@ -1,274 +0,0 @@ -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, - EndCallbackEventType, - TouchableProps, -} from './TouchableProps'; - -const isAndroid = Platform.OS === 'android'; -const TRANSPARENT_RIPPLE = { rippleColor: 'transparent' as const }; -const DEFAULT_IN_DURATION_MS = 50; -const DEFAULT_OUT_DURATION_MS = 100; - -enum PointerState { - UNKNOWN, - INSIDE, - OUTSIDE, -} - -// Clamp user-supplied durations to finite, non-negative milliseconds. -// Negative, NaN, or Infinity values would produce invalid CSS transitions -// on web and negative setTimeout delays in branch 3 of the press-out path. -function sanitizeDuration(value: number): number { - return Number.isFinite(value) && value >= 0 ? value : 0; -} - -function resolveAnimationDuration(value: AnimationDuration | undefined) { - if (value === undefined) { - return { - tapAnimationInDuration: DEFAULT_IN_DURATION_MS, - tapAnimationOutDuration: DEFAULT_OUT_DURATION_MS, - longPressAnimationOutDuration: DEFAULT_OUT_DURATION_MS, - hoverAnimationInDuration: DEFAULT_IN_DURATION_MS, - hoverAnimationOutDuration: DEFAULT_OUT_DURATION_MS, - }; - } - - if (typeof value === 'number') { - const sanitized = sanitizeDuration(value); - return { - tapAnimationInDuration: sanitized, - tapAnimationOutDuration: sanitized, - longPressAnimationOutDuration: sanitized, - hoverAnimationInDuration: sanitized, - hoverAnimationOutDuration: sanitized, - }; - } - - // The union guarantees variant 2 supplies top-level `in`/`out`, variant 3 - // supplies both category objects — so per-category fallback to base is - // always defined for well-typed input; the 0 fallbacks here are unreachable. - const baseIn = 'in' in value ? value.in : 0; - const baseOut = 'out' in value ? value.out : 0; - const tapOut = value.tap?.out ?? baseOut; - - return { - tapAnimationInDuration: sanitizeDuration(value.tap?.in ?? baseIn), - tapAnimationOutDuration: sanitizeDuration(tapOut), - longPressAnimationOutDuration: sanitizeDuration( - value.longPress?.out ?? tapOut - ), - hoverAnimationInDuration: sanitizeDuration(value.hover?.in ?? baseIn), - hoverAnimationOutDuration: sanitizeDuration(value.hover?.out ?? baseOut), - }; -} - -export const Touchable = (props: TouchableProps) => { - const { - underlayColor = 'transparent', - defaultUnderlayOpacity = 0, - activeUnderlayOpacity = 0.105, - defaultOpacity = 1, - animationDuration, - androidRipple, - delayLongPress = 600, - onLongPress, - onPress, - onPressIn, - onPressOut, - children, - disabled = false, - cancelOnLeave = true, - ref, - ...rest - } = props; - - const resolvedDurations = resolveAnimationDuration(animationDuration); - const resolvedDelayLongPress = sanitizeDuration(delayLongPress); - - const shouldUseNativeRipple = isAndroid && androidRipple !== undefined; - - const pointerState = useRef(PointerState.UNKNOWN); - const longPressDetected = useRef(false); - const longPressTimeout = useRef | undefined>( - undefined - ); - // The last event observed by the gesture, used as the payload of the - // long-press callback since it fires from a timer rather than an event. - const lastEvent = useRef(null); - - // 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; - if (lastEvent.current) { - onLongPress?.(lastEvent.current); - } - }, [onLongPress]); - - const startLongPressTimer = useCallback(() => { - longPressDetected.current = false; - - if (onLongPress && !longPressTimeout.current) { - longPressTimeout.current = setTimeout( - wrappedLongPress, - resolvedDelayLongPress - ); - } - }, [onLongPress, resolvedDelayLongPress, wrappedLongPress]); - - const onBegin = useCallback( - (e: CallbackEventType) => { - captureKeyboardDismiss(); - lastEvent.current = e; - - if (!e.pointerInside || dropKeyboardTapRef.current) { - pointerState.current = PointerState.OUTSIDE; - return; - } - - onPressIn?.(e); - startLongPressTimer(); - - pointerState.current = PointerState.INSIDE; - }, - [captureKeyboardDismiss, startLongPressTimer, onPressIn] - ); - - const onActivate = useCallback((e: CallbackEventType) => { - if (!e.pointerInside && longPressTimeout.current !== undefined) { - clearTimeout(longPressTimeout.current); - longPressTimeout.current = undefined; - } - }, []); - - const onFinalize = useCallback( - (e: EndCallbackEventType) => { - if ( - !dropKeyboardTapRef.current && - pointerState.current === PointerState.INSIDE - ) { - onPressOut?.(e); - } - - if ( - !dropKeyboardTapRef.current && - !e.canceled && - !longPressDetected.current && - e.pointerInside - ) { - onPress?.(e); - } - - pointerState.current = PointerState.UNKNOWN; - lastEvent.current = null; - - if (longPressTimeout.current !== undefined) { - clearTimeout(longPressTimeout.current); - longPressTimeout.current = undefined; - } - - resetKeyboardDismiss(); - }, - [resetKeyboardDismiss, onPressOut, onPress] - ); - - const onUpdate = useCallback( - (e: CallbackEventType) => { - lastEvent.current = e; - - if (dropKeyboardTapRef.current) { - return; - } - - if (pointerState.current === PointerState.UNKNOWN) { - return; - } - - if (e.pointerInside) { - if (pointerState.current === PointerState.OUTSIDE) { - onPressIn?.(e); - } - pointerState.current = PointerState.INSIDE; - } else { - if (pointerState.current === PointerState.INSIDE) { - onPressOut?.(e); - - if (longPressTimeout.current !== undefined) { - clearTimeout(longPressTimeout.current); - longPressTimeout.current = undefined; - } - } - pointerState.current = PointerState.OUTSIDE; - } - }, - [onPressIn, onPressOut] - ); - - const nativeGesture = useNativeGesture({ - onBegin, - onActivate, - onFinalize, - onUpdate, - hitSlop: props.hitSlop, - testID: props.testID, - enabled: !disabled, - shouldCancelWhenOutside: cancelOnLeave, - disableReanimated: true, - shouldActivateOnStart: false, - disallowInterruption: true, - yieldsToContinuousGestures: true, - }); - - const rippleProps = shouldUseNativeRipple - ? { - rippleColor: androidRipple?.color, - rippleRadius: androidRipple?.radius, - borderless: androidRipple?.borderless, - foreground: androidRipple?.foreground, - } - : TRANSPARENT_RIPPLE; - - const tvProps = getTVProps(rest); - - return ( - - - {children} - - - ); -}; From 05e3e2d0cfe1d0f559ee284838ef9519d2922b50 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Mon, 27 Jul 2026 14:42:04 +0200 Subject: [PATCH 5/6] Move test file --- .../web/handlers/{ => __tests__}/GestureHandler.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) rename packages/react-native-gesture-handler/src/web/handlers/{ => __tests__}/GestureHandler.test.ts (82%) diff --git a/packages/react-native-gesture-handler/src/web/handlers/GestureHandler.test.ts b/packages/react-native-gesture-handler/src/web/handlers/__tests__/GestureHandler.test.ts similarity index 82% rename from packages/react-native-gesture-handler/src/web/handlers/GestureHandler.test.ts rename to packages/react-native-gesture-handler/src/web/handlers/__tests__/GestureHandler.test.ts index 5e169b8962..1824c16523 100644 --- a/packages/react-native-gesture-handler/src/web/handlers/GestureHandler.test.ts +++ b/packages/react-native-gesture-handler/src/web/handlers/__tests__/GestureHandler.test.ts @@ -1,7 +1,7 @@ -import EventManager from '../tools/EventManager'; -import type { GestureHandlerDelegate } from '../tools/GestureHandlerDelegate'; -import GestureHandler from './GestureHandler'; -import type IGestureHandler from './IGestureHandler'; +import EventManager from '../../tools/EventManager'; +import type { GestureHandlerDelegate } from '../../tools/GestureHandlerDelegate'; +import GestureHandler from '../GestureHandler'; +import type IGestureHandler from '../IGestureHandler'; class TestGestureHandler extends GestureHandler {} From 7348115d1c1001e4af332368a42ae01b582af086 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Mon, 27 Jul 2026 14:43:29 +0200 Subject: [PATCH 6/6] Don't attach press event listeners on legacy buttons --- .../components/GestureHandlerButton.web.tsx | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx index a6e277613c..cf1031569f 100644 --- a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx @@ -278,6 +278,10 @@ export const ButtonComponent = ({ useIsomorphicLayoutEffect(() => { const node = viewRef.current; + if (handlerTag === undefined || node == null) { + return; + } + const wrapEvent = (event: Event): NativeSyntheticEvent => ({ nativeEvent: (event as CustomEvent).detail, @@ -301,26 +305,27 @@ export const ButtonComponent = ({ onButtonInteractionFinished?.(wrapEvent(event)); }; - node?.addEventListener(ButtonEventName.Press, handlePress); - node?.addEventListener(ButtonEventName.PressIn, handlePressIn); - node?.addEventListener(ButtonEventName.PressOut, handlePressOut); - node?.addEventListener(ButtonEventName.LongPress, handleLongPress); - node?.addEventListener( + node.addEventListener(ButtonEventName.Press, handlePress); + node.addEventListener(ButtonEventName.PressIn, handlePressIn); + node.addEventListener(ButtonEventName.PressOut, handlePressOut); + node.addEventListener(ButtonEventName.LongPress, handleLongPress); + node.addEventListener( ButtonEventName.InteractionFinished, handleInteractionFinished ); return () => { - node?.removeEventListener(ButtonEventName.Press, handlePress); - node?.removeEventListener(ButtonEventName.PressIn, handlePressIn); - node?.removeEventListener(ButtonEventName.PressOut, handlePressOut); - node?.removeEventListener(ButtonEventName.LongPress, handleLongPress); - node?.removeEventListener( + node.removeEventListener(ButtonEventName.Press, handlePress); + node.removeEventListener(ButtonEventName.PressIn, handlePressIn); + node.removeEventListener(ButtonEventName.PressOut, handlePressOut); + node.removeEventListener(ButtonEventName.LongPress, handleLongPress); + node.removeEventListener( ButtonEventName.InteractionFinished, handleInteractionFinished ); }; }, [ + handlerTag, onButtonInteractionFinished, onButtonLongPress, onButtonPress,