Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
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
20 changes: 20 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
[submodule "refs/dom-testing-library"]
path = refs/dom-testing-library
url = https://github.com/testing-library/dom-testing-library.git
shallow = true
[submodule "refs/react-testing-library"]
path = refs/react-testing-library
url = https://github.com/testing-library/react-testing-library.git
shallow = true
[submodule "refs/react-native"]
path = refs/react-native
url = https://github.com/facebook/react-native.git
shallow = true
[submodule "refs/react"]
path = refs/react
url = https://github.com/facebook/react.git
shallow = true
[submodule "refs/expensify-app"]
path = refs/expensify-app
url = https://github.com/Expensify/App.git
shallow = true
2 changes: 1 addition & 1 deletion .oxfmtrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,5 @@
]
},
"sortPackageJson": false,
"ignorePatterns": ["node_modules/", ".yarn", "codemods/**/tests/fixtures/**"]
"ignorePatterns": ["node_modules/", ".yarn", "refs/", "codemods/**/tests/fixtures/**"]
}
17 changes: 17 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,23 @@
- [Example app regeneration](contributing/example-apps.md)
- [Git, releases, and PR workflow](contributing/git-workflow.md)

## Reference sources

Upstream sources are checked out as shallow git submodules under `refs/` for code research. Read and search them to see how upstream implements something (event dispatch, renderer internals, query semantics) instead of guessing or fetching from the web.

- `refs/react-native/`: [facebook/react-native](https://github.com/facebook/react-native) (core components in `packages/react-native/Libraries/`)
- `refs/react/`: [facebook/react](https://github.com/facebook/react) (reconciler, test renderer, and RN renderer in `packages/`)
- `refs/dom-testing-library/`: [testing-library/dom-testing-library](https://github.com/testing-library/dom-testing-library) (queries, `fireEvent`, `waitFor`)
- `refs/react-testing-library/`: [testing-library/react-testing-library](https://github.com/testing-library/react-testing-library) (`render`, `act` integration)
- `refs/expensify-app/`: [Expensify/App](https://github.com/Expensify/App), a large production React Native app with about 1,000 test files that use this library (in `tests/ui/`, `tests/unit/`, `tests/perf-test/`). Use it to see how real-world tests call the API and to judge the impact of behavior or API changes. Check its `package.json` for the version it uses.

Notes:

- Treat `refs/` as read-only. Never edit files there or import from it in `src/`.
- Submodules track upstream `main`, which can differ from the versions installed in `node_modules/`. For behavior that must match what this library runs against, check the installed package in `node_modules/` too.
- If `refs/` is empty, ask the human to run `git submodule update --init --depth 1`.
- Tooling ignores `refs/` (Jest, ESLint, oxfmt, `tsc`). Keep it that way when changing configs.

## Agent rules

### Git restrictions
Expand Down
1 change: 1 addition & 0 deletions contributing/build-and-validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,4 @@ After changing docs, run `yarn docs:generate` and commit the result together wit
- `examples/`: example Expo apps
- `codemods/`: codemods for upgrading user code
- `contributing/`: these guides
- `refs/`: upstream sources (React, React Native, Testing Library) and the Expensify app as a real-world test suite, as shallow git submodules, for reading only. Fetch them with `git submodule update --init --depth 1`.
34 changes: 18 additions & 16 deletions contributing/event-dispatch.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,26 @@

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`, and helpers shared with `userEvent` |
| `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), and `types.ts`.

`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`, and imports the rest only through `src/events/legacy/index.ts`, which also re-exports `src/events/shared/handler.ts` and `src/events/shared/native-state.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,11 +31,11 @@ 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.

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` and `longPress`, 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`.
- 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.
Expand All @@ -42,5 +44,5 @@ For the `eventDiagnostics` warning, each action tracks itself with an `Interacti

- 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.
4 changes: 2 additions & 2 deletions 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 Expand Up @@ -34,7 +34,7 @@ Until then, `fireEvent` logs a warning when a direct event bubbles from a nested

`contentSizeChange` is not a native `ScrollView` event, so the table above doesn't list it. The `ScrollView` component calls `onContentSizeChange` from the `onLayout` of its content view and passes `onContentSizeChange: null` to the host element. The Jest `ScrollView` mock passes the prop to the host element instead, so the rule uses `ScrollView` as the emitting element. `FlatList` and `SectionList` always set this handler, and tests fire the event on list items, so making it direct will break more tests than other events.

Both rules depend on the Jest mock. The `FlatList` cases in `src/events/__tests__/fire-event.test.tsx` cover both, so a mock change that moves these handlers fails them.
Both rules depend on the Jest mock. The `FlatList` cases in `src/events/legacy/__tests__/fire-event.test.tsx` cover both, so a mock change that moves these handlers fails them.

## Sources

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.

Tests of legacy behavior the modern event system doesn't have (several handler arguments, bubbling to composite props, direct events bubbling with a warning, ...) call `runInLegacyEventSystem()` from `src/test-utils/event-system.ts` at the top of the file (or in a `describe()`). They run in the legacy event system in both projects, e.g. `src/events/legacy/__tests__/fire-event.test.tsx`.
9 changes: 8 additions & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,14 @@ const patchedCallstackConfig = callstackConfig.map((configItem) => {

export default [
{
ignores: ['dist/', 'experiments-rtl/', 'website/', 'eslint.config.mjs', 'jest-setup.ts'],
ignores: [
'dist/',
'refs/',
'experiments-rtl/',
'website/',
'eslint.config.mjs',
'jest-setup.ts',
],
},
...patchedCallstackConfig,
...tseslint.configs.strict,
Expand Down
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' });
});
24 changes: 21 additions & 3 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,33 @@
module.exports = {
const baseProject = {
preset: '@react-native/jest-preset',
setupFilesAfterEnv: ['./jest-setup.ts'],
testPathIgnorePatterns: ['dist/', 'examples/', 'experiments-app/', 'codemods/'],
testPathIgnorePatterns: ['dist/', 'examples/', 'experiments-app/', 'codemods/', 'refs/'],
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'],
},
],
};
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@
"typecheck": "tsc",
"typecheck:react-19_2": "tsc -p tsconfig.react-19_2.json",
"lint": "eslint src --cache",
"format:check": "oxfmt --check .",
"format:fix": "oxfmt --write .",
"format:check": "oxfmt --check --disable-nested-config .",
"format:fix": "oxfmt --write --disable-nested-config .",
"validate": "yarn typecheck && yarn test && yarn lint && yarn format:check",
"validate:examples": "yarn --cwd examples/basic validate && yarn --cwd examples/cookbook validate",
"validate:all": "yarn validate && yarn validate:examples && yarn docs:check && yarn --cwd website validate && yarn --cwd experiments-app validate",
Expand Down
Loading
Loading