From c9dbc4cf5a5550eefc41063b88487cc13f45f695 Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Fri, 3 Jul 2026 14:27:41 +0200 Subject: [PATCH 1/7] preserve SingleGestureName in gesture types --- .../src/handlers/handlersRegistry.ts | 12 ++++++----- .../src/v3/hooks/gestures/fling/FlingTypes.ts | 5 ++++- .../src/v3/hooks/gestures/hover/HoverTypes.ts | 4 +++- .../src/v3/hooks/gestures/index.ts | 13 ++---------- .../gestures/longPress/LongPressTypes.ts | 5 ++++- .../gestures/longPress/useLongPressGesture.ts | 10 ++++++---- .../v3/hooks/gestures/manual/ManualTypes.ts | 5 ++++- .../v3/hooks/gestures/native/NativeTypes.ts | 5 ++++- .../src/v3/hooks/gestures/pan/PanTypes.ts | 4 +++- .../v3/hooks/gestures/pan/usePanGesture.ts | 3 ++- .../src/v3/hooks/gestures/pinch/PinchTypes.ts | 4 +++- .../hooks/gestures/rotation/RotationTypes.ts | 4 +++- .../v3/hooks/gestures/singleGestureUnion.ts | 20 +++++++++++++++++++ .../src/v3/hooks/gestures/tap/TapTypes.ts | 5 ++++- .../v3/hooks/gestures/tap/useTapGesture.ts | 10 ++++++---- .../src/v3/hooks/useGesture.ts | 5 +++-- .../src/v3/types/GestureTypes.ts | 11 ++++++---- 17 files changed, 85 insertions(+), 40 deletions(-) create mode 100644 packages/react-native-gesture-handler/src/v3/hooks/gestures/singleGestureUnion.ts diff --git a/packages/react-native-gesture-handler/src/handlers/handlersRegistry.ts b/packages/react-native-gesture-handler/src/handlers/handlersRegistry.ts index 2c4426d974..b64549a3e5 100644 --- a/packages/react-native-gesture-handler/src/handlers/handlersRegistry.ts +++ b/packages/react-native-gesture-handler/src/handlers/handlersRegistry.ts @@ -1,4 +1,5 @@ import { isTestEnv } from '../utils'; +import type { AnySingleGesture } from '../v3/hooks/gestures/singleGestureUnion'; import type { SingleGesture } from '../v3/types'; import type { GestureEvent, @@ -8,10 +9,9 @@ import type { GestureType } from './gestures/gesture'; export const handlerIDToTag: Record = {}; -// There were attempts to create types that merge possible HandlerData and Config, -// but ts was not able to infer them properly in many cases, so we use any here. -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const hookGestures = new Map>(); +// Stored as the discriminated union of concrete v3 gestures so lookups by +// test ID can narrow on the `type` field. +const hookGestures = new Map(); const gestures = new Map(); const oldHandlers = new Map(); const testIDs = new Map(); @@ -25,7 +25,9 @@ export function registerGesture< gesture: SingleGesture ) { if (isTestEnv() && gesture.config.testID) { - hookGestures.set(handlerTag, gesture); + // The generic parameters cannot be correlated with the union members + // here, but every gesture created by the v3 hooks is a union member. + hookGestures.set(handlerTag, gesture as unknown as AnySingleGesture); testIDs.set(gesture.config.testID, handlerTag); } } diff --git a/packages/react-native-gesture-handler/src/v3/hooks/gestures/fling/FlingTypes.ts b/packages/react-native-gesture-handler/src/v3/hooks/gestures/fling/FlingTypes.ts index cf093c3afa..fea6a59927 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/gestures/fling/FlingTypes.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/gestures/fling/FlingTypes.ts @@ -3,6 +3,7 @@ import type { ExcludeInternalConfigProps, GestureEvent, SingleGesture, + SingleGestureName, WithSharedValue, } from '../../../types'; @@ -56,5 +57,7 @@ export type FlingGestureActiveEvent = FlingGestureEvent; export type FlingGesture = SingleGesture< FlingGestureProperties, - FlingHandlerData + FlingHandlerData, + FlingHandlerData, + SingleGestureName.Fling >; diff --git a/packages/react-native-gesture-handler/src/v3/hooks/gestures/hover/HoverTypes.ts b/packages/react-native-gesture-handler/src/v3/hooks/gestures/hover/HoverTypes.ts index 5bf13293cc..123f67c8b2 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/gestures/hover/HoverTypes.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/gestures/hover/HoverTypes.ts @@ -5,6 +5,7 @@ import type { ExcludeInternalConfigProps, GestureEvent, SingleGesture, + SingleGestureName, WithSharedValue, } from '../../../types'; @@ -72,5 +73,6 @@ export type HoverGestureActiveEvent = GestureEvent; export type HoverGesture = SingleGesture< HoverGestureInternalProperties, HoverHandlerData, - HoverExtendedHandlerData + HoverExtendedHandlerData, + SingleGestureName.Hover >; diff --git a/packages/react-native-gesture-handler/src/v3/hooks/gestures/index.ts b/packages/react-native-gesture-handler/src/v3/hooks/gestures/index.ts index ca6c7fd015..a990ed2b91 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/gestures/index.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/gestures/index.ts @@ -124,17 +124,8 @@ export type { PanGestureEvent, }; export { usePanGesture } from './pan/usePanGesture'; - -export type SingleGesture = - | TapGesture - | FlingGesture - | LongPressGesture - | PinchGesture - | RotationGesture - | HoverGesture - | ManualGesture - | NativeGesture - | PanGesture; +export type { AnySingleGesture } from './singleGestureUnion'; +export type { AnySingleGesture as SingleGesture } from './singleGestureUnion'; /* eslint-disable @typescript-eslint/no-duplicate-type-constituents */ export type SingleGestureEvent = diff --git a/packages/react-native-gesture-handler/src/v3/hooks/gestures/longPress/LongPressTypes.ts b/packages/react-native-gesture-handler/src/v3/hooks/gestures/longPress/LongPressTypes.ts index 5736206cf7..ca80660f20 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/gestures/longPress/LongPressTypes.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/gestures/longPress/LongPressTypes.ts @@ -3,6 +3,7 @@ import type { ExcludeInternalConfigProps, GestureEvent, SingleGesture, + SingleGestureName, WithSharedValue, } from '../../../types'; @@ -68,5 +69,7 @@ export type LongPressGestureActiveEvent = LongPressGestureEvent; export type LongPressGesture = SingleGesture< LongPressGestureProperties, - LongPressHandlerData + LongPressHandlerData, + LongPressHandlerData, + SingleGestureName.LongPress >; diff --git a/packages/react-native-gesture-handler/src/v3/hooks/gestures/longPress/useLongPressGesture.ts b/packages/react-native-gesture-handler/src/v3/hooks/gestures/longPress/useLongPressGesture.ts index 144dd4d7d8..5ae23c7911 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/gestures/longPress/useLongPressGesture.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/gestures/longPress/useLongPressGesture.ts @@ -39,8 +39,10 @@ export function useLongPressGesture( LongPressGestureInternalProperties >(config, LongPressPropsMapping, transformLongPressProps); - return useGesture( - SingleGestureName.LongPress, - longPressConfig - ); + return useGesture< + LongPressGestureInternalProperties, + LongPressHandlerData, + LongPressHandlerData, + SingleGestureName.LongPress + >(SingleGestureName.LongPress, longPressConfig); } diff --git a/packages/react-native-gesture-handler/src/v3/hooks/gestures/manual/ManualTypes.ts b/packages/react-native-gesture-handler/src/v3/hooks/gestures/manual/ManualTypes.ts index 81946bcac7..7feed55b71 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/gestures/manual/ManualTypes.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/gestures/manual/ManualTypes.ts @@ -3,6 +3,7 @@ import type { ExcludeInternalConfigProps, GestureEvent, SingleGesture, + SingleGestureName, } from '../../../types'; export type ManualGestureNativeProperties = Record; @@ -24,5 +25,7 @@ export type ManualGestureActiveEvent = ManualGestureEvent; export type ManualGesture = SingleGesture< ManualGestureProperties, - ManualHandlerData + ManualHandlerData, + ManualHandlerData, + SingleGestureName.Manual >; diff --git a/packages/react-native-gesture-handler/src/v3/hooks/gestures/native/NativeTypes.ts b/packages/react-native-gesture-handler/src/v3/hooks/gestures/native/NativeTypes.ts index fb3026e7b9..726232f630 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/gestures/native/NativeTypes.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/gestures/native/NativeTypes.ts @@ -3,6 +3,7 @@ import type { ExcludeInternalConfigProps, GestureEvent, SingleGesture, + SingleGestureName, WithSharedValue, } from '../../../types'; @@ -58,5 +59,7 @@ export type NativeGestureActiveEvent = NativeGestureEvent; export type NativeGesture = SingleGesture< NativeGestureProperties, - NativeHandlerData + NativeHandlerData, + NativeHandlerData, + SingleGestureName.Native >; diff --git a/packages/react-native-gesture-handler/src/v3/hooks/gestures/pan/PanTypes.ts b/packages/react-native-gesture-handler/src/v3/hooks/gestures/pan/PanTypes.ts index db5b879912..3d70bfa269 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/gestures/pan/PanTypes.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/gestures/pan/PanTypes.ts @@ -4,6 +4,7 @@ import type { ExcludeInternalConfigProps, GestureEvent, SingleGesture, + SingleGestureName, WithSharedValue, } from '../../../types'; @@ -170,5 +171,6 @@ export type PanGestureActiveEvent = GestureEvent; export type PanGesture = SingleGesture< PanGestureInternalProperties, PanHandlerData, - PanExtendedHandlerData + PanExtendedHandlerData, + SingleGestureName.Pan >; diff --git a/packages/react-native-gesture-handler/src/v3/hooks/gestures/pan/usePanGesture.ts b/packages/react-native-gesture-handler/src/v3/hooks/gestures/pan/usePanGesture.ts index 0f714421c0..44b518dfc9 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/gestures/pan/usePanGesture.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/gestures/pan/usePanGesture.ts @@ -158,6 +158,7 @@ export function usePanGesture( return useGesture< PanGestureInternalProperties, PanHandlerData, - PanExtendedHandlerData + PanExtendedHandlerData, + SingleGestureName.Pan >(SingleGestureName.Pan, panConfig); } diff --git a/packages/react-native-gesture-handler/src/v3/hooks/gestures/pinch/PinchTypes.ts b/packages/react-native-gesture-handler/src/v3/hooks/gestures/pinch/PinchTypes.ts index e6b7aa9877..d0f64fffb2 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/gestures/pinch/PinchTypes.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/gestures/pinch/PinchTypes.ts @@ -3,6 +3,7 @@ import type { ExcludeInternalConfigProps, GestureEvent, SingleGesture, + SingleGestureName, } from '../../../types'; export type PinchGestureNativeProperties = Record; @@ -36,5 +37,6 @@ export type PinchGestureActiveEvent = GestureEvent; export type PinchGesture = SingleGesture< PinchGestureProperties, PinchHandlerData, - PinchExtendedHandlerData + PinchExtendedHandlerData, + SingleGestureName.Pinch >; diff --git a/packages/react-native-gesture-handler/src/v3/hooks/gestures/rotation/RotationTypes.ts b/packages/react-native-gesture-handler/src/v3/hooks/gestures/rotation/RotationTypes.ts index 9327ca1d67..abb0f0c42d 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/gestures/rotation/RotationTypes.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/gestures/rotation/RotationTypes.ts @@ -3,6 +3,7 @@ import type { ExcludeInternalConfigProps, GestureEvent, SingleGesture, + SingleGestureName, } from '../../../types'; export type RotationGestureNativeProperties = Record; @@ -37,5 +38,6 @@ export type RotationGestureActiveEvent = export type RotationGesture = SingleGesture< RotationGestureProperties, RotationHandlerData, - RotationExtendedHandlerData + RotationExtendedHandlerData, + SingleGestureName.Rotation >; diff --git a/packages/react-native-gesture-handler/src/v3/hooks/gestures/singleGestureUnion.ts b/packages/react-native-gesture-handler/src/v3/hooks/gestures/singleGestureUnion.ts new file mode 100644 index 0000000000..f42b78a8a5 --- /dev/null +++ b/packages/react-native-gesture-handler/src/v3/hooks/gestures/singleGestureUnion.ts @@ -0,0 +1,20 @@ +import type { FlingGesture } from './fling/FlingTypes'; +import type { HoverGesture } from './hover/HoverTypes'; +import type { LongPressGesture } from './longPress/LongPressTypes'; +import type { ManualGesture } from './manual/ManualTypes'; +import type { NativeGesture } from './native/NativeTypes'; +import type { PanGesture } from './pan/PanTypes'; +import type { PinchGesture } from './pinch/PinchTypes'; +import type { RotationGesture } from './rotation/RotationTypes'; +import type { TapGesture } from './tap/TapTypes'; + +export type AnySingleGesture = + | TapGesture + | FlingGesture + | LongPressGesture + | PinchGesture + | RotationGesture + | HoverGesture + | ManualGesture + | NativeGesture + | PanGesture; diff --git a/packages/react-native-gesture-handler/src/v3/hooks/gestures/tap/TapTypes.ts b/packages/react-native-gesture-handler/src/v3/hooks/gestures/tap/TapTypes.ts index 4c1dfb1fbb..62e9b73ab6 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/gestures/tap/TapTypes.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/gestures/tap/TapTypes.ts @@ -3,6 +3,7 @@ import type { DiscreteSingleGesture, ExcludeInternalConfigProps, GestureEvent, + SingleGestureName, WithSharedValue, } from '../../../types'; @@ -96,5 +97,7 @@ export type TapGestureActiveEvent = TapGestureEvent; export type TapGesture = DiscreteSingleGesture< TapGestureInternalProperties, - TapHandlerData + TapHandlerData, + TapHandlerData, + SingleGestureName.Tap >; diff --git a/packages/react-native-gesture-handler/src/v3/hooks/gestures/tap/useTapGesture.ts b/packages/react-native-gesture-handler/src/v3/hooks/gestures/tap/useTapGesture.ts index edd8c654e4..de2b8196f0 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/gestures/tap/useTapGesture.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/gestures/tap/useTapGesture.ts @@ -29,8 +29,10 @@ export function useTapGesture( TapGestureInternalProperties >(config, TapPropsMapping); - return useGesture( - SingleGestureName.Tap, - tapConfig - ); + return useGesture< + TapGestureInternalProperties, + TapHandlerData, + TapHandlerData, + SingleGestureName.Tap + >(SingleGestureName.Tap, tapConfig); } diff --git a/packages/react-native-gesture-handler/src/v3/hooks/useGesture.ts b/packages/react-native-gesture-handler/src/v3/hooks/useGesture.ts index ff55841506..3871cdebca 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/useGesture.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/useGesture.ts @@ -25,10 +25,11 @@ export function useGesture< TConfig, THandlerData, TExtendedHandlerData extends THandlerData = THandlerData, + TType extends SingleGestureName = SingleGestureName, >( - type: SingleGestureName, + type: TType, config: BaseGestureConfig -): SingleGesture { +): SingleGesture { const handlerTag = useMemo(() => getNextHandlerTag(), []); const disableReanimated = useMemo(() => config.disableReanimated, []); diff --git a/packages/react-native-gesture-handler/src/v3/types/GestureTypes.ts b/packages/react-native-gesture-handler/src/v3/types/GestureTypes.ts index d78e7adc34..fb7284dba4 100644 --- a/packages/react-native-gesture-handler/src/v3/types/GestureTypes.ts +++ b/packages/react-native-gesture-handler/src/v3/types/GestureTypes.ts @@ -39,9 +39,10 @@ export type SingleGesture< TConfig, THandlerData, TExtendedHandlerData extends THandlerData = THandlerData, + TType extends SingleGestureName = SingleGestureName, > = { handlerTag: number; - type: SingleGestureName; + type: TType; config: BaseGestureConfig; detectorCallbacks: DetectorCallbacks; gestureRelations: GestureRelations; @@ -51,17 +52,19 @@ export type DiscreteSingleGesture< TConfig, THandlerData, TExtendedHandlerData extends THandlerData = THandlerData, + TType extends SingleGestureName = SingleGestureName, > = { [K in keyof SingleGesture< TConfig, THandlerData, - TExtendedHandlerData + TExtendedHandlerData, + TType >]: K extends 'config' ? Omit< - SingleGesture[K], + SingleGesture[K], 'onUpdate' > - : SingleGesture[K]; + : SingleGesture[K]; }; export type ComposedGesture = { From b77d4c8f02c501ff9335f63ee012b31a4344136a Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Fri, 3 Jul 2026 14:53:36 +0200 Subject: [PATCH 2/7] Extract the platform-neutral arbitration core --- .../GestureHandlerOrchestrator.test.ts | 373 +++++++++++++++++ .../GestureArbitrationTypes.ts | 66 +++ .../gestureArbitration/GestureArbitrator.ts | 358 ++++++++++++++++ .../web/tools/GestureHandlerOrchestrator.ts | 393 +++--------------- 4 files changed, 863 insertions(+), 327 deletions(-) create mode 100644 packages/react-native-gesture-handler/src/__tests__/GestureHandlerOrchestrator.test.ts create mode 100644 packages/react-native-gesture-handler/src/handlers/gestureArbitration/GestureArbitrationTypes.ts create mode 100644 packages/react-native-gesture-handler/src/handlers/gestureArbitration/GestureArbitrator.ts diff --git a/packages/react-native-gesture-handler/src/__tests__/GestureHandlerOrchestrator.test.ts b/packages/react-native-gesture-handler/src/__tests__/GestureHandlerOrchestrator.test.ts new file mode 100644 index 0000000000..ec73162572 --- /dev/null +++ b/packages/react-native-gesture-handler/src/__tests__/GestureHandlerOrchestrator.test.ts @@ -0,0 +1,373 @@ +import { PointerType } from '../PointerType'; +import { State } from '../State'; +import type IGestureHandler from '../web/handlers/IGestureHandler'; +import GestureHandlerOrchestrator from '../web/tools/GestureHandlerOrchestrator'; + +interface EmittedEvent { + newState: State; + oldState: State; +} + +// Mimics the state-transition behavior of the web `GestureHandler` base class +// (moveToState + begin/activate/end/fail/cancel guards) without any DOM, +// pointer or delegate logic. Used to characterize the arbitration behavior +// of the orchestrator so it can be extracted into a platform-neutral core. +class FakeHandler { + public state: State = State.UNDETERMINED; + public active = false; + public awaiting = false; + public activationIndex = 0; + public shouldResetProgress = false; + public enabled = true; + public pointerType = PointerType.TOUCH; + + public events: EmittedEvent[] = []; + public resetCalls = 0; + + public delegate = { + view: this, + isPointerInBounds: () => false, + }; + public tracker = { + getLastAbsoluteCoords: () => undefined, + resetTracker: () => undefined, + }; + + private waitFor: FakeHandler[] = []; + private simultaneousWith: FakeHandler[] = []; + + // eslint-disable-next-line no-useless-constructor + constructor(public handlerTag: number) {} + + public asHandler(): IGestureHandler { + return this as unknown as IGestureHandler; + } + + public waitForFailureOf(other: FakeHandler) { + this.waitFor.push(other); + } + + public recognizeSimultaneouslyWith(other: FakeHandler) { + this.simultaneousWith.push(other); + other.simultaneousWith.push(this); + } + + private moveToState(newState: State) { + if (this.state === newState) { + return; + } + + const oldState = this.state; + this.state = newState; + + GestureHandlerOrchestrator.instance.onHandlerStateChange( + this.asHandler(), + newState, + oldState + ); + } + + public begin() { + GestureHandlerOrchestrator.instance.recordHandlerIfNotPresent( + this.asHandler() + ); + + if (this.state === State.UNDETERMINED) { + this.moveToState(State.BEGAN); + } + } + + public activate() { + if (this.state === State.BEGAN) { + this.moveToState(State.ACTIVE); + } + } + + public end() { + if (this.state === State.BEGAN || this.state === State.ACTIVE) { + this.moveToState(State.END); + } + } + + public fail() { + if (this.state === State.ACTIVE || this.state === State.BEGAN) { + this.moveToState(State.FAILED); + } + } + + public cancel() { + if ( + this.state === State.ACTIVE || + this.state === State.UNDETERMINED || + this.state === State.BEGAN + ) { + this.moveToState(State.CANCELLED); + } + } + + public reset() { + this.resetCalls++; + this.state = State.UNDETERMINED; + } + + public sendEvent(newState: State, oldState: State) { + this.events.push({ newState, oldState }); + } + + public shouldWaitForHandlerFailure(other: FakeHandler) { + return this.waitFor.includes(other); + } + + public shouldRequireToWaitForFailure(_other: FakeHandler) { + return false; + } + + public shouldRecognizeSimultaneously(other: FakeHandler) { + return other === this || this.simultaneousWith.includes(other); + } + + public shouldBeCancelledByOther(_other: FakeHandler) { + // Mirrors the InteractionManager default, which returns true only for + // active native handlers. + return false; + } + + public shouldBeginWithRecordedHandlers(_recorded: IGestureHandler[]) { + return true; + } + + public getTrackedPointersID() { + // All fake handlers share a single pointer, like handlers attached to + // overlapping views receiving the same interaction. + return [0]; + } +} + +function flushMicrotasks() { + return new Promise((resolve) => { + setTimeout(resolve, 0); + }); +} + +let createdHandlers: FakeHandler[] = []; +let nextTag = 1; + +function createHandler() { + const handler = new FakeHandler(nextTag++); + createdHandlers.push(handler); + return handler; +} + +afterEach(async () => { + await flushMicrotasks(); + for (const handler of createdHandlers) { + GestureHandlerOrchestrator.instance.removeHandlerFromOrchestrator( + handler.asHandler() + ); + } + createdHandlers = []; +}); + +describe('Orchestrator characterization: single handler', () => { + test('progresses through begin, activation and successful end', () => { + const handler = createHandler(); + + handler.begin(); + handler.activate(); + handler.end(); + + expect(handler.events).toEqual([ + { newState: State.BEGAN, oldState: State.UNDETERMINED }, + { newState: State.ACTIVE, oldState: State.BEGAN }, + { newState: State.END, oldState: State.ACTIVE }, + ]); + }); + + test('failure before activation emits FAILED with BEGAN as previous state', () => { + const handler = createHandler(); + + handler.begin(); + handler.fail(); + + expect(handler.events).toEqual([ + { newState: State.BEGAN, oldState: State.UNDETERMINED }, + { newState: State.FAILED, oldState: State.BEGAN }, + ]); + }); + + test('cancellation after activation emits CANCELLED with ACTIVE as previous state', () => { + const handler = createHandler(); + + handler.begin(); + handler.activate(); + handler.cancel(); + + expect(handler.events).toEqual([ + { newState: State.BEGAN, oldState: State.UNDETERMINED }, + { newState: State.ACTIVE, oldState: State.BEGAN }, + { newState: State.CANCELLED, oldState: State.ACTIVE }, + ]); + }); + + test('duplicate transition requests do not emit duplicate events', () => { + const handler = createHandler(); + + handler.begin(); + handler.begin(); + handler.activate(); + handler.activate(); + handler.end(); + handler.end(); + + expect(handler.events).toEqual([ + { newState: State.BEGAN, oldState: State.UNDETERMINED }, + { newState: State.ACTIVE, oldState: State.BEGAN }, + { newState: State.END, oldState: State.ACTIVE }, + ]); + }); + + test('finished handler is reset and removed from the orchestrator', async () => { + const handler = createHandler(); + + handler.begin(); + handler.activate(); + handler.end(); + + await flushMicrotasks(); + + expect(handler.resetCalls).toBe(1); + expect(handler.active).toBe(false); + expect( + GestureHandlerOrchestrator.instance.isHandlerRecorded(handler.asHandler()) + ).toBe(false); + }); +}); + +describe('Orchestrator characterization: waiting for required handlers', () => { + test('handler awaiting a required handler does not emit activation', () => { + const required = createHandler(); + const awaiting = createHandler(); + awaiting.waitForFailureOf(required); + + required.begin(); + awaiting.begin(); + awaiting.activate(); + + expect(awaiting.awaiting).toBe(true); + expect(awaiting.events).toEqual([ + { newState: State.BEGAN, oldState: State.UNDETERMINED }, + ]); + }); + + test('awaiting handler activates after the required handler fails', () => { + const required = createHandler(); + const awaiting = createHandler(); + awaiting.waitForFailureOf(required); + + required.begin(); + awaiting.begin(); + awaiting.activate(); + required.fail(); + + expect(awaiting.events).toEqual([ + { newState: State.BEGAN, oldState: State.UNDETERMINED }, + { newState: State.ACTIVE, oldState: State.BEGAN }, + ]); + expect(awaiting.awaiting).toBe(false); + expect(awaiting.active).toBe(true); + }); + + test('awaiting handler is cancelled after the required handler ends successfully', () => { + const required = createHandler(); + const awaiting = createHandler(); + awaiting.waitForFailureOf(required); + // Required handler would be cancelled by the awaiting one otherwise, + // as the fake handlers share a pointer. + required.recognizeSimultaneouslyWith(awaiting); + + required.begin(); + awaiting.begin(); + awaiting.activate(); + + required.activate(); + required.end(); + + // The awaiting handler was never observably active, so cancellation is + // reported relative to BEGAN. + expect(awaiting.events).toEqual([ + { newState: State.BEGAN, oldState: State.UNDETERMINED }, + { newState: State.CANCELLED, oldState: State.BEGAN }, + ]); + expect(awaiting.awaiting).toBe(false); + }); + + test('discrete handler that finished while awaiting receives synthetic cancellation', () => { + const required = createHandler(); + const discrete = createHandler(); + discrete.waitForFailureOf(required); + required.recognizeSimultaneouslyWith(discrete); + + required.begin(); + + // Discrete handlers may end immediately after requesting activation. + discrete.begin(); + discrete.activate(); + discrete.end(); + + expect(discrete.state).toBe(State.END); + expect(discrete.events).toEqual([ + { newState: State.BEGAN, oldState: State.UNDETERMINED }, + ]); + + required.activate(); + required.end(); + + // Synthetic BEGAN -> CANCELLED is sent even though the handler already + // reached END internally. + expect(discrete.events).toEqual([ + { newState: State.BEGAN, oldState: State.UNDETERMINED }, + { newState: State.CANCELLED, oldState: State.BEGAN }, + ]); + }); +}); + +describe('Orchestrator characterization: concurrent handlers', () => { + test('simultaneous handlers are not mutually cancelled', () => { + const first = createHandler(); + const second = createHandler(); + first.recognizeSimultaneouslyWith(second); + + first.begin(); + second.begin(); + first.activate(); + second.activate(); + first.end(); + second.end(); + + expect(first.events).toEqual([ + { newState: State.BEGAN, oldState: State.UNDETERMINED }, + { newState: State.ACTIVE, oldState: State.BEGAN }, + { newState: State.END, oldState: State.ACTIVE }, + ]); + expect(second.events).toEqual([ + { newState: State.BEGAN, oldState: State.UNDETERMINED }, + { newState: State.ACTIVE, oldState: State.BEGAN }, + { newState: State.END, oldState: State.ACTIVE }, + ]); + }); + + test('activation cancels competing handlers', () => { + const winner = createHandler(); + const loser = createHandler(); + + winner.begin(); + loser.begin(); + winner.activate(); + + expect(loser.events).toEqual([ + { newState: State.BEGAN, oldState: State.UNDETERMINED }, + { newState: State.CANCELLED, oldState: State.BEGAN }, + ]); + expect(winner.active).toBe(true); + }); +}); diff --git a/packages/react-native-gesture-handler/src/handlers/gestureArbitration/GestureArbitrationTypes.ts b/packages/react-native-gesture-handler/src/handlers/gestureArbitration/GestureArbitrationTypes.ts new file mode 100644 index 0000000000..72c765e4e3 --- /dev/null +++ b/packages/react-native-gesture-handler/src/handlers/gestureArbitration/GestureArbitrationTypes.ts @@ -0,0 +1,66 @@ +import type { State } from '../../State'; + +/** + * Narrow, platform-neutral contract required by the arbitration core. + */ +export interface ArbitratedGestureHandler { + readonly handlerTag: number; + readonly state: State; + readonly enabled: boolean | null; + + active: boolean; + awaiting: boolean; + activationIndex: number; + shouldResetProgress: boolean; + + sendEvent(newState: State, oldState: State): void; + cancel(): void; + fail(): void; + reset(): void; +} + +/** + * Relation decisions injected into the arbitration core. + */ +export interface GestureRelationPolicy< + THandler extends ArbitratedGestureHandler, +> { + /** + * Whether `handler` has to wait for `otherHandler` to fail before it can + * activate. Identity is already excluded by the core. + */ + shouldHandlerWaitForOther(handler: THandler, otherHandler: THandler): boolean; + + /** + * One-directional simultaneity check. The core treats handlers as + * simultaneous when the check passes in either direction. + */ + shouldRecognizeSimultaneously( + handler: THandler, + otherHandler: THandler + ): boolean; + + /** + * Whether an awaiting or active `handler` should be cancelled when + * `otherHandler` activates. + */ + shouldBeCancelledByOther(handler: THandler, otherHandler: THandler): boolean; + + /** + * Platform-specific fallback used for handlers that are neither awaiting + * nor active. + */ + shouldBeCancelledByDefault( + handler: THandler, + otherHandler: THandler + ): boolean; + + /** + * Whether `handler` may begin given the already recorded handlers. On the + * web this implements native-view cancellation rules. + */ + shouldBeginWithRecordedHandlers( + handler: THandler, + recordedHandlers: readonly THandler[] + ): boolean; +} diff --git a/packages/react-native-gesture-handler/src/handlers/gestureArbitration/GestureArbitrator.ts b/packages/react-native-gesture-handler/src/handlers/gestureArbitration/GestureArbitrator.ts new file mode 100644 index 0000000000..54ee6e2f08 --- /dev/null +++ b/packages/react-native-gesture-handler/src/handlers/gestureArbitration/GestureArbitrator.ts @@ -0,0 +1,358 @@ +import { State } from '../../State'; +import type { + ArbitratedGestureHandler, + GestureRelationPolicy, +} from './GestureArbitrationTypes'; + +/** + * Platform-neutral gesture state arbitration core. + * It records participating handlers, tracks + * active and awaiting handlers, defers activation while dependencies are + * unresolved, applies relation decisions supplied by the injected policy and + * emits only observable state transitions. + */ +export default class GestureArbitrator< + THandler extends ArbitratedGestureHandler, +> { + private gestureHandlers: THandler[] = []; + private awaitingHandlers: THandler[] = []; + private awaitingHandlersTags: Set = new Set(); + + private handlingChangeSemaphore = 0; + private activationIndex = 0; + + // eslint-disable-next-line no-useless-constructor + constructor(private relationPolicy: GestureRelationPolicy) {} + + public get recordedHandlers(): readonly THandler[] { + return this.gestureHandlers; + } + + private scheduleFinishedHandlersCleanup(): void { + if (this.handlingChangeSemaphore === 0) { + queueMicrotask(() => { + this.cleanupFinishedHandlers(); + }); + } + } + + private cleanHandler(handler: THandler): void { + handler.reset(); + handler.active = false; + handler.awaiting = false; + handler.activationIndex = Number.MAX_VALUE; + } + + public isHandlerRecorded(handler: THandler): boolean { + return this.gestureHandlers.includes(handler); + } + + public removeHandler(handler: THandler): void { + const indexInGestureHandlers = this.gestureHandlers.indexOf(handler); + const indexInAwaitingHandlers = this.awaitingHandlers.indexOf(handler); + + if (indexInGestureHandlers >= 0) { + this.gestureHandlers.splice(indexInGestureHandlers, 1); + } + + if (indexInAwaitingHandlers >= 0) { + this.awaitingHandlers.splice(indexInAwaitingHandlers, 1); + this.awaitingHandlersTags.delete(handler.handlerTag); + } + } + + private cleanupFinishedHandlers(): void { + const handlersToRemove = new Set(); + + for (let i = this.gestureHandlers.length - 1; i >= 0; --i) { + const handler = this.gestureHandlers[i]; + + if (this.isFinished(handler.state) && !handler.awaiting) { + this.cleanHandler(handler); + handlersToRemove.add(handler); + } + } + + this.gestureHandlers = this.gestureHandlers.filter( + (handler) => !handlersToRemove.has(handler) + ); + } + + private hasOtherHandlerToWaitFor(handler: THandler): boolean { + const hasToWaitFor = (otherHandler: THandler) => { + return ( + !this.isFinished(otherHandler.state) && + this.shouldHandlerWaitForOther(handler, otherHandler) + ); + }; + + return this.gestureHandlers.some(hasToWaitFor); + } + + private shouldBeCancelledByFinishedHandler(handler: THandler): boolean { + const shouldBeCancelled = (otherHandler: THandler) => { + return ( + this.shouldHandlerWaitForOther(handler, otherHandler) && + otherHandler.state === State.END + ); + }; + + return this.gestureHandlers.some(shouldBeCancelled); + } + + private tryActivate(handler: THandler): void { + if (this.shouldBeCancelledByFinishedHandler(handler)) { + handler.cancel(); + return; + } + + if (this.hasOtherHandlerToWaitFor(handler)) { + this.addAwaitingHandler(handler); + return; + } + + const handlerState = handler.state; + + if (handlerState === State.CANCELLED || handlerState === State.FAILED) { + return; + } + + if (this.shouldActivate(handler)) { + this.makeActive(handler); + return; + } + + if (handlerState === State.ACTIVE) { + handler.fail(); + return; + } + + if (handlerState === State.BEGAN) { + handler.cancel(); + } + } + + private shouldActivate(handler: THandler): boolean { + const shouldBeCancelledBy = (otherHandler: THandler) => { + return this.shouldHandlerBeCancelledBy(handler, otherHandler); + }; + + return !this.gestureHandlers.some(shouldBeCancelledBy); + } + + private cleanupAwaitingHandlers(handler: THandler): void { + const shouldWait = (otherHandler: THandler) => { + return ( + !otherHandler.awaiting && + this.shouldHandlerWaitForOther(otherHandler, handler) + ); + }; + + for (const otherHandler of this.awaitingHandlers) { + if (shouldWait(otherHandler)) { + this.cleanHandler(otherHandler); + this.awaitingHandlersTags.delete(otherHandler.handlerTag); + } + } + + this.awaitingHandlers = this.awaitingHandlers.filter((otherHandler) => + this.awaitingHandlersTags.has(otherHandler.handlerTag) + ); + } + + public onHandlerStateChange( + handler: THandler, + newState: State, + oldState: State, + sendIfDisabled?: boolean + ): void { + if (!handler.enabled && !sendIfDisabled) { + return; + } + + this.handlingChangeSemaphore += 1; + + if (this.isFinished(newState)) { + for (const otherHandler of this.awaitingHandlers) { + if ( + !this.shouldHandlerWaitForOther(otherHandler, handler) || + !this.awaitingHandlersTags.has(otherHandler.handlerTag) + ) { + continue; + } + + if (newState !== State.END) { + this.tryActivate(otherHandler); + continue; + } + + otherHandler.cancel(); + + if (otherHandler.state === State.END) { + // Handle edge case, where discrete gestures end immediately after activation thus + // their state is set to END and when the gesture they are waiting for activates they + // should be cancelled, however `cancel` was never sent as gestures were already in the END state. + // Send synthetic BEGAN -> CANCELLED to properly handle JS logic + otherHandler.sendEvent(State.CANCELLED, State.BEGAN); + } + + otherHandler.awaiting = false; + } + } + + if (newState === State.ACTIVE) { + this.tryActivate(handler); + } else if (oldState === State.ACTIVE || oldState === State.END) { + if (handler.active) { + handler.sendEvent(newState, oldState); + } else if ( + oldState === State.ACTIVE && + (newState === State.CANCELLED || newState === State.FAILED) + ) { + handler.sendEvent(newState, State.BEGAN); + } + } else if ( + oldState !== State.UNDETERMINED || + newState !== State.CANCELLED + ) { + handler.sendEvent(newState, oldState); + } + + this.handlingChangeSemaphore -= 1; + + this.scheduleFinishedHandlersCleanup(); + + if (!this.awaitingHandlers.includes(handler)) { + this.cleanupAwaitingHandlers(handler); + } + } + + private makeActive(handler: THandler): void { + const currentState = handler.state; + + handler.active = true; + handler.shouldResetProgress = true; + handler.activationIndex = this.activationIndex++; + + for (let i = this.gestureHandlers.length - 1; i >= 0; --i) { + if (this.shouldHandlerBeCancelledBy(this.gestureHandlers[i], handler)) { + this.gestureHandlers[i].cancel(); + } + } + + for (const otherHandler of this.awaitingHandlers) { + if (this.shouldHandlerBeCancelledBy(otherHandler, handler)) { + otherHandler.awaiting = false; + } + } + + handler.sendEvent(State.ACTIVE, State.BEGAN); + + if (currentState !== State.ACTIVE) { + handler.sendEvent(State.END, State.ACTIVE); + if (currentState !== State.END) { + handler.sendEvent(State.UNDETERMINED, State.END); + } + } + + if (!handler.awaiting) { + return; + } + + handler.awaiting = false; + + this.awaitingHandlers = this.awaitingHandlers.filter( + (otherHandler) => otherHandler !== handler + ); + } + + private addAwaitingHandler(handler: THandler): void { + if (this.awaitingHandlers.includes(handler)) { + return; + } + + this.awaitingHandlers.push(handler); + this.awaitingHandlersTags.add(handler.handlerTag); + + handler.awaiting = true; + handler.activationIndex = this.activationIndex++; + } + + public recordHandlerIfNotPresent(handler: THandler): void { + if (this.isHandlerRecorded(handler)) { + return; + } + + handler.active = false; + handler.awaiting = false; + handler.activationIndex = Number.MAX_SAFE_INTEGER; + + if ( + !this.relationPolicy.shouldBeginWithRecordedHandlers( + handler, + this.gestureHandlers + ) + ) { + handler.cancel(); + } + + this.gestureHandlers.push(handler); + } + + private shouldHandlerWaitForOther( + handler: THandler, + otherHandler: THandler + ): boolean { + return ( + handler !== otherHandler && + this.relationPolicy.shouldHandlerWaitForOther(handler, otherHandler) + ); + } + + private canRunSimultaneously(gh1: THandler, gh2: THandler): boolean { + return ( + gh1 === gh2 || + this.relationPolicy.shouldRecognizeSimultaneously(gh1, gh2) || + this.relationPolicy.shouldRecognizeSimultaneously(gh2, gh1) + ); + } + + private shouldHandlerBeCancelledBy( + handler: THandler, + otherHandler: THandler + ): boolean { + if (this.canRunSimultaneously(handler, otherHandler)) { + return false; + } + + if (handler.awaiting || handler.state === State.ACTIVE) { + return this.relationPolicy.shouldBeCancelledByOther( + handler, + otherHandler + ); + } + + return this.relationPolicy.shouldBeCancelledByDefault( + handler, + otherHandler + ); + } + + private isFinished(state: State): boolean { + return ( + state === State.END || state === State.FAILED || state === State.CANCELLED + ); + } + + /** + * Clears all recorded and awaiting handlers. Used by the Jest utilities to + * keep tests isolated; the web orchestrator does not use it. + */ + public reset(): void { + this.gestureHandlers = []; + this.awaitingHandlers = []; + this.awaitingHandlersTags.clear(); + this.handlingChangeSemaphore = 0; + this.activationIndex = 0; + } +} diff --git a/packages/react-native-gesture-handler/src/web/tools/GestureHandlerOrchestrator.ts b/packages/react-native-gesture-handler/src/web/tools/GestureHandlerOrchestrator.ts index 406da74e56..5c9524cc5e 100644 --- a/packages/react-native-gesture-handler/src/web/tools/GestureHandlerOrchestrator.ts +++ b/packages/react-native-gesture-handler/src/web/tools/GestureHandlerOrchestrator.ts @@ -1,154 +1,85 @@ +import type { GestureRelationPolicy } from '../../handlers/gestureArbitration/GestureArbitrationTypes'; +import GestureArbitrator from '../../handlers/gestureArbitration/GestureArbitrator'; import { PointerType } from '../../PointerType'; -import { State } from '../../State'; +import type { State } from '../../State'; import type IGestureHandler from '../handlers/IGestureHandler'; import PointerTracker from './PointerTracker'; -export default class GestureHandlerOrchestrator { - private static _instance: GestureHandlerOrchestrator; - - private gestureHandlers: IGestureHandler[] = []; - private awaitingHandlers: IGestureHandler[] = []; - private awaitingHandlersTags: Set = new Set(); - - private handlingChangeSemaphore = 0; - private activationIndex = 0; - - // Private beacuse of Singleton - // eslint-disable-next-line no-useless-constructor, @typescript-eslint/no-empty-function - private constructor() {} - - private scheduleFinishedHandlersCleanup(): void { - if (this.handlingChangeSemaphore === 0) { - queueMicrotask(() => { - this.cleanupFinishedHandlers(); - }); - } - } +function checkOverlap( + handler: IGestureHandler, + otherHandler: IGestureHandler +): boolean { + // If handlers don't have common pointers, default return value is false. + // However, if at least on pointer overlaps with both handlers, we return true + // This solves issue in overlapping parents example - private cleanHandler(handler: IGestureHandler): void { - handler.reset(); - handler.active = false; - handler.awaiting = false; - handler.activationIndex = Number.MAX_VALUE; - } + // TODO: Find better way to handle that issue, for example by activation order and handler cancelling - public isHandlerRecorded(handler: IGestureHandler): boolean { - return this.gestureHandlers.includes(handler); - } - - public removeHandlerFromOrchestrator(handler: IGestureHandler): void { - const indexInGestureHandlers = this.gestureHandlers.indexOf(handler); - const indexInAwaitingHandlers = this.awaitingHandlers.indexOf(handler); - - if (indexInGestureHandlers >= 0) { - this.gestureHandlers.splice(indexInGestureHandlers, 1); - } + const isPointerWithinBothBounds = (pointer: number) => { + const point = handler.tracker.getLastAbsoluteCoords(pointer); - if (indexInAwaitingHandlers >= 0) { - this.awaitingHandlers.splice(indexInAwaitingHandlers, 1); - this.awaitingHandlersTags.delete(handler.handlerTag); - } - } - - private cleanupFinishedHandlers(): void { - const handlersToRemove = new Set(); - - for (let i = this.gestureHandlers.length - 1; i >= 0; --i) { - const handler = this.gestureHandlers[i]; - - if (this.isFinished(handler.state) && !handler.awaiting) { - this.cleanHandler(handler); - handlersToRemove.add(handler); - } - } - - this.gestureHandlers = this.gestureHandlers.filter( - (handler) => !handlersToRemove.has(handler) + return ( + point && + handler.delegate.isPointerInBounds(point) && + otherHandler.delegate.isPointerInBounds(point) ); - } + }; - private hasOtherHandlerToWaitFor(handler: IGestureHandler): boolean { - const hasToWaitFor = (otherHandler: IGestureHandler) => { - return ( - !this.isFinished(otherHandler.state) && - this.shouldHandlerWaitForOther(handler, otherHandler) - ); - }; + return handler.getTrackedPointersID().some(isPointerWithinBothBounds); +} - return this.gestureHandlers.some(hasToWaitFor); - } +// Web-specific relation policy. Relation checks are delegated to the +// handlers (which consult the InteractionManager), while the default +// cancellation rule uses shared pointers and view overlap, which only exist +// on the web. +const webRelationPolicy: GestureRelationPolicy = { + shouldHandlerWaitForOther: (handler, otherHandler) => + handler.shouldWaitForHandlerFailure(otherHandler) || + otherHandler.shouldRequireToWaitForFailure(handler), - private shouldBeCancelledByFinishedHandler( - handler: IGestureHandler - ): boolean { - const shouldBeCancelled = (otherHandler: IGestureHandler) => { - return ( - this.shouldHandlerWaitForOther(handler, otherHandler) && - otherHandler.state === State.END - ); - }; + shouldRecognizeSimultaneously: (handler, otherHandler) => + handler.shouldRecognizeSimultaneously(otherHandler), - return this.gestureHandlers.some(shouldBeCancelled); - } + shouldBeCancelledByOther: (handler, otherHandler) => + handler.shouldBeCancelledByOther(otherHandler), - private tryActivate(handler: IGestureHandler): void { - if (this.shouldBeCancelledByFinishedHandler(handler)) { - handler.cancel(); - return; - } + shouldBeCancelledByDefault: (handler, otherHandler) => { + const handlerPointers: number[] = handler.getTrackedPointersID(); + const otherPointers: number[] = otherHandler.getTrackedPointersID(); - if (this.hasOtherHandlerToWaitFor(handler)) { - this.addAwaitingHandler(handler); - return; + if ( + !PointerTracker.shareCommonPointers(handlerPointers, otherPointers) && + handler.delegate.view !== otherHandler.delegate.view + ) { + return checkOverlap(handler, otherHandler); } - const handlerState = handler.state; - - if (handlerState === State.CANCELLED || handlerState === State.FAILED) { - return; - } + return true; + }, - if (this.shouldActivate(handler)) { - this.makeActive(handler); - return; - } + shouldBeginWithRecordedHandlers: (handler, recordedHandlers) => + handler.shouldBeginWithRecordedHandlers( + recordedHandlers as IGestureHandler[] + ), +}; - if (handlerState === State.ACTIVE) { - handler.fail(); - return; - } +export default class GestureHandlerOrchestrator { + private static _instance: GestureHandlerOrchestrator; - if (handlerState === State.BEGAN) { - handler.cancel(); - } - } + private arbitrator = new GestureArbitrator( + webRelationPolicy + ); - private shouldActivate(handler: IGestureHandler): boolean { - const shouldBeCancelledBy = (otherHandler: IGestureHandler) => { - return this.shouldHandlerBeCancelledBy(handler, otherHandler); - }; + // Private beacuse of Singleton + // eslint-disable-next-line no-useless-constructor, @typescript-eslint/no-empty-function + private constructor() {} - return !this.gestureHandlers.some(shouldBeCancelledBy); + public isHandlerRecorded(handler: IGestureHandler): boolean { + return this.arbitrator.isHandlerRecorded(handler); } - private cleanupAwaitingHandlers(handler: IGestureHandler): void { - const shouldWait = (otherHandler: IGestureHandler) => { - return ( - !otherHandler.awaiting && - this.shouldHandlerWaitForOther(otherHandler, handler) - ); - }; - - for (const otherHandler of this.awaitingHandlers) { - if (shouldWait(otherHandler)) { - this.cleanHandler(otherHandler); - this.awaitingHandlersTags.delete(otherHandler.handlerTag); - } - } - - this.awaitingHandlers = this.awaitingHandlers.filter((otherHandler) => - this.awaitingHandlersTags.has(otherHandler.handlerTag) - ); + public removeHandlerFromOrchestrator(handler: IGestureHandler): void { + this.arbitrator.removeHandler(handler); } public onHandlerStateChange( @@ -157,208 +88,16 @@ export default class GestureHandlerOrchestrator { oldState: State, sendIfDisabled?: boolean ): void { - if (!handler.enabled && !sendIfDisabled) { - return; - } - - this.handlingChangeSemaphore += 1; - - if (this.isFinished(newState)) { - for (const otherHandler of this.awaitingHandlers) { - if ( - !this.shouldHandlerWaitForOther(otherHandler, handler) || - !this.awaitingHandlersTags.has(otherHandler.handlerTag) - ) { - continue; - } - - if (newState !== State.END) { - this.tryActivate(otherHandler); - continue; - } - - otherHandler.cancel(); - - if (otherHandler.state === State.END) { - // Handle edge case, where discrete gestures end immediately after activation thus - // their state is set to END and when the gesture they are waiting for activates they - // should be cancelled, however `cancel` was never sent as gestures were already in the END state. - // Send synthetic BEGAN -> CANCELLED to properly handle JS logic - otherHandler.sendEvent(State.CANCELLED, State.BEGAN); - } - - otherHandler.awaiting = false; - } - } - - if (newState === State.ACTIVE) { - this.tryActivate(handler); - } else if (oldState === State.ACTIVE || oldState === State.END) { - if (handler.active) { - handler.sendEvent(newState, oldState); - } else if ( - oldState === State.ACTIVE && - (newState === State.CANCELLED || newState === State.FAILED) - ) { - handler.sendEvent(newState, State.BEGAN); - } - } else if ( - oldState !== State.UNDETERMINED || - newState !== State.CANCELLED - ) { - handler.sendEvent(newState, oldState); - } - - this.handlingChangeSemaphore -= 1; - - this.scheduleFinishedHandlersCleanup(); - - if (!this.awaitingHandlers.includes(handler)) { - this.cleanupAwaitingHandlers(handler); - } - } - - private makeActive(handler: IGestureHandler): void { - const currentState = handler.state; - - handler.active = true; - handler.shouldResetProgress = true; - handler.activationIndex = this.activationIndex++; - - for (let i = this.gestureHandlers.length - 1; i >= 0; --i) { - if (this.shouldHandlerBeCancelledBy(this.gestureHandlers[i], handler)) { - this.gestureHandlers[i].cancel(); - } - } - - for (const otherHandler of this.awaitingHandlers) { - if (this.shouldHandlerBeCancelledBy(otherHandler, handler)) { - otherHandler.awaiting = false; - } - } - - handler.sendEvent(State.ACTIVE, State.BEGAN); - - if (currentState !== State.ACTIVE) { - handler.sendEvent(State.END, State.ACTIVE); - if (currentState !== State.END) { - handler.sendEvent(State.UNDETERMINED, State.END); - } - } - - if (!handler.awaiting) { - return; - } - - handler.awaiting = false; - - this.awaitingHandlers = this.awaitingHandlers.filter( - (otherHandler) => otherHandler !== handler + this.arbitrator.onHandlerStateChange( + handler, + newState, + oldState, + sendIfDisabled ); } - private addAwaitingHandler(handler: IGestureHandler): void { - if (this.awaitingHandlers.includes(handler)) { - return; - } - - this.awaitingHandlers.push(handler); - this.awaitingHandlersTags.add(handler.handlerTag); - - handler.awaiting = true; - handler.activationIndex = this.activationIndex++; - } - public recordHandlerIfNotPresent(handler: IGestureHandler): void { - if (this.isHandlerRecorded(handler)) { - return; - } - - handler.active = false; - handler.awaiting = false; - handler.activationIndex = Number.MAX_SAFE_INTEGER; - - if (!handler.shouldBeginWithRecordedHandlers(this.gestureHandlers)) { - handler.cancel(); - } - - this.gestureHandlers.push(handler); - } - - private shouldHandlerWaitForOther( - handler: IGestureHandler, - otherHandler: IGestureHandler - ): boolean { - return ( - handler !== otherHandler && - (handler.shouldWaitForHandlerFailure(otherHandler) || - otherHandler.shouldRequireToWaitForFailure(handler)) - ); - } - - private canRunSimultaneously( - gh1: IGestureHandler, - gh2: IGestureHandler - ): boolean { - return ( - gh1 === gh2 || - gh1.shouldRecognizeSimultaneously(gh2) || - gh2.shouldRecognizeSimultaneously(gh1) - ); - } - - private shouldHandlerBeCancelledBy( - handler: IGestureHandler, - otherHandler: IGestureHandler - ): boolean { - if (this.canRunSimultaneously(handler, otherHandler)) { - return false; - } - - if (handler.awaiting || handler.state === State.ACTIVE) { - return handler.shouldBeCancelledByOther(otherHandler); - } - - const handlerPointers: number[] = handler.getTrackedPointersID(); - const otherPointers: number[] = otherHandler.getTrackedPointersID(); - - if ( - !PointerTracker.shareCommonPointers(handlerPointers, otherPointers) && - handler.delegate.view !== otherHandler.delegate.view - ) { - return this.checkOverlap(handler, otherHandler); - } - - return true; - } - - private checkOverlap( - handler: IGestureHandler, - otherHandler: IGestureHandler - ): boolean { - // If handlers don't have common pointers, default return value is false. - // However, if at least on pointer overlaps with both handlers, we return true - // This solves issue in overlapping parents example - - // TODO: Find better way to handle that issue, for example by activation order and handler cancelling - - const isPointerWithinBothBounds = (pointer: number) => { - const point = handler.tracker.getLastAbsoluteCoords(pointer); - - return ( - point && - handler.delegate.isPointerInBounds(point) && - otherHandler.delegate.isPointerInBounds(point) - ); - }; - - return handler.getTrackedPointersID().some(isPointerWithinBothBounds); - } - - private isFinished(state: State): boolean { - return ( - state === State.END || state === State.FAILED || state === State.CANCELLED - ); + this.arbitrator.recordHandlerIfNotPresent(handler); } // This function is called when handler receives touchdown event @@ -368,7 +107,7 @@ export default class GestureHandlerOrchestrator { // To handle this, when new touch event is received, we loop through active handlers and check which type of // pointer they're using. If there are any handler with mouse/pen as a pointer, we cancel them public cancelMouseAndPenGestures(currentHandler: IGestureHandler): void { - this.gestureHandlers.forEach((handler: IGestureHandler) => { + this.arbitrator.recordedHandlers.forEach((handler: IGestureHandler) => { if ( handler.pointerType !== PointerType.MOUSE && handler.pointerType !== PointerType.STYLUS From f2fe6bd07cd895b864136541ce5d5f377e29b3fc Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Mon, 6 Jul 2026 12:16:33 +0200 Subject: [PATCH 3/7] Added API for running gesture tests --- .../src/jestUtils/JestGestureHandler.ts | 146 +++++++++++++++ .../src/jestUtils/fireGesture.ts | 165 +++++++++++++++++ .../src/jestUtils/gestureScenarioTypes.ts | 52 ++++++ .../src/jestUtils/gestureScenarios.ts | 171 ++++++++++++++++++ .../src/jestUtils/index.ts | 8 + 5 files changed, 542 insertions(+) create mode 100644 packages/react-native-gesture-handler/src/jestUtils/JestGestureHandler.ts create mode 100644 packages/react-native-gesture-handler/src/jestUtils/fireGesture.ts create mode 100644 packages/react-native-gesture-handler/src/jestUtils/gestureScenarioTypes.ts create mode 100644 packages/react-native-gesture-handler/src/jestUtils/gestureScenarios.ts diff --git a/packages/react-native-gesture-handler/src/jestUtils/JestGestureHandler.ts b/packages/react-native-gesture-handler/src/jestUtils/JestGestureHandler.ts new file mode 100644 index 0000000000..2a21001fcb --- /dev/null +++ b/packages/react-native-gesture-handler/src/jestUtils/JestGestureHandler.ts @@ -0,0 +1,146 @@ +import type { ArbitratedGestureHandler } from '../handlers/gestureArbitration/GestureArbitrationTypes'; +import type GestureArbitrator from '../handlers/gestureArbitration/GestureArbitrator'; +import { State } from '../State'; + +export type JestHandlerDataPayload = Record; + +export interface JestGestureEvent { + state: State; + oldState?: State; + handlerTag: number; + handlerData: JestHandlerDataPayload; +} + +export type JestGestureEventSink = (event: JestGestureEvent) => void; + +export interface JestGesturePayloads { + begin: JestHandlerDataPayload; + activate: JestHandlerDataPayload; + updates: JestHandlerDataPayload[]; + end: JestHandlerDataPayload; +} + +/** + * Lightweight handler used by `fireGesture`. It stores handler state and + * arbitration metadata, requests state transitions through the shared + * arbitration core, and forwards only the transitions emitted by that core + * to the v3 event pipeline. It does not simulate pointers, views, + * coordinates or native recognizer configuration. + */ +export class JestGestureHandler implements ArbitratedGestureHandler { + public state: State = State.UNDETERMINED; + public active = false; + public awaiting = false; + public activationIndex = Number.MAX_SAFE_INTEGER; + public shouldResetProgress = false; + public readonly enabled = true; + + private lastSentState: State | null = null; + + // eslint-disable-next-line no-useless-constructor + constructor( + public readonly handlerTag: number, + private arbitrator: GestureArbitrator, + private sink: JestGestureEventSink, + private payloads: JestGesturePayloads + ) {} + + private moveToState(newState: State) { + if (this.state === newState) { + return; + } + + const oldState = this.state; + this.state = newState; + + this.arbitrator.onHandlerStateChange(this, newState, oldState); + } + + public begin() { + this.arbitrator.recordHandlerIfNotPresent(this); + + if (this.state === State.UNDETERMINED) { + this.moveToState(State.BEGAN); + } + } + + public activate() { + if (this.state === State.BEGAN) { + this.moveToState(State.ACTIVE); + } + } + + public end() { + if (this.state === State.BEGAN || this.state === State.ACTIVE) { + this.moveToState(State.END); + } + } + + public fail() { + if (this.state === State.ACTIVE || this.state === State.BEGAN) { + this.moveToState(State.FAILED); + } + } + + public cancel() { + if ( + this.state === State.ACTIVE || + this.state === State.UNDETERMINED || + this.state === State.BEGAN + ) { + this.moveToState(State.CANCELLED); + } + } + + public reset() { + this.state = State.UNDETERMINED; + } + + // Called by the arbitration core with observable transitions only. + public sendEvent(newState: State, oldState: State) { + if (this.lastSentState === newState) { + return; + } + + this.lastSentState = newState; + + this.sink({ + state: newState, + oldState, + handlerTag: this.handlerTag, + handlerData: this.payloadForState(newState), + }); + } + + /** + * Update events are not state transitions — they are forwarded only while + * the arbitration core considers this handler observably active. + */ + public dispatchUpdates() { + for (const update of this.payloads.updates) { + if (!this.active || this.lastSentState !== State.ACTIVE) { + return; + } + + this.sink({ + state: State.ACTIVE, + handlerTag: this.handlerTag, + handlerData: update, + }); + } + } + + private payloadForState(state: State): JestHandlerDataPayload { + switch (state) { + case State.BEGAN: + case State.FAILED: + // A failed gesture was never activated, so it reports the begin + // payload. + return this.payloads.begin; + case State.ACTIVE: + return this.payloads.activate; + default: + return this.payloads.end; + } + } +} diff --git a/packages/react-native-gesture-handler/src/jestUtils/fireGesture.ts b/packages/react-native-gesture-handler/src/jestUtils/fireGesture.ts new file mode 100644 index 0000000000..f96dd375ce --- /dev/null +++ b/packages/react-native-gesture-handler/src/jestUtils/fireGesture.ts @@ -0,0 +1,165 @@ +import type { GestureRelationPolicy } from '../handlers/gestureArbitration/GestureArbitrationTypes'; +import GestureArbitrator from '../handlers/gestureArbitration/GestureArbitrator'; +import { BaseGesture } from '../handlers/gestures/gesture'; +import { tagMessage } from '../utils'; +import type { AnySingleGesture } from '../v3/hooks/gestures/singleGestureUnion'; +import { maybeUnpackValue } from '../v3/hooks/utils'; +import { SingleGestureName } from '../v3/types'; +import { + buildScenarioPayloads, + runOutcome, + validateOutcome, +} from './gestureScenarios'; +import type { + FireGestureTarget, + FlingGestureScenario, + NoInferT, + PanGestureScenario, + ScenarioForTarget, + TapGestureScenario, +} from './gestureScenarioTypes'; +import type { JestGestureEvent } from './JestGestureHandler'; +import { JestGestureHandler } from './JestGestureHandler'; +import { getByGestureTestId } from './jestUtils'; + +let act = (callback: () => void) => callback(); + +try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const React = require('react'); + if (typeof React.act === 'function') { + act = (callback: () => void) => { + React.act(() => { + callback(); + }); + }; + } +} catch (_e) { + // Do nothing if not available +} + +const SUPPORTED_GESTURES = new Set([ + SingleGestureName.Tap, + SingleGestureName.Pan, + SingleGestureName.Fling, +]); + +const SUPPORTED_GESTURES_MESSAGE = + 'Currently supported gesture kinds: tap (useTapGesture), pan (usePanGesture) and fling (useFlingGesture).'; + +function isHookGesture(target: object): target is AnySingleGesture { + return 'detectorCallbacks' in target && 'type' in target; +} + +// Only one handler participates for now, so the policy resolves every +// relation to "independent". Grouped scenarios will derive the policy from +// the participating gestures' `gestureRelations`. +const jestRelationPolicy: GestureRelationPolicy = { + shouldHandlerWaitForOther: () => false, + shouldRecognizeSimultaneously: () => false, + shouldBeCancelledByOther: () => false, + shouldBeCancelledByDefault: () => false, + shouldBeginWithRecordedHandlers: () => true, +}; + +/** + * Simulates the JavaScript callback lifecycle of a single v3 gesture + * interaction. + * + * The target may be a gesture test ID or a gesture returned by a v3 hook. + * The gesture kind is inferred from the resolved target. + * + * ```ts + * fireGesture('save-button'); + * + * fireGesture('draggable-card', { + * updates: [{ translationX: 20 }, { translationX: 100 }], + * }); + * + * fireGesture(pan, { updates: [{ translationX: 100 }], outcome: 'cancelled' }); + * ``` + * + * Every requested state change is routed through the same arbitration core + * that drives the web implementation, so the emitted callback lifecycle + * matches the runtime contract. + */ +export function fireGesture( + target: TTarget, + scenario?: ScenarioForTarget> +): void { + const resolved = + typeof target === 'string' ? getByGestureTestId(target) : target; + + if (resolved instanceof BaseGesture) { + throw new Error( + tagMessage( + `fireGesture supports only gestures created with the v3 hooks API. ` + + `For gestures created with the builder API, use fireGestureHandler instead.` + ) + ); + } + + if ( + typeof resolved !== 'object' || + resolved === null || + !isHookGesture(resolved) + ) { + throw new Error( + tagMessage( + `fireGesture received an unsupported target. Pass a gesture test ID or a gesture returned by a v3 hook. ` + + `For components and builder gestures, use fireGestureHandler instead.` + ) + ); + } + + const gesture = resolved; + + if (!SUPPORTED_GESTURES.has(gesture.type)) { + throw new Error( + tagMessage( + `fireGesture does not support '${gesture.type}' yet. ${SUPPORTED_GESTURES_MESSAGE}` + ) + ); + } + + // Disabled gestures produce no callbacks, matching fireGestureHandler. + if (maybeUnpackValue(gesture.config.enabled) === false) { + return; + } + + const gestureScenario: + | PanGestureScenario + | TapGestureScenario + | FlingGestureScenario = scenario ?? {}; + const outcome = validateOutcome(gestureScenario.outcome); + const payloads = buildScenarioPayloads(gesture.type, gestureScenario); + + const arbitrator = new GestureArbitrator( + jestRelationPolicy + ); + + // The exact event generics cannot be correlated across the gesture union, + // but the emitted envelopes match the v3 event handler contract. + const jsEventHandler = gesture.detectorCallbacks.jsEventHandler as + | ((event: JestGestureEvent) => void) + | undefined; + + const handler = new JestGestureHandler( + gesture.handlerTag, + arbitrator, + (event: JestGestureEvent) => { + jsEventHandler?.(event); + }, + payloads + ); + + try { + act(() => { + runOutcome(handler, outcome); + }); + } finally { + // Each call uses its own arbitration session; dispose it so no handler + // state leaks between tests. + arbitrator.reset(); + } +} diff --git a/packages/react-native-gesture-handler/src/jestUtils/gestureScenarioTypes.ts b/packages/react-native-gesture-handler/src/jestUtils/gestureScenarioTypes.ts new file mode 100644 index 0000000000..2733960afe --- /dev/null +++ b/packages/react-native-gesture-handler/src/jestUtils/gestureScenarioTypes.ts @@ -0,0 +1,52 @@ +import type { GestureType } from '../handlers/gestures/gesture'; +import type { PanGestureActiveEvent } from '../v3/hooks/gestures/pan/PanTypes'; +import type { AnySingleGesture } from '../v3/hooks/gestures/singleGestureUnion'; +import type { TapGestureEvent } from '../v3/hooks/gestures/tap/TapTypes'; +import type { SingleGestureName } from '../v3/types'; + +/** + * Semantic outcome of a simulated interaction: + * - `success` — the gesture begins, activates, dispatches updates and ends. + * - `failed` — the gesture begins but fails before activation. + * - `cancelled` — the gesture begins, activates, dispatches updates and is + * cancelled. + */ +export type GestureOutcome = 'success' | 'failed' | 'cancelled'; + +// Scenario payloads use the v3 callback event shape, never the native state +// machine envelope. `handlerTag` is filled in by the implementation. +type SemanticEventData = Partial>; + +export type PanGestureScenario = { + /** Payloads dispatched as `onUpdate` events while the gesture is active. */ + updates?: SemanticEventData[]; + outcome?: GestureOutcome; +}; + +export type TapGestureScenario = { + /** Payload used for every lifecycle callback of the tap. */ + event?: SemanticEventData; + outcome?: GestureOutcome; +}; + +export type ResolvedGestureTarget = GestureType | AnySingleGesture; +export type FireGestureTarget = string | ResolvedGestureTarget; + +export type GestureScenario = + TGesture extends { type: SingleGestureName.Pan } + ? PanGestureScenario + : TGesture extends { type: SingleGestureName.Tap } + ? TapGestureScenario + : never; + +export type ScenarioForTarget = + TTarget extends string + ? GestureScenario + : TTarget extends ResolvedGestureTarget + ? GestureScenario + : never; + +// Equivalent of the built-in `NoInfer` (available since TypeScript 5.4). +// Kept local so the generated declarations do not force a newer TypeScript +// version onto consumers. +export type NoInferT = [T][T extends unknown ? 0 : never]; diff --git a/packages/react-native-gesture-handler/src/jestUtils/gestureScenarios.ts b/packages/react-native-gesture-handler/src/jestUtils/gestureScenarios.ts new file mode 100644 index 0000000000..012923d170 --- /dev/null +++ b/packages/react-native-gesture-handler/src/jestUtils/gestureScenarios.ts @@ -0,0 +1,171 @@ +import { PointerType } from '../PointerType'; +import { tagMessage } from '../utils'; +import { SingleGestureName } from '../v3/types'; +import type { + GestureOutcome, + PanGestureScenario, + TapGestureScenario, +} from './gestureScenarioTypes'; +import type { + JestGestureHandler, + JestGesturePayloads, + JestHandlerDataPayload, +} from './JestGestureHandler'; + +const GESTURE_OUTCOMES: GestureOutcome[] = ['success', 'failed', 'cancelled']; + +// Fields of the native state machine envelope which must never appear in +// semantic scenario payloads. +const FORBIDDEN_PAYLOAD_FIELDS = [ + 'state', + 'oldState', + 'handlerTag', + 'nativeEvent', + 'handlerData', +]; + +const COMMON_DEFAULTS = { + numberOfPointers: 1, + pointerType: PointerType.TOUCH, +}; + +const PAN_DEFAULTS: JestHandlerDataPayload = { + ...COMMON_DEFAULTS, + x: 0, + y: 0, + absoluteX: 0, + absoluteY: 0, + translationX: 0, + translationY: 0, + velocityX: 0, + velocityY: 0, + stylusData: undefined, +}; + +const TAP_DEFAULTS: JestHandlerDataPayload = { + ...COMMON_DEFAULTS, + x: 0, + y: 0, + absoluteX: 0, + absoluteY: 0, +}; + +function validatePayload( + payload: Record, + description: string +) { + for (const field of FORBIDDEN_PAYLOAD_FIELDS) { + if (field in payload) { + throw new Error( + tagMessage( + `fireGesture scenarios describe interactions in gesture terms — '${field}' found in ${description} is managed internally and cannot be provided. ` + + `If you need to control exact state transitions, use fireGestureHandler instead.` + ) + ); + } + } +} + +export function validateOutcome(outcome: unknown): GestureOutcome { + if (outcome === undefined) { + return 'success'; + } + + if (!GESTURE_OUTCOMES.includes(outcome as GestureOutcome)) { + throw new Error( + tagMessage( + `fireGesture received an unknown outcome: '${String(outcome)}'. Supported outcomes: ${GESTURE_OUTCOMES.join(', ')}.` + ) + ); + } + + return outcome as GestureOutcome; +} + +export function buildPanPayloads( + scenario: PanGestureScenario +): JestGesturePayloads { + if ('event' in scenario) { + throw new Error( + tagMessage( + `fireGesture received an 'event' field for a pan gesture. Pan is a continuous gesture — pass update payloads through 'updates' instead.` + ) + ); + } + + const updates = (scenario.updates ?? []).map((update, index) => { + validatePayload(update, `updates[${index}]`); + return { ...PAN_DEFAULTS, ...update }; + }); + + return { + begin: PAN_DEFAULTS, + activate: updates[0] ?? PAN_DEFAULTS, + updates, + end: updates[updates.length - 1] ?? PAN_DEFAULTS, + }; +} + +export function buildTapPayloads( + scenario: TapGestureScenario +): JestGesturePayloads { + if ('updates' in scenario) { + throw new Error( + tagMessage( + `fireGesture received 'updates' for a tap gesture. Tap is a discrete gesture and does not dispatch update events — pass the payload through 'event' instead.` + ) + ); + } + + const event = { ...TAP_DEFAULTS, ...scenario.event }; + validatePayload(scenario.event ?? {}, `'event'`); + + return { + begin: event, + activate: event, + updates: [], + end: event, + }; +} + +export function buildScenarioPayloads( + type: SingleGestureName, + scenario: PanGestureScenario | TapGestureScenario +): JestGesturePayloads { + switch (type) { + case SingleGestureName.Pan: + return buildPanPayloads(scenario); + case SingleGestureName.Tap: + return buildTapPayloads(scenario); + default: + throw new Error( + tagMessage(`fireGesture does not support '${type}' scenarios.`) + ); + } +} + +/** + * Translates the semantic outcome into transition requests. Which of those + * requests become observable callbacks is decided by the arbitration core, + * not here. + */ +export function runOutcome( + handler: JestGestureHandler, + outcome: GestureOutcome +) { + handler.begin(); + + if (outcome === 'failed') { + handler.fail(); + return; + } + + handler.activate(); + handler.dispatchUpdates(); + + if (outcome === 'cancelled') { + handler.cancel(); + } else { + handler.end(); + } +} diff --git a/packages/react-native-gesture-handler/src/jestUtils/index.ts b/packages/react-native-gesture-handler/src/jestUtils/index.ts index 99351f2813..2c4364a7af 100644 --- a/packages/react-native-gesture-handler/src/jestUtils/index.ts +++ b/packages/react-native-gesture-handler/src/jestUtils/index.ts @@ -1 +1,9 @@ +export { fireGesture } from './fireGesture'; +export type { + FireGestureTarget, + GestureOutcome, + GestureScenario, + PanGestureScenario, + TapGestureScenario, +} from './gestureScenarioTypes'; export { fireGestureHandler, getByGestureTestId } from './jestUtils'; From 0a70ba35e6152df2c30b9984c48c8ecfc9af1116 Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Mon, 6 Jul 2026 13:07:53 +0200 Subject: [PATCH 4/7] added fling gesture --- .../src/__tests__/fireGesture.test.tsx | 656 ++++++++++++++++++ .../jestUtilsCharacterization.test.tsx | 151 ++++ .../src/jestUtils/gestureScenarioTypes.ts | 28 +- .../src/jestUtils/gestureScenarios.ts | 52 +- .../src/jestUtils/index.ts | 1 + 5 files changed, 878 insertions(+), 10 deletions(-) create mode 100644 packages/react-native-gesture-handler/src/__tests__/fireGesture.test.tsx create mode 100644 packages/react-native-gesture-handler/src/__tests__/jestUtilsCharacterization.test.tsx diff --git a/packages/react-native-gesture-handler/src/__tests__/fireGesture.test.tsx b/packages/react-native-gesture-handler/src/__tests__/fireGesture.test.tsx new file mode 100644 index 0000000000..6e980ae3c6 --- /dev/null +++ b/packages/react-native-gesture-handler/src/__tests__/fireGesture.test.tsx @@ -0,0 +1,656 @@ +import { render, renderHook, screen } from '@testing-library/react-native'; +import { useState } from 'react'; +import { Text, View } from 'react-native'; + +import GestureHandlerRootView from '../components/GestureHandlerRootView'; +import GestureArbitrator from '../handlers/gestureArbitration/GestureArbitrator'; +import { Gesture } from '../index'; +import { + fireGesture, + fireGestureHandler, + getByGestureTestId, +} from '../jestUtils'; +import { GestureDetector } from '../v3'; +import type { PanGesture, TapGesture } from '../v3/hooks/gestures'; +import { + useFlingGesture, + useLongPressGesture, + usePanGesture, + useTapGesture, +} from '../v3/hooks/gestures'; + +function mockedV3Callbacks() { + const order: string[] = []; + return { + order, + callbacks: { + onBegin: jest.fn(() => order.push('onBegin')), + onActivate: jest.fn(() => order.push('onActivate')), + onUpdate: jest.fn(() => order.push('onUpdate')), + onDeactivate: jest.fn(() => order.push('onDeactivate')), + onFinalize: jest.fn(() => order.push('onFinalize')), + }, + }; +} + +function mockedTapCallbacks() { + const order: string[] = []; + return { + order, + callbacks: { + onBegin: jest.fn(() => order.push('onBegin')), + onActivate: jest.fn(() => order.push('onActivate')), + onDeactivate: jest.fn(() => order.push('onDeactivate')), + onFinalize: jest.fn(() => order.push('onFinalize')), + }, + }; +} + +describe('fireGesture: tap', () => { + test('successful tap invokes the expected v3 lifecycle', () => { + const { order, callbacks } = mockedTapCallbacks(); + const tap = renderHook(() => + useTapGesture({ disableReanimated: true, ...callbacks }) + ).result.current; + + fireGesture(tap); + + expect(order).toEqual([ + 'onBegin', + 'onActivate', + 'onDeactivate', + 'onFinalize', + ]); + expect(callbacks.onDeactivate).toHaveBeenCalledWith( + expect.objectContaining({ canceled: false }) + ); + expect(callbacks.onFinalize).toHaveBeenCalledWith( + expect.objectContaining({ canceled: false }) + ); + }); + + test('event payload defaults are stable', () => { + const { callbacks } = mockedTapCallbacks(); + const tap = renderHook(() => + useTapGesture({ disableReanimated: true, ...callbacks }) + ).result.current; + + fireGesture(tap); + + expect(callbacks.onActivate).toHaveBeenCalledWith({ + handlerTag: tap.handlerTag, + x: 0, + y: 0, + absoluteX: 0, + absoluteY: 0, + numberOfPointers: 1, + pointerType: expect.anything(), + }); + }); + + test('user-supplied event values reach application callbacks', () => { + const { callbacks } = mockedTapCallbacks(); + const tap = renderHook(() => + useTapGesture({ disableReanimated: true, ...callbacks }) + ).result.current; + + fireGesture(tap, { event: { x: 5, y: 15 } }); + + expect(callbacks.onBegin).toHaveBeenCalledWith( + expect.objectContaining({ x: 5, y: 15 }) + ); + expect(callbacks.onActivate).toHaveBeenCalledWith( + expect.objectContaining({ x: 5, y: 15 }) + ); + }); + + test('failed tap finalizes without activation or deactivation', () => { + const { order, callbacks } = mockedTapCallbacks(); + const tap = renderHook(() => + useTapGesture({ disableReanimated: true, ...callbacks }) + ).result.current; + + fireGesture(tap, { outcome: 'failed' }); + + expect(order).toEqual(['onBegin', 'onFinalize']); + expect(callbacks.onFinalize).toHaveBeenCalledWith( + expect.objectContaining({ canceled: true }) + ); + }); +}); + +describe('fireGesture: pan', () => { + test('successful pan dispatches every supplied update in order', () => { + const { order, callbacks } = mockedV3Callbacks(); + const pan = renderHook(() => + usePanGesture({ disableReanimated: true, ...callbacks }) + ).result.current; + + fireGesture(pan, { + updates: [ + { translationX: 20, translationY: 0 }, + { translationX: 100, translationY: 0 }, + ], + }); + + expect(order).toEqual([ + 'onBegin', + 'onActivate', + 'onUpdate', + 'onUpdate', + 'onDeactivate', + 'onFinalize', + ]); + expect(callbacks.onUpdate).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ translationX: 20 }) + ); + expect(callbacks.onUpdate).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ translationX: 100 }) + ); + }); + + test('change fields are calculated through the v3 callback pipeline', () => { + const { callbacks } = mockedV3Callbacks(); + const pan = renderHook(() => + usePanGesture({ disableReanimated: true, ...callbacks }) + ).result.current; + + fireGesture(pan, { + updates: [{ translationX: 10 }, { translationX: 100 }], + }); + + expect(callbacks.onActivate).toHaveBeenCalledWith( + expect.objectContaining({ changeX: 0, changeY: 0 }) + ); + expect(callbacks.onUpdate).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ changeX: 10 }) + ); + expect(callbacks.onUpdate).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ changeX: 90 }) + ); + }); + + test('empty updates still produce a complete successful lifecycle', () => { + const { order, callbacks } = mockedV3Callbacks(); + const pan = renderHook(() => + usePanGesture({ disableReanimated: true, ...callbacks }) + ).result.current; + + fireGesture(pan); + + expect(order).toEqual([ + 'onBegin', + 'onActivate', + 'onDeactivate', + 'onFinalize', + ]); + }); + + test('cancelled pan deactivates and finalizes as cancelled', () => { + const { order, callbacks } = mockedV3Callbacks(); + const pan = renderHook(() => + usePanGesture({ disableReanimated: true, ...callbacks }) + ).result.current; + + fireGesture(pan, { + updates: [{ translationX: 20 }], + outcome: 'cancelled', + }); + + expect(order).toEqual([ + 'onBegin', + 'onActivate', + 'onUpdate', + 'onDeactivate', + 'onFinalize', + ]); + expect(callbacks.onDeactivate).toHaveBeenCalledWith( + expect.objectContaining({ canceled: true }) + ); + expect(callbacks.onFinalize).toHaveBeenCalledWith( + expect.objectContaining({ canceled: true }) + ); + }); + + test('the end payload uses the last supplied update', () => { + const { callbacks } = mockedV3Callbacks(); + const pan = renderHook(() => + usePanGesture({ disableReanimated: true, ...callbacks }) + ).result.current; + + fireGesture(pan, { + updates: [{ translationX: 10 }, { translationX: 100 }], + }); + + expect(callbacks.onDeactivate).toHaveBeenCalledWith( + expect.objectContaining({ translationX: 100 }) + ); + }); +}); + +describe('fireGesture: fling', () => { + test('successful fling invokes the discrete v3 lifecycle', () => { + const { order, callbacks } = mockedTapCallbacks(); + const fling = renderHook(() => + useFlingGesture({ disableReanimated: true, ...callbacks }) + ).result.current; + + fireGesture(fling); + + expect(order).toEqual([ + 'onBegin', + 'onActivate', + 'onDeactivate', + 'onFinalize', + ]); + expect(callbacks.onDeactivate).toHaveBeenCalledWith( + expect.objectContaining({ canceled: false }) + ); + expect(callbacks.onFinalize).toHaveBeenCalledWith( + expect.objectContaining({ canceled: false }) + ); + }); + + test('event payload defaults are stable', () => { + const { callbacks } = mockedTapCallbacks(); + const fling = renderHook(() => + useFlingGesture({ disableReanimated: true, ...callbacks }) + ).result.current; + + fireGesture(fling); + + expect(callbacks.onActivate).toHaveBeenCalledWith({ + handlerTag: fling.handlerTag, + x: 0, + y: 0, + absoluteX: 0, + absoluteY: 0, + numberOfPointers: 1, + pointerType: expect.anything(), + }); + }); + + test('user-supplied event values reach application callbacks', () => { + const { callbacks } = mockedTapCallbacks(); + const fling = renderHook(() => + useFlingGesture({ disableReanimated: true, ...callbacks }) + ).result.current; + + fireGesture(fling, { event: { x: 5, absoluteX: 5 } }); + + expect(callbacks.onBegin).toHaveBeenCalledWith( + expect.objectContaining({ x: 5, absoluteX: 5 }) + ); + expect(callbacks.onActivate).toHaveBeenCalledWith( + expect.objectContaining({ x: 5, absoluteX: 5 }) + ); + }); + + test('failed fling finalizes without activation or deactivation', () => { + const { order, callbacks } = mockedTapCallbacks(); + const fling = renderHook(() => + useFlingGesture({ disableReanimated: true, ...callbacks }) + ).result.current; + + fireGesture(fling, { outcome: 'failed' }); + + expect(order).toEqual(['onBegin', 'onFinalize']); + expect(callbacks.onFinalize).toHaveBeenCalledWith( + expect.objectContaining({ canceled: true }) + ); + }); + + test('cancelled fling deactivates and finalizes as cancelled', () => { + const { order, callbacks } = mockedTapCallbacks(); + const fling = renderHook(() => + useFlingGesture({ disableReanimated: true, ...callbacks }) + ).result.current; + + fireGesture(fling, { outcome: 'cancelled' }); + + expect(order).toEqual([ + 'onBegin', + 'onActivate', + 'onDeactivate', + 'onFinalize', + ]); + expect(callbacks.onDeactivate).toHaveBeenCalledWith( + expect.objectContaining({ canceled: true }) + ); + }); + + test('does not dispatch update events (fling is discrete)', () => { + const { order } = mockedV3Callbacks(); + const onUpdate = jest.fn(); + const fling = renderHook(() => + useFlingGesture({ + disableReanimated: true, + onBegin: () => order.push('onBegin'), + onActivate: () => order.push('onActivate'), + onDeactivate: () => order.push('onDeactivate'), + onFinalize: () => order.push('onFinalize'), + }) + ).result.current; + + fireGesture(fling); + + expect(order).not.toContain('onUpdate'); + expect(onUpdate).not.toHaveBeenCalled(); + }); + + test('rejects updates for the discrete fling gesture', () => { + const fling = renderHook(() => useFlingGesture({ disableReanimated: true })) + .result.current; + + expect(() => fireGesture(fling, { updates: [{ x: 1 }] } as never)).toThrow( + /discrete gesture and does not dispatch update events/ + ); + }); + + test('resolves fling gestures by test ID string', () => { + const { order, callbacks } = mockedTapCallbacks(); + renderHook(() => + useFlingGesture({ + testID: 'string-target-fling', + disableReanimated: true, + ...callbacks, + }) + ); + + fireGesture('string-target-fling'); + + expect(order).toEqual([ + 'onBegin', + 'onActivate', + 'onDeactivate', + 'onFinalize', + ]); + }); + + test('per-stage events reach the matching callbacks', () => { + const { callbacks } = mockedTapCallbacks(); + const fling = renderHook(() => + useFlingGesture({ disableReanimated: true, ...callbacks }) + ).result.current; + + fireGesture(fling, { + stageEvents: { + begin: { x: 0, absoluteX: 0 }, + activate: { x: 100, absoluteX: 100 }, + end: { x: 120, absoluteX: 120 }, + }, + }); + + expect(callbacks.onBegin).toHaveBeenCalledWith( + expect.objectContaining({ x: 0, absoluteX: 0 }) + ); + expect(callbacks.onActivate).toHaveBeenCalledWith( + expect.objectContaining({ x: 100, absoluteX: 100 }) + ); + expect(callbacks.onDeactivate).toHaveBeenCalledWith( + expect.objectContaining({ x: 120, absoluteX: 120, canceled: false }) + ); + expect(callbacks.onFinalize).toHaveBeenCalledWith( + expect.objectContaining({ x: 120, absoluteX: 120, canceled: false }) + ); + }); + + test('per-stage events override the shared `event` base', () => { + const { callbacks } = mockedTapCallbacks(); + const fling = renderHook(() => + useFlingGesture({ disableReanimated: true, ...callbacks }) + ).result.current; + + fireGesture(fling, { + event: { y: 7 }, + stageEvents: { activate: { x: 50 } }, + }); + + // `event` applies to every stage; `events.activate` adds to activate only. + expect(callbacks.onBegin).toHaveBeenCalledWith( + expect.objectContaining({ x: 0, y: 7 }) + ); + expect(callbacks.onActivate).toHaveBeenCalledWith( + expect.objectContaining({ x: 50, y: 7 }) + ); + }); + + test('failed outcome uses the begin-stage payload for onFinalize', () => { + const { callbacks } = mockedTapCallbacks(); + const fling = renderHook(() => + useFlingGesture({ disableReanimated: true, ...callbacks }) + ).result.current; + + fireGesture(fling, { + outcome: 'failed', + stageEvents: { begin: { x: 3 }, end: { x: 99 } }, + }); + + // A failed gesture never activated, so onFinalize reports the begin data. + expect(callbacks.onFinalize).toHaveBeenCalledWith( + expect.objectContaining({ x: 3, canceled: true }) + ); + expect(callbacks.onActivate).not.toHaveBeenCalled(); + }); +}); + +describe('fireGesture: targets', () => { + test('resolves gestures by test ID string', () => { + const { order, callbacks } = mockedV3Callbacks(); + renderHook(() => + usePanGesture({ + testID: 'string-target-pan', + disableReanimated: true, + ...callbacks, + }) + ); + + fireGesture('string-target-pan', { + updates: [{ translationX: 50 }], + }); + + expect(order).toEqual([ + 'onBegin', + 'onActivate', + 'onUpdate', + 'onDeactivate', + 'onFinalize', + ]); + }); + + test('accepts getByGestureTestId results', () => { + const { order, callbacks } = mockedTapCallbacks(); + renderHook(() => + useTapGesture({ + testID: 'queried-tap', + disableReanimated: true, + ...callbacks, + }) + ); + + fireGesture(getByGestureTestId('queried-tap')); + + expect(order).toEqual([ + 'onBegin', + 'onActivate', + 'onDeactivate', + 'onFinalize', + ]); + }); + + test('unknown test IDs report the requested ID', () => { + expect(() => fireGesture('missing-gesture')).toThrow( + "Handler with id: 'missing-gesture' cannot be found" + ); + }); + + test('disabled targets do nothing', () => { + const { order, callbacks } = mockedV3Callbacks(); + const pan = renderHook(() => + usePanGesture({ disableReanimated: true, enabled: false, ...callbacks }) + ).result.current; + + fireGesture(pan); + + expect(order).toEqual([]); + }); + + test('unsupported v3 gestures produce an error listing supported kinds', () => { + const longPress = renderHook(() => + useLongPressGesture({ disableReanimated: true }) + ).result.current; + + expect(() => fireGesture(longPress as never)).toThrow( + /does not support 'LongPressGestureHandler'.*tap.*pan/ + ); + }); + + test('legacy builder gestures are directed to fireGestureHandler', () => { + const pan = Gesture.Pan(); + pan.handlerTag = 998; + + expect(() => fireGesture(pan as never)).toThrow(/fireGestureHandler/); + }); +}); + +describe('fireGesture: scenario validation', () => { + test('rejects updates for discrete gestures', () => { + const tap = renderHook(() => useTapGesture({ disableReanimated: true })) + .result.current; + + expect(() => fireGesture(tap, { updates: [{ x: 1 }] } as never)).toThrow( + /discrete gesture and does not dispatch update events/ + ); + }); + + test('rejects an event payload for continuous gestures', () => { + const pan = renderHook(() => usePanGesture({ disableReanimated: true })) + .result.current; + + expect(() => fireGesture(pan, { event: { x: 1 } } as never)).toThrow( + /continuous gesture/ + ); + }); + + test('rejects native state-machine fields in payloads', () => { + const pan = renderHook(() => usePanGesture({ disableReanimated: true })) + .result.current; + + expect(() => + fireGesture(pan, { updates: [{ state: 4 }] } as never) + ).toThrow(/'state'/); + }); + + test('rejects unknown outcomes', () => { + const pan = renderHook(() => usePanGesture({ disableReanimated: true })) + .result.current; + + expect(() => fireGesture(pan, { outcome: 'finished' } as never)).toThrow( + /unknown outcome: 'finished'/ + ); + }); +}); + +describe('fireGesture: arbitration core integration', () => { + test('every state change passes through the shared arbitration core', () => { + const onHandlerStateChange = jest.spyOn( + GestureArbitrator.prototype, + 'onHandlerStateChange' + ); + + const { order, callbacks } = mockedV3Callbacks(); + const pan = renderHook(() => + usePanGesture({ disableReanimated: true, ...callbacks }) + ).result.current; + + fireGesture(pan, { updates: [{ translationX: 5 }] }); + + // begin, activate and end are all requested through the core. + expect(onHandlerStateChange).toHaveBeenCalledTimes(3); + expect(order).toEqual([ + 'onBegin', + 'onActivate', + 'onUpdate', + 'onDeactivate', + 'onFinalize', + ]); + + onHandlerStateChange.mockRestore(); + }); +}); + +describe('fireGesture: application behavior', () => { + test('gesture callbacks drive rendered output', () => { + function DraggableValue() { + const [x, setX] = useState(0); + const pan = usePanGesture({ + testID: 'drag', + disableReanimated: true, + onUpdate: (event) => setX(event.translationX), + }); + + return ( + + + + + {x} + + ); + } + + render(); + + fireGesture('drag', { + updates: [{ translationX: 10 }, { translationX: 100 }], + }); + + expect(screen.getByText('100')).toBeTruthy(); + }); +}); + +// Compile-time only checks — never executed. Verified by `yarn ts-check`: +// an incorrectly accepted scenario surfaces as an unused '@ts-expect-error'. +function _fireGestureTypeChecks(pan: PanGesture, tap: TapGesture) { + // Directly returned hook gestures infer their scenario payload types + // without a gesture-name argument. + fireGesture(pan, { updates: [{ translationX: 1, velocityX: 2 }] }); + fireGesture(pan, { updates: [], outcome: 'cancelled' }); + fireGesture(tap, { event: { x: 1 }, outcome: 'failed' }); + + // @ts-expect-error tap event payloads are not valid pan scenarios + fireGesture(pan, { event: { x: 1 } }); + // @ts-expect-error pan updates are not valid tap scenarios + fireGesture(tap, { updates: [{ translationX: 1 }] }); + // @ts-expect-error scenario payloads never accept native state fields + fireGesture(pan, { updates: [{ state: 4 }] }); + // @ts-expect-error scenario payloads never accept handler tags + fireGesture(tap, { event: { handlerTag: 1 } }); + // @ts-expect-error outcomes are limited to success/failed/cancelled + fireGesture(pan, { outcome: 'finished' }); + + // Queried gestures accept the union of supported scenarios without + // requiring a gesture-name argument. + fireGesture('any-test-id', { updates: [{ translationX: 1 }] }); + fireGesture('any-test-id', { event: { x: 1 } }); + fireGesture('any-test-id', { outcome: 'cancelled' }); +} +void _fireGestureTypeChecks; + +describe('fireGesture: coexistence with fireGestureHandler', () => { + test('the low-level API keeps working for the same gesture', () => { + const { order, callbacks } = mockedV3Callbacks(); + const pan = renderHook(() => + usePanGesture({ disableReanimated: true, ...callbacks }) + ).result.current; + + fireGesture(pan); + fireGestureHandler(pan, []); + + expect(order.filter((entry) => entry === 'onBegin')).toHaveLength(2); + }); +}); diff --git a/packages/react-native-gesture-handler/src/__tests__/jestUtilsCharacterization.test.tsx b/packages/react-native-gesture-handler/src/__tests__/jestUtilsCharacterization.test.tsx new file mode 100644 index 0000000000..75f4ad7aaa --- /dev/null +++ b/packages/react-native-gesture-handler/src/__tests__/jestUtilsCharacterization.test.tsx @@ -0,0 +1,151 @@ +import { renderHook } from '@testing-library/react-native'; + +import { Gesture } from '../index'; +import { fireGestureHandler } from '../jestUtils'; +import { State } from '../State'; +import { usePanGesture, useTapGesture } from '../v3/hooks/gestures'; + +// These tests record the current behavior of the low-level +// `fireGestureHandler` API so the semantic `fireGesture` API can be built +// next to it without changing it. + +function mockedV3Callbacks() { + const order: string[] = []; + return { + order, + callbacks: { + onBegin: jest.fn(() => order.push('onBegin')), + onActivate: jest.fn(() => order.push('onActivate')), + onUpdate: jest.fn(() => order.push('onUpdate')), + onDeactivate: jest.fn(() => order.push('onDeactivate')), + onFinalize: jest.fn(() => order.push('onFinalize')), + }, + }; +} + +describe('fireGestureHandler characterization: v3 hook gestures', () => { + test('successful pan stream invokes the full v3 lifecycle in order', () => { + const { order, callbacks } = mockedV3Callbacks(); + const pan = renderHook(() => + usePanGesture({ disableReanimated: true, ...callbacks }) + ).result.current; + + fireGestureHandler(pan, [ + { state: State.BEGAN }, + { state: State.ACTIVE }, + { translationX: 10 }, + { translationX: 20 }, + { state: State.END }, + ]); + + expect(order).toEqual([ + 'onBegin', + 'onActivate', + 'onUpdate', + 'onUpdate', + 'onDeactivate', + 'onFinalize', + ]); + expect(callbacks.onFinalize).toHaveBeenCalledWith( + expect.objectContaining({ canceled: false }) + ); + }); + + test('explicit failure stream still synthesizes an ACTIVE event', () => { + // The low-level API always fills the BEGAN -> ACTIVE -> END sequence for + // continuous handlers, so a "failure before activation" cannot be + // expressed without activation callbacks being invoked. + const { order, callbacks } = mockedV3Callbacks(); + const pan = renderHook(() => + usePanGesture({ disableReanimated: true, ...callbacks }) + ).result.current; + + fireGestureHandler(pan, [{ state: State.BEGAN }, { state: State.FAILED }]); + + expect(order).toEqual([ + 'onBegin', + 'onActivate', + 'onDeactivate', + 'onFinalize', + ]); + expect(callbacks.onFinalize).toHaveBeenCalledWith( + expect.objectContaining({ canceled: true }) + ); + }); + + test('cancellation after activation finalizes with canceled flag', () => { + const { order, callbacks } = mockedV3Callbacks(); + const pan = renderHook(() => + usePanGesture({ disableReanimated: true, ...callbacks }) + ).result.current; + + fireGestureHandler(pan, [ + { state: State.BEGAN }, + { state: State.ACTIVE }, + { state: State.CANCELLED }, + ]); + + expect(order).toEqual([ + 'onBegin', + 'onActivate', + 'onDeactivate', + 'onFinalize', + ]); + expect(callbacks.onDeactivate).toHaveBeenCalledWith( + expect.objectContaining({ canceled: true }) + ); + expect(callbacks.onFinalize).toHaveBeenCalledWith( + expect.objectContaining({ canceled: true }) + ); + }); + + test('discrete tap gesture always receives a synthesized activation', () => { + // Even for discrete handlers, the low-level API fills the ACTIVE state + // between BEGAN and the terminal event. + const { order, callbacks } = mockedV3Callbacks(); + const tap = renderHook(() => + useTapGesture({ disableReanimated: true, ...callbacks }) + ).result.current; + + fireGestureHandler(tap, [{ state: State.BEGAN }, { state: State.END }]); + + expect(order).toEqual([ + 'onBegin', + 'onActivate', + 'onDeactivate', + 'onFinalize', + ]); + }); + + test('disabled v3 hook gesture receives no callbacks', () => { + const { order, callbacks } = mockedV3Callbacks(); + const pan = renderHook(() => + usePanGesture({ disableReanimated: true, enabled: false, ...callbacks }) + ).result.current; + + fireGestureHandler(pan, [ + { state: State.BEGAN }, + { state: State.ACTIVE }, + { state: State.END }, + ]); + + expect(order).toEqual([]); + }); +}); + +describe('fireGestureHandler characterization: v2 builder gestures', () => { + test('disabled v2 gesture receives no callbacks', () => { + const begin = jest.fn(); + const pan = Gesture.Pan().enabled(false).onBegin(begin); + // Builder gestures need a handler tag to be dispatchable. + pan.handlerTag = 999; + + fireGestureHandler(pan, [ + { state: State.BEGAN }, + { state: State.ACTIVE }, + { state: State.END }, + ]); + + expect(begin).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react-native-gesture-handler/src/jestUtils/gestureScenarioTypes.ts b/packages/react-native-gesture-handler/src/jestUtils/gestureScenarioTypes.ts index 2733960afe..207ddcf90f 100644 --- a/packages/react-native-gesture-handler/src/jestUtils/gestureScenarioTypes.ts +++ b/packages/react-native-gesture-handler/src/jestUtils/gestureScenarioTypes.ts @@ -1,4 +1,5 @@ import type { GestureType } from '../handlers/gestures/gesture'; +import type { FlingGestureEvent } from '../v3/hooks/gestures/fling/FlingTypes'; import type { PanGestureActiveEvent } from '../v3/hooks/gestures/pan/PanTypes'; import type { AnySingleGesture } from '../v3/hooks/gestures/singleGestureUnion'; import type { TapGestureEvent } from '../v3/hooks/gestures/tap/TapTypes'; @@ -17,6 +18,19 @@ export type GestureOutcome = 'success' | 'failed' | 'cancelled'; // machine envelope. `handlerTag` is filled in by the implementation. type SemanticEventData = Partial>; +// Per-stage payload overrides for a discrete gesture. On a real device each +// state change carries its own coordinate snapshot (e.g. a fling travels +// between touch-down and recognition), so tests may describe distinct data +// for each stage. Each stage is merged over `event`. +type DiscreteStageEvents = { + /** Payload for `onBegin` (and `onFinalize` when the outcome is `failed`). */ + begin?: SemanticEventData; + /** Payload for `onActivate`. */ + activate?: SemanticEventData; + /** Payload for `onDeactivate` and `onFinalize`. */ + end?: SemanticEventData; +}; + export type PanGestureScenario = { /** Payloads dispatched as `onUpdate` events while the gesture is active. */ updates?: SemanticEventData[]; @@ -26,6 +40,16 @@ export type PanGestureScenario = { export type TapGestureScenario = { /** Payload used for every lifecycle callback of the tap. */ event?: SemanticEventData; + /** Per-stage payload overrides, merged over `event`. */ + stageEvents?: DiscreteStageEvents; + outcome?: GestureOutcome; +}; + +export type FlingGestureScenario = { + /** Payload used for every lifecycle callback of the fling. */ + event?: SemanticEventData; + /** Per-stage payload overrides, merged over `event`. */ + stageEvents?: DiscreteStageEvents; outcome?: GestureOutcome; }; @@ -37,7 +61,9 @@ export type GestureScenario = ? PanGestureScenario : TGesture extends { type: SingleGestureName.Tap } ? TapGestureScenario - : never; + : TGesture extends { type: SingleGestureName.Fling } + ? FlingGestureScenario + : never; export type ScenarioForTarget = TTarget extends string diff --git a/packages/react-native-gesture-handler/src/jestUtils/gestureScenarios.ts b/packages/react-native-gesture-handler/src/jestUtils/gestureScenarios.ts index 012923d170..97a9edad47 100644 --- a/packages/react-native-gesture-handler/src/jestUtils/gestureScenarios.ts +++ b/packages/react-native-gesture-handler/src/jestUtils/gestureScenarios.ts @@ -2,6 +2,7 @@ import { PointerType } from '../PointerType'; import { tagMessage } from '../utils'; import { SingleGestureName } from '../v3/types'; import type { + FlingGestureScenario, GestureOutcome, PanGestureScenario, TapGestureScenario, @@ -50,6 +51,14 @@ const TAP_DEFAULTS: JestHandlerDataPayload = { absoluteY: 0, }; +const FLING_DEFAULTS: JestHandlerDataPayload = { + ...COMMON_DEFAULTS, + x: 0, + y: 0, + absoluteX: 0, + absoluteY: 0, +}; + function validatePayload( payload: Record, description: string @@ -106,37 +115,62 @@ export function buildPanPayloads( }; } -export function buildTapPayloads( - scenario: TapGestureScenario +// Tap and fling are discrete gestures: they never dispatch update events. A +// single `event` payload drives the whole lifecycle by default, but each +// state change carries its own snapshot on a real device, so `stageEvents` +// may override individual stages (merged over `event`). +function buildDiscretePayloads( + scenario: TapGestureScenario | FlingGestureScenario, + defaults: JestHandlerDataPayload, + gestureLabel: string ): JestGesturePayloads { if ('updates' in scenario) { throw new Error( tagMessage( - `fireGesture received 'updates' for a tap gesture. Tap is a discrete gesture and does not dispatch update events — pass the payload through 'event' instead.` + `fireGesture received 'updates' for a ${gestureLabel} gesture. ${gestureLabel} is a discrete gesture and does not dispatch update events — pass the payload through 'event' instead.` ) ); } - const event = { ...TAP_DEFAULTS, ...scenario.event }; - validatePayload(scenario.event ?? {}, `'event'`); + const base = scenario.event ?? {}; + const stages = scenario.stageEvents ?? {}; + + validatePayload(base, `'event'`); + validatePayload(stages.begin ?? {}, `'stageEvents.begin'`); + validatePayload(stages.activate ?? {}, `'stageEvents.activate'`); + validatePayload(stages.end ?? {}, `'stageEvents.end'`); return { - begin: event, - activate: event, + begin: { ...defaults, ...base, ...stages.begin }, + activate: { ...defaults, ...base, ...stages.activate }, updates: [], - end: event, + end: { ...defaults, ...base, ...stages.end }, }; } +export function buildTapPayloads( + scenario: TapGestureScenario +): JestGesturePayloads { + return buildDiscretePayloads(scenario, TAP_DEFAULTS, 'tap'); +} + +export function buildFlingPayloads( + scenario: FlingGestureScenario +): JestGesturePayloads { + return buildDiscretePayloads(scenario, FLING_DEFAULTS, 'fling'); +} + export function buildScenarioPayloads( type: SingleGestureName, - scenario: PanGestureScenario | TapGestureScenario + scenario: PanGestureScenario | TapGestureScenario | FlingGestureScenario ): JestGesturePayloads { switch (type) { case SingleGestureName.Pan: return buildPanPayloads(scenario); case SingleGestureName.Tap: return buildTapPayloads(scenario); + case SingleGestureName.Fling: + return buildFlingPayloads(scenario); default: throw new Error( tagMessage(`fireGesture does not support '${type}' scenarios.`) diff --git a/packages/react-native-gesture-handler/src/jestUtils/index.ts b/packages/react-native-gesture-handler/src/jestUtils/index.ts index 2c4364a7af..c9a6a63f67 100644 --- a/packages/react-native-gesture-handler/src/jestUtils/index.ts +++ b/packages/react-native-gesture-handler/src/jestUtils/index.ts @@ -1,6 +1,7 @@ export { fireGesture } from './fireGesture'; export type { FireGestureTarget, + FlingGestureScenario, GestureOutcome, GestureScenario, PanGestureScenario, From a90ac385ca811f66945df1c57c44e249abe7c4ac Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Mon, 6 Jul 2026 13:39:34 +0200 Subject: [PATCH 5/7] added pinch gesture --- .../src/__tests__/fireGesture.test.tsx | 148 ++++++++++++++++++ .../src/jestUtils/fireGesture.ts | 7 +- .../src/jestUtils/gestureScenarioTypes.ts | 11 +- .../src/jestUtils/gestureScenarios.ts | 52 +++++- .../src/jestUtils/index.ts | 1 + 5 files changed, 208 insertions(+), 11 deletions(-) diff --git a/packages/react-native-gesture-handler/src/__tests__/fireGesture.test.tsx b/packages/react-native-gesture-handler/src/__tests__/fireGesture.test.tsx index 6e980ae3c6..1bf4a75df4 100644 --- a/packages/react-native-gesture-handler/src/__tests__/fireGesture.test.tsx +++ b/packages/react-native-gesture-handler/src/__tests__/fireGesture.test.tsx @@ -16,6 +16,7 @@ import { useFlingGesture, useLongPressGesture, usePanGesture, + usePinchGesture, useTapGesture, } from '../v3/hooks/gestures'; @@ -232,6 +233,153 @@ describe('fireGesture: pan', () => { }); }); +describe('fireGesture: pinch', () => { + test('successful pinch dispatches every supplied update in order', () => { + const { order, callbacks } = mockedV3Callbacks(); + const pinch = renderHook(() => + usePinchGesture({ disableReanimated: true, ...callbacks }) + ).result.current; + + fireGesture(pinch, { + updates: [{ scale: 1.5 }, { scale: 2 }], + }); + + expect(order).toEqual([ + 'onBegin', + 'onActivate', + 'onUpdate', + 'onUpdate', + 'onDeactivate', + 'onFinalize', + ]); + expect(callbacks.onUpdate).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ scale: 1.5 }) + ); + expect(callbacks.onUpdate).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ scale: 2 }) + ); + }); + + test('scaleChange is calculated through the v3 callback pipeline', () => { + const { callbacks } = mockedV3Callbacks(); + const pinch = renderHook(() => + usePinchGesture({ disableReanimated: true, ...callbacks }) + ).result.current; + + fireGesture(pinch, { + updates: [{ scale: 2 }, { scale: 4 }], + }); + + // Activation fills the default scaleChange of 1; each update divides the + // current scale by the previous one. + expect(callbacks.onActivate).toHaveBeenCalledWith( + expect.objectContaining({ scaleChange: 1 }) + ); + expect(callbacks.onUpdate).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ scaleChange: 2 }) + ); + expect(callbacks.onUpdate).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ scaleChange: 2 }) + ); + }); + + test('empty updates still produce a complete successful lifecycle', () => { + const { order, callbacks } = mockedV3Callbacks(); + const pinch = renderHook(() => + usePinchGesture({ disableReanimated: true, ...callbacks }) + ).result.current; + + fireGesture(pinch); + + expect(order).toEqual([ + 'onBegin', + 'onActivate', + 'onDeactivate', + 'onFinalize', + ]); + expect(callbacks.onUpdate).not.toHaveBeenCalled(); + }); + + test('event payload defaults use two pointers and identity scale', () => { + const { callbacks } = mockedV3Callbacks(); + const pinch = renderHook(() => + usePinchGesture({ disableReanimated: true, ...callbacks }) + ).result.current; + + fireGesture(pinch); + + expect(callbacks.onActivate).toHaveBeenCalledWith( + expect.objectContaining({ + numberOfPointers: 2, + scale: 1, + velocity: 0, + focalX: 0, + focalY: 0, + }) + ); + }); + + test('cancelled pinch deactivates and finalizes as cancelled', () => { + const { order, callbacks } = mockedV3Callbacks(); + const pinch = renderHook(() => + usePinchGesture({ disableReanimated: true, ...callbacks }) + ).result.current; + + fireGesture(pinch, { + updates: [{ scale: 1.5 }], + outcome: 'cancelled', + }); + + expect(order).toEqual([ + 'onBegin', + 'onActivate', + 'onUpdate', + 'onDeactivate', + 'onFinalize', + ]); + expect(callbacks.onDeactivate).toHaveBeenCalledWith( + expect.objectContaining({ canceled: true }) + ); + expect(callbacks.onFinalize).toHaveBeenCalledWith( + expect.objectContaining({ canceled: true }) + ); + }); + + test('rejects an event payload for the continuous pinch gesture', () => { + const pinch = renderHook(() => usePinchGesture({ disableReanimated: true })) + .result.current; + + expect(() => fireGesture(pinch, { event: { scale: 2 } } as never)).toThrow( + /continuous gesture/ + ); + }); + + test('resolves pinch gestures by test ID string', () => { + const { order, callbacks } = mockedV3Callbacks(); + renderHook(() => + usePinchGesture({ + testID: 'string-target-pinch', + disableReanimated: true, + ...callbacks, + }) + ); + + fireGesture('string-target-pinch', { updates: [{ scale: 3 }] }); + + expect(order).toEqual([ + 'onBegin', + 'onActivate', + 'onUpdate', + 'onDeactivate', + 'onFinalize', + ]); + }); +}); + describe('fireGesture: fling', () => { test('successful fling invokes the discrete v3 lifecycle', () => { const { order, callbacks } = mockedTapCallbacks(); diff --git a/packages/react-native-gesture-handler/src/jestUtils/fireGesture.ts b/packages/react-native-gesture-handler/src/jestUtils/fireGesture.ts index f96dd375ce..7083713283 100644 --- a/packages/react-native-gesture-handler/src/jestUtils/fireGesture.ts +++ b/packages/react-native-gesture-handler/src/jestUtils/fireGesture.ts @@ -15,6 +15,7 @@ import type { FlingGestureScenario, NoInferT, PanGestureScenario, + PinchGestureScenario, ScenarioForTarget, TapGestureScenario, } from './gestureScenarioTypes'; @@ -42,10 +43,11 @@ const SUPPORTED_GESTURES = new Set([ SingleGestureName.Tap, SingleGestureName.Pan, SingleGestureName.Fling, + SingleGestureName.Pinch, ]); const SUPPORTED_GESTURES_MESSAGE = - 'Currently supported gesture kinds: tap (useTapGesture), pan (usePanGesture) and fling (useFlingGesture).'; + 'Currently supported gesture kinds: tap (useTapGesture), pan (usePanGesture), fling (useFlingGesture) and pinch (usePinchGesture).'; function isHookGesture(target: object): target is AnySingleGesture { return 'detectorCallbacks' in target && 'type' in target; @@ -130,7 +132,8 @@ export function fireGesture( const gestureScenario: | PanGestureScenario | TapGestureScenario - | FlingGestureScenario = scenario ?? {}; + | FlingGestureScenario + | PinchGestureScenario = scenario ?? {}; const outcome = validateOutcome(gestureScenario.outcome); const payloads = buildScenarioPayloads(gesture.type, gestureScenario); diff --git a/packages/react-native-gesture-handler/src/jestUtils/gestureScenarioTypes.ts b/packages/react-native-gesture-handler/src/jestUtils/gestureScenarioTypes.ts index 207ddcf90f..c4bc0a516d 100644 --- a/packages/react-native-gesture-handler/src/jestUtils/gestureScenarioTypes.ts +++ b/packages/react-native-gesture-handler/src/jestUtils/gestureScenarioTypes.ts @@ -1,6 +1,7 @@ import type { GestureType } from '../handlers/gestures/gesture'; import type { FlingGestureEvent } from '../v3/hooks/gestures/fling/FlingTypes'; import type { PanGestureActiveEvent } from '../v3/hooks/gestures/pan/PanTypes'; +import type { PinchGestureActiveEvent } from '../v3/hooks/gestures/pinch/PinchTypes'; import type { AnySingleGesture } from '../v3/hooks/gestures/singleGestureUnion'; import type { TapGestureEvent } from '../v3/hooks/gestures/tap/TapTypes'; import type { SingleGestureName } from '../v3/types'; @@ -37,6 +38,12 @@ export type PanGestureScenario = { outcome?: GestureOutcome; }; +export type PinchGestureScenario = { + /** Payloads dispatched as `onUpdate` events while the gesture is active. */ + updates?: SemanticEventData[]; + outcome?: GestureOutcome; +}; + export type TapGestureScenario = { /** Payload used for every lifecycle callback of the tap. */ event?: SemanticEventData; @@ -63,7 +70,9 @@ export type GestureScenario = ? TapGestureScenario : TGesture extends { type: SingleGestureName.Fling } ? FlingGestureScenario - : never; + : TGesture extends { type: SingleGestureName.Pinch } + ? PinchGestureScenario + : never; export type ScenarioForTarget = TTarget extends string diff --git a/packages/react-native-gesture-handler/src/jestUtils/gestureScenarios.ts b/packages/react-native-gesture-handler/src/jestUtils/gestureScenarios.ts index 97a9edad47..307798cc7e 100644 --- a/packages/react-native-gesture-handler/src/jestUtils/gestureScenarios.ts +++ b/packages/react-native-gesture-handler/src/jestUtils/gestureScenarios.ts @@ -5,6 +5,7 @@ import type { FlingGestureScenario, GestureOutcome, PanGestureScenario, + PinchGestureScenario, TapGestureScenario, } from './gestureScenarioTypes'; import type { @@ -59,6 +60,17 @@ const FLING_DEFAULTS: JestHandlerDataPayload = { absoluteY: 0, }; +const PINCH_DEFAULTS: JestHandlerDataPayload = { + ...COMMON_DEFAULTS, + numberOfPointers: 2, + scale: 1, + velocity: 0, + focalX: 0, + focalY: 0, + // `scaleChange` is derived by the change-event calculator, like pan's + // `changeX`, so it is intentionally omitted here. +}; + function validatePayload( payload: Record, description: string @@ -91,30 +103,48 @@ export function validateOutcome(outcome: unknown): GestureOutcome { return outcome as GestureOutcome; } -export function buildPanPayloads( - scenario: PanGestureScenario +// Pan and pinch are continuous gestures: they dispatch a stream of update +// events while active. The `updates` array is that stream — the first update +// is the activation payload and the last is the end payload; `begin` uses the +// defaults, since no movement has happened yet. +function buildContinuousPayloads( + scenario: PanGestureScenario | PinchGestureScenario, + defaults: JestHandlerDataPayload, + gestureLabel: string ): JestGesturePayloads { if ('event' in scenario) { throw new Error( tagMessage( - `fireGesture received an 'event' field for a pan gesture. Pan is a continuous gesture — pass update payloads through 'updates' instead.` + `fireGesture received an 'event' field for a ${gestureLabel} gesture. ${gestureLabel} is a continuous gesture — pass update payloads through 'updates' instead.` ) ); } const updates = (scenario.updates ?? []).map((update, index) => { validatePayload(update, `updates[${index}]`); - return { ...PAN_DEFAULTS, ...update }; + return { ...defaults, ...update }; }); return { - begin: PAN_DEFAULTS, - activate: updates[0] ?? PAN_DEFAULTS, + begin: defaults, + activate: updates[0] ?? defaults, updates, - end: updates[updates.length - 1] ?? PAN_DEFAULTS, + end: updates[updates.length - 1] ?? defaults, }; } +export function buildPanPayloads( + scenario: PanGestureScenario +): JestGesturePayloads { + return buildContinuousPayloads(scenario, PAN_DEFAULTS, 'pan'); +} + +export function buildPinchPayloads( + scenario: PinchGestureScenario +): JestGesturePayloads { + return buildContinuousPayloads(scenario, PINCH_DEFAULTS, 'pinch'); +} + // Tap and fling are discrete gestures: they never dispatch update events. A // single `event` payload drives the whole lifecycle by default, but each // state change carries its own snapshot on a real device, so `stageEvents` @@ -162,7 +192,11 @@ export function buildFlingPayloads( export function buildScenarioPayloads( type: SingleGestureName, - scenario: PanGestureScenario | TapGestureScenario | FlingGestureScenario + scenario: + | PanGestureScenario + | TapGestureScenario + | FlingGestureScenario + | PinchGestureScenario ): JestGesturePayloads { switch (type) { case SingleGestureName.Pan: @@ -171,6 +205,8 @@ export function buildScenarioPayloads( return buildTapPayloads(scenario); case SingleGestureName.Fling: return buildFlingPayloads(scenario); + case SingleGestureName.Pinch: + return buildPinchPayloads(scenario); default: throw new Error( tagMessage(`fireGesture does not support '${type}' scenarios.`) diff --git a/packages/react-native-gesture-handler/src/jestUtils/index.ts b/packages/react-native-gesture-handler/src/jestUtils/index.ts index c9a6a63f67..e73933db9d 100644 --- a/packages/react-native-gesture-handler/src/jestUtils/index.ts +++ b/packages/react-native-gesture-handler/src/jestUtils/index.ts @@ -5,6 +5,7 @@ export type { GestureOutcome, GestureScenario, PanGestureScenario, + PinchGestureScenario, TapGestureScenario, } from './gestureScenarioTypes'; export { fireGestureHandler, getByGestureTestId } from './jestUtils'; From ac651cd2407834677ab4829a976e3e94685032a1 Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Mon, 6 Jul 2026 13:49:08 +0200 Subject: [PATCH 6/7] add rotation gesture --- .../src/__tests__/fireGesture.test.tsx | 149 ++++++++++++++++++ .../src/jestUtils/fireGesture.ts | 7 +- .../src/jestUtils/gestureScenarioTypes.ts | 11 +- .../src/jestUtils/gestureScenarios.ts | 23 ++- .../src/jestUtils/index.ts | 1 + 5 files changed, 187 insertions(+), 4 deletions(-) diff --git a/packages/react-native-gesture-handler/src/__tests__/fireGesture.test.tsx b/packages/react-native-gesture-handler/src/__tests__/fireGesture.test.tsx index 1bf4a75df4..bcc9f0c9b1 100644 --- a/packages/react-native-gesture-handler/src/__tests__/fireGesture.test.tsx +++ b/packages/react-native-gesture-handler/src/__tests__/fireGesture.test.tsx @@ -17,6 +17,7 @@ import { useLongPressGesture, usePanGesture, usePinchGesture, + useRotationGesture, useTapGesture, } from '../v3/hooks/gestures'; @@ -380,6 +381,154 @@ describe('fireGesture: pinch', () => { }); }); +describe('fireGesture: rotation', () => { + test('successful rotation dispatches every supplied update in order', () => { + const { order, callbacks } = mockedV3Callbacks(); + const rotation = renderHook(() => + useRotationGesture({ disableReanimated: true, ...callbacks }) + ).result.current; + + fireGesture(rotation, { + updates: [{ rotation: 0.5 }, { rotation: 1 }], + }); + + expect(order).toEqual([ + 'onBegin', + 'onActivate', + 'onUpdate', + 'onUpdate', + 'onDeactivate', + 'onFinalize', + ]); + expect(callbacks.onUpdate).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ rotation: 0.5 }) + ); + expect(callbacks.onUpdate).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ rotation: 1 }) + ); + }); + + test('rotationChange is calculated through the v3 callback pipeline', () => { + const { callbacks } = mockedV3Callbacks(); + const rotation = renderHook(() => + useRotationGesture({ disableReanimated: true, ...callbacks }) + ).result.current; + + fireGesture(rotation, { + updates: [{ rotation: 0.5 }, { rotation: 2 }], + }); + + // Activation fills the default rotationChange of 0; each update reports the + // delta from the previous rotation. + expect(callbacks.onActivate).toHaveBeenCalledWith( + expect.objectContaining({ rotationChange: 0 }) + ); + expect(callbacks.onUpdate).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ rotationChange: 0.5 }) + ); + expect(callbacks.onUpdate).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ rotationChange: 1.5 }) + ); + }); + + test('empty updates still produce a complete successful lifecycle', () => { + const { order, callbacks } = mockedV3Callbacks(); + const rotation = renderHook(() => + useRotationGesture({ disableReanimated: true, ...callbacks }) + ).result.current; + + fireGesture(rotation); + + expect(order).toEqual([ + 'onBegin', + 'onActivate', + 'onDeactivate', + 'onFinalize', + ]); + expect(callbacks.onUpdate).not.toHaveBeenCalled(); + }); + + test('event payload defaults use two pointers and zeroed rotation', () => { + const { callbacks } = mockedV3Callbacks(); + const rotation = renderHook(() => + useRotationGesture({ disableReanimated: true, ...callbacks }) + ).result.current; + + fireGesture(rotation); + + expect(callbacks.onActivate).toHaveBeenCalledWith( + expect.objectContaining({ + numberOfPointers: 2, + rotation: 0, + velocity: 0, + anchorX: 0, + anchorY: 0, + }) + ); + }); + + test('cancelled rotation deactivates and finalizes as cancelled', () => { + const { order, callbacks } = mockedV3Callbacks(); + const rotation = renderHook(() => + useRotationGesture({ disableReanimated: true, ...callbacks }) + ).result.current; + + fireGesture(rotation, { + updates: [{ rotation: 0.5 }], + outcome: 'cancelled', + }); + + expect(order).toEqual([ + 'onBegin', + 'onActivate', + 'onUpdate', + 'onDeactivate', + 'onFinalize', + ]); + expect(callbacks.onDeactivate).toHaveBeenCalledWith( + expect.objectContaining({ canceled: true }) + ); + expect(callbacks.onFinalize).toHaveBeenCalledWith( + expect.objectContaining({ canceled: true }) + ); + }); + + test('rejects an event payload for the continuous rotation gesture', () => { + const rotation = renderHook(() => + useRotationGesture({ disableReanimated: true }) + ).result.current; + + expect(() => + fireGesture(rotation, { event: { rotation: 1 } } as never) + ).toThrow(/continuous gesture/); + }); + + test('resolves rotation gestures by test ID string', () => { + const { order, callbacks } = mockedV3Callbacks(); + renderHook(() => + useRotationGesture({ + testID: 'string-target-rotation', + disableReanimated: true, + ...callbacks, + }) + ); + + fireGesture('string-target-rotation', { updates: [{ rotation: 1.2 }] }); + + expect(order).toEqual([ + 'onBegin', + 'onActivate', + 'onUpdate', + 'onDeactivate', + 'onFinalize', + ]); + }); +}); + describe('fireGesture: fling', () => { test('successful fling invokes the discrete v3 lifecycle', () => { const { order, callbacks } = mockedTapCallbacks(); diff --git a/packages/react-native-gesture-handler/src/jestUtils/fireGesture.ts b/packages/react-native-gesture-handler/src/jestUtils/fireGesture.ts index 7083713283..6c910760b6 100644 --- a/packages/react-native-gesture-handler/src/jestUtils/fireGesture.ts +++ b/packages/react-native-gesture-handler/src/jestUtils/fireGesture.ts @@ -16,6 +16,7 @@ import type { NoInferT, PanGestureScenario, PinchGestureScenario, + RotationGestureScenario, ScenarioForTarget, TapGestureScenario, } from './gestureScenarioTypes'; @@ -44,10 +45,11 @@ const SUPPORTED_GESTURES = new Set([ SingleGestureName.Pan, SingleGestureName.Fling, SingleGestureName.Pinch, + SingleGestureName.Rotation, ]); const SUPPORTED_GESTURES_MESSAGE = - 'Currently supported gesture kinds: tap (useTapGesture), pan (usePanGesture), fling (useFlingGesture) and pinch (usePinchGesture).'; + 'Currently supported gesture kinds: tap (useTapGesture), pan (usePanGesture), fling (useFlingGesture), pinch (usePinchGesture) and rotation (useRotationGesture).'; function isHookGesture(target: object): target is AnySingleGesture { return 'detectorCallbacks' in target && 'type' in target; @@ -133,7 +135,8 @@ export function fireGesture( | PanGestureScenario | TapGestureScenario | FlingGestureScenario - | PinchGestureScenario = scenario ?? {}; + | PinchGestureScenario + | RotationGestureScenario = scenario ?? {}; const outcome = validateOutcome(gestureScenario.outcome); const payloads = buildScenarioPayloads(gesture.type, gestureScenario); diff --git a/packages/react-native-gesture-handler/src/jestUtils/gestureScenarioTypes.ts b/packages/react-native-gesture-handler/src/jestUtils/gestureScenarioTypes.ts index c4bc0a516d..5d55663bed 100644 --- a/packages/react-native-gesture-handler/src/jestUtils/gestureScenarioTypes.ts +++ b/packages/react-native-gesture-handler/src/jestUtils/gestureScenarioTypes.ts @@ -2,6 +2,7 @@ import type { GestureType } from '../handlers/gestures/gesture'; import type { FlingGestureEvent } from '../v3/hooks/gestures/fling/FlingTypes'; import type { PanGestureActiveEvent } from '../v3/hooks/gestures/pan/PanTypes'; import type { PinchGestureActiveEvent } from '../v3/hooks/gestures/pinch/PinchTypes'; +import type { RotationGestureActiveEvent } from '../v3/hooks/gestures/rotation/RotationTypes'; import type { AnySingleGesture } from '../v3/hooks/gestures/singleGestureUnion'; import type { TapGestureEvent } from '../v3/hooks/gestures/tap/TapTypes'; import type { SingleGestureName } from '../v3/types'; @@ -44,6 +45,12 @@ export type PinchGestureScenario = { outcome?: GestureOutcome; }; +export type RotationGestureScenario = { + /** Payloads dispatched as `onUpdate` events while the gesture is active. */ + updates?: SemanticEventData[]; + outcome?: GestureOutcome; +}; + export type TapGestureScenario = { /** Payload used for every lifecycle callback of the tap. */ event?: SemanticEventData; @@ -72,7 +79,9 @@ export type GestureScenario = ? FlingGestureScenario : TGesture extends { type: SingleGestureName.Pinch } ? PinchGestureScenario - : never; + : TGesture extends { type: SingleGestureName.Rotation } + ? RotationGestureScenario + : never; export type ScenarioForTarget = TTarget extends string diff --git a/packages/react-native-gesture-handler/src/jestUtils/gestureScenarios.ts b/packages/react-native-gesture-handler/src/jestUtils/gestureScenarios.ts index 307798cc7e..cf0c40e521 100644 --- a/packages/react-native-gesture-handler/src/jestUtils/gestureScenarios.ts +++ b/packages/react-native-gesture-handler/src/jestUtils/gestureScenarios.ts @@ -6,6 +6,7 @@ import type { GestureOutcome, PanGestureScenario, PinchGestureScenario, + RotationGestureScenario, TapGestureScenario, } from './gestureScenarioTypes'; import type { @@ -71,6 +72,17 @@ const PINCH_DEFAULTS: JestHandlerDataPayload = { // `changeX`, so it is intentionally omitted here. }; +const ROTATION_DEFAULTS: JestHandlerDataPayload = { + ...COMMON_DEFAULTS, + numberOfPointers: 2, + rotation: 0, + velocity: 0, + anchorX: 0, + anchorY: 0, + // `rotationChange` is derived by the change-event calculator, so it is + // intentionally omitted here. +}; + function validatePayload( payload: Record, description: string @@ -103,7 +115,7 @@ export function validateOutcome(outcome: unknown): GestureOutcome { return outcome as GestureOutcome; } -// Pan and pinch are continuous gestures: they dispatch a stream of update +// Continuous gestures dispatch a stream of update // events while active. The `updates` array is that stream — the first update // is the activation payload and the last is the end payload; `begin` uses the // defaults, since no movement has happened yet. @@ -145,6 +157,12 @@ export function buildPinchPayloads( return buildContinuousPayloads(scenario, PINCH_DEFAULTS, 'pinch'); } +export function buildRotationPayloads( + scenario: RotationGestureScenario +): JestGesturePayloads { + return buildContinuousPayloads(scenario, ROTATION_DEFAULTS, 'rotation'); +} + // Tap and fling are discrete gestures: they never dispatch update events. A // single `event` payload drives the whole lifecycle by default, but each // state change carries its own snapshot on a real device, so `stageEvents` @@ -197,6 +215,7 @@ export function buildScenarioPayloads( | TapGestureScenario | FlingGestureScenario | PinchGestureScenario + | RotationGestureScenario ): JestGesturePayloads { switch (type) { case SingleGestureName.Pan: @@ -207,6 +226,8 @@ export function buildScenarioPayloads( return buildFlingPayloads(scenario); case SingleGestureName.Pinch: return buildPinchPayloads(scenario); + case SingleGestureName.Rotation: + return buildRotationPayloads(scenario); default: throw new Error( tagMessage(`fireGesture does not support '${type}' scenarios.`) diff --git a/packages/react-native-gesture-handler/src/jestUtils/index.ts b/packages/react-native-gesture-handler/src/jestUtils/index.ts index e73933db9d..9b000d28f3 100644 --- a/packages/react-native-gesture-handler/src/jestUtils/index.ts +++ b/packages/react-native-gesture-handler/src/jestUtils/index.ts @@ -6,6 +6,7 @@ export type { GestureScenario, PanGestureScenario, PinchGestureScenario, + RotationGestureScenario, TapGestureScenario, } from './gestureScenarioTypes'; export { fireGestureHandler, getByGestureTestId } from './jestUtils'; From 5be8e596b4fe0caf675d6ef2f8c8841ccfe636f5 Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Mon, 6 Jul 2026 15:47:14 +0200 Subject: [PATCH 7/7] add long press gesture --- .../src/__tests__/fireGesture.test.tsx | 231 +++++++++++++++++- .../src/jestUtils/fireGesture.ts | 170 ++++++++++--- .../src/jestUtils/gestureScenarioTypes.ts | 21 +- .../src/jestUtils/gestureScenarios.ts | 84 +++++++ .../src/jestUtils/index.ts | 5 + 5 files changed, 474 insertions(+), 37 deletions(-) diff --git a/packages/react-native-gesture-handler/src/__tests__/fireGesture.test.tsx b/packages/react-native-gesture-handler/src/__tests__/fireGesture.test.tsx index bcc9f0c9b1..cacb361431 100644 --- a/packages/react-native-gesture-handler/src/__tests__/fireGesture.test.tsx +++ b/packages/react-native-gesture-handler/src/__tests__/fireGesture.test.tsx @@ -1,5 +1,5 @@ import { render, renderHook, screen } from '@testing-library/react-native'; -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { Text, View } from 'react-native'; import GestureHandlerRootView from '../components/GestureHandlerRootView'; @@ -15,6 +15,7 @@ import type { PanGesture, TapGesture } from '../v3/hooks/gestures'; import { useFlingGesture, useLongPressGesture, + useManualGesture, usePanGesture, usePinchGesture, useRotationGesture, @@ -529,6 +530,226 @@ describe('fireGesture: rotation', () => { }); }); +describe('fireGesture: long press', () => { + test('successful long press invokes the discrete v3 lifecycle', () => { + const { order, callbacks } = mockedTapCallbacks(); + const longPress = renderHook(() => + useLongPressGesture({ disableReanimated: true, ...callbacks }) + ).result.current; + + fireGesture(longPress); + + expect(order).toEqual([ + 'onBegin', + 'onActivate', + 'onDeactivate', + 'onFinalize', + ]); + expect(callbacks.onFinalize).toHaveBeenCalledWith( + expect.objectContaining({ canceled: false }) + ); + }); + + test('duration populates the activation and end payloads', () => { + const { callbacks } = mockedTapCallbacks(); + const longPress = renderHook(() => + useLongPressGesture({ disableReanimated: true, ...callbacks }) + ).result.current; + + fireGesture(longPress, { duration: 800 }); + + // Begin reports no held time yet; activation and end report the duration. + expect(callbacks.onBegin).toHaveBeenCalledWith( + expect.objectContaining({ duration: 0 }) + ); + expect(callbacks.onActivate).toHaveBeenCalledWith( + expect.objectContaining({ duration: 800 }) + ); + expect(callbacks.onDeactivate).toHaveBeenCalledWith( + expect.objectContaining({ duration: 800 }) + ); + }); + + test('failed long press finalizes without activation (released early)', () => { + const { order, callbacks } = mockedTapCallbacks(); + const longPress = renderHook(() => + useLongPressGesture({ disableReanimated: true, ...callbacks }) + ).result.current; + + fireGesture(longPress, { outcome: 'failed' }); + + expect(order).toEqual(['onBegin', 'onFinalize']); + expect(callbacks.onFinalize).toHaveBeenCalledWith( + expect.objectContaining({ canceled: true }) + ); + }); + + test('rejects updates for the discrete long press gesture', () => { + const longPress = renderHook(() => + useLongPressGesture({ disableReanimated: true }) + ).result.current; + + expect(() => + fireGesture(longPress, { updates: [{ x: 1 }] } as never) + ).toThrow(/discrete gesture and does not dispatch update events/); + }); +}); + +describe('fireGesture.setup (clock contract)', () => { + test('advances fake timers so an onBegin timer fires before activation', async () => { + jest.useFakeTimers(); + try { + const fired: string[] = []; + const longPress = renderHook(() => + useLongPressGesture({ + disableReanimated: true, + onBegin: () => { + setTimeout(() => fired.push('timer'), 500); + }, + onActivate: () => fired.push('onActivate'), + }) + ).result.current; + + const fireGestureWithTimers = fireGesture.setup({ + advanceTimers: jest.advanceTimersByTime, + }); + + await fireGestureWithTimers(longPress, { duration: 800 }); + + // The onBegin timer fires during the advanced hold, before activation. + expect(fired).toEqual(['timer', 'onActivate']); + } finally { + jest.useRealTimers(); + } + }); + + test('does not fire the onBegin timer for a failed long press', async () => { + jest.useFakeTimers(); + try { + const timerFired = jest.fn(); + const longPress = renderHook(() => + useLongPressGesture({ + disableReanimated: true, + onBegin: () => { + setTimeout(timerFired, 500); + }, + }) + ).result.current; + + const fireGestureWithTimers = fireGesture.setup({ + advanceTimers: jest.advanceTimersByTime, + }); + + // A failed long press is released before the hold completes, so the + // clock is not advanced and the timer never fires. + await fireGestureWithTimers(longPress, { + duration: 800, + outcome: 'failed', + }); + + expect(timerFired).not.toHaveBeenCalled(); + } finally { + jest.useRealTimers(); + } + }); + + test('works with real timers', async () => { + const timerFired = jest.fn(); + const longPress = renderHook(() => + useLongPressGesture({ + disableReanimated: true, + onBegin: () => { + setTimeout(timerFired, 5); + }, + }) + ).result.current; + + const fireGestureWithTimers = fireGesture.setup({ + advanceTimers: (ms) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }), + }); + + await fireGestureWithTimers(longPress, { duration: 20 }); + + expect(timerFired).toHaveBeenCalledTimes(1); + }); + + test('drives long-press application behavior into rendered output', async () => { + jest.useFakeTimers(); + try { + const LongPressLabel = () => { + const [label, setLabel] = useState('idle'); + const timer = useRef>(undefined); + const longPress = useLongPressGesture({ + testID: 'held', + disableReanimated: true, + onBegin: () => { + timer.current = setTimeout(() => setLabel('long-pressed'), 500); + }, + onFinalize: () => { + if (timer.current) { + clearTimeout(timer.current); + } + }, + }); + + return ( + + + + + {label} + + ); + }; + + render(); + + const fireGestureWithTimers = fireGesture.setup({ + advanceTimers: jest.advanceTimersByTime, + }); + + await fireGestureWithTimers('held', { duration: 800 }); + + expect(screen.getByText('long-pressed')).toBeTruthy(); + } finally { + jest.useRealTimers(); + } + }); + + test('short hold does not trigger the long-press effect', async () => { + jest.useFakeTimers(); + try { + const longPressed = jest.fn(); + const longPress = renderHook(() => + useLongPressGesture({ + disableReanimated: true, + onBegin: () => { + setTimeout(longPressed, 500); + }, + onFinalize: () => { + // Real components clear the timer on release; emulate that here so + // a short hold cannot fire the long-press effect afterwards. + }, + }) + ).result.current; + + const fireGestureWithTimers = fireGesture.setup({ + advanceTimers: jest.advanceTimersByTime, + }); + + // Held for less than the 500ms threshold. + await fireGestureWithTimers(longPress, { duration: 200 }); + + expect(longPressed).not.toHaveBeenCalled(); + } finally { + jest.useRealTimers(); + } + }); +}); + describe('fireGesture: fling', () => { test('successful fling invokes the discrete v3 lifecycle', () => { const { order, callbacks } = mockedTapCallbacks(); @@ -797,12 +1018,12 @@ describe('fireGesture: targets', () => { }); test('unsupported v3 gestures produce an error listing supported kinds', () => { - const longPress = renderHook(() => - useLongPressGesture({ disableReanimated: true }) + const manual = renderHook(() => + useManualGesture({ disableReanimated: true }) ).result.current; - expect(() => fireGesture(longPress as never)).toThrow( - /does not support 'LongPressGestureHandler'.*tap.*pan/ + expect(() => fireGesture(manual as never)).toThrow( + /does not support 'ManualGestureHandler'.*tap.*pan/ ); }); diff --git a/packages/react-native-gesture-handler/src/jestUtils/fireGesture.ts b/packages/react-native-gesture-handler/src/jestUtils/fireGesture.ts index 6c910760b6..f5d1769966 100644 --- a/packages/react-native-gesture-handler/src/jestUtils/fireGesture.ts +++ b/packages/react-native-gesture-handler/src/jestUtils/fireGesture.ts @@ -8,11 +8,14 @@ import { SingleGestureName } from '../v3/types'; import { buildScenarioPayloads, runOutcome, + runOutcomeAsync, validateOutcome, } from './gestureScenarios'; import type { FireGestureTarget, FlingGestureScenario, + GestureOutcome, + LongPressGestureScenario, NoInferT, PanGestureScenario, PinchGestureScenario, @@ -25,6 +28,9 @@ import { JestGestureHandler } from './JestGestureHandler'; import { getByGestureTestId } from './jestUtils'; let act = (callback: () => void) => callback(); +let actAsync = async (callback: () => void | Promise) => { + await callback(); +}; try { // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -35,6 +41,11 @@ try { callback(); }); }; + actAsync = async (callback: () => void | Promise) => { + await React.act(async () => { + await callback(); + }); + }; } } catch (_e) { // Do nothing if not available @@ -46,10 +57,19 @@ const SUPPORTED_GESTURES = new Set([ SingleGestureName.Fling, SingleGestureName.Pinch, SingleGestureName.Rotation, + SingleGestureName.LongPress, ]); const SUPPORTED_GESTURES_MESSAGE = - 'Currently supported gesture kinds: tap (useTapGesture), pan (usePanGesture), fling (useFlingGesture), pinch (usePinchGesture) and rotation (useRotationGesture).'; + 'Currently supported gesture kinds: tap (useTapGesture), pan (usePanGesture), fling (useFlingGesture), pinch (usePinchGesture), rotation (useRotationGesture) and long press (useLongPressGesture).'; + +type AnyGestureScenario = + | PanGestureScenario + | TapGestureScenario + | FlingGestureScenario + | PinchGestureScenario + | RotationGestureScenario + | LongPressGestureScenario; function isHookGesture(target: object): target is AnySingleGesture { return 'detectorCallbacks' in target && 'type' in target; @@ -66,31 +86,23 @@ const jestRelationPolicy: GestureRelationPolicy = { shouldBeginWithRecordedHandlers: () => true, }; +interface PreparedGesture { + handler: JestGestureHandler; + arbitrator: GestureArbitrator; + outcome: GestureOutcome; + /** Milliseconds the clock should advance between begin and activation. */ + duration: number; +} + /** - * Simulates the JavaScript callback lifecycle of a single v3 gesture - * interaction. - * - * The target may be a gesture test ID or a gesture returned by a v3 hook. - * The gesture kind is inferred from the resolved target. - * - * ```ts - * fireGesture('save-button'); - * - * fireGesture('draggable-card', { - * updates: [{ translationX: 20 }, { translationX: 100 }], - * }); - * - * fireGesture(pan, { updates: [{ translationX: 100 }], outcome: 'cancelled' }); - * ``` - * - * Every requested state change is routed through the same arbitration core - * that drives the web implementation, so the emitted callback lifecycle - * matches the runtime contract. + * Resolves and validates the target, builds the scenario payloads and creates + * the lightweight Jest handler. Returns `null` when the interaction is a + * no-op (a disabled gesture), matching `fireGestureHandler`. */ -export function fireGesture( - target: TTarget, - scenario?: ScenarioForTarget> -): void { +function prepareGesture( + target: FireGestureTarget, + scenario: AnyGestureScenario | undefined +): PreparedGesture | null { const resolved = typeof target === 'string' ? getByGestureTestId(target) : target; @@ -128,18 +140,18 @@ export function fireGesture( // Disabled gestures produce no callbacks, matching fireGestureHandler. if (maybeUnpackValue(gesture.config.enabled) === false) { - return; + return null; } - const gestureScenario: - | PanGestureScenario - | TapGestureScenario - | FlingGestureScenario - | PinchGestureScenario - | RotationGestureScenario = scenario ?? {}; + const gestureScenario: AnyGestureScenario = scenario ?? {}; const outcome = validateOutcome(gestureScenario.outcome); const payloads = buildScenarioPayloads(gesture.type, gestureScenario); + const duration = + gesture.type === SingleGestureName.LongPress + ? ((gestureScenario as LongPressGestureScenario).duration ?? 0) + : 0; + const arbitrator = new GestureArbitrator( jestRelationPolicy ); @@ -159,6 +171,66 @@ export function fireGesture( payloads ); + return { handler, arbitrator, outcome, duration }; +} + +/** + * Advance the clock so JavaScript timers scheduled by the application (e.g. a + * long-press timer started in `onBegin`) fire. Provided to `fireGesture.setup`. + */ +export interface FireGestureSetupOptions { + /** + * Advances timers by the given number of milliseconds. Pass + * `jest.advanceTimersByTime` when using fake timers, or a real-timer waiter + * such as `(ms) => new Promise((r) => setTimeout(r, ms))`. The helper never + * switches Jest timer modes itself. + */ + advanceTimers: (durationMs: number) => void | Promise; +} + +export type FireGestureWithTimers = ( + target: TTarget, + scenario?: ScenarioForTarget> +) => Promise; + +/** + * Simulates the JavaScript callback lifecycle of a single v3 gesture + * interaction. It does not run the platform gesture recognizer — use + * device-level tests when behavior depends on recognition thresholds, hit + * testing, gesture competition or platform-specific input handling. + * + * The target may be a gesture test ID or a gesture returned by a v3 hook. + * The gesture kind is inferred from the resolved target. + * + * ```ts + * fireGesture('save-button'); + * + * fireGesture('draggable-card', { + * updates: [{ translationX: 20 }, { translationX: 100 }], + * }); + * + * fireGesture(pan, { updates: [{ translationX: 100 }], outcome: 'cancelled' }); + * ``` + * + * Every requested state change is routed through the same arbitration core + * that drives the web implementation, so the emitted callback lifecycle + * matches the runtime contract. + * + * For gestures whose application logic depends on elapsed time (e.g. a long + * press whose component starts a timer in `onBegin`), create a timer-aware + * variant with {@link fireGesture.setup}. + */ +function fireGestureImpl( + target: TTarget, + scenario?: ScenarioForTarget> +): void { + const prepared = prepareGesture(target, scenario as AnyGestureScenario); + if (!prepared) { + return; + } + + const { handler, arbitrator, outcome } = prepared; + try { act(() => { runOutcome(handler, outcome); @@ -169,3 +241,39 @@ export function fireGesture( arbitrator.reset(); } } + +/** + * Creates a timer-aware `fireGesture`. The returned function is async: it + * advances the clock between begin and activation using the supplied + * `advanceTimers`, so timers started in `onBegin` fire before activation. + * + * ```ts + * const fireGestureWithTimers = fireGesture.setup({ + * advanceTimers: jest.advanceTimersByTime, + * }); + * + * await fireGestureWithTimers(longPress, { duration: 800 }); + * ``` + */ +function setup(options: FireGestureSetupOptions): FireGestureWithTimers { + const { advanceTimers } = options; + + return async function fireGestureWithTimers(target, scenario) { + const prepared = prepareGesture(target, scenario as AnyGestureScenario); + if (!prepared) { + return; + } + + const { handler, arbitrator, outcome, duration } = prepared; + + try { + await actAsync(async () => { + await runOutcomeAsync(handler, outcome, () => advanceTimers(duration)); + }); + } finally { + arbitrator.reset(); + } + }; +} + +export const fireGesture = Object.assign(fireGestureImpl, { setup }); diff --git a/packages/react-native-gesture-handler/src/jestUtils/gestureScenarioTypes.ts b/packages/react-native-gesture-handler/src/jestUtils/gestureScenarioTypes.ts index 5d55663bed..56ffa8ceb5 100644 --- a/packages/react-native-gesture-handler/src/jestUtils/gestureScenarioTypes.ts +++ b/packages/react-native-gesture-handler/src/jestUtils/gestureScenarioTypes.ts @@ -1,5 +1,6 @@ import type { GestureType } from '../handlers/gestures/gesture'; import type { FlingGestureEvent } from '../v3/hooks/gestures/fling/FlingTypes'; +import type { LongPressGestureEvent } from '../v3/hooks/gestures/longPress/LongPressTypes'; import type { PanGestureActiveEvent } from '../v3/hooks/gestures/pan/PanTypes'; import type { PinchGestureActiveEvent } from '../v3/hooks/gestures/pinch/PinchTypes'; import type { RotationGestureActiveEvent } from '../v3/hooks/gestures/rotation/RotationTypes'; @@ -67,6 +68,22 @@ export type FlingGestureScenario = { outcome?: GestureOutcome; }; +export type LongPressGestureScenario = { + /** Payload used for every lifecycle callback of the long press. */ + event?: SemanticEventData; + /** Per-stage payload overrides, merged over `event`. */ + stageEvents?: DiscreteStageEvents; + /** + * How long the press is held, in milliseconds. Populates the `duration` + * field of the activation/end payloads, and — when the helper is created + * with `fireGesture.setup({ advanceTimers })` — is the amount the clock is + * advanced between begin and activation so JavaScript timers started in + * `onBegin` fire. + */ + duration?: number; + outcome?: GestureOutcome; +}; + export type ResolvedGestureTarget = GestureType | AnySingleGesture; export type FireGestureTarget = string | ResolvedGestureTarget; @@ -81,7 +98,9 @@ export type GestureScenario = ? PinchGestureScenario : TGesture extends { type: SingleGestureName.Rotation } ? RotationGestureScenario - : never; + : TGesture extends { type: SingleGestureName.LongPress } + ? LongPressGestureScenario + : never; export type ScenarioForTarget = TTarget extends string diff --git a/packages/react-native-gesture-handler/src/jestUtils/gestureScenarios.ts b/packages/react-native-gesture-handler/src/jestUtils/gestureScenarios.ts index cf0c40e521..ac9c5acba2 100644 --- a/packages/react-native-gesture-handler/src/jestUtils/gestureScenarios.ts +++ b/packages/react-native-gesture-handler/src/jestUtils/gestureScenarios.ts @@ -4,6 +4,7 @@ import { SingleGestureName } from '../v3/types'; import type { FlingGestureScenario, GestureOutcome, + LongPressGestureScenario, PanGestureScenario, PinchGestureScenario, RotationGestureScenario, @@ -61,6 +62,15 @@ const FLING_DEFAULTS: JestHandlerDataPayload = { absoluteY: 0, }; +const LONG_PRESS_DEFAULTS: JestHandlerDataPayload = { + ...COMMON_DEFAULTS, + x: 0, + y: 0, + absoluteX: 0, + absoluteY: 0, + duration: 0, +}; + const PINCH_DEFAULTS: JestHandlerDataPayload = { ...COMMON_DEFAULTS, numberOfPointers: 2, @@ -208,6 +218,46 @@ export function buildFlingPayloads( return buildDiscretePayloads(scenario, FLING_DEFAULTS, 'fling'); } +// Long press is discrete, but also carries a `duration`: the held time is +// meaningful once the gesture activates, so `duration` is layered into the +// activation and end payloads. Precedence is defaults < scenario.duration < +// `event` < `stageEvents`, so an explicit payload value still wins, while +// begin keeps the default duration of 0 (nothing has been held yet). +export function buildLongPressPayloads( + scenario: LongPressGestureScenario +): JestGesturePayloads { + if ('updates' in scenario) { + throw new Error( + tagMessage( + `fireGesture received 'updates' for a long press gesture. Long press is a discrete gesture and does not dispatch update events — pass the payload through 'event' instead.` + ) + ); + } + + const base = scenario.event ?? {}; + const stages = scenario.stageEvents ?? {}; + + validatePayload(base, `'event'`); + validatePayload(stages.begin ?? {}, `'stageEvents.begin'`); + validatePayload(stages.activate ?? {}, `'stageEvents.activate'`); + validatePayload(stages.end ?? {}, `'stageEvents.end'`); + + const durationLayer = + scenario.duration !== undefined ? { duration: scenario.duration } : {}; + + return { + begin: { ...LONG_PRESS_DEFAULTS, ...base, ...stages.begin }, + activate: { + ...LONG_PRESS_DEFAULTS, + ...durationLayer, + ...base, + ...stages.activate, + }, + updates: [], + end: { ...LONG_PRESS_DEFAULTS, ...durationLayer, ...base, ...stages.end }, + }; +} + export function buildScenarioPayloads( type: SingleGestureName, scenario: @@ -216,6 +266,7 @@ export function buildScenarioPayloads( | FlingGestureScenario | PinchGestureScenario | RotationGestureScenario + | LongPressGestureScenario ): JestGesturePayloads { switch (type) { case SingleGestureName.Pan: @@ -228,6 +279,8 @@ export function buildScenarioPayloads( return buildPinchPayloads(scenario); case SingleGestureName.Rotation: return buildRotationPayloads(scenario); + case SingleGestureName.LongPress: + return buildLongPressPayloads(scenario); default: throw new Error( tagMessage(`fireGesture does not support '${type}' scenarios.`) @@ -260,3 +313,34 @@ export function runOutcome( handler.end(); } } + +/** + * Same lifecycle as `runOutcome`, but awaits `onBeforeActivate` between begin + * and activation. This is where the clock is advanced for long press, so that + * JavaScript timers started in `onBegin` fire before the gesture activates. A + * `failed` interaction returns before the hold completes, so the clock is not + * advanced (the press was released too early to be recognized). + */ +export async function runOutcomeAsync( + handler: JestGestureHandler, + outcome: GestureOutcome, + onBeforeActivate: () => void | Promise +) { + handler.begin(); + + if (outcome === 'failed') { + handler.fail(); + return; + } + + await onBeforeActivate(); + + handler.activate(); + handler.dispatchUpdates(); + + if (outcome === 'cancelled') { + handler.cancel(); + } else { + handler.end(); + } +} diff --git a/packages/react-native-gesture-handler/src/jestUtils/index.ts b/packages/react-native-gesture-handler/src/jestUtils/index.ts index 9b000d28f3..bfb7838917 100644 --- a/packages/react-native-gesture-handler/src/jestUtils/index.ts +++ b/packages/react-native-gesture-handler/src/jestUtils/index.ts @@ -1,9 +1,14 @@ +export type { + FireGestureSetupOptions, + FireGestureWithTimers, +} from './fireGesture'; export { fireGesture } from './fireGesture'; export type { FireGestureTarget, FlingGestureScenario, GestureOutcome, GestureScenario, + LongPressGestureScenario, PanGestureScenario, PinchGestureScenario, RotationGestureScenario,