From 642cc5baabd245b5240e809f4fd118e1bce45119 Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Thu, 9 Jul 2026 16:30:20 +0200 Subject: [PATCH 01/10] Added new jest API --- .../src/__tests__/gestureController.test.tsx | 170 ++++++++++++++++ .../src/jestUtils/index.ts | 7 +- .../src/jestUtils/jestUtils.ts | 187 ++++++++++++++++++ 3 files changed, 363 insertions(+), 1 deletion(-) create mode 100644 packages/react-native-gesture-handler/src/__tests__/gestureController.test.tsx diff --git a/packages/react-native-gesture-handler/src/__tests__/gestureController.test.tsx b/packages/react-native-gesture-handler/src/__tests__/gestureController.test.tsx new file mode 100644 index 0000000000..34a1577648 --- /dev/null +++ b/packages/react-native-gesture-handler/src/__tests__/gestureController.test.tsx @@ -0,0 +1,170 @@ +import { renderHook } from '@testing-library/react-native'; + +import { createGestureController } from '../jestUtils'; +import { State } from '../State'; +import { usePanGesture, useTapGesture } from '../v3/hooks/gestures'; + +function mockedContinuousCallbacks() { + 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 mockedDiscreteCallbacks() { + 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('createGestureController', () => { + test('allows assertions after each gesture lifecycle step', () => { + const { order, callbacks } = mockedContinuousCallbacks(); + const pan = renderHook(() => + usePanGesture({ disableReanimated: true, ...callbacks }) + ).result.current; + const gesture = createGestureController(pan); + + gesture.begin({ translationX: 0 }); + + expect(gesture.getState()).toBe(State.BEGAN); + expect(order).toEqual(['onBegin']); + expect(callbacks.onBegin).toHaveBeenCalledWith( + expect.objectContaining({ translationX: 0 }) + ); + + gesture.activate({ translationX: 10 }); + + expect(gesture.getState()).toBe(State.ACTIVE); + expect(order).toEqual(['onBegin', 'onActivate']); + expect(callbacks.onActivate).toHaveBeenCalledWith( + expect.objectContaining({ translationX: 10 }) + ); + + gesture.update({ translationX: 50 }); + + expect(gesture.getState()).toBe(State.ACTIVE); + expect(order).toEqual(['onBegin', 'onActivate', 'onUpdate']); + expect(callbacks.onUpdate).toHaveBeenCalledWith( + expect.objectContaining({ translationX: 50 }) + ); + + gesture.end({ translationX: 50 }); + + expect(gesture.getState()).toBe(State.END); + expect(order).toEqual([ + 'onBegin', + 'onActivate', + 'onUpdate', + 'onDeactivate', + 'onFinalize', + ]); + expect(callbacks.onFinalize).toHaveBeenCalledWith( + expect.objectContaining({ canceled: false, translationX: 50 }) + ); + }); + + test('can fail a gesture before activation', () => { + const { order, callbacks } = mockedDiscreteCallbacks(); + const tap = renderHook(() => + useTapGesture({ disableReanimated: true, ...callbacks }) + ).result.current; + const gesture = createGestureController(tap); + + gesture.begin({ x: 10 }); + gesture.fail({ x: 10 }); + + expect(gesture.getState()).toBe(State.FAILED); + expect(order).toEqual(['onBegin', 'onFinalize']); + expect(callbacks.onActivate).not.toHaveBeenCalled(); + expect(callbacks.onDeactivate).not.toHaveBeenCalled(); + expect(callbacks.onFinalize).toHaveBeenCalledWith( + expect.objectContaining({ canceled: true, x: 10 }) + ); + }); + + test('resolves gesture test ID strings internally', () => { + const { order, callbacks } = mockedDiscreteCallbacks(); + renderHook(() => + useTapGesture({ + testID: 'controlled-tap', + disableReanimated: true, + ...callbacks, + }) + ); + const gesture = createGestureController('controlled-tap'); + + gesture.begin(); + gesture.activate(); + gesture.end(); + + expect(order).toEqual([ + 'onBegin', + 'onActivate', + 'onDeactivate', + 'onFinalize', + ]); + }); + + test('guards against invalid lifecycle order', () => { + const tap = renderHook(() => useTapGesture({ disableReanimated: true })) + .result.current; + const gesture = createGestureController(tap); + + expect(() => gesture.update()).toThrow( + 'Cannot update gesture from UNDETERMINED state.' + ); + + gesture.begin(); + + expect(() => gesture.begin()).toThrow( + 'Cannot begin gesture from BEGAN state.' + ); + }); + + test('rejects raw state-machine fields in event payloads', () => { + const tap = renderHook(() => useTapGesture({ disableReanimated: true })) + .result.current; + const gesture = createGestureController(tap); + + expect(() => gesture.begin({ state: State.BEGAN } as never)).toThrow( + "createGestureController manages 'state' internally." + ); + }); + + test('disabled gestures are no-ops', () => { + const { order, callbacks } = mockedContinuousCallbacks(); + const pan = renderHook(() => + usePanGesture({ + disableReanimated: true, + enabled: false, + ...callbacks, + }) + ).result.current; + const gesture = createGestureController(pan); + + gesture.begin(); + gesture.activate(); + gesture.update({ translationX: 50 }); + gesture.end(); + + expect(gesture.getState()).toBe(State.UNDETERMINED); + expect(order).toEqual([]); + }); +}); diff --git a/packages/react-native-gesture-handler/src/jestUtils/index.ts b/packages/react-native-gesture-handler/src/jestUtils/index.ts index 99351f2813..48db50fc32 100644 --- a/packages/react-native-gesture-handler/src/jestUtils/index.ts +++ b/packages/react-native-gesture-handler/src/jestUtils/index.ts @@ -1 +1,6 @@ -export { fireGestureHandler, getByGestureTestId } from './jestUtils'; +export type { GestureController, GestureControllerEvent } from './jestUtils'; +export { + createGestureController, + fireGestureHandler, + getByGestureTestId, +} from './jestUtils'; diff --git a/packages/react-native-gesture-handler/src/jestUtils/jestUtils.ts b/packages/react-native-gesture-handler/src/jestUtils/jestUtils.ts index d40060e43f..c20fbc3a13 100644 --- a/packages/react-native-gesture-handler/src/jestUtils/jestUtils.ts +++ b/packages/react-native-gesture-handler/src/jestUtils/jestUtils.ts @@ -491,6 +491,193 @@ type ExtractConfig = ? ExtractPayloadFromProps : Record; +export type GestureControllerEvent< + TEventPayload extends Record = Record, +> = Partial & { + handlerTag?: never; + nativeEvent?: never; + oldState?: never; + state?: never; +}; + +type GestureControllerTarget = + | ReactTestInstance + | GestureType + | SingleGesture + | string; + +export interface GestureController< + TEventPayload extends Record = Record, +> { + begin: (event?: GestureControllerEvent) => void; + activate: (event?: GestureControllerEvent) => void; + update: (event?: GestureControllerEvent) => void; + end: (event?: GestureControllerEvent) => void; + fail: (event?: GestureControllerEvent) => void; + cancel: (event?: GestureControllerEvent) => void; + getState: () => State; +} + +const FORBIDDEN_CONTROLLER_EVENT_FIELDS = [ + 'handlerTag', + 'nativeEvent', + 'oldState', + 'state', +]; + +function getStateName(state: State): string { + return ( + Object.entries(State).find(([, value]) => value === state)?.[0] ?? + String(state) + ); +} + +function validateControllerEvent(event: Record) { + for (const field of FORBIDDEN_CONTROLLER_EVENT_FIELDS) { + invariant( + !(field in event), + `createGestureController manages '${field}' internally. Pass only gesture event payload fields.` + ); + } +} + +function resolveGestureControllerTarget(target: GestureControllerTarget) { + return typeof target === 'string' ? getByGestureTestId(target) : target; +} + +class GestureControllerImpl< + TEventPayload extends Record = Record, +> implements GestureController +{ + private state: State = State.UNDETERMINED; + + // eslint-disable-next-line no-useless-constructor + constructor(private handlerData: HandlerData) {} + + public getState() { + return this.state; + } + + public begin(event: GestureControllerEvent = {}) { + if (!this.isEnabled()) { + return; + } + + this.transition('begin', State.BEGAN, [State.UNDETERMINED], event); + } + + public activate(event: GestureControllerEvent = {}) { + if (!this.isEnabled()) { + return; + } + + this.transition('activate', State.ACTIVE, [State.BEGAN], event); + } + + public update(event: GestureControllerEvent = {}) { + if (!this.isEnabled()) { + return; + } + + this.assertCurrentState('update', [State.ACTIVE]); + + const nativeEvent = this.buildEvent(State.ACTIVE, event); + this.handlerData.emitEvent( + 'onGestureHandlerEvent', + wrapWithNativeEvent(nativeEvent as GestureHandlerTestEvent) + ); + } + + public end(event: GestureControllerEvent = {}) { + if (!this.isEnabled()) { + return; + } + + this.transition('end', State.END, [State.BEGAN, State.ACTIVE], event); + } + + public fail(event: GestureControllerEvent = {}) { + if (!this.isEnabled()) { + return; + } + + this.transition('fail', State.FAILED, [State.BEGAN, State.ACTIVE], event); + } + + public cancel(event: GestureControllerEvent = {}) { + if (!this.isEnabled()) { + return; + } + + this.transition( + 'cancel', + State.CANCELLED, + [State.BEGAN, State.ACTIVE], + event + ); + } + + private transition( + action: string, + nextState: State, + allowedStates: State[], + event: GestureControllerEvent + ) { + this.assertCurrentState(action, allowedStates); + + const oldState = this.state; + this.state = nextState; + + const nativeEvent = { + oldState, + ...this.buildEvent(nextState, event), + } as GestureHandlerTestEvent; + + this.handlerData.emitEvent( + 'onGestureHandlerStateChange', + wrapWithNativeEvent(nativeEvent) + ); + } + + private isEnabled() { + return this.handlerData.enabled !== false; + } + + private assertCurrentState(action: string, allowedStates: State[]) { + invariant( + allowedStates.includes(this.state), + `Cannot ${action} gesture from ${getStateName(this.state)} state.` + ); + } + + private buildEvent( + state: State, + event: GestureControllerEvent + ): Omit { + validateControllerEvent(event); + + return fillMissingDefaultsFor(this.handlerData)({ + ...event, + state, + } as Partial) as Omit< + GestureHandlerTestEvent, + 'oldState' + >; + } +} + +export function createGestureController< + TEventPayload extends Record = Record, +>( + componentOrGesture: GestureControllerTarget +): GestureController { + const handlerData = getHandlerData( + resolveGestureControllerTarget(componentOrGesture) + ); + + return new GestureControllerImpl(handlerData); +} + export function fireGestureHandler( componentOrGesture: | ReactTestInstance From 2235dc00b0d9e9edea02a09b9144859dceddacac Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Thu, 9 Jul 2026 17:05:51 +0200 Subject: [PATCH 02/10] fixes --- .../src/__tests__/gestureController.test.tsx | 25 ++++++++++++++++ .../src/jestUtils/jestUtils.ts | 29 ++++++++++++++++--- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/packages/react-native-gesture-handler/src/__tests__/gestureController.test.tsx b/packages/react-native-gesture-handler/src/__tests__/gestureController.test.tsx index 34a1577648..867ef8775d 100644 --- a/packages/react-native-gesture-handler/src/__tests__/gestureController.test.tsx +++ b/packages/react-native-gesture-handler/src/__tests__/gestureController.test.tsx @@ -2,6 +2,7 @@ import { renderHook } from '@testing-library/react-native'; import { createGestureController } from '../jestUtils'; import { State } from '../State'; +import type { PanGesture, TapGesture } from '../v3/hooks/gestures'; import { usePanGesture, useTapGesture } from '../v3/hooks/gestures'; function mockedContinuousCallbacks() { @@ -33,6 +34,30 @@ function mockedDiscreteCallbacks() { }; } +function assertGestureControllerTypes(pan: PanGesture, tap: TapGesture) { + const panController = createGestureController(pan); + panController.begin({ x: 1 }); + panController.update({ translationX: 1, velocityX: 2 }); + // @ts-expect-error unknown pan payload field + panController.update({ translationXX: 1 }); + // @ts-expect-error raw state-machine fields are managed internally + panController.begin({ state: State.BEGAN }); + + const tapController = createGestureController(tap); + tapController.begin({ x: 1 }); + // @ts-expect-error tap payloads do not include pan translation fields + tapController.update({ translationX: 1 }); + + const stringController = createGestureController<{ translationX: number }>( + 'pan-id' + ); + stringController.update({ translationX: 1 }); + // @ts-expect-error explicit payload type is respected for string targets + stringController.update({ translationXX: 1 }); +} + +void assertGestureControllerTypes; + describe('createGestureController', () => { test('allows assertions after each gesture lifecycle step', () => { const { order, callbacks } = mockedContinuousCallbacks(); diff --git a/packages/react-native-gesture-handler/src/jestUtils/jestUtils.ts b/packages/react-native-gesture-handler/src/jestUtils/jestUtils.ts index c20fbc3a13..0f02a0d532 100644 --- a/packages/react-native-gesture-handler/src/jestUtils/jestUtils.ts +++ b/packages/react-native-gesture-handler/src/jestUtils/jestUtils.ts @@ -47,7 +47,7 @@ import { tapHandlerName } from '../handlers/TapGestureHandler'; import { State } from '../State'; import { hasProperty, withPrevAndCurrent } from '../utils'; import { maybeUnpackValue } from '../v3/hooks/utils'; -import type { SingleGesture } from '../v3/types'; +import type { DetectorCallbacks, SingleGesture } from '../v3/types'; // Load fireEvent conditionally, so RNGH may be used in setups without testing-library let fireEvent = ( @@ -491,6 +491,19 @@ type ExtractConfig = ? ExtractPayloadFromProps : Record; +type ExtractHookGesturePayload = T extends { + detectorCallbacks: DetectorCallbacks; +} + ? TExtendedHandlerData + : never; + +type ExtractGestureControllerPayload = + T extends SingleGesture + ? ExtractHookGesturePayload + : T extends BaseGesture + ? ExtractConfig + : Record; + export type GestureControllerEvent< TEventPayload extends Record = Record, > = Partial & { @@ -535,7 +548,7 @@ function getStateName(state: State): string { function validateControllerEvent(event: Record) { for (const field of FORBIDDEN_CONTROLLER_EVENT_FIELDS) { invariant( - !(field in event), + !hasProperty(event, field), `createGestureController manages '${field}' internally. Pass only gesture event payload fields.` ); } @@ -666,16 +679,24 @@ class GestureControllerImpl< } } +export function createGestureController< + TTarget extends GestureType | SingleGesture, +>( + componentOrGesture: TTarget +): GestureController>; export function createGestureController< TEventPayload extends Record = Record, >( + componentOrGesture: ReactTestInstance | string +): GestureController; +export function createGestureController( componentOrGesture: GestureControllerTarget -): GestureController { +): GestureController> { const handlerData = getHandlerData( resolveGestureControllerTarget(componentOrGesture) ); - return new GestureControllerImpl(handlerData); + return new GestureControllerImpl(handlerData); } export function fireGestureHandler( From 19ce8b9a68ca622f9a656d5cc8e447248278a3f9 Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Mon, 13 Jul 2026 13:45:28 +0200 Subject: [PATCH 03/10] fix calling stale hooks after rerender --- .../src/__tests__/gestureController.test.tsx | 47 ++++++++ .../src/handlers/handlersRegistry.ts | 18 ++- .../src/jestUtils/jestUtils.ts | 107 +++++++++++++----- 3 files changed, 139 insertions(+), 33 deletions(-) diff --git a/packages/react-native-gesture-handler/src/__tests__/gestureController.test.tsx b/packages/react-native-gesture-handler/src/__tests__/gestureController.test.tsx index 867ef8775d..030e2fd6a9 100644 --- a/packages/react-native-gesture-handler/src/__tests__/gestureController.test.tsx +++ b/packages/react-native-gesture-handler/src/__tests__/gestureController.test.tsx @@ -171,6 +171,53 @@ describe('createGestureController', () => { expect(() => gesture.begin({ state: State.BEGAN } as never)).toThrow( "createGestureController manages 'state' internally." ); + expect(gesture.getState()).toBe(State.UNDETERMINED); + + gesture.begin(); + + expect(gesture.getState()).toBe(State.BEGAN); + }); + + test('uses the latest callbacks after rerender', () => { + const calls: string[] = []; + const hook = renderHook( + ({ value }: { value: number }) => + usePanGesture({ + disableReanimated: true, + onBegin: () => calls.push(`begin-${value}`), + onActivate: () => calls.push(`activate-${value}`), + }), + { initialProps: { value: 1 } } + ); + const gesture = createGestureController(hook.result.current); + + gesture.begin(); + hook.rerender({ value: 2 }); + gesture.activate(); + + expect(calls).toEqual(['begin-1', 'activate-2']); + }); + + test('uses the latest enabled value after rerender', () => { + const onBegin = jest.fn(); + const hook = renderHook( + ({ enabled }: { enabled: boolean }) => + usePanGesture({ disableReanimated: true, enabled, onBegin }), + { initialProps: { enabled: true } } + ); + const gesture = createGestureController(hook.result.current); + + hook.rerender({ enabled: false }); + gesture.begin(); + + expect(gesture.getState()).toBe(State.UNDETERMINED); + expect(onBegin).not.toHaveBeenCalled(); + + hook.rerender({ enabled: true }); + gesture.begin(); + + expect(gesture.getState()).toBe(State.BEGAN); + expect(onBegin).toHaveBeenCalledTimes(1); }); test('disabled gestures are no-ops', () => { diff --git a/packages/react-native-gesture-handler/src/handlers/handlersRegistry.ts b/packages/react-native-gesture-handler/src/handlers/handlersRegistry.ts index 2c4426d974..88cb276510 100644 --- a/packages/react-native-gesture-handler/src/handlers/handlersRegistry.ts +++ b/packages/react-native-gesture-handler/src/handlers/handlersRegistry.ts @@ -24,19 +24,29 @@ export function registerGesture< handlerTag: number, gesture: SingleGesture ) { - if (isTestEnv() && gesture.config.testID) { - hookGestures.set(handlerTag, gesture); + if (!isTestEnv()) { + return; + } + + hookGestures.set(handlerTag, gesture); + + if (gesture.config.testID) { testIDs.set(gesture.config.testID, handlerTag); } } export function unregisterGesture(handlerTag: number) { + if (!isTestEnv()) { + return; + } + const gesture = hookGestures.get(handlerTag); - if (gesture && isTestEnv() && gesture.config.testID) { + if (gesture?.config.testID) { testIDs.delete(gesture.config.testID); - hookGestures.delete(handlerTag); } + + hookGestures.delete(handlerTag); } export function registerHandler( diff --git a/packages/react-native-gesture-handler/src/jestUtils/jestUtils.ts b/packages/react-native-gesture-handler/src/jestUtils/jestUtils.ts index 0f02a0d532..8eca77bc33 100644 --- a/packages/react-native-gesture-handler/src/jestUtils/jestUtils.ts +++ b/packages/react-native-gesture-handler/src/jestUtils/jestUtils.ts @@ -31,7 +31,7 @@ import type { PanGesture } from '../handlers/gestures/panGesture'; import type { PinchGesture } from '../handlers/gestures/pinchGesture'; import type { RotationGesture } from '../handlers/gestures/rotationGesture'; import type { TapGesture } from '../handlers/gestures/tapGesture'; -import { findHandlerByTestID } from '../handlers/handlersRegistry'; +import { findGesture, findHandlerByTestID } from '../handlers/handlersRegistry'; import type { LongPressGestureHandler } from '../handlers/LongPressGestureHandler'; import { longPressHandlerName } from '../handlers/LongPressGestureHandler'; import type { NativeViewGestureHandler } from '../handlers/NativeViewGestureHandler'; @@ -555,7 +555,19 @@ function validateControllerEvent(event: Record) { } function resolveGestureControllerTarget(target: GestureControllerTarget) { - return typeof target === 'string' ? getByGestureTestId(target) : target; + if (typeof target === 'string') { + return getByGestureTestId(target); + } + + if (isGesture(target)) { + return target; + } + + if (isHookGesture(target)) { + return findGesture(target.handlerTag) ?? target; + } + + return target; } class GestureControllerImpl< @@ -565,60 +577,96 @@ class GestureControllerImpl< private state: State = State.UNDETERMINED; // eslint-disable-next-line no-useless-constructor - constructor(private handlerData: HandlerData) {} + constructor(private resolveHandlerData: () => HandlerData) {} public getState() { return this.state; } public begin(event: GestureControllerEvent = {}) { - if (!this.isEnabled()) { + const handlerData = this.resolveHandlerData(); + + if (!this.isEnabled(handlerData)) { return; } - this.transition('begin', State.BEGAN, [State.UNDETERMINED], event); + this.transition( + 'begin', + State.BEGAN, + [State.UNDETERMINED], + event, + handlerData + ); } public activate(event: GestureControllerEvent = {}) { - if (!this.isEnabled()) { + const handlerData = this.resolveHandlerData(); + + if (!this.isEnabled(handlerData)) { return; } - this.transition('activate', State.ACTIVE, [State.BEGAN], event); + this.transition( + 'activate', + State.ACTIVE, + [State.BEGAN], + event, + handlerData + ); } public update(event: GestureControllerEvent = {}) { - if (!this.isEnabled()) { + const handlerData = this.resolveHandlerData(); + + if (!this.isEnabled(handlerData)) { return; } this.assertCurrentState('update', [State.ACTIVE]); - const nativeEvent = this.buildEvent(State.ACTIVE, event); - this.handlerData.emitEvent( + const nativeEvent = this.buildEvent(State.ACTIVE, event, handlerData); + handlerData.emitEvent( 'onGestureHandlerEvent', wrapWithNativeEvent(nativeEvent as GestureHandlerTestEvent) ); } public end(event: GestureControllerEvent = {}) { - if (!this.isEnabled()) { + const handlerData = this.resolveHandlerData(); + + if (!this.isEnabled(handlerData)) { return; } - this.transition('end', State.END, [State.BEGAN, State.ACTIVE], event); + this.transition( + 'end', + State.END, + [State.BEGAN, State.ACTIVE], + event, + handlerData + ); } public fail(event: GestureControllerEvent = {}) { - if (!this.isEnabled()) { + const handlerData = this.resolveHandlerData(); + + if (!this.isEnabled(handlerData)) { return; } - this.transition('fail', State.FAILED, [State.BEGAN, State.ACTIVE], event); + this.transition( + 'fail', + State.FAILED, + [State.BEGAN, State.ACTIVE], + event, + handlerData + ); } public cancel(event: GestureControllerEvent = {}) { - if (!this.isEnabled()) { + const handlerData = this.resolveHandlerData(); + + if (!this.isEnabled(handlerData)) { return; } @@ -626,7 +674,8 @@ class GestureControllerImpl< 'cancel', State.CANCELLED, [State.BEGAN, State.ACTIVE], - event + event, + handlerData ); } @@ -634,26 +683,27 @@ class GestureControllerImpl< action: string, nextState: State, allowedStates: State[], - event: GestureControllerEvent + event: GestureControllerEvent, + handlerData: HandlerData ) { this.assertCurrentState(action, allowedStates); const oldState = this.state; - this.state = nextState; - const nativeEvent = { oldState, - ...this.buildEvent(nextState, event), + ...this.buildEvent(nextState, event, handlerData), } as GestureHandlerTestEvent; - this.handlerData.emitEvent( + this.state = nextState; + + handlerData.emitEvent( 'onGestureHandlerStateChange', wrapWithNativeEvent(nativeEvent) ); } - private isEnabled() { - return this.handlerData.enabled !== false; + private isEnabled(handlerData: HandlerData) { + return handlerData.enabled !== false; } private assertCurrentState(action: string, allowedStates: State[]) { @@ -665,11 +715,12 @@ class GestureControllerImpl< private buildEvent( state: State, - event: GestureControllerEvent + event: GestureControllerEvent, + handlerData: HandlerData ): Omit { validateControllerEvent(event); - return fillMissingDefaultsFor(this.handlerData)({ + return fillMissingDefaultsFor(handlerData)({ ...event, state, } as Partial) as Omit< @@ -692,11 +743,9 @@ export function createGestureController< export function createGestureController( componentOrGesture: GestureControllerTarget ): GestureController> { - const handlerData = getHandlerData( - resolveGestureControllerTarget(componentOrGesture) + return new GestureControllerImpl(() => + getHandlerData(resolveGestureControllerTarget(componentOrGesture)) ); - - return new GestureControllerImpl(handlerData); } export function fireGestureHandler( From aecbee407b3f82b21cfe755cf74bf19814379245 Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Mon, 13 Jul 2026 14:15:36 +0200 Subject: [PATCH 04/10] enable beginning another stream after previous gesture ended --- .../src/__tests__/gestureController.test.tsx | 31 +++++++++++++++++++ .../src/jestUtils/jestUtils.ts | 14 +++++++++ 2 files changed, 45 insertions(+) diff --git a/packages/react-native-gesture-handler/src/__tests__/gestureController.test.tsx b/packages/react-native-gesture-handler/src/__tests__/gestureController.test.tsx index 030e2fd6a9..a72dd39f11 100644 --- a/packages/react-native-gesture-handler/src/__tests__/gestureController.test.tsx +++ b/packages/react-native-gesture-handler/src/__tests__/gestureController.test.tsx @@ -163,6 +163,37 @@ describe('createGestureController', () => { ); }); + test.each([ + ['end', State.END], + ['fail', State.FAILED], + ['cancel', State.CANCELLED], + ] as const)( + 'reuses the controller for streams ending with %s', + (terminalAction, terminalState) => { + const onBegin = jest.fn(); + const onFinalize = jest.fn(); + const tap = renderHook(() => + useTapGesture({ disableReanimated: true, onBegin, onFinalize }) + ).result.current; + const gesture = createGestureController(tap); + + gesture.begin(); + gesture[terminalAction](); + + expect(gesture.getState()).toBe(terminalState); + + gesture.begin(); + + expect(gesture.getState()).toBe(State.BEGAN); + + gesture[terminalAction](); + + expect(gesture.getState()).toBe(terminalState); + expect(onBegin).toHaveBeenCalledTimes(2); + expect(onFinalize).toHaveBeenCalledTimes(2); + } + ); + test('rejects raw state-machine fields in event payloads', () => { const tap = renderHook(() => useTapGesture({ disableReanimated: true })) .result.current; diff --git a/packages/react-native-gesture-handler/src/jestUtils/jestUtils.ts b/packages/react-native-gesture-handler/src/jestUtils/jestUtils.ts index 8eca77bc33..9c44559ae7 100644 --- a/packages/react-native-gesture-handler/src/jestUtils/jestUtils.ts +++ b/packages/react-native-gesture-handler/src/jestUtils/jestUtils.ts @@ -538,6 +538,12 @@ const FORBIDDEN_CONTROLLER_EVENT_FIELDS = [ 'state', ]; +const FINISHED_CONTROLLER_STATES: State[] = [ + State.END, + State.FAILED, + State.CANCELLED, +]; + function getStateName(state: State): string { return ( Object.entries(State).find(([, value]) => value === state)?.[0] ?? @@ -590,6 +596,8 @@ class GestureControllerImpl< return; } + this.resetIfFinished(); + this.transition( 'begin', State.BEGAN, @@ -706,6 +714,12 @@ class GestureControllerImpl< return handlerData.enabled !== false; } + private resetIfFinished() { + if (FINISHED_CONTROLLER_STATES.includes(this.state)) { + this.state = State.UNDETERMINED; + } + } + private assertCurrentState(action: string, allowedStates: State[]) { invariant( allowedStates.includes(this.state), From af47056c63b91972d15f66b38d8fe214a71c0d1f Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Mon, 13 Jul 2026 14:26:47 +0200 Subject: [PATCH 05/10] add docs --- .../docs/guides/testing.mdx | 91 ++++++++++++++++++- 1 file changed, 89 insertions(+), 2 deletions(-) diff --git a/packages/docs-gesture-handler/docs/guides/testing.mdx b/packages/docs-gesture-handler/docs/guides/testing.mdx index ecefa47ae5..1f9d027a88 100644 --- a/packages/docs-gesture-handler/docs/guides/testing.mdx +++ b/packages/docs-gesture-handler/docs/guides/testing.mdx @@ -62,7 +62,94 @@ In order to load mocks provided by RNGH, add the following to your jest config: ## Testing Gestures' and Gesture handlers' callbacks -RNGH provides APIs, specifically [`fireGestureHandler`](#firegesturehandler) and [`getByGestureTestId`](#getbygesturetestid), to trigger selected handlers. +RNGH provides the following APIs for triggering selected handlers: + +- [`createGestureController`](#creategesturecontroller) to imperatively control a gesture lifecycle one step at a time. +- [`fireGestureHandler`](#firegesturehandler) to dispatch a complete event stream. +- [`getByGestureTestId`](#getbygesturetestid) to find a gesture by its test ID. + +### createGestureController + +```ts +import { createGestureController } from 'react-native-gesture-handler/jest-utils'; + +createGestureController: (componentOrGesture) => GestureController; +``` + +Creates an imperative controller that dispatches gesture lifecycle events one step +at a time. This allows the test to assert application state between lifecycle steps +without manually supplying `state`, `oldState`, or `handlerTag`. + +`componentOrGesture` can be: + +- A Gesture Handler component found using a Jest query such as `getByTestId`. +- A gesture object. +- A gesture test ID string. + +When a gesture object is passed directly, the event payload type is inferred from +the gesture. Every lifecycle method accepts an optional partial event payload and +fills omitted handler-specific properties with defaults. + +The controller exposes these methods: + +| Method | Behavior | +| ------------ | ---------------------------------------------------------------------------------------------------------------- | +| `begin()` | Starts a stream and calls `onBegin`. If the previous stream finished, the controller resets it before beginning. | +| `activate()` | Activates a begun stream and calls `onActivate`. | +| `update()` | Dispatches an update for an active stream and calls `onUpdate`. It can be called multiple times. | +| `end()` | Ends a begun or active stream. Calls `onDeactivate` if active, then `onFinalize` with `canceled: false`. | +| `fail()` | Fails a begun or active stream. Calls `onDeactivate` if active, then `onFinalize` with `canceled: true`. | +| `cancel()` | Cancels a begun or active stream. Calls `onDeactivate` if active, then `onFinalize` with `canceled: true`. | +| `getState()` | Returns the controller's current state without dispatching an event. | + +After `end()`, `fail()`, or `cancel()`, the terminal state remains available through +`getState()`. Calling `begin()` again starts another stream with the same controller. +Calling methods in an invalid order throws an error. State-machine fields such as +`state`, `oldState`, `handlerTag`, and `nativeEvent` cannot be supplied in event +payloads because the controller manages them internally. + +```tsx +test('updates application state after each gesture step', () => { + const onBegin = jest.fn(); + const onActivate = jest.fn(); + const onUpdate = jest.fn(); + const onFinalize = jest.fn(); + + const panGesture = renderHook(() => + usePanGesture({ + disableReanimated: true, + onBegin, + onActivate, + onUpdate, + onFinalize, + }) + ).result.current; + + const controller = createGestureController(panGesture); + + controller.begin(); + expect(onBegin).toHaveBeenCalledTimes(1); + + controller.activate(); + expect(onActivate).toHaveBeenCalledTimes(1); + + controller.update({ translationX: 50 }); + expect(onUpdate).toHaveBeenCalledWith( + expect.objectContaining({ translationX: 50 }) + ); + + controller.end(); + expect(onFinalize).toHaveBeenCalledWith( + expect.objectContaining({ canceled: false }) + ); +}); +``` + +:::note +`createGestureController` controls lifecycle events directly. It does not generate +pointer input, run platform gesture recognizers, or evaluate relations between +gestures. +::: ### fireGestureHandler @@ -133,7 +220,7 @@ components. `testID` must be unique among components rendered in test. ::: -## Example +## fireGestureHandler example Extracted from RNGH tests, check [`api_v3.test.tsx`](https://github.com/software-mansion/react-native-gesture-handler/blob/main/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx) for full implementation. From b08877a14ff5fe4e6d83579868b41064d95bdd03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20Ma=C5=82ecki?= Date: Tue, 21 Jul 2026 13:22:21 +0200 Subject: [PATCH 06/10] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jakub Piasecki Co-authored-by: MichaƂ Bert <63123542+m-bert@users.noreply.github.com> --- packages/docs-gesture-handler/docs/guides/testing.mdx | 2 +- .../src/handlers/handlersRegistry.ts | 2 +- .../react-native-gesture-handler/src/jestUtils/jestUtils.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/docs-gesture-handler/docs/guides/testing.mdx b/packages/docs-gesture-handler/docs/guides/testing.mdx index 1f9d027a88..dad80e9321 100644 --- a/packages/docs-gesture-handler/docs/guides/testing.mdx +++ b/packages/docs-gesture-handler/docs/guides/testing.mdx @@ -90,7 +90,7 @@ When a gesture object is passed directly, the event payload type is inferred fro the gesture. Every lifecycle method accepts an optional partial event payload and fills omitted handler-specific properties with defaults. -The controller exposes these methods: +The controller exposes the following methods: | Method | Behavior | | ------------ | ---------------------------------------------------------------------------------------------------------------- | diff --git a/packages/react-native-gesture-handler/src/handlers/handlersRegistry.ts b/packages/react-native-gesture-handler/src/handlers/handlersRegistry.ts index 88cb276510..2489478270 100644 --- a/packages/react-native-gesture-handler/src/handlers/handlersRegistry.ts +++ b/packages/react-native-gesture-handler/src/handlers/handlersRegistry.ts @@ -42,7 +42,7 @@ export function unregisterGesture(handlerTag: number) { const gesture = hookGestures.get(handlerTag); - if (gesture?.config.testID) { + if (gesture?.config?.testID) { testIDs.delete(gesture.config.testID); } diff --git a/packages/react-native-gesture-handler/src/jestUtils/jestUtils.ts b/packages/react-native-gesture-handler/src/jestUtils/jestUtils.ts index 9c44559ae7..7265ead8b4 100644 --- a/packages/react-native-gesture-handler/src/jestUtils/jestUtils.ts +++ b/packages/react-native-gesture-handler/src/jestUtils/jestUtils.ts @@ -555,7 +555,7 @@ function validateControllerEvent(event: Record) { for (const field of FORBIDDEN_CONTROLLER_EVENT_FIELDS) { invariant( !hasProperty(event, field), - `createGestureController manages '${field}' internally. Pass only gesture event payload fields.` + `GestureController manages '${field}' internally. Pass only gesture event payload fields.` ); } } From 060e0859219a4a6e98bfb7b8d636e26a742d6731 Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Tue, 21 Jul 2026 16:14:36 +0200 Subject: [PATCH 07/10] remove getState from public API --- .../docs/guides/testing.mdx | 4 +- .../src/__tests__/gestureController.test.tsx | 39 +++++++++++-------- .../src/jestUtils/jestUtils.ts | 5 --- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/packages/docs-gesture-handler/docs/guides/testing.mdx b/packages/docs-gesture-handler/docs/guides/testing.mdx index dad80e9321..99a4812539 100644 --- a/packages/docs-gesture-handler/docs/guides/testing.mdx +++ b/packages/docs-gesture-handler/docs/guides/testing.mdx @@ -100,10 +100,8 @@ The controller exposes the following methods: | `end()` | Ends a begun or active stream. Calls `onDeactivate` if active, then `onFinalize` with `canceled: false`. | | `fail()` | Fails a begun or active stream. Calls `onDeactivate` if active, then `onFinalize` with `canceled: true`. | | `cancel()` | Cancels a begun or active stream. Calls `onDeactivate` if active, then `onFinalize` with `canceled: true`. | -| `getState()` | Returns the controller's current state without dispatching an event. | -After `end()`, `fail()`, or `cancel()`, the terminal state remains available through -`getState()`. Calling `begin()` again starts another stream with the same controller. +Calling `begin()` again after `end()`, `fail()`, or `cancel()` starts another stream with the same controller. Calling methods in an invalid order throws an error. State-machine fields such as `state`, `oldState`, `handlerTag`, and `nativeEvent` cannot be supplied in event payloads because the controller manages them internally. diff --git a/packages/react-native-gesture-handler/src/__tests__/gestureController.test.tsx b/packages/react-native-gesture-handler/src/__tests__/gestureController.test.tsx index a72dd39f11..fd47976331 100644 --- a/packages/react-native-gesture-handler/src/__tests__/gestureController.test.tsx +++ b/packages/react-native-gesture-handler/src/__tests__/gestureController.test.tsx @@ -58,8 +58,12 @@ function assertGestureControllerTypes(pan: PanGesture, tap: TapGesture) { void assertGestureControllerTypes; +function getControllerState(gesture: object): State { + return (gesture as { state: State }).state; +} + describe('createGestureController', () => { - test('allows assertions after each gesture lifecycle step', () => { + test('transitions to the expected state after each gesture lifecycle step', () => { const { order, callbacks } = mockedContinuousCallbacks(); const pan = renderHook(() => usePanGesture({ disableReanimated: true, ...callbacks }) @@ -68,31 +72,30 @@ describe('createGestureController', () => { gesture.begin({ translationX: 0 }); - expect(gesture.getState()).toBe(State.BEGAN); expect(order).toEqual(['onBegin']); + expect(getControllerState(gesture)).toBe(State.BEGAN); expect(callbacks.onBegin).toHaveBeenCalledWith( expect.objectContaining({ translationX: 0 }) ); gesture.activate({ translationX: 10 }); - expect(gesture.getState()).toBe(State.ACTIVE); expect(order).toEqual(['onBegin', 'onActivate']); + expect(getControllerState(gesture)).toBe(State.ACTIVE); expect(callbacks.onActivate).toHaveBeenCalledWith( expect.objectContaining({ translationX: 10 }) ); gesture.update({ translationX: 50 }); - expect(gesture.getState()).toBe(State.ACTIVE); expect(order).toEqual(['onBegin', 'onActivate', 'onUpdate']); + expect(getControllerState(gesture)).toBe(State.ACTIVE); expect(callbacks.onUpdate).toHaveBeenCalledWith( expect.objectContaining({ translationX: 50 }) ); gesture.end({ translationX: 50 }); - expect(gesture.getState()).toBe(State.END); expect(order).toEqual([ 'onBegin', 'onActivate', @@ -100,6 +103,7 @@ describe('createGestureController', () => { 'onDeactivate', 'onFinalize', ]); + expect(getControllerState(gesture)).toBe(State.END); expect(callbacks.onFinalize).toHaveBeenCalledWith( expect.objectContaining({ canceled: false, translationX: 50 }) ); @@ -115,8 +119,8 @@ describe('createGestureController', () => { gesture.begin({ x: 10 }); gesture.fail({ x: 10 }); - expect(gesture.getState()).toBe(State.FAILED); expect(order).toEqual(['onBegin', 'onFinalize']); + expect(getControllerState(gesture)).toBe(State.FAILED); expect(callbacks.onActivate).not.toHaveBeenCalled(); expect(callbacks.onDeactivate).not.toHaveBeenCalled(); expect(callbacks.onFinalize).toHaveBeenCalledWith( @@ -180,33 +184,36 @@ describe('createGestureController', () => { gesture.begin(); gesture[terminalAction](); - expect(gesture.getState()).toBe(terminalState); + expect(getControllerState(gesture)).toBe(terminalState); gesture.begin(); - expect(gesture.getState()).toBe(State.BEGAN); + expect(getControllerState(gesture)).toBe(State.BEGAN); gesture[terminalAction](); - expect(gesture.getState()).toBe(terminalState); + expect(getControllerState(gesture)).toBe(terminalState); expect(onBegin).toHaveBeenCalledTimes(2); expect(onFinalize).toHaveBeenCalledTimes(2); } ); test('rejects raw state-machine fields in event payloads', () => { - const tap = renderHook(() => useTapGesture({ disableReanimated: true })) - .result.current; + const onBegin = jest.fn(); + const tap = renderHook(() => + useTapGesture({ disableReanimated: true, onBegin }) + ).result.current; const gesture = createGestureController(tap); expect(() => gesture.begin({ state: State.BEGAN } as never)).toThrow( "createGestureController manages 'state' internally." ); - expect(gesture.getState()).toBe(State.UNDETERMINED); + expect(getControllerState(gesture)).toBe(State.UNDETERMINED); + expect(onBegin).not.toHaveBeenCalled(); gesture.begin(); - expect(gesture.getState()).toBe(State.BEGAN); + expect(getControllerState(gesture)).toBe(State.BEGAN); }); test('uses the latest callbacks after rerender', () => { @@ -241,13 +248,13 @@ describe('createGestureController', () => { hook.rerender({ enabled: false }); gesture.begin(); - expect(gesture.getState()).toBe(State.UNDETERMINED); + expect(getControllerState(gesture)).toBe(State.UNDETERMINED); expect(onBegin).not.toHaveBeenCalled(); hook.rerender({ enabled: true }); gesture.begin(); - expect(gesture.getState()).toBe(State.BEGAN); + expect(getControllerState(gesture)).toBe(State.BEGAN); expect(onBegin).toHaveBeenCalledTimes(1); }); @@ -267,7 +274,7 @@ describe('createGestureController', () => { gesture.update({ translationX: 50 }); gesture.end(); - expect(gesture.getState()).toBe(State.UNDETERMINED); + expect(getControllerState(gesture)).toBe(State.UNDETERMINED); expect(order).toEqual([]); }); }); diff --git a/packages/react-native-gesture-handler/src/jestUtils/jestUtils.ts b/packages/react-native-gesture-handler/src/jestUtils/jestUtils.ts index 7265ead8b4..6591ba228d 100644 --- a/packages/react-native-gesture-handler/src/jestUtils/jestUtils.ts +++ b/packages/react-native-gesture-handler/src/jestUtils/jestUtils.ts @@ -528,7 +528,6 @@ export interface GestureController< end: (event?: GestureControllerEvent) => void; fail: (event?: GestureControllerEvent) => void; cancel: (event?: GestureControllerEvent) => void; - getState: () => State; } const FORBIDDEN_CONTROLLER_EVENT_FIELDS = [ @@ -585,10 +584,6 @@ class GestureControllerImpl< // eslint-disable-next-line no-useless-constructor constructor(private resolveHandlerData: () => HandlerData) {} - public getState() { - return this.state; - } - public begin(event: GestureControllerEvent = {}) { const handlerData = this.resolveHandlerData(); From 09efb7845927b67ae07de67fdf400ee4f7da00b2 Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Tue, 21 Jul 2026 16:24:24 +0200 Subject: [PATCH 08/10] Add onDeactivate call --- packages/docs-gesture-handler/docs/guides/testing.mdx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/docs-gesture-handler/docs/guides/testing.mdx b/packages/docs-gesture-handler/docs/guides/testing.mdx index 99a4812539..ed628020bc 100644 --- a/packages/docs-gesture-handler/docs/guides/testing.mdx +++ b/packages/docs-gesture-handler/docs/guides/testing.mdx @@ -111,6 +111,7 @@ test('updates application state after each gesture step', () => { const onBegin = jest.fn(); const onActivate = jest.fn(); const onUpdate = jest.fn(); + const onDeactivate = jest.fn(); const onFinalize = jest.fn(); const panGesture = renderHook(() => @@ -119,6 +120,7 @@ test('updates application state after each gesture step', () => { onBegin, onActivate, onUpdate, + onDeactivate, onFinalize, }) ).result.current; @@ -137,6 +139,7 @@ test('updates application state after each gesture step', () => { ); controller.end(); + expect(onDeactivate).toHaveBeenCalledTimes(1); expect(onFinalize).toHaveBeenCalledWith( expect.objectContaining({ canceled: false }) ); From d9e940eaf4192fddd065323c892079e291ff41a5 Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Thu, 23 Jul 2026 11:38:09 +0200 Subject: [PATCH 09/10] fix CI --- .../src/__tests__/gestureController.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native-gesture-handler/src/__tests__/gestureController.test.tsx b/packages/react-native-gesture-handler/src/__tests__/gestureController.test.tsx index fd47976331..8b575d8105 100644 --- a/packages/react-native-gesture-handler/src/__tests__/gestureController.test.tsx +++ b/packages/react-native-gesture-handler/src/__tests__/gestureController.test.tsx @@ -206,7 +206,7 @@ describe('createGestureController', () => { const gesture = createGestureController(tap); expect(() => gesture.begin({ state: State.BEGAN } as never)).toThrow( - "createGestureController manages 'state' internally." + "GestureController manages 'state' internally." ); expect(getControllerState(gesture)).toBe(State.UNDETERMINED); expect(onBegin).not.toHaveBeenCalled(); From 72bdad4019d87e238a09fd829f87c1308e920e30 Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Thu, 23 Jul 2026 14:58:44 +0200 Subject: [PATCH 10/10] Add collapsable section --- .../docs/guides/testing.mdx | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/docs-gesture-handler/docs/guides/testing.mdx b/packages/docs-gesture-handler/docs/guides/testing.mdx index ed628020bc..0097362309 100644 --- a/packages/docs-gesture-handler/docs/guides/testing.mdx +++ b/packages/docs-gesture-handler/docs/guides/testing.mdx @@ -70,11 +70,35 @@ RNGH provides the following APIs for triggering selected handlers: ### createGestureController -```ts + GestureController; -``` + +export interface GestureController< + TEventPayload extends Record = Record, +> { + begin: (event?: GestureControllerEvent) => void; + activate: (event?: GestureControllerEvent) => void; + update: (event?: GestureControllerEvent) => void; + end: (event?: GestureControllerEvent) => void; + fail: (event?: GestureControllerEvent) => void; + cancel: (event?: GestureControllerEvent) => void; +} + +export type GestureControllerEvent< + TEventPayload extends Record = Record, +> = Partial & { + handlerTag?: never; + nativeEvent?: never; + oldState?: never; + state?: never; +}; +`}/> Creates an imperative controller that dispatches gesture lifecycle events one step at a time. This allows the test to assert application state between lifecycle steps