Skip to content
Draft
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
42 changes: 25 additions & 17 deletions contributing/event-dispatch.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,30 @@

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/`, which also holds `fireEvent` itself:
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. A `'modern'` event system that follows React Native's event dispatch is in progress.

| File | Contents |
| ------------------------------------------- | --------------------------------------------------------------------------------------- |
| `fire-event.ts` | Public `fireEvent` API |
| `handler.ts` | Finding the `on*` handler for an event name in props |
| `propagation.ts` | Bubbling vs direct events, walking up host and composite elements |
| `is-enabled.ts` | Whether a device would deliver the event: `pointerEvents`, `editable`, touch responders |
| `dispatch.ts` | `dispatchEvent()`: calls the target's own handler in `act()`, used by `userEvent` |
| `warnings.ts` | `eventDiagnostics` warnings for `fireEvent`, and helpers shared with `userEvent` |
| `builders/` | Event payloads, matching what React Native sends on a device |
| `native-state.ts`, `update-native-state.ts` | [Native state](native-state.md) and how `fireEvent` updates it |
The files in `src/events/legacy/`:

`src/user-event/` is a separate module on top of `src/events/` and imports it only through `src/events/index.ts`.
| File | Contents |
| ---------------- | --------------------------------------------------------------------------------------------------------- |
| `fire-event.ts` | Public `fireEvent` API |
| `propagation.ts` | Bubbling vs direct events, walking up host and composite elements |
| `is-enabled.ts` | Whether a device would deliver the event: `pointerEvents`, `editable`, touch responders |
| `dispatch.ts` | `dispatchEvent()`: calls the target's own handler in `act()`, used by `userEvent` |
| `warnings.ts` | `eventDiagnostics` warnings for `fireEvent` |
| `builders/` | Legacy event objects: `wrapNativeEvent()` (stubs from `baseSyntheticEvent()`), touch and responder events |

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

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.

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

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

## `fireEvent`

`src/events/fire-event.ts` is the public API. It calls a single handler for a single event, found with `findEventHandler()` from `src/events/propagation.ts`. The work is in finding the right handler:
`src/events/legacy/fire-event.ts` is the public API. It calls a single handler for a single event, found with `findEventHandler()` from `src/events/legacy/propagation.ts`. The work is in finding the right handler:

- It starts at the target and moves up the tree until it finds a handler. It also checks props of composite components, not only host elements.
- Direct events (see [Native event propagation](native-events.md)) still bubble, with a warning when they reach an ancestor that emits them. `fireEvent.layout()` only checks the target.
Expand All @@ -29,18 +35,20 @@ Both are built on the shared event subsystem in `src/events/`, which also holds

`src/user-event/` simulates a whole interaction (press, type, scroll, …) as a realistic sequence of events with delays between them. The sequences are based on how React Native behaves on real devices.

Each step uses `dispatchEvent()`, which only calls the target's own handler. It doesn't bubble or check whether the element is enabled. Each action does those checks itself, so the rules for an interaction live in one place.
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. `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.

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

- Dispatch events with `interaction.dispatchEvent()`, so it records whether any handler ran. Events go to `interaction.target`, which is the element the action was called with, unless the action moves it (as `press()` does when an ancestor handles the press). If the action has to call a handler itself, record it with `interaction.recordEvent()` (as `pullToRefresh()` does for `onRefresh` on the `refreshControl` prop).
- Dispatch native events with `interaction.dispatchEvent(eventType, buildFocusEvent())`, using the `build*Event()` helpers from `src/events/create-event.ts`, and call JavaScript callbacks with `interaction.invokeEventHandler(eventType, ...params)` (or `interaction.dispatchTouchEvent()` for `pressIn`, `pressOut`, `longPress` and `press`, which passes a touch event), so it records whether any handler ran. Events go to `interaction.target`, which is the element the action was called with, unless the action moves it (as `press()` does when an ancestor handles the press). If the action has to call a handler itself, record it with `interaction.recordEvent()` (as `pullToRefresh()` does for `onRefresh` on the `refreshControl` prop).
- Set `hasUpdatedNativeState` when the action writes to `nativeState`.
- Add elements that could handle the action but don't accept it to `skippedTargets`: disabled, non-editable `TextInput`, blocked by `pointerEvents`, or with a responder that declines the touch. The warning first reports the ones blocked by `pointerEvents`, with the element that blocks them (`getPointerEventsBlocker()`). Otherwise it reports the disabled ones (`computeAriaDisabled()`, which includes non-editable `TextInput`; when all of them are non-editable `TextInput`, the message calls them non-editable, see `formatDisabledTargets()`), and skips the warning if every skipped element has a responder that declines the touch. Text actions (`type()`, `clear()`, `paste()`) add the `TextInput` when it is non-editable or blocked by `pointerEvents`.
- Add elements that could handle the action but don't accept it to `skippedTargets`: disabled, blocked by `pointerEvents`, or with a responder that declines the touch. The warning first reports the ones blocked by `pointerEvents`, with the element that blocks them (`getPointerEventsBlocker()`). Otherwise it reports the disabled ones (`computeAriaDisabled()`, which includes non-editable `TextInput`; when all of them are non-editable `TextInput`, the message calls them non-editable, see `formatDisabledTargets()`), and skips the warning if every skipped element has a responder that declines the touch. Text actions (`type()`, `clear()`, `paste()`) add the `TextInput` when it is non-editable or blocked by `pointerEvents`. In the modern event system, `press()` doesn't skip a non-editable `TextInput`: as on iOS, it calls its press callbacks, since `editable={false}` only stops it from focusing. On Android, a non-editable `TextInput` is never the touch target, so the touch goes to its ancestors; RNTL doesn't model this. Legacy `press()` skips it.
- Call `warnAboutUnhandledInteraction()` from `src/user-event/utils/warnings.ts` at the end. It warns only if no handler ran and native state didn't change.

## Guidelines

- To change which handler gets a single event, change `fireEvent`. To make an interaction more realistic, change the `userEvent` action.
- Keep `dispatchEvent()` simple.
- Put event rules that both need, like the `pointerEvents` and `editable` checks, in `src/events/`. They may build on general helpers from `src/helpers/` (for example `isEditableTextInput`). Code used only by `userEvent`, like delays and scroll steps, stays in `src/user-event/`.
- Put event rules that both need, like the `pointerEvents` and `editable` checks, in `src/events/legacy/`. They may build on general helpers from `src/helpers/` (for example `isEditableTextInput`). Code used only by `userEvent`, like delays and scroll steps, stays in `src/user-event/`.
- Event sequences should match a real device. Check on a device before changing one, and keep the code comments explaining the observed behavior.
2 changes: 1 addition & 1 deletion contributing/native-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

In React Native, some events **bubble** up to parent elements and others are **direct**, meaning only the element that emitted them receives them. `fireEvent` should behave the same way.

Today, `fireEvent` still bubbles direct events, with a warning (see [Known gaps](#known-gaps)). Only `fireEvent.layout()` does not bubble. The rules live in `isDirectEvent()` in `src/events/propagation.ts`.
Today, `fireEvent` still bubbles direct events, with a warning (see [Known gaps](#known-gaps)). Only `fireEvent.layout()` does not bubble. The rules live in `isDirectEvent()` in `src/events/legacy/propagation.ts`.

## Which events are which

Expand Down
6 changes: 3 additions & 3 deletions contributing/native-state.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Native State

On a device, some component state lives in native views, not in React. Jest has no native views, so RNTL keeps this state itself in `src/events/native-state.ts`.
On a device, some component state lives in native views, not in React. Jest has no native views, so RNTL keeps this state itself in `src/events/shared/native-state.ts`.

## What is stored

Expand All @@ -10,8 +10,8 @@ On a device, some component state lives in native views, not in React. Jest has

## Key points

- **Writes.** `fireEvent` and `userEvent` update native state when they simulate a change that a native view would make. `fireEvent` does it through `updateNativeStateFromEvent()` in `src/events/update-native-state.ts`. Each `userEvent` action writes it directly.
- **Reads.** Helpers read native state, like `getTextInputValue()` in `src/helpers/text-input.ts`. Queries and matchers use those helpers instead of reading native state directly.
- **Writes.** `fireEvent` and `userEvent` update native state when they simulate a change that a native view would make. Both `fireEvent` implementations do it through `updateNativeStateFromEvent()` in `src/events/shared/update-native-state.ts`. It saves the `TextInput` value from `changeText` and `change` events (`nativeEvent.text`). Each `userEvent` action writes it directly.
- **Reads.** Helpers read native state, like `getTextInputValue()` in `src/helpers/text-input.ts`. Queries and matchers use those helpers instead of reading native state directly. Event payloads read it through `getNativeStateEventProps()`, next to `updateNativeStateFromEvent()`. Modern `fireEvent` applies it centrally, in `fireEventInternal()`, to the default payload of `fireEvent.scroll()` and `fireEvent.layout()`.
- **Props win.** A controlled prop (like `value`) always takes precedence over native state.
- **No reset.** State is stored in `WeakMap`s keyed by host instance. It disappears when the instance is unmounted, so `cleanup()` doesn't need to clear it.
- **Internal.** Native state isn't part of the public API.
13 changes: 13 additions & 0 deletions contributing/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,16 @@ Every change in `src/` should come with tests. Tests use Jest and live next to t
- Shared setup lives in `jest-setup.ts`.
- Auto-cleanup between tests comes from `src/index.ts`.
- Coverage is collected from `src/`, excluding tests and `src/test-utils/`.

## Event systems

`fireEvent` and `userEvent` work with both event systems (`configure({ unstable_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.

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.
6 changes: 6 additions & 0 deletions jest-setup-modern.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { configure } from './src/pure';

// Runs after `resetToDefaults()` in `jest-setup.ts`.
beforeEach(() => {
configure({ unstable_eventSystem: 'modern' });
});
21 changes: 19 additions & 2 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,33 @@
module.exports = {
const baseProject = {
preset: '@react-native/jest-preset',
setupFilesAfterEnv: ['./jest-setup.ts'],
testPathIgnorePatterns: ['dist/', 'examples/', 'experiments-app/', 'codemods/'],
modulePathIgnorePatterns: ['<rootDir>/refs/'],
testTimeout: 60000,
transformIgnorePatterns: ['/node_modules/(?!(@react-native|react-native)/).*/'],
snapshotSerializers: ['@relmify/jest-serializer-strip-ansi/always'],
snapshotSerializers: [
'@relmify/jest-serializer-strip-ansi/always',
'./src/test-utils/event-serializer.ts',
],
clearMocks: true,
};

module.exports = {
testTimeout: 60000,
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/**/__tests__/**',
'!src/**/*.test.js',
'!src/test-utils/**', // Exclude setup files
],
projects: [
{ ...baseProject, displayName: 'legacy' },
// All tests again with `configure({ unstable_eventSystem: 'modern' })`. They share snapshots with the
// legacy run, so both event systems must call the same handlers with the same native events.
{
...baseProject,
displayName: 'modern',
setupFilesAfterEnv: [...baseProject.setupFilesAfterEnv, './jest-setup-modern.ts'],
},
],
};
14 changes: 14 additions & 0 deletions src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ afterEach(() => {
test('getConfig() returns existing configuration', () => {
expect(getConfig().asyncUtilTimeout).toEqual(1000);
expect(getConfig().defaultIncludeHiddenElements).toEqual(false);
expect(getConfig().unstable_eventSystem).toEqual('legacy');
});

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

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

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

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

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

expect(_console.warn).not.toHaveBeenCalled();
Expand Down
Loading
Loading