Skip to content

Add Imperative Gesture Handler Testing API#4309

Open
coado wants to merge 5 commits into
mainfrom
@coado/jest-new-api
Open

Add Imperative Gesture Handler Testing API#4309
coado wants to merge 5 commits into
mainfrom
@coado/jest-new-api

Conversation

@coado

@coado coado commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Description

The goal is to make gesture lifecycle tests easier to write when we want to assert application state after each gesture step, without requiring users to manually construct RNGH state events with state, oldState, or handlerTag.

Instead of this lower-level style:

fireGestureHandler(panGesture, [
  { oldState: State.UNDETERMINED, state: State.BEGAN },
  { oldState: State.BEGAN, state: State.ACTIVE },
  { oldState: State.ACTIVE, state: State.END },
]);

tests can now use imperative controller:

const gesture = createGestureController(panGesture);

gesture.begin();
expect(onBegin).toHaveBeenCalled();

gesture.activate();
expect(onActivate).toHaveBeenCalled();

gesture.update({ translationX: 50 });
expect(onUpdate).toHaveBeenCalledWith(
  expect.objectContaining({ translationX: 50 })
);

gesture.end();
expect(onFinalize).toHaveBeenCalledWith(
  expect.objectContaining({ canceled: false })
);

Hook gesture rerenders

Hook-based gestures can be recreated when their callbacks or configuration change during a React rerender while retaining the same handler tag. The controller now resolves the latest registered gesture before every lifecycle operation. This ensures that subsequent steps use the newest callback closures and configuration, including the current enabled value.

For example, if a rerender occurs between begin() and activate(), activate() invokes the callback from the latest render rather than the callback captured when the controller was created.

Reusing a controller for another stream

A controller can now run multiple gesture streams.

After end(), fail(), or cancel(), the terminal state remains available for assertions through getState(). Calling begin() again resets the finished controller internally and starts a new stream.

controller.begin();
controller.activate();
controller.end();

expect(controller.getState()).toBe(State.END);

controller.begin();
expect(controller.getState()).toBe(State.BEGAN);

Test plan

Added tests using new API.

Copilot AI review requested due to automatic review settings July 9, 2026 14:43

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces an imperative testing helper (createGestureController) to simplify gesture lifecycle testing in RNGH Jest utilities, enabling step-by-step state transitions and assertions without manually crafting low-level state/oldState events.

Changes:

  • Added createGestureController, GestureController, and GestureControllerEvent to drive gesture lifecycles imperatively in tests.
  • Exported the new controller API from src/jestUtils/index.ts.
  • Added Jest tests covering lifecycle sequencing, validation, testID resolution, and disabled-gesture behavior.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
packages/react-native-gesture-handler/src/jestUtils/jestUtils.ts Adds the gesture controller implementation and public API surface.
packages/react-native-gesture-handler/src/jestUtils/index.ts Re-exports the controller types and factory function.
packages/react-native-gesture-handler/src/tests/gestureController.test.tsx Adds tests validating the new imperative controller behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/react-native-gesture-handler/src/jestUtils/jestUtils.ts
Comment thread packages/react-native-gesture-handler/src/jestUtils/jestUtils.ts
@coado
coado marked this pull request as ready for review July 13, 2026 12:38
expect.objectContaining({ translationX: 50 })
);

controller.end();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume this will also call onDeactivate in this scenario, right?

| `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. |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this when the internal states are no longer exposed to users from the hooks API?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd go even further. If the user imperatively manipulates gesture states by calling begin(), etc., then do we even need to check state? If we don't support relations then we know exactly at which state we are after calling given lifecycle method. Or have I missed something?

```ts
import { createGestureController } from 'react-native-gesture-handler/jest-utils';

createGestureController: (componentOrGesture) => GestureController;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does GestureController work with older APIs or only V3?

const gesture = hookGestures.get(handlerTag);

if (gesture && isTestEnv() && gesture.config.testID) {
if (gesture?.config.testID) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (gesture?.config.testID) {
if (gesture?.config?.testID) {

for (const field of FORBIDDEN_CONTROLLER_EVENT_FIELDS) {
invariant(
!hasProperty(event, field),
`createGestureController manages '${field}' internally. Pass only gesture event payload fields.`

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
`createGestureController manages '${field}' internally. Pass only gesture event payload fields.`
`GestureController manages '${field}' internally. Pass only gesture event payload fields.`

}

if (isHookGesture(target)) {
return findGesture(target.handlerTag) ?? target;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the case where findGesture(target.handlerTag) returns something else than target

return;
}

this.resetIfFinished();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if not finished? I.e. begin called on a gesture in ACTIVE state.


const tapController = createGestureController(tap);
tapController.begin({ x: 1 });
// @ts-expect-error tap payloads do not include pan translation fields

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is only a type error, right? Will runtime allow this?

the gesture. Every lifecycle method accepts an optional partial event payload and
fills omitted handler-specific properties with defaults.

The controller exposes these methods:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
The controller exposes these methods:
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. |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd go even further. If the user imperatively manipulates gesture states by calling begin(), etc., then do we even need to check state? If we don't support relations then we know exactly at which state we are after calling given lifecycle method. Or have I missed something?

return;
}

hookGestures.set(handlerTag, gesture);

@m-bert m-bert Jul 15, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to remove the necessity of testID?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants