Add Imperative Gesture Handler Testing API#4309
Conversation
There was a problem hiding this comment.
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, andGestureControllerEventto 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.
| expect.objectContaining({ translationX: 50 }) | ||
| ); | ||
|
|
||
| controller.end(); |
There was a problem hiding this comment.
I assume this will also call onDeactivate in this scenario, right?
There was a problem hiding this comment.
Yes, we can add that to the test as well.
| | `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. | |
There was a problem hiding this comment.
Do we need this when the internal states are no longer exposed to users from the hooks API?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
@j-piasecki I would say it is not necessary to have it in the API.
@m-bert I think we should enforce only legal state changes. If we didn't do that, users would be able to test their implementation logic against impossible state flows. I see that it might be annoying to always start with, for instance, begin() and activate() if the intention is to test onUpdate behavior, so maybe we could also think about adding short-path methods that would internally run the necessary state transitions to get into the desired state directly?
There was a problem hiding this comment.
I think we should enforce only legal state changes.
With that I agree, true. However, if we are talking about getState it doesn't enforce anything, unless user has to manually check if they're in the correct state before calling next lifecycle method, which doesn't make much sense.
But I guess that
then do we even need to check state?
Could sound like we don't have to do this and that's not what I had in mind 😅
There was a problem hiding this comment.
That's why I said that is it not necessary to have that in the public API. This method was only used in internal tests which I changed already to not depend on it.
| ```ts | ||
| import { createGestureController } from 'react-native-gesture-handler/jest-utils'; | ||
|
|
||
| createGestureController: (componentOrGesture) => GestureController; |
There was a problem hiding this comment.
Does GestureController work with older APIs or only V3?
There was a problem hiding this comment.
Technically it should work with older APIs but not sure if we need to add test examples for them.
| } | ||
|
|
||
| if (isHookGesture(target)) { | ||
| return findGesture(target.handlerTag) ?? target; |
There was a problem hiding this comment.
What's the case where findGesture(target.handlerTag) returns something else than target
There was a problem hiding this comment.
On rerender previous target goes stale.
| return; | ||
| } | ||
|
|
||
| this.resetIfFinished(); |
There was a problem hiding this comment.
What if not finished? I.e. begin called on a gesture in ACTIVE state.
There was a problem hiding this comment.
Then it throws an error.
|
|
||
| const tapController = createGestureController(tap); | ||
| tapController.begin({ x: 1 }); | ||
| // @ts-expect-error tap payloads do not include pan translation fields |
There was a problem hiding this comment.
This is only a type error, right? Will runtime allow this?
There was a problem hiding this comment.
Yes, almost all keywords are accepted (modulo handlerTag, nativeEvent, oldState, and state) and forwarded into the event.
| | `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. | |
There was a problem hiding this comment.
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?
Co-authored-by: Jakub Piasecki <jakubpiasecki67@gmail.com> Co-authored-by: Michał Bert <63123542+m-bert@users.noreply.github.com>
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, orhandlerTag.Instead of this lower-level style:
tests can now use imperative controller:
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()andactivate(),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(), orcancel(), the terminal state remains available for assertions throughgetState(). Callingbegin()again resets the finished controller internally and starts a new stream.Test plan
Added tests using new API.