Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions contributing/event-dispatch.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

RNTL has two ways to trigger events. Neither goes through React Native's native event system. Both find `on*` props in the rendered tree and call them inside `act()`.

Both are built on the shared event subsystem in `src/events/legacy/`, which also holds `fireEvent` itself. This is the `'legacy'` event system, the default for the `unstable_eventSystem` config option.
Both are built on the shared event subsystem in `src/events/legacy/`, which also holds `fireEvent` itself. This is the `'legacy'` event system, the default for the `eventSystem` config option.

The `'modern'` event system (`src/events/modern/`), still in progress, follows the W3C [`EventTarget`](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget) dispatch that React Native enables by default from 0.88 (the `enableNativeEventTargetEventDispatching` feature flag; `src/private/renderer/events/dispatchNativeEvent.js` and `src/private/webapis/dom/events/EventTarget.js` in `node_modules/react-native`). The path holds only host elements. Bubbling events run a capture phase from the root to the target (`on*Capture` props), then a bubble phase back up (`on*` props), and direct events reach only the target. Event objects have the W3C `Event` fields (`eventPhase`, `currentTarget`, `composedPath()`, `stopPropagation()`, `preventDefault()`) next to the legacy synthetic event API (`nativeEvent`, `persist()`, `isDefaultPrevented()`). Check changes to modern dispatch against these upstream files, not React's legacy plugin system.

