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
11 changes: 6 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
`@testing-library/react-native` is a TypeScript/Jest library for testing React Native components with user-focused testing patterns.

> [!IMPORTANT]
> Never run git commands that create commits, push, or modify the index/history. The human stages and commits. See [Agent rules](#agent-rules).
> Don't commit, push, or change the index or history on your own initiative. Do it only when the human asks, directly or through a skill or command they invoked. See [Agent rules](#agent-rules).

- Package manager: `yarn` (`yarn@4.11.0`)
- Common commands:
Expand Down Expand Up @@ -49,10 +49,11 @@ Notes:

### Git restrictions

- Never run git commands that create commits, push, or modify the index/history. The human owns these actions.
- Forbidden commands include (non-exhaustive): `git commit`, `git push`, `git add`, `git rm`, `git mv`, `git restore --staged`, `git reset`, `git rebase`, `git merge`, `git cherry-pick`, `git stash`, `git commit --amend`, and `git tag`.
- Read-only inspection is fine: `git status`, `git log`, `git diff`, `git show`, `git blame`.
- When conflicts or staging are involved, resolve file contents in the working tree only, then hand off to the human to stage and commit. Describe the exact commands you would run instead of running them.
- Without an instruction, don't run git commands that create commits, push, or modify the index/history. These include (non-exhaustive): `git commit`, `git push`, `git add`, `git rm`, `git mv`, `git restore --staged`, `git reset`, `git rebase`, `git merge`, `git cherry-pick`, `git stash`, `git commit --amend`, and `git tag`.
- When the human asks for commits, either directly or through a skill or command they invoked (for example one that commits after each fix), create them on the current branch. Stage only the files that belong to that change, and never stage `PR.txt` or an unrelated `refs/` pointer.
- Pushing, rewriting history (`rebase`, `reset`, `--amend`), and discarding work (`stash`, `reset --hard`) each need an explicit request for that specific action. A request to commit doesn't cover them.
- Read-only inspection is always fine: `git status`, `git log`, `git diff`, `git show`, `git blame`.
- Without an instruction to commit, leave changes in the working tree and hand off to the human. Describe the exact commands you would run instead of running them.

### PR draft

Expand Down
57 changes: 40 additions & 17 deletions contributing/event-dispatch.md

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion contributing/native-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

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`.
In the `'legacy'` event system, the default, `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`.

The `'modern'` event system dispatches as React Native 0.88 does, through its W3C `EventTarget` dispatch: direct events reach only the target, and bubbling events run capture and bubble phases. It does so on every supported React Native version, including those before 0.88. Bubbling and direct event types come from `src/events/modern/event-types.ts`. See [Event dispatch](event-dispatch.md).

## Which events are which

Expand Down
8 changes: 4 additions & 4 deletions contributing/native-state.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
# 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

- **`TextInput` value** (`valueForInstance`): the text in an uncontrolled `TextInput`. Without it, the input would show no text after `user.type()`.
- **`ScrollView` content offset** (`contentOffsetForInstance`): the current scroll position. Without it, every `scrollTo()` would start from `0, 0`.
- **`ScrollView` content offset** (`contentOffsetForInstance`): the current scroll position. Without it, every `scrollTo()` would start from `0, 0`. Modern `fireEvent` scroll events use it as the default `contentOffset`.
- **Layout size** (`layoutSizeForInstance`): an element's width and height from the last `layout` event. Scroll events use it as the `ScrollView`'s default `layoutMeasurement`.

## 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/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 `buildNativeEvent()`, to the default payload of scroll events, and also reads `contentOffsetForInstance` there. Legacy `fireEvent.scroll()` reads only the layout size, so its `contentOffset` defaults to `0, 0`.
- **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.
15 changes: 15 additions & 0 deletions contributing/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,18 @@ 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({ 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().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.

`fireEvent` types follow the legacy signatures. Importing `@testing-library/react-native/modern-types` (`src/modern-types.ts`) switches them to the modern ones, for the whole TypeScript program. So `tsconfig.json` excludes it, and `src/__tests__/modern-types/` type-checks the modern signatures in their own program (`tsconfig.modern-types.json`, run by `yarn typecheck`). There, calls the types reject use `// @ts-expect-error` and also assert that they throw at runtime.
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
43 changes: 43 additions & 0 deletions docs/api/fire-event.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,3 +206,46 @@ await render(<View testID="box" onLayout={onLayoutMock} />);

await fireEvent.layout(screen.getByTestId('box'), { width: 320, height: 80 });
```

## `UNSAFE_invokeHandler`

_Added in v14.2.0_

```ts
function UNSAFE_invokeHandler(
instance: TestInstance,
handlerName: string,
...args: unknown[]
): Promise<unknown>;
```

Calls the handler prop named `handlerName` with the given arguments, inside async `act`, and resolves to the handler's return value. Use it as an escape hatch for callbacks that no event reaches in a test, for example:

- callbacks that components call from JavaScript with custom arguments, like `onContentSizeChange(width, height)` of `ScrollView` and `FlatList`,
- props of your own composite components, like `onInputChange(value)` of a date picker,
- `onHoverIn` and `onHoverOut` of `Pressable`, which React Native doesn't call on iOS and Android by default, but which you might want to test for React Native Web.

> [!WARNING]
> This API doesn't simulate anything a device does. It doesn't check whether the element is disabled, blocked by `pointerEvents` or non-editable, so a test can pass for behavior that can't happen on a device. Use [User Event](./user-event.md) or [`fireEvent`](#fire-event) when they support your case.

`handlerName` is the exact prop name, e.g. `onContentSizeChange`, not `contentSizeChange`. Any function prop can be called, not only `on*` handlers. The prop is looked up on the given element first, then on the composite components that render it, nearest first, up to the next host element. Handlers of parent host elements are never called. If no function prop with that name is found, or the element is unmounted, it throws.

```jsx
import { FlatList, Text } from 'react-native';
import { render, screen, UNSAFE_invokeHandler } from '@testing-library/react-native';

const onContentSizeChangeMock = jest.fn();

await render(
<FlatList
testID="list"
data={['a', 'b']}
renderItem={({ item }) => <Text>{item}</Text>}
onContentSizeChange={onContentSizeChangeMock}
/>,
);

// `FlatList` calls `onContentSizeChange` from JavaScript, so no event reaches it.
await UNSAFE_invokeHandler(screen.getByTestId('list'), 'onContentSizeChange', 320, 1200);
expect(onContentSizeChangeMock).toHaveBeenCalledWith(320, 1200);
```
8 changes: 8 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ export default [
},
},
},
{
files: ['src/__tests__/modern-types/**/*.{ts,tsx}', 'src/modern-types.ts'],
languageOptions: {
parserOptions: {
project: './tsconfig.modern-types.json',
},
},
},
{
files: ['**/*.test.{ts,tsx}', 'src/test-utils/**'],
plugins: {
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({ 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({ 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'],
},
],
};
1 change: 1 addition & 0 deletions modern-types.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './dist/modern-types';
2 changes: 2 additions & 0 deletions modern-types.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// makes it so people can import from '@testing-library/react-native/modern-types'
module.exports = require('./dist/modern-types');
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
"test:codemods": "node scripts/test-codemods.mjs",
"docs:generate": "node scripts/generate-package-docs.mjs",
"docs:check": "yarn docs:generate && git diff --exit-code -- docs",
"typecheck": "tsc",
"typecheck": "tsc && tsc -p tsconfig.modern-types.json",
"typecheck:react-19_2": "tsc -p tsconfig.react-19_2.json",
"lint": "eslint src --cache",
"format:check": "oxfmt --check --disable-nested-config .",
Expand All @@ -52,6 +52,8 @@
"matchers.d.ts",
"pure.js",
"pure.d.ts",
"modern-types.js",
"modern-types.d.ts",
"dont-cleanup-after-each.js",
"docs/"
],
Expand Down
2 changes: 1 addition & 1 deletion refs/react-native
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().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,
eventSystem: 'legacy',
});
});

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

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

resetToDefaults();
expect(getConfig().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,
eventSystem: 'modern',
});

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