Skip to content

Commit 69645cf

Browse files
committed
feat: modern userEvent.press (#1949)
1 parent c73badc commit 69645cf

36 files changed

Lines changed: 898 additions & 540 deletions

‎contributing/event-dispatch.md‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,16 @@ The files in `src/events/legacy/`:
1111
| `fire-event.ts` | Public `fireEvent` API |
1212
| `propagation.ts` | Bubbling vs direct events, walking up host and composite elements |
1313
| `is-enabled.ts` | Whether a device would deliver the event: `pointerEvents`, `editable`, touch responders |
14-
| `dispatch.ts` | `dispatchEvent()`: calls the target's own handler in `act()`, used by `userEvent` |
1514
| `warnings.ts` | `eventDiagnostics` warnings for `fireEvent` |
1615
| `builders/` | Legacy event objects: `wrapNativeEvent()` (stubs from `baseSyntheticEvent()`), touch and responder events |
1716

1817
Code used by both event systems lives in `src/events/shared/`: `handler.ts` (finding the `on*` handler for an event name in props), `native-state.ts` and `update-native-state.ts` ([native state](native-state.md) and how `fireEvent` updates it), `payloads.ts` (`nativeEvent` payloads matching what React Native sends on a device), `merge.ts` (deep merging custom props into them), `pointer-events.ts` (whether `pointerEvents` lets an element be the target of touches), `warnings.ts` (formatting and logging `eventDiagnostics` warnings, used by legacy `fireEvent` and `userEvent`), and `types.ts`.
1918

20-
In the modern event system, `src/events/modern/pressability.ts` calls the `onPress`, `onPressIn`, `onPressOut` or `onLongPress` callback of the element that becomes the touch responder, reading `Pressable` and `Touchable*` callbacks from Pressability's `testOnly_pressabilityConfig()`. These callbacks aren't native events, so modern `fireEvent` sends the event types in `PRESSABILITY_EVENT_TYPES` (`src/events/modern/event-types.ts`) there instead of to `dispatchEvent()`: `fireEvent.press()`, and `fireEvent()` with `press`, `pressIn`, `pressOut` or `longPress`. `dispatchEvent()` still dispatches the native `press` event, which reaches only host `onPress` props.
19+
In the modern event system, `src/events/modern/pressability.ts` calls the `onPress`, `onPressIn`, `onPressOut` or `onLongPress` callback of the element that becomes the touch responder, reading `Pressable` and `Touchable*` callbacks from Pressability's `testOnly_pressabilityConfig()`. These callbacks aren't native events, so modern `fireEvent` sends the event types in `PRESSABILITY_EVENT_TYPES` (`src/events/modern/event-types.ts`) there instead of to `dispatchEvent()`: `fireEvent.press()`, and `fireEvent()` with `press`, `pressIn`, `pressOut` or `longPress`. `dispatchEvent()` still dispatches the native `press` event, which reaches only host `onPress` props. The callbacks get a `ResponderEvent` from `src/events/modern/responder-event.ts`, like React Native's responder system creates in JavaScript: `responderGrant` or `responderRelease`, with a touch payload, but no `touchHistory`, which only `PanResponder` reads. It isn't trusted, has no event phase or composed path, and has a `null` `currentTarget` in `onLongPress`, which Pressability calls from a timer.
2120

2221
Modern `fireEvent` doesn't log `eventDiagnostics` warnings yet. Only legacy `fireEvent` and `userEvent` do.
2322

24-
`src/user-event/` is a separate module on top of the event subsystem. It creates and dispatches native events through the facades `src/events/create-event.ts` and `src/events/dispatch-event.ts`. Legacy helpers (touch and responder event builders) come from `src/events/legacy/index.ts`, which also re-exports `src/events/shared/handler.ts`, `src/events/shared/native-state.ts` and `isPointerEventEnabled()`. Other code imports `src/events/modern/` and `src/events/shared/` modules directly: `src/user-event/modern/press.ts` (hit testing and responder negotiation from `pressability.ts`, handler lookup from `handler.ts`) and `src/user-event/utils/` (`invokeEventHandler()`, the `SyntheticEvent` type and the warning helpers from `warnings.ts`).
23+
`src/user-event/` is a separate module on top of the event subsystem. It creates and dispatches native events through the facades `src/events/create-event.ts` and `src/events/dispatch-event.ts`. For a legacy event object, `dispatchEvent()` calls the target's own handler with `invokeEventHandler()` from `src/events/shared/invoke-event-handler.ts`. Other modules are imported directly: legacy touch and responder event builders from `src/events/legacy/builders/common.ts`, and `src/events/modern/` and `src/events/shared/` modules, e.g. `src/user-event/modern/press.ts` (hit testing and responder negotiation from `pressability.ts`, handler lookup from `handler.ts`) and `src/user-event/utils/` (`invokeEventHandler()`, the `SyntheticEvent` type and the warning helpers from `warnings.ts`).
2524

2625
## `fireEvent`
2726

@@ -37,7 +36,7 @@ Modern `fireEvent` doesn't log `eventDiagnostics` warnings yet. Only legacy `fir
3736

3837
Each step dispatches a native event, created for the configured event system by `createEvent()` (`src/events/create-event.ts`: a legacy event object or a `SyntheticEvent`) and dispatched by `dispatchEvent()` (`src/events/dispatch-event.ts`), or calls a JavaScript callback (`changeText`, `pressIn`, ...) with `invokeEventHandler()`, which only calls the target's own handler. Modern `fireEvent.changeText()` instead calls `onChangeText` from the `change` dispatch, through `dispatchEvent()`'s `afterTargetHandler` option, right after the input's own `onChange`, where `TextInput` calls it. Neither checks whether the element is enabled. Each action does those checks itself, so the rules for an interaction live in one place.
3938

40-
Where the two event systems need different rules, the action picks them by `getConfig().unstable_eventSystem` from `src/user-event/legacy/` or `src/user-event/modern/`, e.g. `findPressTarget()` for `press()` and `longPress()`. The modern one reuses modern `fireEvent`'s hit testing and responder negotiation (`getTouchPath()` and `findResponder()` in `src/events/modern/pressability.ts`), so both press the same element. It keeps the press on a `TextInput`, which claims the touch on a device even without `onPress*` props; the legacy one passes it on to the nearest ancestor that handles it.
39+
Where the two event systems need different rules, the action picks them by `getConfig().unstable_eventSystem` from `src/user-event/legacy/` or `src/user-event/modern/`, e.g. for `press()` and `longPress()`. Legacy `findPressTarget()` finds the element, then the action calls `onResponderGrant` and `onResponderRelease` of a touch responder, so Pressability runs with its timers, or the `onPress*` props of a host, with stub touch events. Modern `emitPressEvents()` reuses modern `fireEvent`'s hit testing and responder negotiation (`getTouchPath()` and `findResponder()` in `src/events/modern/pressability.ts`), so both press the same element. It keeps the press on a `TextInput`, which claims the touch on a device even without `onPress*` props; the legacy one passes it on to the nearest ancestor that handles it. It then calls the responder's Pressability callbacks itself, in Pressability's order, with `SyntheticEvent`s (`dispatchPressabilityEvent()`): `onPressIn`, `onLongPress` for `longPress()`, `onPressOut`, then `onPress` unless `onLongPress` was called. The action, not the touch duration, decides whether it is a long press, so neither action waits by default: `duration` only holds the touch, for timers in the component. Pressability's timing props (`delayLongPress`, `minPressDuration`, `delayPressIn`, `delayPressOut`) are ignored.
4140

4241
For the `eventDiagnostics` warning, each action tracks itself with an `Interaction` from `src/user-event/utils/interaction.ts`:
4342

‎src/events/__tests__/fire-event.test.tsx‎

Lines changed: 63 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -39,17 +39,25 @@ const horizontalScrollEvent = { nativeEvent: { contentOffset: { x: 50 } } };
3939
const pressEventData = { nativeEvent: { pageX: 20, pageY: 30 } };
4040
const anySyntheticEvent = expect.any(SyntheticEvent);
4141

42-
const defaultTouchPayload = {
43-
changedTouches: [],
42+
const defaultTouch = {
4443
identifier: 0,
4544
locationX: 0,
4645
locationY: 0,
4746
pageX: 0,
4847
pageY: 0,
4948
target: 0,
5049
timestamp: expect.any(Number),
51-
touches: [],
5250
};
51+
const defaultTouchPayload = { ...defaultTouch, changedTouches: [], touches: [] };
52+
53+
/**
54+
* Modern touch payload, as on a device: `changedTouches` has the touch, and so does `touches` until
55+
* the touch ends.
56+
*/
57+
function getTouchPayload(isTouchStart: boolean, touchProps?: object) {
58+
const touch = { ...defaultTouch, ...touchProps };
59+
return { ...touch, changedTouches: [touch], touches: isTouchStart ? [touch] : [] };
60+
}
5361

5462
beforeEach(() => {
5563
jest.spyOn(Date, 'now').mockImplementation(() => 100100100100);
@@ -301,10 +309,10 @@ describe('event object', () => {
301309
);
302310

303311
test.each([
304-
[{ target: 1 }, '"target". "target" is set by the dispatch.'],
312+
[{ target: 1 }, '"target". "target" is provided by the event.'],
305313
[
306314
{ target: {}, currentTarget: { measure: () => {} } },
307-
'"target", "currentTarget". "target", "currentTarget" are set by the dispatch.',
315+
'"target", "currentTarget". "target", "currentTarget" are provided by the event.',
308316
],
309317
[{ persist: () => {} }, '"persist". "persist" is provided by the event.'],
310318
[
@@ -314,7 +322,7 @@ describe('event object', () => {
314322
[{ text: 'Hello' }, '"text". Pass "text" in "nativeEvent" instead.'],
315323
[
316324
{ text: 'Hello', target: 1, persist: () => {} },
317-
'"text", "target", "persist". "target" is set by the dispatch. "persist" is provided by the event. Pass "text" in "nativeEvent" instead.',
325+
'"text", "target", "persist". "target", "persist" are provided by the event. Pass "text" in "nativeEvent" instead.',
318326
],
319327
])(
320328
'throws on unsupported keys %p (legacy: passes them to the handler)',
@@ -426,19 +434,10 @@ describe('fireEvent.press', () => {
426434
const onPress = jest.fn();
427435
await render(<Pressable testID="btn" onPress={onPress} />);
428436
await fireEvent.press(screen.getByTestId('btn'));
429-
expect(onPress.mock.calls[0][0].nativeEvent).toMatchInlineSnapshot(`
430-
{
431-
"changedTouches": [],
432-
"identifier": 0,
433-
"locationX": 0,
434-
"locationY": 0,
435-
"pageX": 0,
436-
"pageY": 0,
437-
"target": 0,
438-
"timestamp": 100100100100,
439-
"touches": [],
440-
}
441-
`);
437+
// Modern matches React Native, which fills the touch arrays. Legacy leaves them empty.
438+
expect(onPress.mock.calls[0][0].nativeEvent).toEqual(
439+
isModern() ? getTouchPayload(false) : { ...defaultTouchPayload, timestamp: 100100100100 },
440+
);
442441
// Modern matches React Native, which passes a `SyntheticEvent`. Legacy passes a plain object
443442
// with stubs of its methods.
444443
expect(onPress.mock.calls[0][0] instanceof SyntheticEvent).toBe(isModern());
@@ -480,14 +479,20 @@ describe('fireEvent.press', () => {
480479

481480
await fireEvent.press(screen.getByText('Press me'));
482481

483-
expect(event?.nativeEvent).toEqual(defaultTouchPayload);
482+
expect(event?.nativeEvent).toEqual(isModern() ? getTouchPayload(false) : defaultTouchPayload);
484483
// Modern matches React Native: the event targets the pressed element, and Pressability calls
485484
// `onPress` of the responder. Legacy passes stubs as `target` and `currentTarget`.
486485
if (!isModern()) return;
487-
expect(event?.type).toBe('press');
486+
expect(event?.type).toBe('responderRelease');
487+
expect(event?.dispatchConfig).toEqual({
488+
registrationName: 'onResponderRelease',
489+
dependencies: ['topTouchCancel', 'topTouchEnd'],
490+
});
488491
expect(event?.target).toBe(screen.getByText('Press me'));
489492
expect(currentTarget).toBe(screen.getByTestId('pressable'));
490-
expect(eventPhase).toBe(event?.BUBBLING_PHASE);
493+
// The responder system calls the handler directly, without an event dispatch.
494+
expect(eventPhase).toBe(event?.NONE);
495+
expect(event?.isTrusted).toBe(false);
491496
// Reset after the dispatch.
492497
expect(event?.currentTarget).toBeNull();
493498
});
@@ -497,19 +502,11 @@ describe('fireEvent.press', () => {
497502
await render(<Pressable testID="btn" onPress={onPress} />);
498503
const customEventData = { nativeEvent: { pageX: 20, pageY: 30 }, timeStamp: 123 };
499504
await fireEvent.press(screen.getByTestId('btn'), customEventData);
500-
expect(onPress.mock.calls[0][0].nativeEvent).toMatchInlineSnapshot(`
501-
{
502-
"changedTouches": [],
503-
"identifier": 0,
504-
"locationX": 0,
505-
"locationY": 0,
506-
"pageX": 20,
507-
"pageY": 30,
508-
"target": 0,
509-
"timestamp": 100100100100,
510-
"touches": [],
511-
}
512-
`);
505+
expect(onPress.mock.calls[0][0].nativeEvent).toEqual(
506+
isModern()
507+
? getTouchPayload(false, { pageX: 20, pageY: 30 })
508+
: { ...defaultTouchPayload, pageX: 20, pageY: 30, timestamp: 100100100100 },
509+
);
513510
expect(onPress.mock.calls[0][0].timeStamp).toBe(123);
514511
// Legacy merges the passed props into a plain event object.
515512
if (isModern()) return;
@@ -1644,7 +1641,6 @@ describe('direct events', () => {
16441641
),
16451642
];
16461643

1647-
// Builds one case per event type, passing the matching `on*` handler prop to `ui`.
16481644
function buildDirectEventCases(
16491645
source: string,
16501646
eventTypes: string[],
@@ -1665,8 +1661,7 @@ describe('direct events', () => {
16651661

16661662
await fireEvent(screen.getByTestId('target'), eventType, emptyEvent);
16671663

1668-
// Modern matches React Native: direct events don't bubble. Legacy still bubbles them, with a
1669-
// warning.
1664+
// Modern matches React Native: direct events don't bubble.
16701665
expect(handler).toHaveBeenCalledTimes(isModern() ? 0 : 1);
16711666
expect(warnSpy).toHaveBeenCalledTimes(isModern() ? 0 : 1);
16721667
},
@@ -1712,8 +1707,7 @@ describe('direct events', () => {
17121707

17131708
await fireEvent(screen.getByTestId('child'), 'onMomentumScrollEnd', emptyEvent);
17141709

1715-
// Modern matches React Native: direct events don't bubble. Legacy still bubbles them, with a
1716-
// warning.
1710+
// Modern matches React Native: direct events don't bubble.
17171711
expect(onMomentumScrollEnd).toHaveBeenCalledTimes(isModern() ? 0 : 1);
17181712
expect(warnSpy).toHaveBeenCalledTimes(isModern() ? 0 : 1);
17191713
});
@@ -1728,8 +1722,7 @@ describe('direct events', () => {
17281722

17291723
await fireEvent.scroll(screen.getByTestId('child'));
17301724

1731-
// Modern matches React Native: direct events don't bubble. Legacy still bubbles them, with a
1732-
// warning.
1725+
// Modern matches React Native: direct events don't bubble.
17331726
expect(onScroll).toHaveBeenCalledTimes(isModern() ? 0 : 1);
17341727
expect(warnSpy).toHaveBeenCalledTimes(isModern() ? 0 : 1);
17351728
if (isModern()) return;
@@ -1775,7 +1768,6 @@ describe('direct events', () => {
17751768
await fireEvent.scroll(screen.getByTestId('child'));
17761769

17771770
// Modern matches React Native: scroll doesn't bubble, and composite props are never called.
1778-
// Legacy calls the composite prop, with a warning.
17791771
expect(onScroll).toHaveBeenCalledTimes(isModern() ? 0 : 1);
17801772
expect(warnSpy).toHaveBeenCalledTimes(isModern() ? 0 : 1);
17811773
});
@@ -1890,7 +1882,7 @@ describe('Pressability event types', () => {
18901882
expect(calls).toEqual([`pressable.${name}`, `touchable.${name}`]);
18911883
});
18921884

1893-
test('passes a direct event with default touch payload (legacy: passes the event object as given)', async () => {
1885+
test('passes a responder event with default touch payload (legacy: passes the event object as given)', async () => {
18941886
const onPressIn = jest.fn();
18951887
await render(
18961888
<Pressable testID="pressable" onPressIn={onPressIn}>
@@ -1906,15 +1898,39 @@ describe('Pressability event types', () => {
19061898
const event = onPressIn.mock.calls[0][0];
19071899
// Modern matches React Native, which derives Pressability events from a touch.
19081900
expect(event.nativeEvent).toEqual(
1909-
isModern() ? { ...defaultTouchPayload, pageX: 20 } : { pageX: 20 },
1901+
isModern() ? getTouchPayload(true, { pageX: 20 }) : { pageX: 20 },
19101902
);
19111903
expect(event.timeStamp).toBe(123);
19121904
if (!isModern()) return;
1913-
expect(event.type).toBe('pressin');
1905+
expect(event.type).toBe('responderGrant');
1906+
expect(event.dispatchConfig).toEqual({
1907+
registrationName: 'onResponderGrant',
1908+
dependencies: [],
1909+
});
1910+
// Not a direct event: the responder system calls the handler without an event dispatch.
19141911
expect(event.bubbles).toBe(false);
1915-
expect(event.rnIsDirect).toBe(true);
1912+
expect(event.rnIsDirect).toBe(false);
19161913
expect(event.target).toBe(screen.getByText('Press me'));
19171914
});
1915+
1916+
test('calls onLongPress without currentTarget (legacy: passes the event object as given)', async () => {
1917+
let currentTarget: unknown;
1918+
await render(
1919+
<Pressable
1920+
onLongPress={(event) => {
1921+
currentTarget = event.currentTarget;
1922+
}}
1923+
>
1924+
<Text>Press me</Text>
1925+
</Pressable>,
1926+
);
1927+
1928+
await fireEvent(screen.getByText('Press me'), 'longPress', emptyEvent);
1929+
1930+
// Modern matches React Native, where Pressability calls `onLongPress` from a timer, after the
1931+
// dispatch of the responder grant event.
1932+
expect(currentTarget).toBe(isModern() ? null : undefined);
1933+
});
19181934
});
19191935

19201936
test('fireEvent throws for custom event (onCustomEvent) on composite component (legacy: calls it)', async () => {

‎src/events/dispatch-event.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,29 @@
11
import type { TestInstance } from 'test-renderer';
22

33
import type { LegacyEvent } from './create-event';
4-
import { dispatchEvent as dispatchLegacyEvent } from './legacy/dispatch';
54
import { dispatchEvent as dispatchModernEvent } from './modern/dispatch';
65
import { SyntheticEvent } from './modern/event';
6+
import { invokeEventHandler } from './shared/invoke-event-handler';
77

88
/**
99
* Dispatches an event from `createEvent()` to a host element. Used by `userEvent`.
1010
*
11-
* - Legacy event object: calls the element's own `on*` prop with it.
1211
* - `SyntheticEvent`: dispatched as React Native does (capture and bubble phases for bubbling
1312
* events). `press` is a native event type (`topPress`), so it reaches host `onPress` props only,
1413
* not `Pressable`'s `onPress`.
14+
* - Legacy event object: calls the element's own `on*` prop with it.
1515
*
1616
* @param eventType a native event type without the `on*` prefix, e.g. `focus`
1717
* @returns `true` if a handler was called.
1818
*/
1919
export async function dispatchEvent(
2020
instance: TestInstance,
2121
eventType: string,
22-
event: LegacyEvent | SyntheticEvent,
22+
event: SyntheticEvent | LegacyEvent,
2323
): Promise<boolean> {
2424
if (event instanceof SyntheticEvent) {
2525
return await dispatchModernEvent(instance, event);
2626
}
2727

28-
return await dispatchLegacyEvent(instance, eventType, event);
28+
return await invokeEventHandler(instance, eventType, event);
2929
}

‎src/events/legacy/__tests__/dispatch.test.tsx‎

Lines changed: 0 additions & 40 deletions
This file was deleted.

‎src/events/legacy/builders/base.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ export function baseSyntheticEvent(): BaseEvent {
2020
};
2121
}
2222

23-
/** Wraps a `nativeEvent` in a legacy event object with `baseSyntheticEvent()` stubs. */
2423
export function wrapNativeEvent<T extends object>(nativeEvent: T) {
2524
return { ...baseSyntheticEvent(), nativeEvent };
2625
}

0 commit comments

Comments
 (0)