Expand Down Expand Up @@ -48,7 +48,7 @@ Modern `fireEvent` doesn't log `eventDiagnostics` warnings yet. Only legacy `fir

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.

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.
Where the two event systems need different rules, the action picks them by `getConfig().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.

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

Expand Down
4 changes: 2 additions & 2 deletions contributing/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,14 @@ Every change in `src/` should come with tests. Tests use Jest and live next to t

## Event systems

`fireEvent` and `userEvent` work with both event systems (`configure({ unstable_eventSystem })`), so `jest.config.js` has two projects:
`fireEvent` and `userEvent` work with both event systems (`configure({ eventSystem })`), so `jest.config.js` has two projects:

- `legacy` runs all tests with the default `'legacy'` event system.
- `modern` runs all tests again with `'modern'` (`jest-setup-modern.ts`).

Both projects share the same snapshots. `createEventLogger()` entries print only the `nativeEvent` of event payloads (`src/test-utils/event-serializer.ts`), so a snapshot is the same for a legacy event object and a modern `SyntheticEvent`, and a difference between the two systems fails the snapshot. Use `--selectProjects legacy` or `--selectProjects modern` to run one of them.

When a test expects different behavior in the two systems, e.g. events bubbling to a parent, branch on `getConfig().unstable_eventSystem` inside the test instead of skipping it, with a short comment saying which system matches React Native. Name the test after the modern behavior, which aims to match React Native, with the legacy difference in a `(legacy: ...)` suffix, e.g. `does not bubble $name (legacy: bubbles with a warning)`. Prefer ternaries in the expectations (`toHaveBeenCalledTimes(isModern ? 0 : 1)`). For assertions only one system can make, like a legacy warning message, end the test with `if (isModern) return;` before them (`jest/no-conditional-expect` forbids `expect()` inside `if` blocks). Generic `fireEvent(element, eventType, event)` calls that run in both systems pass an event object, e.g. `{ nativeEvent: {} }`, as modern `fireEvent` requires one. `src/events/__tests__/fire-event.test.tsx` runs the public `fireEvent` this way.
When a test expects different behavior in the two systems, e.g. events bubbling to a parent, branch on `getConfig().eventSystem` inside the test instead of skipping it, with a short comment saying which system matches React Native. Name the test after the modern behavior, which aims to match React Native, with the legacy difference in a `(legacy: ...)` suffix, e.g. `does not bubble $name (legacy: bubbles with a warning)`. Prefer ternaries in the expectations (`toHaveBeenCalledTimes(isModern ? 0 : 1)`). For assertions only one system can make, like a legacy warning message, end the test with `if (isModern) return;` before them (`jest/no-conditional-expect` forbids `expect()` inside `if` blocks). Generic `fireEvent(element, eventType, event)` calls that run in both systems pass an event object, e.g. `{ nativeEvent: {} }`, as modern `fireEvent` requires one. `src/events/__tests__/fire-event.test.tsx` runs the public `fireEvent` this way.

Tests that can't run in the modern event system at all can call `runInLegacyEventSystem()` from `src/test-utils/event-system.ts` at the top of the file (or in a `describe()`). They then run in the legacy event system in both projects.

Expand Down
16 changes: 16 additions & 0 deletions docs/api/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ type Config = {
/** Warn when `fireEvent` or a `userEvent` interaction calls no handler. Off by default. */
eventDiagnostics: boolean;

/** Event system used by `fireEvent` and `userEvent`. Defaults to `'legacy'`. */
eventSystem: 'legacy' | 'modern';

/** Default options for `debug` helper. */
defaultDebugOptions?: Partial<DebugOptions>;
};
Expand Down Expand Up @@ -56,6 +59,19 @@ No warning is logged when the event updates native state, e.g. `fireEvent.change
configure({ eventDiagnostics: true });
```

### `eventSystem` option

Event system used by [`fireEvent`](./fire-event.md), [`userEvent`](./user-event.md) and [`UNSAFE_invokeHandler`](./fire-event.md#unsafe-invoke-handler). Defaults to `'legacy'`.

- `'legacy'`: the simplified event system RNTL has used from the beginning.
- `'modern'` (recommended): simulates React Native's event dispatch, with direct and bubbling events and `SyntheticEvent` objects.

```ts
configure({ eventSystem: 'modern' });
```

See [Modern Event System](/docs/api/events/modern-event-system) for the differences and how to enable it.

### `defaultDebugOptions` option

Default [debug options](#debug) to be used when calling `debug()`. These default options will be overridden by the ones you specify directly when calling `debug()`.
Expand Down
2 changes: 1 addition & 1 deletion jest-setup-modern.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@ import { configure } from './src/pure';

// Runs after `resetToDefaults()` in `jest-setup.ts`.
beforeEach(() => {
configure({ unstable_eventSystem: 'modern' });
configure({ eventSystem: 'modern' });
});
2 changes: 1 addition & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ module.exports = {
],
projects: [
{ ...baseProject, displayName: 'legacy' },
// All tests again with `configure({ unstable_eventSystem: 'modern' })`. They share snapshots with the
// All tests again with `configure({ eventSystem: 'modern' })`. They share snapshots with the
// legacy run, so both event systems must call the same handlers with the same native events.
{
...baseProject,
Expand Down
16 changes: 8 additions & 8 deletions src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ afterEach(() => {
test('getConfig() returns existing configuration', () => {
expect(getConfig().asyncUtilTimeout).toEqual(1000);
expect(getConfig().defaultIncludeHiddenElements).toEqual(false);
expect(getConfig().unstable_eventSystem).toEqual('legacy');
expect(getConfig().eventSystem).toEqual('legacy');
});

test('configure() overrides existing config values', () => {
Expand All @@ -24,19 +24,19 @@ test('configure() overrides existing config values', () => {
defaultDebugOptions: { message: 'debug message' },
defaultIncludeHiddenElements: false,
eventDiagnostics: false,
unstable_eventSystem: 'legacy',
eventSystem: 'legacy',
});
});

test('configure() sets unstable_eventSystem', () => {
configure({ unstable_eventSystem: 'modern' });
expect(getConfig().unstable_eventSystem).toEqual('modern');
test('configure() sets eventSystem', () => {
configure({ eventSystem: 'modern' });
expect(getConfig().eventSystem).toEqual('modern');

configure({ asyncUtilTimeout: 2000 });
expect(getConfig().unstable_eventSystem).toEqual('modern');
expect(getConfig().eventSystem).toEqual('modern');

resetToDefaults();
expect(getConfig().unstable_eventSystem).toEqual('legacy');
expect(getConfig().eventSystem).toEqual('legacy');
});

test('resetToDefaults() resets config to defaults', () => {
Expand Down Expand Up @@ -79,7 +79,7 @@ test('does not warn when only valid options are passed', () => {
defaultIncludeHiddenElements: true,
defaultDebugOptions: { message: 'test' },
defaultHidden: false,
unstable_eventSystem: 'modern',
eventSystem: 'modern',
});

expect(_console.warn).not.toHaveBeenCalled();
Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/modern-types/fire-event.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { _console } from '../../helpers/logger';
// modern signatures. Calls the types reject also throw at runtime.

beforeEach(() => {
configure({ unstable_eventSystem: 'modern' });
configure({ eventSystem: 'modern' });
});

const emptyEvent = { nativeEvent: {} };
Expand Down
13 changes: 6 additions & 7 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,10 @@ export type Config = {
eventDiagnostics: boolean;

/**
* Experimental, may change or be removed in any release. Event system used by `fireEvent` and
* `userEvent`. `'legacy'` is the current simplified implementation. `'modern'` follows React
* Native's event dispatch. Defaults to `'legacy'`.
* Event system used by `fireEvent` and `userEvent`. `'legacy'` is the current simplified
* implementation. `'modern'` follows React Native's event dispatch. Defaults to `'legacy'`.
*/
unstable_eventSystem: EventSystem;
eventSystem: EventSystem;

/** Default options for `debug` helper. */
defaultDebugOptions?: Partial<DebugOptions>;
Expand All @@ -40,7 +39,7 @@ const defaultConfig: Config = {
asyncUtilTimeout: 1000,
defaultIncludeHiddenElements: false,
eventDiagnostics: false,
unstable_eventSystem: 'legacy',
eventSystem: 'legacy',
};

let config = { ...defaultConfig };
Expand All @@ -55,7 +54,7 @@ export function configure(options: Partial<Config & ConfigAliasOptions>) {
defaultHidden,
defaultIncludeHiddenElements,
eventDiagnostics,
unstable_eventSystem,
eventSystem,
...rest
} = options;

Expand All @@ -70,7 +69,7 @@ export function configure(options: Partial<Config & ConfigAliasOptions>) {
defaultDebugOptions,
defaultIncludeHiddenElements: resolvedDefaultIncludeHiddenElements,
eventDiagnostics: eventDiagnostics ?? config.eventDiagnostics,
unstable_eventSystem: unstable_eventSystem ?? config.unstable_eventSystem,
eventSystem: eventSystem ?? config.eventSystem,
};
}

Expand Down
10 changes: 5 additions & 5 deletions src/events/__tests__/create-event.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ afterEach(() => {
});

test('builds a legacy event object in the legacy event system', () => {
configure({ unstable_eventSystem: 'legacy' });
configure({ eventSystem: 'legacy' });
const nativeEvent = { target: 0 };

const event = createEvent('focus', nativeEvent);
Expand All @@ -29,7 +29,7 @@ test('builds a legacy event object in the legacy event system', () => {
});

test('builds a SyntheticEvent in the modern event system', () => {
configure({ unstable_eventSystem: 'modern' });
configure({ eventSystem: 'modern' });
const nativeEvent = { target: 0 };

const event = createEvent('focus', nativeEvent);
Expand All @@ -42,7 +42,7 @@ test('builds a SyntheticEvent in the modern event system', () => {
test.each(['legacy', 'modern'] as const)(
'throws for event types that are not native in the %s event system',
(eventSystem) => {
configure({ unstable_eventSystem: eventSystem });
configure({ eventSystem: eventSystem });

expect(() => createEvent('changeText', {})).toThrow(
'"changeText" is not a native event type. Use invokeEventHandler() instead.',
Expand All @@ -64,9 +64,9 @@ test.each([
])('builds %s event for the configured event system', (type, build) => {
// The touch payload has a `Date.now()` timestamp.
jest.spyOn(Date, 'now').mockReturnValue(1);
configure({ unstable_eventSystem: 'legacy' });
configure({ eventSystem: 'legacy' });
const legacyEvent = build();
configure({ unstable_eventSystem: 'modern' });
configure({ eventSystem: 'modern' });
const modernEvent = build();

expect(legacyEvent).not.toBeInstanceOf(SyntheticEvent);
Expand Down
4 changes: 2 additions & 2 deletions src/events/__tests__/fire-event.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3956,9 +3956,9 @@ test('fireEvent reads the event system on each call', async () => {
const onLayout = jest.fn();
await render(<View testID="view" onLayout={onLayout} />);

configure({ unstable_eventSystem: 'legacy' });
configure({ eventSystem: 'legacy' });
await fireEvent.layout(screen.getByTestId('view'));
configure({ unstable_eventSystem: 'modern' });
configure({ eventSystem: 'modern' });
await fireEvent.layout(screen.getByTestId('view'));
expect(onLayout.mock.calls[0][0]).not.toBeInstanceOf(SyntheticEvent);
expect(onLayout.mock.calls[1][0]).toBeInstanceOf(SyntheticEvent);
Expand Down
4 changes: 2 additions & 2 deletions src/events/create-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export type LegacyEvent = ReturnType<typeof wrapNativeEvent<NativeEventPayload>>

/**
* Creates the event object for a native event (`focus`, `change`, `scroll`, ...) in the event
* system selected by `configure({ unstable_eventSystem })`: a legacy event object with stubs, or a
* system selected by `configure({ eventSystem })`: a legacy event object with stubs, or a
* `SyntheticEvent`. Used by `userEvent`, with `dispatchEvent()`. Unlike modern `createEvent()`,
* takes the `nativeEvent` itself, not an init object.
*
Expand All @@ -39,7 +39,7 @@ export function createEvent(
throw new Error(`"${eventType}" is not a native event type. Use invokeEventHandler() instead.`);
}

if (getConfig().unstable_eventSystem === 'legacy') {
if (getConfig().eventSystem === 'legacy') {
return wrapNativeEvent(nativeEvent);
}

Expand Down
4 changes: 2 additions & 2 deletions src/events/fire-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export type FireEvent = RegisteredEventSystem extends 'modern' ? ModernFireEvent

/**
* Public `fireEvent`. Calls the implementation of the event system selected by
* `configure({ unstable_eventSystem })`, read on each call, so `configure()` inside a test applies.
* `configure({ eventSystem })`, read on each call, so `configure()` inside a test applies.
*
* Types follow the legacy signatures, or the modern ones when `Register` sets `eventSystem`. The
* modern implementation also checks its arguments at runtime.
Expand Down Expand Up @@ -117,7 +117,7 @@ fireEventImpl.layout = async (
};

function isModern() {
return getConfig().unstable_eventSystem === 'modern';
return getConfig().eventSystem === 'modern';
}

const fireEvent = fireEventImpl as FireEvent;
Expand Down
2 changes: 1 addition & 1 deletion src/events/modern/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ function dispatchBubblingEvent(
}

/** Target first, then host ancestors. The root container is not an element, so it's excluded. */
function getAncestorPath(target: TestInstance): TestInstance[] {
export function getAncestorPath(target: TestInstance): TestInstance[] {
const path: TestInstance[] = [];
let current: TestInstance | null = target;
while (current?.parent != null) {
Expand Down
14 changes: 4 additions & 10 deletions src/events/modern/pressability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from '../shared/handler';
import { getPointerEventsBlocker } from '../shared/pointer-events';
import type { EventHandler } from '../shared/types';
import { getAncestorPath } from './dispatch';
import type { SyntheticEvent } from './event';
import { getEventInternals } from './event';
import type { PressabilityEventType } from './event-types';
Expand Down Expand Up @@ -111,19 +112,12 @@ export function getTouchPath(target: TestInstance): TestInstance[] {
return [];
}

let hitTarget: TestInstance | null = target;
while (hitTarget?.parent != null && getPointerEventsBlocker(hitTarget) != null) {
let hitTarget = target;
while (hitTarget.parent != null && getPointerEventsBlocker(hitTarget) != null) {
hitTarget = hitTarget.parent;
}

const path: TestInstance[] = [];
let current = hitTarget;
while (current?.parent != null) {
path.push(current);
current = current.parent;
}

return path;
return getAncestorPath(hitTarget);
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/modern-types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* Switches `fireEvent` types to the modern event system. Users import it as
* `@testing-library/react-native/modern-types`, next to `configure({ unstable_eventSystem: 'modern' })`.
* `@testing-library/react-native/modern-types`, next to `configure({ eventSystem: 'modern' })`.
*
* Not part of the main tsconfig program, as the augmentation would apply to all tests.
*/
Expand Down
4 changes: 2 additions & 2 deletions src/test-utils/event-system.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { configure, getConfig } from '../config';

export function isModernEventSystem() {
return getConfig().unstable_eventSystem === 'modern';
return getConfig().eventSystem === 'modern';
}

/**
Expand All @@ -10,6 +10,6 @@ export function isModernEventSystem() {
*/
export function runInLegacyEventSystem() {
beforeEach(() => {
configure({ unstable_eventSystem: 'legacy' });
configure({ eventSystem: 'legacy' });
});
}
2 changes: 1 addition & 1 deletion src/user-event/press/press.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ async function emitPressEvents(
instance: TestInstance,
options: EmitPressOptions,
): Promise<void> {
if (getConfig().unstable_eventSystem === 'modern') {
if (getConfig().eventSystem === 'modern') {
await emitModernPressEvents(config, interaction, instance, options);
} else {
await emitLegacyPressEvents(config, interaction, instance, options);
Expand Down
Loading
Loading