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
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
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.
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
108 changes: 108 additions & 0 deletions src/__tests__/modern-types/fire-event.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import '../../modern-types';

import * as React from 'react';
import { Pressable, ScrollView, TextInput, View } from 'react-native';

import { fireEvent, render, screen } from '../..';
import { configure } from '../../config';
import { _console } from '../../helpers/logger';

// Type-checked by `tsconfig.modern-types.json`, where `modern-types` switches `fireEvent` to the
// modern signatures. Calls the types reject also throw at runtime.

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

const emptyEvent = { nativeEvent: {} };

test('accepts known event types, with or without the deprecated "on" prefix', async () => {
const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {});
const onFocus = jest.fn();
const onLayout = jest.fn();
await render(<TextInput testID="input" onFocus={onFocus} onLayout={onLayout} />);
const input = screen.getByTestId('input');

await fireEvent(input, 'focus', emptyEvent);
await fireEvent(input, 'onLayout', { nativeEvent: { layout: { width: 100 } }, timeStamp: 1 });
// The event object is optional.
await fireEvent(input, 'focus');
await fireEvent(input, 'onLayout');

expect(onFocus).toHaveBeenCalledTimes(2);
expect(onLayout).toHaveBeenCalledTimes(2);
expect(warnSpy).toHaveBeenCalledTimes(2);
warnSpy.mockRestore();
});

test('accepts custom event types with dispatch', async () => {
const onPageSelected = jest.fn();
const onChangeText = jest.fn();
await render(<View testID="native" {...{ onPageSelected, onChangeText }} />);
const native = screen.getByTestId('native');

await fireEvent(native, 'pageSelected', { nativeEvent: { position: 1 }, dispatch: 'direct' });
// A native component's own `changeText` event.
await fireEvent(native, 'changeText', { nativeEvent: { text: 'Hello' }, dispatch: 'direct' });

expect(onPageSelected).toHaveBeenCalledTimes(1);
expect(onChangeText).toHaveBeenCalledTimes(1);
});

test('accepts the helper signatures', async () => {
const onPress = jest.fn();
const onChangeText = jest.fn();
const onScroll = jest.fn();
const onLayout = jest.fn();
await render(
<>
<Pressable testID="pressable" onPress={onPress} />
<TextInput testID="input" onChangeText={onChangeText} />
<ScrollView testID="scroll" onScroll={onScroll} onLayout={onLayout} />
</>,
);

await fireEvent.press(screen.getByTestId('pressable'));
await fireEvent.changeText(screen.getByTestId('input'), 'Hello');
await fireEvent.scroll(screen.getByTestId('scroll'), {
nativeEvent: { contentOffset: { y: 200 } },
});
await fireEvent.layout(screen.getByTestId('scroll'), { width: 100 });

expect(onPress).toHaveBeenCalledTimes(1);
expect(onChangeText).toHaveBeenCalledWith('Hello');
expect(onScroll).toHaveBeenCalledTimes(1);
expect(onLayout).toHaveBeenCalledTimes(1);
});

test('rejects custom event types without dispatch', async () => {
await render(<View testID="pager" />);
const pager = screen.getByTestId('pager');

// @ts-expect-error `dispatch` is required for event types React Native doesn't know
await expect(fireEvent(pager, 'pageSelected', emptyEvent)).rejects.toThrow();
// @ts-expect-error the event object with `dispatch` is required for these event types
await expect(fireEvent(pager, 'pageSelected')).rejects.toThrow();
// @ts-expect-error `changeText` is not a native event type, the type error suggests alternatives
await expect(fireEvent(pager, 'changeText', 'Hello')).rejects.toThrow();
// @ts-expect-error `changeText` is not a native event type, the type error suggests alternatives
await expect(fireEvent(pager, 'onChangeText', emptyEvent)).rejects.toThrow();
// @ts-expect-error unsupported `dispatch` value
await expect(fireEvent(pager, 'pageSelected', { dispatch: 'capture' })).rejects.toThrow();
});

test('rejects anything but at most one event object', async () => {
await render(<View testID="view" />);
const view = screen.getByTestId('view');

// @ts-expect-error more than one argument
await expect(fireEvent(view, 'focus', emptyEvent, emptyEvent)).rejects.toThrow();
// @ts-expect-error not an event object
await expect(fireEvent(view, 'focus', 'event')).rejects.toThrow();
// @ts-expect-error `nativeEvent` must be an object
await expect(fireEvent(view, 'focus', { nativeEvent: 1 })).rejects.toThrow();
// @ts-expect-error unsupported event object key
await expect(fireEvent(view, 'focus', { nativeEvent: {}, target: view })).rejects.toThrow();
// @ts-expect-error unsupported event object key
await expect(fireEvent.press(view, { pageX: 20 })).rejects.toThrow();
});
Loading
Loading