diff --git a/contributing/event-dispatch.md b/contributing/event-dispatch.md
index db90c0c6b..9bb951c1c 100644
--- a/contributing/event-dispatch.md
+++ b/contributing/event-dispatch.md
@@ -2,24 +2,33 @@
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 |
+| `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. The callbacks get a `ResponderEvent` from `src/events/modern/responder-event.ts`, like React Native's responder system creates in JavaScript: `responderGrant` or `responderRelease`, with a touch payload, but no `touchHistory`, which only `PanResponder` reads. It isn't trusted, has no event phase or composed path, and has a `null` `currentTarget` in `onLongPress`, which Pressability calls from a timer.
+
+Modern `fireEvent` throws for event types React Native doesn't know (`ensureEventType()` in `src/events/modern/fire-event-utils.ts`), unless the event object has `dispatch: 'direct' | 'bubbling'`. This is for native events of third-party components, which render as host elements with all their props in Jest, e.g. `fireEvent(pager, 'pageSelected', { nativeEvent: { position: 1 }, dispatch: 'direct' })`. `getCustomEventTypeConfig()` in `src/events/modern/event-types.ts` builds the config codegen would: handler props named after the event type, and no `skipBubbling`, which codegen can't declare. `dispatch` is required, not defaulted, so a legacy call for a JavaScript callback that the Jest mocks put on a host, like `fireEvent(list, 'endReached', ...)`, still throws and points to `UNSAFE_invokeHandler()`. For known event types, `dispatch` overrides React Native's config, as a native component can declare its own event under a built-in name with different semantics, e.g. `react-native-maps` declares `onPress` as a `BubblingEventHandler`. `createEvent()` then uses `getCustomEventTypeConfig()`, and Pressability event types skip Pressability and are dispatched with `dispatchEvent()`. `fireEvent.press()` with `dispatch` also skips the default touch payload. `dispatch` on a `Pressable` therefore calls no callback, as its host has no `onPress` prop.
+
+Modern `fireEvent` takes event types without the `on*` prefix (`press`, not `onPress`), as React Native names them. Like legacy `fireEvent`, it still strips the prefix, but logs a deprecation warning (`warnOnPrefix()` in `src/events/modern/fire-event-utils.ts`), after its checks, so a call that throws doesn't also warn. Support for the prefix will be removed in the next major release. Legacy `fireEvent` accepts both without a warning.
+
+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`. For a legacy event object, `dispatchEvent()` calls the target's own handler with `invokeEventHandler()` from `src/events/shared/invoke-event-handler.ts`. Other modules are imported directly: legacy touch and responder event builders from `src/events/legacy/builders/common.ts`, and `src/events/modern/` and `src/events/shared/` modules, e.g. `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.
@@ -29,18 +38,24 @@ 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. 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`:
-- 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.
+## `UNSAFE_invokeHandler`
+
+`src/events/invoke-handler.ts` is the escape hatch for handlers no event reaches: callbacks with custom arguments (`onContentSizeChange(width, height)`), props of composite components, and `Pressable`'s `onHoverIn`, which React Native doesn't call on iOS or Android by default. It calls the prop with the exact name passed, on the element or the composite components that render it, up to the next host element, in `act()`. It doesn't depend on the event system and checks nothing a device would (disabled, `pointerEvents`, `editable`). It throws when it finds no handler, so a test can't pass without calling one. Modern `fireEvent`'s errors for unknown event types and extra arguments point to it.
+
## 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.
diff --git a/contributing/native-events.md b/contributing/native-events.md
index 4d5ab48b5..f1ccee9f6 100644
--- a/contributing/native-events.md
+++ b/contributing/native-events.md
@@ -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
diff --git a/contributing/native-state.md b/contributing/native-state.md
index 02e132846..dfbbc7c94 100644
--- a/contributing/native-state.md
+++ b/contributing/native-state.md
@@ -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
@@ -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.
diff --git a/contributing/testing.md b/contributing/testing.md
index 7ff84b537..1992db051 100644
--- a/contributing/testing.md
+++ b/contributing/testing.md
@@ -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({ 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.
+
+`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.
diff --git a/docs/api/fire-event.md b/docs/api/fire-event.md
index b102ae99a..85af21a3b 100644
--- a/docs/api/fire-event.md
+++ b/docs/api/fire-event.md
@@ -206,3 +206,46 @@ await render();
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;
+```
+
+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(
+ {item}}
+ 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);
+```
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 893704448..7fe6a335c 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -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: {
diff --git a/jest-setup-modern.ts b/jest-setup-modern.ts
new file mode 100644
index 000000000..6274d014d
--- /dev/null
+++ b/jest-setup-modern.ts
@@ -0,0 +1,6 @@
+import { configure } from './src/pure';
+
+// Runs after `resetToDefaults()` in `jest-setup.ts`.
+beforeEach(() => {
+ configure({ unstable_eventSystem: 'modern' });
+});
diff --git a/jest.config.js b/jest.config.js
index b47157dd6..8fe6f054f 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -1,16 +1,33 @@
-module.exports = {
+const baseProject = {
preset: '@react-native/jest-preset',
setupFilesAfterEnv: ['./jest-setup.ts'],
testPathIgnorePatterns: ['dist/', 'examples/', 'experiments-app/', 'codemods/'],
modulePathIgnorePatterns: ['/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'],
+ },
+ ],
};
diff --git a/modern-types.d.ts b/modern-types.d.ts
new file mode 100644
index 000000000..ac41e5427
--- /dev/null
+++ b/modern-types.d.ts
@@ -0,0 +1 @@
+export * from './dist/modern-types';
diff --git a/modern-types.js b/modern-types.js
new file mode 100644
index 000000000..9d1a89aa6
--- /dev/null
+++ b/modern-types.js
@@ -0,0 +1,2 @@
+// makes it so people can import from '@testing-library/react-native/modern-types'
+module.exports = require('./dist/modern-types');
diff --git a/package.json b/package.json
index db2db8305..e82abf834 100644
--- a/package.json
+++ b/package.json
@@ -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 .",
@@ -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/"
],
diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts
index 1fecd4f33..5b88a76f8 100644
--- a/src/__tests__/config.test.ts
+++ b/src/__tests__/config.test.ts
@@ -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', () => {
@@ -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,
@@ -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();
diff --git a/src/__tests__/modern-types/fire-event.test.tsx b/src/__tests__/modern-types/fire-event.test.tsx
new file mode 100644
index 000000000..dfe56b248
--- /dev/null
+++ b/src/__tests__/modern-types/fire-event.test.tsx
@@ -0,0 +1,105 @@
+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({ unstable_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();
+ const input = screen.getByTestId('input');
+
+ await fireEvent(input, 'focus', emptyEvent);
+ await fireEvent(input, 'onLayout', { nativeEvent: { layout: { width: 100 } }, timeStamp: 1 });
+
+ expect(onFocus).toHaveBeenCalledTimes(1);
+ expect(onLayout).toHaveBeenCalledTimes(1);
+ expect(warnSpy).toHaveBeenCalledTimes(1);
+ warnSpy.mockRestore();
+});
+
+test('accepts custom event types with dispatch', async () => {
+ const onPageSelected = jest.fn();
+ const onChangeText = jest.fn();
+ await render();
+ 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(
+ <>
+
+
+
+ >,
+ );
+
+ 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();
+ 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 `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 a single event object', async () => {
+ await render();
+ const view = screen.getByTestId('view');
+
+ // @ts-expect-error missing event object
+ await expect(fireEvent(view, 'focus')).rejects.toThrow();
+ // @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();
+});
diff --git a/src/config.ts b/src/config.ts
index b19cb18cf..895b06829 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -5,6 +5,8 @@ import { validateOptions } from './helpers/validate-options';
* Global configuration options for React Native Testing Library.
*/
+export type EventSystem = 'legacy' | 'modern';
+
export type Config = {
/** Default timeout, in ms, for `waitFor` and `findBy*` queries. */
asyncUtilTimeout: number;
@@ -18,6 +20,13 @@ 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'`.
+ */
+ unstable_eventSystem: EventSystem;
+
/** Default options for `debug` helper. */
defaultDebugOptions?: Partial;
};
@@ -31,6 +40,7 @@ const defaultConfig: Config = {
asyncUtilTimeout: 1000,
defaultIncludeHiddenElements: false,
eventDiagnostics: false,
+ unstable_eventSystem: 'legacy',
};
let config = { ...defaultConfig };
@@ -45,6 +55,7 @@ export function configure(options: Partial) {
defaultHidden,
defaultIncludeHiddenElements,
eventDiagnostics,
+ unstable_eventSystem,
...rest
} = options;
@@ -59,6 +70,7 @@ export function configure(options: Partial) {
defaultDebugOptions,
defaultIncludeHiddenElements: resolvedDefaultIncludeHiddenElements,
eventDiagnostics: eventDiagnostics ?? config.eventDiagnostics,
+ unstable_eventSystem: unstable_eventSystem ?? config.unstable_eventSystem,
};
}
diff --git a/src/events/__tests__/create-event.test.ts b/src/events/__tests__/create-event.test.ts
new file mode 100644
index 000000000..d37ba1a14
--- /dev/null
+++ b/src/events/__tests__/create-event.test.ts
@@ -0,0 +1,82 @@
+import { configure } from '../..';
+import {
+ buildAccessibilityActionEvent,
+ buildBlurEvent,
+ buildContentSizeChangeEvent,
+ buildEndEditingEvent,
+ createEvent,
+ buildFocusEvent,
+ buildKeyPressEvent,
+ buildScrollEvent,
+ buildSubmitEditingEvent,
+ buildTextChangeEvent,
+ buildTextSelectionChangeEvent,
+} from '../create-event';
+import { SyntheticEvent } from '../modern/event';
+
+afterEach(() => {
+ jest.restoreAllMocks();
+});
+
+test('builds a legacy event object in the legacy event system', () => {
+ configure({ unstable_eventSystem: 'legacy' });
+ const nativeEvent = { target: 0 };
+
+ const event = createEvent('focus', nativeEvent);
+
+ expect(event).not.toBeInstanceOf(SyntheticEvent);
+ expect(event).toMatchObject({ nativeEvent, timeStamp: 0, preventDefault: expect.any(Function) });
+});
+
+test('builds a SyntheticEvent in the modern event system', () => {
+ configure({ unstable_eventSystem: 'modern' });
+ const nativeEvent = { target: 0 };
+
+ const event = createEvent('focus', nativeEvent);
+
+ expect(event).toBeInstanceOf(SyntheticEvent);
+ expect(event.nativeEvent).toBe(nativeEvent);
+ expect(event.type).toBe('focus');
+});
+
+test.each(['legacy', 'modern'] as const)(
+ 'throws for event types that are not native in the %s event system',
+ (eventSystem) => {
+ configure({ unstable_eventSystem: eventSystem });
+
+ expect(() => createEvent('changeText', {})).toThrow(
+ '"changeText" is not a native event type. Use invokeEventHandler() instead.',
+ );
+ },
+);
+
+test.each([
+ ['focus', () => buildFocusEvent()],
+ ['blur', () => buildBlurEvent()],
+ ['accessibilityaction', () => buildAccessibilityActionEvent('increment')],
+ ['momentumscrollend', () => buildScrollEvent('momentumScrollEnd', { x: 0, y: 100 })],
+ ['change', () => buildTextChangeEvent('Hello', { start: 5, end: 5 })],
+ ['keypress', () => buildKeyPressEvent('a')],
+ ['submitediting', () => buildSubmitEditingEvent('Hello')],
+ ['endediting', () => buildEndEditingEvent('Hello')],
+ ['selectionchange', () => buildTextSelectionChangeEvent({ start: 0, end: 5 })],
+ ['contentsizechange', () => buildContentSizeChangeEvent({ width: 100, height: 20 })],
+])('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' });
+ const legacyEvent = build();
+ configure({ unstable_eventSystem: 'modern' });
+ const modernEvent = build();
+
+ expect(legacyEvent).not.toBeInstanceOf(SyntheticEvent);
+ expect(modernEvent).toBeInstanceOf(SyntheticEvent);
+ expect(modernEvent.type).toBe(type);
+ expect(modernEvent.nativeEvent).toEqual(legacyEvent.nativeEvent);
+});
+
+test('buildScrollEvent passes its arguments to the native event', () => {
+ expect(buildScrollEvent('scroll', { x: 0, y: 100 }).nativeEvent).toMatchObject({
+ contentOffset: { x: 0, y: 100 },
+ });
+});
diff --git a/src/events/__tests__/dispatch-event.test.tsx b/src/events/__tests__/dispatch-event.test.tsx
new file mode 100644
index 000000000..5ea271c28
--- /dev/null
+++ b/src/events/__tests__/dispatch-event.test.tsx
@@ -0,0 +1,69 @@
+import * as React from 'react';
+import { TextInput, View } from 'react-native';
+
+import { render, screen } from '../..';
+import { isModernEventSystem } from '../../test-utils/event-system';
+import { createEvent } from '../create-event';
+import { dispatchEvent } from '../dispatch-event';
+import { SyntheticEvent } from '../modern/event';
+import { buildFocusNativeEvent } from '../shared/payloads';
+
+test('dispatches the event of the selected event system', async () => {
+ const calls: string[] = [];
+ const onTargetPointerDown = jest.fn(() => calls.push('target.onPointerDown'));
+ await render(
+ calls.push('parent.onPointerDownCapture')}
+ onPointerDown={() => calls.push('parent.onPointerDown')}
+ >
+
+ ,
+ );
+
+ const event = createEvent('pointerDown', { pointerId: 1 });
+ expect(await dispatchEvent(screen.getByTestId('target'), 'pointerDown', event)).toBe(true);
+ expect(onTargetPointerDown).toHaveBeenCalledWith(event);
+ const isModern = isModernEventSystem();
+ expect(event instanceof SyntheticEvent).toBe(isModern);
+ // Modern goes through capture and bubble phases. Legacy calls only the target prop.
+ expect(calls).toEqual(
+ isModern
+ ? ['parent.onPointerDownCapture', 'target.onPointerDown', 'parent.onPointerDown']
+ : ['target.onPointerDown'],
+ );
+});
+
+test('returns whether an ancestor handled the event', async () => {
+ const onParentPointerDown = jest.fn();
+ await render(
+
+
+ ,
+ );
+
+ const event = createEvent('pointerDown', { pointerId: 1 });
+ const result = await dispatchEvent(screen.getByTestId('target'), 'pointerDown', event);
+ const isModern = isModernEventSystem();
+ expect(result).toBe(isModern);
+ expect(onParentPointerDown).toHaveBeenCalledTimes(isModern ? 1 : 0);
+});
+
+test('returns false without a handler on the path', async () => {
+ await render(
+
+
+ ,
+ );
+
+ const event = createEvent('focus', buildFocusNativeEvent());
+ expect(await dispatchEvent(screen.getByTestId('input'), 'focus', event)).toBe(false);
+});
+
+test('calls testOnly_ props', async () => {
+ const onFocus = jest.fn();
+ await render();
+
+ const event = createEvent('focus', buildFocusNativeEvent());
+ expect(await dispatchEvent(screen.getByTestId('view'), 'focus', event)).toBe(true);
+ expect(onFocus).toHaveBeenCalledWith(event);
+});
diff --git a/src/events/__tests__/dispatch.test.tsx b/src/events/__tests__/dispatch.test.tsx
deleted file mode 100644
index 6e00b0613..000000000
--- a/src/events/__tests__/dispatch.test.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-import * as React from 'react';
-import { Text } from 'react-native';
-
-import { render, screen } from '../..';
-import { buildTouchEvent } from '../builders/common';
-import { dispatchEvent } from '../dispatch';
-
-const TOUCH_EVENT = buildTouchEvent();
-
-test('dispatchEvent calls the target handler', async () => {
- const onPress = jest.fn();
- await render();
-
- await dispatchEvent(screen.getByTestId('text'), 'press', TOUCH_EVENT);
- expect(onPress).toHaveBeenCalledTimes(1);
-});
-
-test('dispatchEvent does not call the parent host component handler', async () => {
- const onPressParent = jest.fn();
- await render(
-
-
- ,
- );
-
- await dispatchEvent(screen.getByTestId('text'), 'press', TOUCH_EVENT);
- expect(onPressParent).not.toHaveBeenCalled();
-});
-
-test('dispatchEvent does not throw when no handler is found', async () => {
- await render(
-
-
- ,
- );
-
- await expect(
- dispatchEvent(screen.getByTestId('text'), 'press', TOUCH_EVENT),
- ).resolves.not.toThrow();
-});
diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx
index e1e950893..f736dfb58 100644
--- a/src/events/__tests__/fire-event.test.tsx
+++ b/src/events/__tests__/fire-event.test.tsx
@@ -16,45 +16,222 @@ import {
TouchableWithoutFeedback,
View,
} from 'react-native';
+import type { TestInstance } from 'test-renderer';
import { fireEvent, render, screen } from '../..';
import { configure } from '../../config';
import { _console, logger } from '../../helpers/logger';
-import { getEventHandlerName } from '../handler';
-import { nativeState } from '../native-state';
+import {
+ isModernEventSystem as isModern,
+ runInLegacyEventSystem,
+} from '../../test-utils/event-system';
+import { SyntheticEvent } from '../modern/event';
+import { getEventHandlerName } from '../shared/handler';
+import { nativeState } from '../shared/native-state';
+
+// Runs in both event systems, through the public `fireEvent`. Where they differ, tests branch on
+// `isModern()`, with a comment saying which one matches React Native.
+const emptyEvent = { nativeEvent: {} };
const layoutEvent = { nativeEvent: { layout: { width: 100, height: 100 } } };
const verticalScrollEvent = { nativeEvent: { contentOffset: { y: 200 } } };
const horizontalScrollEvent = { nativeEvent: { contentOffset: { x: 50 } } };
const pressEventData = { nativeEvent: { pageX: 20, pageY: 30 } };
+const anySyntheticEvent = expect.any(SyntheticEvent);
+
+const defaultTouch = {
+ identifier: 0,
+ locationX: 0,
+ locationY: 0,
+ pageX: 0,
+ pageY: 0,
+ target: 0,
+ timestamp: expect.any(Number),
+};
+const defaultTouchPayload = { ...defaultTouch, changedTouches: [], touches: [] };
+
+/**
+ * Modern touch payload, as on a device: `changedTouches` has the touch, and so does `touches` until
+ * the touch ends.
+ */
+function getTouchPayload(isTouchStart: boolean, touchProps?: object) {
+ const touch = { ...defaultTouch, ...touchProps };
+ return { ...touch, changedTouches: [touch], touches: isTouchStart ? [touch] : [] };
+}
beforeEach(() => {
jest.spyOn(Date, 'now').mockImplementation(() => 100100100100);
});
-test('fireEvent accepts event name with or without "on" prefix', async () => {
- const onPress = jest.fn();
- await render();
+/** Error message `fireEvent` rejects with, or `undefined` when it resolves. */
+async function getErrorMessage(promise: Promise) {
+ try {
+ await promise;
+ return undefined;
+ } catch (error) {
+ return (error as Error).message;
+ }
+}
- await fireEvent(screen.getByTestId('btn'), 'press');
- expect(onPress).toHaveBeenCalledTimes(1);
+test('fireEvent sets event type (legacy: passes the event object as given)', async () => {
+ const onFocus = jest.fn();
+ await render();
- await fireEvent(screen.getByTestId('btn'), 'onPress');
- expect(onPress).toHaveBeenCalledTimes(2);
+ await fireEvent(screen.getByTestId('input'), 'focus', emptyEvent);
+
+ // Modern matches React Native, where handlers receive an event with a type. Legacy passes the
+ // event object as given, without a type.
+ expect(onFocus.mock.calls[0][0].type).toBe(isModern() ? 'focus' : undefined);
});
-test('fireEvent with "on" prefixed name does not call unprefixed handler props', async () => {
+test('fireEvent does not call handler props without the "on" prefix (legacy: calls them for unprefixed event names)', async () => {
const press = jest.fn();
- const testOnlyPress = jest.fn();
// @ts-expect-error Intentionally passing such props
- await render();
+ await render();
+
+ // Modern matches React Native, where only Pressability calls press callbacks. Legacy calls any
+ // `press` prop.
+ await fireEvent(screen.getByTestId('view'), 'press', emptyEvent);
+ expect(press).toHaveBeenCalledTimes(isModern() ? 0 : 1);
+});
+
+describe('"on" prefixed event types', () => {
+ let warnSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ warnSpy.mockRestore();
+ });
+
+ function getDeprecationWarnings() {
+ return warnSpy.mock.calls
+ .map(([message]) => String(message))
+ .filter((message) => message.includes('"on" prefix'));
+ }
+
+ test.each([
+ ['onPress', 'press', (onPress: jest.Mock) => ],
+ [
+ 'onPressIn',
+ 'pressIn',
+ (onPressIn: jest.Mock) => ,
+ ],
+ ['onFocus', 'focus', (onFocus: jest.Mock) => ],
+ ['onLayout', 'layout', (onLayout: jest.Mock) => ],
+ ])(
+ 'fires "%s" as "%s" with a deprecation warning (legacy: no warning)',
+ async (eventType, unprefixedType, ui) => {
+ const handler = jest.fn();
+ await render(ui(handler));
+
+ await fireEvent(screen.getByTestId('target'), eventType, emptyEvent);
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ // Modern takes event types as React Native names them, and deprecates the "on" prefix.
+ const message =
+ `Passing event types with the "on" prefix ("${eventType}") to fireEvent is deprecated and ` +
+ 'will be removed in the next major release. ' +
+ `Use "${unprefixedType}" instead, e.g. fireEvent(element, '${unprefixedType}', { nativeEvent: {} }).`;
+ const warning = expect.stringContaining(message);
+ expect(getDeprecationWarnings()).toEqual(isModern() ? [warning] : []);
+ },
+ );
+
+ test('fires a custom event type with dispatch, with a deprecation warning (legacy: no warning)', async () => {
+ const onPageSelected = jest.fn();
+ await render(React.createElement('RNCViewPager', { testID: 'pager', onPageSelected }));
+ const event = { nativeEvent: { position: 2 }, dispatch: 'direct' };
+
+ await fireEvent(screen.getByTestId('pager'), 'onPageSelected', event);
+
+ // Modern calls `onPageSelected` for `pageSelected`, as codegen names handler props after event
+ // types. Legacy passes the event object as given.
+ expect(onPageSelected.mock.calls).toEqual([[isModern() ? anySyntheticEvent : event]]);
+ expect(onPageSelected.mock.calls[0][0].nativeEvent).toEqual({ position: 2 });
+ expect(getDeprecationWarnings()).toHaveLength(isModern() ? 1 : 0);
+ });
+
+ test('does not warn when the call throws', async () => {
+ await render();
+
+ const error = await getErrorMessage(
+ fireEvent(screen.getByTestId('input'), 'onChangeText', 'new text'),
+ );
+
+ expect(error).toBe(
+ isModern()
+ ? `Unable to fire a "changeText" event. React Native doesn't dispatch "changeText" natively. Use fireEvent.changeText() or userEvent.type() instead.`
+ : undefined,
+ );
+ expect(getDeprecationWarnings()).toHaveLength(0);
+ });
+
+ test('accepts event types starting with "on" but without the "on" prefix, without a warning', async () => {
+ const onOnline = jest.fn();
+ await render(React.createElement('RNCNetInfo', { testID: 'net', onOnline }));
+
+ await fireEvent(screen.getByTestId('net'), 'online', { nativeEvent: {}, dispatch: 'direct' });
- await fireEvent(screen.getByTestId('view'), 'onPress');
- expect(press).not.toHaveBeenCalled();
- expect(testOnlyPress).not.toHaveBeenCalled();
+ expect(onOnline).toHaveBeenCalledTimes(1);
+ expect(getDeprecationWarnings()).toHaveLength(0);
+ });
+
+ describe('legacy', () => {
+ runInLegacyEventSystem();
+
+ test('does not call handler props without the "on" prefix', async () => {
+ const press = jest.fn();
+ const testOnlyPress = jest.fn();
+ // @ts-expect-error Intentionally passing such props
+ await render();
+
+ await fireEvent(screen.getByTestId('view'), 'onPress', emptyEvent);
+
+ expect(press).not.toHaveBeenCalled();
+ expect(testOnlyPress).not.toHaveBeenCalled();
+ });
+
+ test('onChange saves value of TextInput in native state', async () => {
+ await render();
+ const input = screen.getByTestId('input');
+
+ await fireEvent(input, 'onChange', { nativeEvent: { text: 'new text' } });
+
+ expect(nativeState.valueForInstance.get(input)).toBe('new text');
+ });
+
+ test('onScroll updates native state', async () => {
+ const onScroll = jest.fn();
+ await render();
+ const scrollView = screen.getByTestId('scroll');
- await fireEvent(screen.getByTestId('view'), 'press');
- expect(press).toHaveBeenCalledTimes(1);
+ await fireEvent(scrollView, 'onScroll', verticalScrollEvent);
+
+ expect(onScroll.mock.calls).toEqual([[verticalScrollEvent]]);
+ expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ x: 0, y: 200 });
+ });
+
+ test.each([
+ ['onLayout', (onLayout: jest.Mock) => ],
+ [
+ 'onMomentumScrollEnd',
+ (onMomentumScrollEnd: jest.Mock) => (
+
+ ),
+ ],
+ ])('%s bubbles with a warning, as unprefixed direct events do', async (eventType, ui) => {
+ const handler = jest.fn();
+ await render(React.cloneElement(ui(handler), {}, ));
+
+ await fireEvent(screen.getByTestId('child'), eventType, emptyEvent);
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ expect(warnSpy).toHaveBeenCalledTimes(1);
+ });
+ });
});
test('fireEvent passes event data to handler', async () => {
@@ -64,18 +241,85 @@ test('fireEvent passes event data to handler', async () => {
expect(onPress.mock.calls[0][0]).toMatchObject(pressEventData);
});
-test('fireEvent passes multiple parameters to handler', async () => {
+test('fireEvent throws with more than one event argument (legacy: passes them all to the handler)', async () => {
const handlePress = jest.fn();
await render();
- await fireEvent(screen.getByTestId('btn'), 'press', 'param1', 'param2', 'param3');
- expect(handlePress).toHaveBeenCalledWith('param1', 'param2', 'param3');
+ const error = await getErrorMessage(
+ fireEvent(screen.getByTestId('btn'), 'press', 'param1', 'param2', 'param3'),
+ );
+ // Modern matches React Native, where handlers receive a single event.
+ expect(error).toBe(
+ isModern()
+ ? 'Unable to fire a "press" event. Expected a single event object, received 3 arguments. ' +
+ 'To call a handler prop with custom arguments, use UNSAFE_invokeHandler().'
+ : undefined,
+ );
+ expect(handlePress.mock.calls).toEqual(isModern() ? [] : [['param1', 'param2', 'param3']]);
});
-test('fireEvent.press returns undefined when event handler returns a value', async () => {
- const handler = jest.fn().mockReturnValue('result');
- await render();
- const result = await fireEvent.press(screen.getByTestId('btn'));
- expect(result).toBe(undefined);
+// React Native has no default actions for `preventDefault()` to cancel, so modern has nothing to
+// resolve to.
+test.each([
+ {
+ name: 'fireEvent.press()',
+ ui: (handler: jest.Mock) => ,
+ fire: (subject: TestInstance) => fireEvent.press(subject),
+ legacyResult: undefined,
+ },
+ {
+ name: 'fireEvent() with a native event',
+ ui: (handler: jest.Mock) => ,
+ fire: (subject: TestInstance) => fireEvent(subject, 'focus', emptyEvent),
+ legacyResult: 'result',
+ },
+ {
+ name: 'fireEvent() with a Pressability event',
+ ui: (handler: jest.Mock) => ,
+ fire: (subject: TestInstance) => fireEvent(subject, 'longPress', emptyEvent),
+ legacyResult: 'result',
+ },
+ {
+ name: 'fireEvent.changeText()',
+ ui: (handler: jest.Mock) => (
+
+ ),
+ fire: (subject: TestInstance) => fireEvent.changeText(subject, 'Hello'),
+ legacyResult: 'result',
+ },
+])(
+ '$name resolves to undefined when the handler calls preventDefault() and returns a value (legacy: to the handler result)',
+ async ({ ui, fire, legacyResult }) => {
+ // Legacy passes event objects as given, which may have no `preventDefault()`.
+ const handler = jest.fn((event: unknown) => {
+ (event as Partial | undefined)?.preventDefault?.();
+ return 'result';
+ });
+ await render(ui(handler));
+
+ await expect(fire(screen.getByTestId('subject'))).resolves.toBe(
+ isModern() ? undefined : legacyResult,
+ );
+ expect(handler).toHaveBeenCalled();
+ },
+);
+
+test.each([
+ {
+ name: 'fireEvent.press()',
+ ui: (handler: jest.Mock) => ,
+ fire: (subject: TestInstance) => fireEvent.press(subject),
+ },
+ {
+ name: 'fireEvent()',
+ ui: (handler: jest.Mock) => ,
+ fire: (subject: TestInstance) => fireEvent(subject, 'focus', emptyEvent),
+ },
+])('$name resolves to undefined when the handler returns nothing', async ({ ui, fire }) => {
+ const handler = jest.fn();
+ await render(ui(handler));
+
+ await expect(fire(screen.getByTestId('subject'))).resolves.toBeUndefined();
+ expect(handler).toHaveBeenCalledTimes(1);
});
test('fireEvent bubbles event to parent handler', async () => {
@@ -89,12 +333,240 @@ test('fireEvent bubbles event to parent handler', async () => {
expect(onPress).toHaveBeenCalled();
});
+test('fireEvent dispatches bubbling events through capture and bubble phases (legacy: calls only the nearest handler)', async () => {
+ const calls: string[] = [];
+ const onPointerDown = jest.fn(() => calls.push('target.onPointerDown'));
+ await render(
+ calls.push('parent.onPointerDownCapture')}
+ onPointerDown={() => calls.push('parent.onPointerDown')}
+ >
+
+ ,
+ );
+
+ await fireEvent(screen.getByTestId('target'), 'pointerDown', { nativeEvent: { pointerId: 1 } });
+
+ // Modern matches React Native, which dispatches a `SyntheticEvent` through capture and bubble
+ // phases. Legacy calls only the target prop with the passed arguments.
+ expect(calls).toEqual(
+ isModern()
+ ? ['parent.onPointerDownCapture', 'target.onPointerDown', 'parent.onPointerDown']
+ : ['target.onPointerDown'],
+ );
+ const event = (onPointerDown.mock.calls[0] as unknown[])[0] as SyntheticEvent;
+ expect(event instanceof SyntheticEvent).toBe(isModern());
+ expect(event.nativeEvent).toEqual({ pointerId: 1 });
+});
+
+test('fireEvent does not call props of composite components (legacy: calls them)', async () => {
+ const onCompositeFocus = jest.fn();
+ function Wrapper({ children }: { children: React.ReactNode; onFocus: () => void }) {
+ return {children};
+ }
+
+ await render(
+
+
+ ,
+ );
+
+ await fireEvent(screen.getByTestId('input'), 'focus', emptyEvent);
+
+ // Modern matches React Native, which dispatches events only to host elements.
+ expect(onCompositeFocus).toHaveBeenCalledTimes(isModern() ? 0 : 1);
+});
+
+describe('event object', () => {
+ test('throws without an event object (legacy: calls the handler without arguments)', async () => {
+ const onFocus = jest.fn();
+ await render();
+
+ const error = await getErrorMessage(fireEvent(screen.getByTestId('input'), 'focus'));
+
+ // Modern matches React Native, where handlers always receive an event.
+ expect(error).toBe(
+ isModern()
+ ? `Unable to fire a "focus" event. Please provide an event object, e.g. fireEvent(element, 'focus', { nativeEvent: {} }).`
+ : undefined,
+ );
+ expect(onFocus.mock.calls).toEqual(isModern() ? [] : [[]]);
+ });
+
+ test('throws without an event object for Pressability events (legacy: calls the callback without arguments)', async () => {
+ const onPressIn = jest.fn();
+ await render();
+
+ const error = await getErrorMessage(fireEvent(screen.getByTestId('pressable'), 'pressIn'));
+
+ expect(error).toBe(
+ isModern()
+ ? `Unable to fire a "pressIn" event. Please provide an event object, e.g. fireEvent(element, 'pressIn', { nativeEvent: {} }).`
+ : undefined,
+ );
+ expect(onPressIn.mock.calls).toEqual(isModern() ? [] : [[]]);
+ });
+
+ test.each([
+ ['text', 'string "text"'],
+ [42, 'number 42'],
+ [null, 'null'],
+ [undefined, 'undefined'],
+ [[{ nativeEvent: {} }], 'an array'],
+ [new Date(0), 'an instance of Date'],
+ [() => {}, 'a function'],
+ ])(
+ 'throws when the event is %p (legacy: passes it to the handler)',
+ async (event, description) => {
+ const onFocus = jest.fn();
+ await render();
+
+ const error = await getErrorMessage(fireEvent(screen.getByTestId('input'), 'focus', event));
+
+ expect(error).toBe(
+ isModern()
+ ? `Unable to fire a "focus" event. Expected an event object, received ${description}.`
+ : undefined,
+ );
+ expect(onFocus.mock.calls).toEqual(isModern() ? [] : [[event]]);
+ },
+ );
+
+ test.each([
+ [{ target: 1 }, '"target". "target" is provided by the event.'],
+ [
+ { target: {}, currentTarget: { measure: () => {} } },
+ '"target", "currentTarget". "target", "currentTarget" are provided by the event.',
+ ],
+ [{ persist: () => {} }, '"persist". "persist" is provided by the event.'],
+ [
+ { preventDefault: () => {}, dispatchConfig: {} },
+ '"preventDefault", "dispatchConfig". "preventDefault", "dispatchConfig" are provided by the event.',
+ ],
+ [{ text: 'Hello' }, '"text". Pass "text" in "nativeEvent" instead.'],
+ [
+ { text: 'Hello', target: 1, persist: () => {} },
+ '"text", "target", "persist". "target", "persist" are provided by the event. Pass "text" in "nativeEvent" instead.',
+ ],
+ ])(
+ 'throws on unsupported keys %p (legacy: passes them to the handler)',
+ async (keys, message) => {
+ const onFocus = jest.fn();
+ await render();
+
+ const event = { nativeEvent: {}, ...keys };
+ const error = await getErrorMessage(fireEvent(screen.getByTestId('input'), 'focus', event));
+
+ expect(error).toBe(
+ isModern()
+ ? `Unable to fire a "focus" event. Unsupported event object keys: ${message}`
+ : undefined,
+ );
+ expect(onFocus.mock.calls).toEqual(isModern() ? [] : [[event]]);
+ },
+ );
+
+ test('throws when nativeEvent is not an object (legacy: passes it to the handler)', async () => {
+ const onFocus = jest.fn();
+ await render();
+
+ const error = await getErrorMessage(
+ fireEvent(screen.getByTestId('input'), 'focus', { nativeEvent: 'text' }),
+ );
+
+ expect(error).toBe(
+ isModern()
+ ? 'Unable to fire a "focus" event. Expected "nativeEvent" to be an object, received string "text".'
+ : undefined,
+ );
+ expect(onFocus).toHaveBeenCalledTimes(isModern() ? 0 : 1);
+ });
+
+ test.each([
+ ['123', 'string "123"'],
+ [Number.NaN, 'number NaN'],
+ [Infinity, 'number Infinity'],
+ [null, 'null'],
+ ])(
+ 'throws when timeStamp is %p (legacy: passes it to the handler)',
+ async (timeStamp, description) => {
+ const onFocus = jest.fn();
+ await render();
+
+ const error = await getErrorMessage(
+ fireEvent(screen.getByTestId('input'), 'focus', { timeStamp }),
+ );
+
+ expect(error).toBe(
+ isModern()
+ ? `Unable to fire a "focus" event. Expected "timeStamp" to be a finite number, received ${description}.`
+ : undefined,
+ );
+ expect(onFocus).toHaveBeenCalledTimes(isModern() ? 0 : 1);
+ },
+ );
+
+ test('passes timeStamp to handlers as event.timeStamp', async () => {
+ const onFocus = jest.fn();
+ await render();
+
+ const nativeEvent = { timestamp: 100 };
+ await fireEvent(screen.getByTestId('input'), 'focus', { nativeEvent, timeStamp: 42 });
+
+ const event = onFocus.mock.calls[0][0];
+ expect(event.timeStamp).toBe(42);
+ expect(event.nativeEvent).toBe(nativeEvent);
+ expect(nativeEvent).toEqual({ timestamp: 100 });
+ });
+
+ test('passes nativeEvent to handlers in an event targeting the element (legacy: passes the event object as given)', async () => {
+ const onSubmitEditing = jest.fn();
+ await render();
+
+ const nativeEvent = { text: 'Hello', target: 1 };
+ await fireEvent(screen.getByTestId('input'), 'submitEditing', { nativeEvent });
+
+ expect(onSubmitEditing).toHaveBeenCalledTimes(1);
+ const event = onSubmitEditing.mock.calls[0][0];
+ expect(event.nativeEvent).toBe(nativeEvent);
+ // Modern matches React Native, which passes a `SyntheticEvent`.
+ expect(event.type).toBe(isModern() ? 'submitediting' : undefined);
+ expect(event.target).toBe(isModern() ? screen.getByTestId('input') : undefined);
+ });
+
+ test('uses an empty nativeEvent when the event object has none (legacy: passes the event object as given)', async () => {
+ const onFocus = jest.fn();
+ await render();
+
+ await fireEvent(screen.getByTestId('input'), 'focus', {});
+
+ expect(onFocus.mock.calls[0][0].nativeEvent).toEqual(isModern() ? {} : undefined);
+ });
+
+ test('accepts an event object without a prototype', async () => {
+ const onFocus = jest.fn();
+ await render();
+
+ await fireEvent(screen.getByTestId('input'), 'focus', Object.create(null));
+
+ expect(onFocus).toHaveBeenCalledTimes(1);
+ });
+});
+
describe('fireEvent.press', () => {
- test('passes default press event object to handler', async () => {
+ test('passes a SyntheticEvent with default touch payload to handler (legacy: a plain event object)', async () => {
const onPress = jest.fn();
await render();
await fireEvent.press(screen.getByTestId('btn'));
- expect(onPress.mock.calls[0][0]).toMatchInlineSnapshot(`
+ // Modern matches React Native, which fills the touch arrays. Legacy leaves them empty.
+ expect(onPress.mock.calls[0][0].nativeEvent).toEqual(
+ isModern() ? getTouchPayload(false) : { ...defaultTouchPayload, timestamp: 100100100100 },
+ );
+ // Modern matches React Native, which passes a `SyntheticEvent`. Legacy passes a plain object
+ // with stubs of its methods.
+ expect(onPress.mock.calls[0][0] instanceof SyntheticEvent).toBe(isModern());
+ if (isModern()) return;
+ expect({ ...onPress.mock.calls[0][0], nativeEvent: undefined }).toMatchInlineSnapshot(`
{
"currentTarget": {
"measure": [Function],
@@ -102,17 +574,7 @@ describe('fireEvent.press', () => {
"isDefaultPrevented": [Function],
"isPersistent": [Function],
"isPropagationStopped": [Function],
- "nativeEvent": {
- "changedTouches": [],
- "identifier": 0,
- "locationX": 0,
- "locationY": 0,
- "pageX": 0,
- "pageY": 0,
- "target": 0,
- "timestamp": 100100100100,
- "touches": [],
- },
+ "nativeEvent": undefined,
"persist": [Function],
"preventDefault": [Function],
"stopPropagation": [Function],
@@ -122,11 +584,56 @@ describe('fireEvent.press', () => {
`);
});
+ test('passes a press event from the pressed element to the responder (legacy: a plain event object)', async () => {
+ let event: SyntheticEvent | undefined;
+ let currentTarget: unknown;
+ let eventPhase: number | undefined;
+ await render(
+ {
+ event = e as unknown as SyntheticEvent;
+ currentTarget = event.currentTarget;
+ eventPhase = event.eventPhase;
+ }}
+ >
+ Press me
+ ,
+ );
+
+ await fireEvent.press(screen.getByText('Press me'));
+
+ expect(event?.nativeEvent).toEqual(isModern() ? getTouchPayload(false) : defaultTouchPayload);
+ // Modern matches React Native: the event targets the pressed element, and Pressability calls
+ // `onPress` of the responder. Legacy passes stubs as `target` and `currentTarget`.
+ if (!isModern()) return;
+ expect(event?.type).toBe('responderRelease');
+ expect(event?.dispatchConfig).toEqual({
+ registrationName: 'onResponderRelease',
+ dependencies: ['topTouchCancel', 'topTouchEnd'],
+ });
+ expect(event?.target).toBe(screen.getByText('Press me'));
+ expect(currentTarget).toBe(screen.getByTestId('pressable'));
+ // The responder system calls the handler directly, without an event dispatch.
+ expect(eventPhase).toBe(event?.NONE);
+ expect(event?.isTrusted).toBe(false);
+ // Reset after the dispatch.
+ expect(event?.currentTarget).toBeNull();
+ });
+
test('overrides default event properties with passed event props', async () => {
const onPress = jest.fn();
await render();
- const customEventData = { nativeEvent: { pageX: 20, pageY: 30 } };
+ const customEventData = { nativeEvent: { pageX: 20, pageY: 30 }, timeStamp: 123 };
await fireEvent.press(screen.getByTestId('btn'), customEventData);
+ expect(onPress.mock.calls[0][0].nativeEvent).toEqual(
+ isModern()
+ ? getTouchPayload(false, { pageX: 20, pageY: 30 })
+ : { ...defaultTouchPayload, pageX: 20, pageY: 30, timestamp: 100100100100 },
+ );
+ expect(onPress.mock.calls[0][0].timeStamp).toBe(123);
+ // Legacy merges the passed props into a plain event object.
+ if (isModern()) return;
expect(onPress.mock.calls[0][0]).toMatchInlineSnapshot(`
{
"currentTarget": {
@@ -150,11 +657,29 @@ describe('fireEvent.press', () => {
"preventDefault": [Function],
"stopPropagation": [Function],
"target": {},
- "timeStamp": 0,
+ "timeStamp": 123,
}
`);
});
+ test('throws on unsupported event object keys (legacy: merges them into the event)', async () => {
+ const onPress = jest.fn();
+ await render();
+ const pressable = screen.getByTestId('pressable');
+
+ const error = await getErrorMessage(fireEvent.press(pressable, { persist: jest.fn() }));
+ // Modern matches React Native, where the event provides its own methods.
+ expect(error).toBe(
+ isModern()
+ ? 'Unable to fire a "press" event. Unsupported event object keys: "persist". "persist" is provided by the event.'
+ : undefined,
+ );
+ expect(onPress).toHaveBeenCalledTimes(isModern() ? 0 : 1);
+
+ await fireEvent.press(pressable, undefined);
+ expect(onPress).toHaveBeenCalledTimes(isModern() ? 1 : 2);
+ });
+
test.each([
['Pressable', Pressable],
['TouchableOpacity', TouchableOpacity],
@@ -174,6 +699,87 @@ describe('fireEvent.press', () => {
expect(onPress).toHaveBeenCalled();
});
+ test('calls only onPress, not onPressIn, onPressOut or onLongPress', async () => {
+ const calls: string[] = [];
+ await render(
+ calls.push('onPress')}
+ onPressIn={() => calls.push('onPressIn')}
+ onPressOut={() => calls.push('onPressOut')}
+ onLongPress={() => calls.push('onLongPress')}
+ />,
+ );
+
+ await fireEvent.press(screen.getByTestId('pressable'));
+
+ expect(calls).toEqual(['onPress']);
+ });
+
+ test('calls onPress of host elements that have it, the innermost only', async () => {
+ const onInnerPress = jest.fn();
+ const onOuterPress = jest.fn();
+ await render(
+
+ Outer Inner
+ ,
+ );
+
+ await fireEvent.press(screen.getByText('Inner'));
+
+ expect(onInnerPress).toHaveBeenCalledTimes(1);
+ expect(onOuterPress).not.toHaveBeenCalled();
+ // Modern resets the event after the dispatch, as React Native does.
+ if (!isModern()) return;
+ expect(onInnerPress.mock.calls[0][0].currentTarget).toBeNull();
+ });
+
+ test('calls onPress of editable TextInput', async () => {
+ const onPress = jest.fn();
+ await render();
+
+ await fireEvent.press(screen.getByTestId('input'));
+
+ expect(onPress).toHaveBeenCalledTimes(1);
+ });
+
+ test('calls onPress of TextInput, not of the Pressable around it', async () => {
+ const onInputPress = jest.fn();
+ const onPress = jest.fn();
+ await render(
+
+
+ ,
+ );
+
+ await fireEvent.press(screen.getByTestId('input'));
+
+ expect(onInputPress).toHaveBeenCalledTimes(1);
+ expect(onPress).not.toHaveBeenCalled();
+ });
+
+ test.each([
+ ['without onPress* props', {}],
+ ['with only onPressIn', { onPressIn: () => {} }],
+ ])(
+ 'does not call onPress of the Pressable around TextInput %s (legacy: calls it)',
+ async (_, inputProps) => {
+ const onPress = jest.fn();
+ await render(
+
+
+ ,
+ );
+
+ await fireEvent.press(screen.getByTestId('input'));
+
+ // Modern matches React Native: TextInput's Pressability always claims the touch, so it
+ // never reaches the Pressable. The Jest mock of TextInput has no responder props, so legacy
+ // goes on to the nearest ancestor with `onPress`.
+ expect(onPress).toHaveBeenCalledTimes(isModern() ? 0 : 1);
+ },
+ );
+
test('works with testOnly_onPress handlers', async () => {
const onPress = jest.fn();
const onPressIn = jest.fn();
@@ -193,48 +799,330 @@ describe('fireEvent.press', () => {
await fireEvent.press(subject);
expect(onPress).toHaveBeenCalledTimes(1);
- await fireEvent(subject, 'pressIn');
+ await fireEvent(subject, 'pressIn', emptyEvent);
expect(onPressIn).toHaveBeenCalledTimes(1);
- await fireEvent(subject, 'pressOut');
+ await fireEvent(subject, 'pressOut', emptyEvent);
expect(onPressOut).toHaveBeenCalledTimes(1);
- await fireEvent(subject, 'longPress');
+ await fireEvent(subject, 'longPress', emptyEvent);
expect(onLongPress).toHaveBeenCalledTimes(1);
});
+
+ test('renders state updates from onPress', async () => {
+ const Counter = () => {
+ const [count, setCount] = React.useState(0);
+ return (
+ setCount(count + 1)}>
+ Count: {count}
+
+ );
+ };
+ await render();
+
+ await fireEvent.press(screen.getByText('Count: 0'));
+
+ expect(screen.getByText('Count: 1')).toBeOnTheScreen();
+ });
+
+ test('rethrows onPress error after rendering state updates (legacy: without rendering them)', async () => {
+ const Counter = () => {
+ const [count, setCount] = React.useState(0);
+ return (
+ {
+ setCount(count + 1);
+ throw new Error('Press error');
+ }}
+ >
+ Count: {count}
+
+ );
+ };
+ await render();
+
+ await expect(fireEvent.press(screen.getByText('Count: 0'))).rejects.toThrow('Press error');
+ // Modern rethrows after `act()`, so React renders the update first. Legacy throws inside it.
+ expect(screen.getByText(isModern() ? 'Count: 1' : 'Count: 0')).toBeOnTheScreen();
+ });
});
describe('fireEvent.changeText', () => {
- test('works on TextInput', async () => {
+ function logChangeHandlers(calls: string[], id: string) {
+ return {
+ onChange: () => calls.push(`${id}.onChange`),
+ onChangeCapture: () => calls.push(`${id}.onChangeCapture`),
+ };
+ }
+
+ test('fires change event with the text, then calls onChangeText (legacy: calls only onChangeText)', async () => {
+ const calls: string[] = [];
+ const onChange = jest.fn((_event: unknown) => calls.push('onChange'));
+ const onChangeText = jest.fn((_text: string) => calls.push('onChangeText'));
+ await render();
+ const input = screen.getByTestId('input');
+
+ await fireEvent.changeText(input, 'Hello');
+
+ // Modern matches React Native, where TextInput calls `onChangeText` from its `onChange`.
+ expect(calls).toEqual(isModern() ? ['onChange', 'onChangeText'] : ['onChangeText']);
+ expect(onChangeText).toHaveBeenCalledWith('Hello');
+ expect(nativeState.valueForInstance.get(input)).toBe('Hello');
+ if (!isModern()) return;
+ const event = onChange.mock.calls[0][0] as SyntheticEvent;
+ expect(event.type).toBe('change');
+ expect(event.target).toBe(input);
+ expect(event.nativeEvent).toEqual({
+ text: 'Hello',
+ target: 0,
+ eventCount: 0,
+ selection: { start: 5, end: 5 },
+ });
+ });
+
+ test('calls onChangeText after the input onChange, before ancestors onChange (legacy: calls only onChangeText)', async () => {
+ const calls: string[] = [];
+ await render(
+
+ calls.push('input.onChangeText')}
+ />
+ ,
+ );
+
+ await fireEvent.changeText(screen.getByTestId('input'), 'Hello');
+
+ // Modern matches React Native, where `change` goes through capture and bubble phases, and
+ // TextInput calls `onChangeText` from its `onChange`.
+ expect(calls).toEqual(
+ isModern()
+ ? [
+ 'parent.onChangeCapture',
+ 'input.onChangeCapture',
+ 'input.onChange',
+ 'input.onChangeText',
+ 'parent.onChange',
+ ]
+ : ['input.onChangeText'],
+ );
+ });
+
+ test('does not call onChangeText of an ancestor (legacy: calls it)', async () => {
const onChangeText = jest.fn();
+ await render(
+
+
+ ,
+ );
+
+ await fireEvent.changeText(screen.getByTestId('input'), 'Hello');
+
+ // Modern matches React Native, where only TextInput calls its own `onChangeText`.
+ expect(onChangeText).toHaveBeenCalledTimes(isModern() ? 0 : 1);
+ });
+
+ test('does not call onChange or onChangeText when a capture handler stops propagation (legacy: calls onChangeText)', async () => {
+ const onChange = jest.fn();
+ const onChangeText = jest.fn();
+ await render(
+ event.stopPropagation() }}>
+
+ ,
+ );
+
+ await fireEvent.changeText(screen.getByTestId('input'), 'Hello');
+
+ // Modern matches React Native. Legacy calls no capture handlers.
+ expect(onChange).not.toHaveBeenCalled();
+ expect(onChangeText).toHaveBeenCalledTimes(isModern() ? 0 : 1);
+ });
+
+ test('calls onChangeText when the input onChange stops propagation', async () => {
+ const onParentChange = jest.fn();
+ const onChangeText = jest.fn();
+ await render(
+
+ event.stopPropagation()}
+ onChangeText={onChangeText}
+ />
+ ,
+ );
+
+ await fireEvent.changeText(screen.getByTestId('input'), 'Hello');
+
+ expect(onChangeText).toHaveBeenCalledWith('Hello');
+ expect(onParentChange).not.toHaveBeenCalled();
+ });
+
+ test('does not call onChangeText when the input onChange throws (legacy: does not call onChange)', async () => {
+ const onChangeText = jest.fn();
+ await render(
+ {
+ throw new Error('Change error');
+ }}
+ onChangeText={onChangeText}
+ />,
+ );
+
+ const error = await getErrorMessage(fireEvent.changeText(screen.getByTestId('input'), 'Hello'));
+
+ expect(error).toBe(isModern() ? 'Change error' : undefined);
+ expect(onChangeText).toHaveBeenCalledTimes(isModern() ? 0 : 1);
+ });
+
+ test('saves value of uncontrolled TextInput before handlers run', async () => {
+ let value;
+ const onChangeText = jest.fn(() => {
+ value = nativeState.valueForInstance.get(screen.getByTestId('input'));
+ });
await render();
+
+ await fireEvent.changeText(screen.getByTestId('input'), 'Hello');
+
+ expect(value).toBe('Hello');
+ expect(screen.getByTestId('input')).toHaveDisplayValue('Hello');
+ });
+
+ test('renders state updates from handlers', async () => {
+ function Subject() {
+ const [value, setValue] = React.useState('');
+ return ;
+ }
+
+ await render();
+ await fireEvent.changeText(screen.getByTestId('input'), 'Hello');
+
+ expect(screen.getByTestId('input')).toHaveDisplayValue('Hello');
+ });
+
+ test('does not fire on non-editable TextInput', async () => {
+ const onChange = jest.fn();
+ const onChangeText = jest.fn();
+ await render(
+ ,
+ );
const input = screen.getByTestId('input');
- await fireEvent.changeText(input, 'new text');
- expect(onChangeText).toHaveBeenCalledWith('new text');
- expect(nativeState.valueForInstance.get(input)).toBe('new text');
+
+ await fireEvent.changeText(input, 'Hello');
+
+ expect(onChange).not.toHaveBeenCalled();
+ expect(onChangeText).not.toHaveBeenCalled();
+ expect(nativeState.valueForInstance.get(input)).toBeUndefined();
+ });
+
+ test('throws on elements other than TextInput (legacy: calls onChangeText of any element)', async () => {
+ const onChangeText = jest.fn();
+ await render();
+
+ const error = await getErrorMessage(fireEvent.changeText(screen.getByTestId('view'), 'Hello'));
+
+ // Modern matches React Native, where only TextInput calls `onChangeText`.
+ expect(error).toBe(
+ isModern()
+ ? 'Unable to fire a "changeText" event. Expected a host "TextInput" element, received "View".'
+ : undefined,
+ );
+ expect(onChangeText).toHaveBeenCalledTimes(isModern() ? 0 : 1);
});
- test('updates native state when fired with `on*` prefixed name', async () => {
+ test('throws when text is not a string (legacy: passes it to onChangeText)', async () => {
const onChangeText = jest.fn();
await render();
+
+ const error = await getErrorMessage(
+ fireEvent.changeText(screen.getByTestId('input'), 5 as unknown as string),
+ );
+
+ expect(error).toBe(
+ isModern()
+ ? 'Unable to fire a "changeText" event. Expected text to be a string, received number 5.'
+ : undefined,
+ );
+ expect(onChangeText.mock.calls).toEqual(isModern() ? [] : [[5]]);
+ });
+
+ test('throws when fired as `onChangeText` (legacy: calls it and updates native state)', async () => {
+ const onChangeText = jest.fn();
+ await render();
+ const input = screen.getByTestId('input');
+ const error = await getErrorMessage(fireEvent(input, 'onChangeText', 'new text'));
+ // Modern matches React Native: `changeText` isn't a native event, TextInput calls
+ // `onChangeText` from its `onChange`. Use `fireEvent.changeText()` in both.
+ expect(error).toBe(
+ isModern()
+ ? `Unable to fire a "changeText" event. React Native doesn't dispatch "changeText" natively. Use fireEvent.changeText() or userEvent.type() instead.`
+ : undefined,
+ );
+ expect(onChangeText.mock.calls).toEqual(isModern() ? [] : [['new text']]);
+ expect(nativeState.valueForInstance.get(input)).toBe(isModern() ? undefined : 'new text');
+ });
+
+ test('throws when fired as `changeText` with an event object (legacy: calls onChangeText)', async () => {
+ const onChangeText = jest.fn();
+ await render();
+
+ const error = await getErrorMessage(
+ fireEvent(screen.getByTestId('input'), 'changeText', emptyEvent),
+ );
+
+ // Modern checks the event type before the event object, to point to `fireEvent.changeText()`.
+ expect(error).toBe(
+ isModern()
+ ? `Unable to fire a "changeText" event. React Native doesn't dispatch "changeText" natively. Use fireEvent.changeText() or userEvent.type() instead.`
+ : undefined,
+ );
+ expect(onChangeText.mock.calls).toEqual(isModern() ? [] : [[emptyEvent]]);
+ });
+});
+
+describe('native state', () => {
+ test('change event saves value of TextInput in native state', async () => {
+ await render();
const input = screen.getByTestId('input');
- await fireEvent(input, 'onChangeText', 'new text');
- expect(onChangeText).toHaveBeenCalledWith('new text');
+
+ await fireEvent(input, 'change', { nativeEvent: { text: 'new text' } });
+
expect(nativeState.valueForInstance.get(input)).toBe('new text');
});
- test('does not fire on non-editable TextInput', async () => {
- const onChangeText = jest.fn();
- await render();
+ test('change event without text or on non-editable TextInput does not save value', async () => {
+ await render(
+ <>
+
+
+ >,
+ );
const input = screen.getByTestId('input');
- await fireEvent.changeText(input, 'new text');
- expect(onChangeText).not.toHaveBeenCalled();
+ const nonEditable = screen.getByTestId('non-editable');
+
+ await fireEvent(input, 'change', emptyEvent);
+ await fireEvent(nonEditable, 'change', { nativeEvent: { text: 'Hello' } });
+
expect(nativeState.valueForInstance.get(input)).toBeUndefined();
+ expect(nativeState.valueForInstance.get(nonEditable)).toBeUndefined();
+ });
+
+ test('is saved before handlers run', async () => {
+ let contentOffset;
+ const onScroll = jest.fn(() => {
+ contentOffset = nativeState.contentOffsetForInstance.get(screen.getByTestId('scroll'));
+ });
+ await render();
+
+ await fireEvent(screen.getByTestId('scroll'), 'scroll', verticalScrollEvent);
+
+ expect(contentOffset).toEqual({ x: 0, y: 200 });
});
});
describe('fireEvent.scroll', () => {
- test('passes default scroll event object to handler', async () => {
+ test('passes a SyntheticEvent with default scroll payload to handler (legacy: a plain event object)', async () => {
const onScroll = jest.fn();
await render(
@@ -243,38 +1131,47 @@ describe('fireEvent.scroll', () => {
);
const scrollView = screen.getByTestId('scroll');
await fireEvent.scroll(scrollView);
- expect(onScroll.mock.calls[0][0]).toMatchInlineSnapshot(`
+ expect(onScroll.mock.calls[0][0].nativeEvent).toMatchInlineSnapshot(`
+ {
+ "contentInset": {
+ "bottom": 0,
+ "left": 0,
+ "right": 0,
+ "top": 0,
+ },
+ "contentOffset": {
+ "x": 0,
+ "y": 0,
+ },
+ "contentSize": {
+ "height": 0,
+ "width": 0,
+ },
+ "layoutMeasurement": {
+ "height": 0,
+ "width": 0,
+ },
+ "responderIgnoreScroll": true,
+ "target": 0,
+ "velocity": {
+ "x": 0,
+ "y": 0,
+ },
+ }
+ `);
+ // Modern matches React Native, which passes a direct `SyntheticEvent`. Legacy passes a plain
+ // object with stubs of its methods.
+ expect(onScroll.mock.calls[0][0] instanceof SyntheticEvent).toBe(isModern());
+ expect(onScroll.mock.calls[0][0].type).toBe(isModern() ? 'scroll' : undefined);
+ expect(onScroll.mock.calls[0][0].rnIsDirect).toBe(isModern() ? true : undefined);
+ if (isModern()) return;
+ expect({ ...onScroll.mock.calls[0][0], nativeEvent: undefined }).toMatchInlineSnapshot(`
{
"currentTarget": {},
"isDefaultPrevented": [Function],
"isPersistent": [Function],
"isPropagationStopped": [Function],
- "nativeEvent": {
- "contentInset": {
- "bottom": 0,
- "left": 0,
- "right": 0,
- "top": 0,
- },
- "contentOffset": {
- "x": 0,
- "y": 0,
- },
- "contentSize": {
- "height": 0,
- "width": 0,
- },
- "layoutMeasurement": {
- "height": 0,
- "width": 0,
- },
- "responderIgnoreScroll": true,
- "target": 0,
- "velocity": {
- "x": 0,
- "y": 0,
- },
- },
+ "nativeEvent": undefined,
"persist": [Function],
"preventDefault": [Function],
"stopPropagation": [Function],
@@ -293,48 +1190,57 @@ describe('fireEvent.scroll', () => {
);
const scrollView = screen.getByTestId('scroll');
const customEventData = {
- nativeEvent: { contentOffset: { x: 50, y: 200 } },
+ nativeEvent: { contentOffset: { x: 50, y: 200 }, zoomScale: 2 },
+ timeStamp: 123,
};
await fireEvent.scroll(scrollView, customEventData);
- expect(onScroll.mock.calls[0][0]).toMatchInlineSnapshot(`
+ expect(onScroll.mock.calls[0][0].nativeEvent).toMatchInlineSnapshot(`
{
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
- "nativeEvent": {
- "contentInset": {
- "bottom": 0,
- "left": 0,
- "right": 0,
- "top": 0,
- },
- "contentOffset": {
- "x": 50,
- "y": 200,
- },
- "contentSize": {
- "height": 0,
- "width": 0,
- },
- "layoutMeasurement": {
- "height": 0,
- "width": 0,
- },
- "responderIgnoreScroll": true,
- "target": 0,
- "velocity": {
- "x": 0,
- "y": 0,
- },
+ "contentInset": {
+ "bottom": 0,
+ "left": 0,
+ "right": 0,
+ "top": 0,
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
+ "contentOffset": {
+ "x": 50,
+ "y": 200,
+ },
+ "contentSize": {
+ "height": 0,
+ "width": 0,
+ },
+ "layoutMeasurement": {
+ "height": 0,
+ "width": 0,
+ },
+ "responderIgnoreScroll": true,
+ "target": 0,
+ "velocity": {
+ "x": 0,
+ "y": 0,
+ },
+ "zoomScale": 2,
}
`);
+ expect(onScroll.mock.calls[0][0].timeStamp).toBe(123);
+ });
+
+ test('throws on unsupported event object keys (legacy: merges them into the event)', async () => {
+ const onScroll = jest.fn();
+ await render();
+
+ const error = await getErrorMessage(
+ fireEvent.scroll(screen.getByTestId('scroll'), { persist: () => {} }),
+ );
+
+ // Modern matches React Native, where the event provides its own methods.
+ expect(error).toBe(
+ isModern()
+ ? 'Unable to fire a "scroll" event. Unsupported event object keys: "persist". "persist" is provided by the event.'
+ : undefined,
+ );
+ expect(onScroll).toHaveBeenCalledTimes(isModern() ? 0 : 1);
});
test('works on ScrollView', async () => {
@@ -353,18 +1259,6 @@ describe('fireEvent.scroll', () => {
});
});
- test('updates native state when fired with `on*` prefixed name', async () => {
- const onScroll = jest.fn();
- await render();
- const scrollView = screen.getByTestId('scroll');
- await fireEvent(scrollView, 'onScroll', verticalScrollEvent);
- expect(onScroll).toHaveBeenCalledWith(verticalScrollEvent);
- expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({
- x: 0,
- y: 200,
- });
- });
-
test.each([
['onScroll', 'scroll'],
['onScrollBeginDrag', 'scrollBeginDrag'],
@@ -376,7 +1270,10 @@ describe('fireEvent.scroll', () => {
await render();
const scrollView = screen.getByTestId('scroll');
await fireEvent(scrollView, eventType, verticalScrollEvent);
- expect(handler).toHaveBeenCalledWith(verticalScrollEvent);
+ // Modern matches React Native, which passes a `SyntheticEvent`. Legacy passes the event object
+ // as given.
+ expect(handler.mock.calls).toEqual([[isModern() ? anySyntheticEvent : verticalScrollEvent]]);
+ expect(handler.mock.calls[0][0]).toMatchObject(verticalScrollEvent);
expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({
x: 0,
y: 200,
@@ -401,6 +1298,18 @@ describe('fireEvent.scroll', () => {
});
});
+ test('uses default payload when the event object is undefined', async () => {
+ const onScroll = jest.fn();
+ await render();
+ const scrollView = screen.getByTestId('scroll');
+
+ await fireEvent.scroll(scrollView, undefined);
+ await fireEvent.scroll(scrollView);
+
+ expect(onScroll).toHaveBeenCalledTimes(2);
+ expect(onScroll.mock.calls[0][0].nativeEvent).toEqual(onScroll.mock.calls[1][0].nativeEvent);
+ });
+
test('with non-finite contentOffset values uses 0', async () => {
const onScroll = jest.fn();
await render(
@@ -497,11 +1406,11 @@ describe('fireEvent.scroll', () => {
});
test('does not use layout size of non-ScrollView element as layoutMeasurement', async () => {
- const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {});
const onScroll = jest.fn();
await render(
-
- {}} />
+
+ {/* Spread because `View` types don't include `onScroll`. */}
+ {}} {...{ onScroll }} />
,
);
const content = screen.getByTestId('content');
@@ -513,32 +1422,40 @@ describe('fireEvent.scroll', () => {
width: 0,
height: 0,
});
- warnSpy.mockRestore();
});
});
describe('fireEvent.layout', () => {
- test('passes default layout event object to handler', async () => {
+ test('passes a SyntheticEvent with default layout payload to handler (legacy: a plain event object)', async () => {
const onLayout = jest.fn();
await render();
await fireEvent.layout(screen.getByTestId('view'));
- expect(onLayout.mock.calls[0][0]).toMatchInlineSnapshot(`
+ expect(onLayout.mock.calls[0][0].nativeEvent).toMatchInlineSnapshot(`
+ {
+ "layout": {
+ "height": 0,
+ "width": 0,
+ "x": 0,
+ "y": 0,
+ },
+ "target": 0,
+ }
+ `);
+ // Modern matches React Native, which passes a direct `SyntheticEvent`. Legacy passes a plain
+ // object with stubs of its methods.
+ expect(onLayout.mock.calls[0][0] instanceof SyntheticEvent).toBe(isModern());
+ expect(onLayout.mock.calls[0][0].type).toBe(isModern() ? 'layout' : undefined);
+ expect(onLayout.mock.calls[0][0].rnIsDirect).toBe(isModern() ? true : undefined);
+ if (isModern()) return;
+ expect({ ...onLayout.mock.calls[0][0], nativeEvent: undefined }).toMatchInlineSnapshot(`
{
"currentTarget": {},
"isDefaultPrevented": [Function],
"isPersistent": [Function],
"isPropagationStopped": [Function],
- "nativeEvent": {
- "layout": {
- "height": 0,
- "width": 0,
- "x": 0,
- "y": 0,
- },
- "target": 0,
- },
+ "nativeEvent": undefined,
"persist": [Function],
"preventDefault": [Function],
"stopPropagation": [Function],
@@ -599,7 +1516,7 @@ describe('fireEvent.layout', () => {
expect(onLayout).not.toHaveBeenCalled();
});
- test('bubbles with a warning when fired as generic layout event', async () => {
+ test('does not bubble when fired as generic layout event (legacy: bubbles with a warning)', async () => {
const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {});
const onLayout = jest.fn();
await render(
@@ -609,10 +1526,11 @@ describe('fireEvent.layout', () => {
);
await fireEvent(screen.getByTestId('child'), 'layout', layoutEvent);
- await fireEvent(screen.getByTestId('child'), 'onLayout', layoutEvent);
- expect(onLayout).toHaveBeenCalledTimes(2);
- expect(warnSpy).toHaveBeenCalledTimes(2);
+ // Modern matches React Native: layout is a direct event, so it doesn't bubble. Legacy still
+ // bubbles it, with a warning.
+ expect(onLayout).toHaveBeenCalledTimes(isModern() ? 0 : 1);
+ expect(warnSpy).toHaveBeenCalledTimes(isModern() ? 0 : 1);
warnSpy.mockRestore();
});
@@ -627,26 +1545,6 @@ describe('fireEvent.layout', () => {
warnSpy.mockRestore();
});
- test('warns when element has no onLayout handler and event has no layout', async () => {
- configure({ eventDiagnostics: true });
- const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {});
- await render();
-
- await fireEvent(screen.getByTestId('view'), 'layout', { nativeEvent: {} });
-
- expect(warnSpy).toHaveBeenCalledTimes(1);
- expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(`
- " ▲ No "onLayout" handler found on the element or its ancestors.
- If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`.
-
-
- "
- `);
- warnSpy.mockRestore();
- });
-
test('is not blocked by element responder rejecting touches', async () => {
const onLayout = jest.fn();
await render(
@@ -833,7 +1731,6 @@ describe('direct events', () => {
),
];
- // Builds one case per event type, passing the matching `on*` handler prop to `ui`.
function buildDirectEventCases(
source: string,
eventTypes: string[],
@@ -846,15 +1743,19 @@ describe('direct events', () => {
}));
}
- test.each(directEventCases)('bubbles $name with a warning', async ({ eventType, ui }) => {
- const handler = jest.fn();
- await render(ui(handler));
+ test.each(directEventCases)(
+ 'does not bubble $name (legacy: bubbles with a warning)',
+ async ({ eventType, ui }) => {
+ const handler = jest.fn();
+ await render(ui(handler));
- await fireEvent(screen.getByTestId('target'), eventType);
+ await fireEvent(screen.getByTestId('target'), eventType, emptyEvent);
- expect(handler).toHaveBeenCalledTimes(1);
- expect(warnSpy).toHaveBeenCalledTimes(1);
- });
+ // Modern matches React Native: direct events don't bubble.
+ expect(handler).toHaveBeenCalledTimes(isModern() ? 0 : 1);
+ expect(warnSpy).toHaveBeenCalledTimes(isModern() ? 0 : 1);
+ },
+ );
test.each(directEventCases)(
'does not warn for $name when fired on the emitting element',
@@ -862,36 +1763,44 @@ describe('direct events', () => {
const handler = jest.fn();
await render(ui(handler));
- await fireEvent(screen.getByTestId('emitter'), eventType);
+ await fireEvent(screen.getByTestId('emitter'), eventType, emptyEvent);
expect(handler).toHaveBeenCalledTimes(1);
expect(warnSpy).not.toHaveBeenCalled();
},
);
- test('warns when fired with "on" prefixed event name', async () => {
- const onMomentumScrollEnd = jest.fn();
+ test('calls only the handler of the target when its ancestor has one too', async () => {
+ const onParentLayout = jest.fn();
+ const onLayout = jest.fn();
await render(
-
-
- ,
+
+
+ ,
);
- await fireEvent(screen.getByTestId('child'), 'onMomentumScrollEnd');
+ const nativeEvent = { layout: { x: 0, y: 0, width: 100, height: 50 } };
+ await fireEvent(screen.getByTestId('target'), 'layout', { nativeEvent });
- expect(onMomentumScrollEnd).toHaveBeenCalledTimes(1);
- expect(warnSpy).toHaveBeenCalledTimes(1);
+ expect(onLayout).toHaveBeenCalledTimes(1);
+ expect(onLayout.mock.calls[0][0].nativeEvent).toBe(nativeEvent);
+ expect(onParentLayout).not.toHaveBeenCalled();
});
- test('warns about stopping bubbling in the next major version', async () => {
+ test('does not bubble scroll (legacy: warns about stopping bubbling in the next major version)', async () => {
+ const onScroll = jest.fn();
await render(
- {}}>
+
,
);
await fireEvent.scroll(screen.getByTestId('child'));
+ // Modern matches React Native: direct events don't bubble.
+ expect(onScroll).toHaveBeenCalledTimes(isModern() ? 0 : 1);
+ expect(warnSpy).toHaveBeenCalledTimes(isModern() ? 0 : 1);
+ if (isModern()) return;
expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(`
" ▲ fireEvent: "scroll" does not bubble in React Native. fireEvent will stop bubbling it in the next major version. Fire it on:
@@ -903,7 +1812,7 @@ describe('direct events', () => {
});
test.each([true, false])(
- 'warns about bubbling regardless of eventDiagnostics (%s)',
+ 'does not bubble regardless of eventDiagnostics (%s) (legacy: always warns about bubbling)',
async (eventDiagnostics) => {
configure({ eventDiagnostics });
await render(
@@ -914,12 +1823,15 @@ describe('direct events', () => {
await fireEvent.scroll(screen.getByTestId('child'));
- expect(warnSpy).toHaveBeenCalledTimes(1);
+ // Modern matches React Native: direct events don't bubble. Legacy still bubbles them, with
+ // a warning.
+ expect(warnSpy).toHaveBeenCalledTimes(isModern() ? 0 : 1);
+ if (isModern()) return;
expect(warnSpy.mock.calls[0][0]).toContain('"scroll" does not bubble in React Native');
},
);
- test('warns when handler is on composite component above the emitting element', async () => {
+ test('does not call handler on composite component above the emitting element (legacy: calls it with a warning)', async () => {
const onScroll = jest.fn();
const Screen = (_props: { onScroll: () => void }) => (
@@ -930,12 +1842,13 @@ describe('direct events', () => {
await fireEvent.scroll(screen.getByTestId('child'));
- expect(onScroll).toHaveBeenCalledTimes(1);
- expect(warnSpy).toHaveBeenCalledTimes(1);
+ // Modern matches React Native: scroll doesn't bubble, and composite props are never called.
+ expect(onScroll).toHaveBeenCalledTimes(isModern() ? 0 : 1);
+ expect(warnSpy).toHaveBeenCalledTimes(isModern() ? 0 : 1);
});
// Known gap: only the type of the element with the handler is checked.
- test('does not warn when handler is on an element that does not emit the event', async () => {
+ test('does not call handler on an element that does not emit the event (legacy: calls it without a warning)', async () => {
const onScroll = jest.fn();
const Screen = (_props: { onScroll: () => void }) => (
@@ -948,11 +1861,12 @@ describe('direct events', () => {
await fireEvent.scroll(screen.getByTestId('child'));
- expect(onScroll).toHaveBeenCalledTimes(1);
+ // Modern matches React Native: scroll doesn't bubble, and composite props are never called.
+ expect(onScroll).toHaveBeenCalledTimes(isModern() ? 0 : 1);
expect(warnSpy).not.toHaveBeenCalled();
});
- test('does not warn when bubbling to composite component handler', async () => {
+ test('does not call composite component handlers (legacy: calls them without a warning)', async () => {
const onLoad = jest.fn();
const onShow = jest.fn();
const Card = (_props: { onLoad: () => void; onShow: () => void }) => (
@@ -962,15 +1876,17 @@ describe('direct events', () => {
);
await render();
- await fireEvent(screen.getByText('Card'), 'load');
- await fireEvent(screen.getByText('Card'), 'show');
+ await fireEvent(screen.getByText('Card'), 'load', emptyEvent);
+ await fireEvent(screen.getByText('Card'), 'show', emptyEvent);
- expect(onLoad).toHaveBeenCalledTimes(1);
- expect(onShow).toHaveBeenCalledTimes(1);
+ // Modern matches React Native: composite props are never called, and these events don't
+ // bubble.
+ expect(onLoad).toHaveBeenCalledTimes(isModern() ? 0 : 1);
+ expect(onShow).toHaveBeenCalledTimes(isModern() ? 0 : 1);
expect(warnSpy).not.toHaveBeenCalled();
});
- test('does not warn when bubbling to host element that does not emit the event', async () => {
+ test('does not bubble to host element that does not emit the event (legacy: bubbles without a warning)', async () => {
const onLoad = jest.fn();
await render(
// @ts-expect-error View does not have onLoad prop
@@ -979,13 +1895,14 @@ describe('direct events', () => {
,
);
- await fireEvent(screen.getByText('Content'), 'load');
+ await fireEvent(screen.getByText('Content'), 'load', emptyEvent);
- expect(onLoad).toHaveBeenCalledTimes(1);
+ // Modern matches React Native: load is a direct event, so it doesn't bubble.
+ expect(onLoad).toHaveBeenCalledTimes(isModern() ? 0 : 1);
expect(warnSpy).not.toHaveBeenCalled();
});
- test('does not warn when bubbling load event to ImageBackground handler', async () => {
+ test('does not bubble load event to ImageBackground handler (legacy: bubbles without a warning)', async () => {
const onLoad = jest.fn();
await render(
@@ -993,14 +1910,376 @@ describe('direct events', () => {
,
);
- await fireEvent(screen.getByText('Caption'), 'load');
+ await fireEvent(screen.getByText('Caption'), 'load', emptyEvent);
- expect(onLoad).toHaveBeenCalledTimes(1);
+ // Modern matches React Native: load doesn't bubble, and ImageBackground passes `onLoad` to its
+ // Image, which isn't an ancestor of the caption.
+ expect(onLoad).toHaveBeenCalledTimes(isModern() ? 0 : 1);
expect(warnSpy).not.toHaveBeenCalled();
});
});
-test('fireEvent fires custom event (onCustomEvent) on composite component', async () => {
+describe('custom event types', () => {
+ // Third-party native components render as host elements with all their props in Jest, as
+ // `@react-native/jest-preset` mocks `requireNativeComponent()` and `NativeComponentRegistry`.
+ test('dispatches a direct custom event to the target only (legacy: passes the event object as given)', async () => {
+ const onPageSelected = jest.fn();
+ const onParentPageSelected = jest.fn();
+ await render(
+ React.createElement(
+ 'RNCViewPager',
+ { onPageSelected: onParentPageSelected },
+ React.createElement('RNCViewPager', { testID: 'pager', onPageSelected }),
+ ),
+ );
+ const pager = screen.getByTestId('pager');
+
+ const event = { nativeEvent: { position: 1 }, dispatch: 'direct' };
+ await fireEvent(pager, 'pageSelected', event);
+
+ // Modern matches React Native, which calls only the target's prop for direct events.
+ expect(onParentPageSelected).not.toHaveBeenCalled();
+ expect(onPageSelected).toHaveBeenCalledTimes(1);
+ const received = (onPageSelected.mock.calls[0] as unknown[])[0] as SyntheticEvent;
+ expect(received).toEqual(isModern() ? anySyntheticEvent : event);
+ expect(received.nativeEvent).toEqual({ position: 1 });
+ expect(received.type).toBe(isModern() ? 'pageselected' : undefined);
+ expect(received.rnIsDirect).toBe(isModern() ? true : undefined);
+ expect(received.target).toBe(isModern() ? pager : undefined);
+ });
+
+ test('dispatches a bubbling custom event through capture and bubble phases (legacy: calls only the nearest handler)', async () => {
+ const calls: string[] = [];
+ const getHandlers = (id: string) => ({
+ onCustomGestureCapture: () => calls.push(`${id}.onCustomGestureCapture`),
+ onCustomGesture: () => calls.push(`${id}.onCustomGesture`),
+ });
+ await render(
+
+ {React.createElement('RNCustomView', { testID: 'target', ...getHandlers('target') })}
+ ,
+ );
+
+ await fireEvent(screen.getByTestId('target'), 'customGesture', {
+ nativeEvent: {},
+ dispatch: 'bubbling',
+ });
+
+ expect(calls).toEqual(
+ isModern()
+ ? [
+ 'parent.onCustomGestureCapture',
+ 'target.onCustomGestureCapture',
+ 'target.onCustomGesture',
+ 'parent.onCustomGesture',
+ ]
+ : ['target.onCustomGesture'],
+ );
+ });
+
+ test('reaches the user callback through a wrapper that adapts the host prop', async () => {
+ // Shaped like `react-native-pager-view`: the composite passes its own handler to the host,
+ // which then calls the user's prop.
+ type PageSelectedEvent = { nativeEvent: { position: number } };
+ class PagerView extends React.Component<{
+ testID?: string;
+ onPageSelected?: (event: PageSelectedEvent) => void;
+ }> {
+ _onPageSelected = (event: PageSelectedEvent) => {
+ this.props.onPageSelected?.(event);
+ };
+
+ render() {
+ return React.createElement('RNCViewPager', {
+ testID: this.props.testID,
+ onPageSelected: this._onPageSelected,
+ });
+ }
+ }
+
+ const onPageSelected = jest.fn();
+ await render();
+
+ await fireEvent(screen.getByTestId('pager'), 'pageSelected', {
+ nativeEvent: { position: 3 },
+ dispatch: 'direct',
+ });
+
+ expect(onPageSelected).toHaveBeenCalledTimes(1);
+ expect(onPageSelected.mock.calls[0][0].nativeEvent).toEqual({ position: 3 });
+ });
+
+ test('throws for a custom event type without dispatch (legacy: calls the handler)', async () => {
+ const onEndReached = jest.fn();
+ // The Jest mock of `ScrollView` puts `FlatList`'s JavaScript callbacks, like `onEndReached`,
+ // on the host element.
+ await render(
+ {item}}
+ onEndReached={onEndReached}
+ />,
+ );
+
+ const error = await getErrorMessage(
+ fireEvent(screen.getByTestId('list'), 'endReached', {
+ nativeEvent: { distanceFromEnd: 0 },
+ }),
+ );
+
+ expect(error).toBe(
+ isModern()
+ ? 'Unable to fire a "endReached" event. React Native doesn\'t know "endReached". For a native event of a third-party component, pass dispatch: "direct" or "bubbling" in the event object, matching its DirectEventHandler or BubblingEventHandler prop type. To call a handler prop directly, use UNSAFE_invokeHandler().'
+ : undefined,
+ );
+ expect(onEndReached).toHaveBeenCalledTimes(isModern() ? 0 : 1);
+ });
+
+ test('dispatch overrides a bubbling built-in event type, skipping Pressability (legacy: calls only the nearest handler)', async () => {
+ // Shaped like `react-native-maps`: the native map declares `onPress` as a
+ // `BubblingEventHandler`, so a tap reaches it as a native `press` event, not from Pressability.
+ const calls: string[] = [];
+ const getHandlers = (id: string) => ({
+ onPressCapture: () => calls.push(`${id}.onPressCapture`),
+ onPress: () => calls.push(`${id}.onPress`),
+ });
+ await render(
+ calls.push('pressable.onPress')}>
+ {React.createElement(
+ 'AIRMapContainer',
+ getHandlers('parent'),
+ React.createElement('AIRMap', { testID: 'map', ...getHandlers('map') }),
+ )}
+ ,
+ );
+
+ await fireEvent(screen.getByTestId('map'), 'press', {
+ nativeEvent: { coordinate: { latitude: 1, longitude: 2 } },
+ dispatch: 'bubbling',
+ });
+
+ expect(calls).toEqual(
+ isModern()
+ ? ['parent.onPressCapture', 'map.onPressCapture', 'map.onPress', 'parent.onPress']
+ : ['map.onPress'],
+ );
+ });
+
+ test('dispatch overrides a Pressability event type with a direct native event', async () => {
+ const onLongPress = jest.fn();
+ const onParentLongPress = jest.fn();
+ await render(
+ React.createElement(
+ 'AIRMapContainer',
+ { onLongPress: onParentLongPress },
+ React.createElement('AIRMap', { testID: 'map', onLongPress }),
+ ),
+ );
+ const map = screen.getByTestId('map');
+
+ await fireEvent(map, 'longPress', { nativeEvent: { x: 1 }, dispatch: 'direct' });
+
+ expect(onParentLongPress).not.toHaveBeenCalled();
+ expect(onLongPress).toHaveBeenCalledTimes(1);
+ const received = onLongPress.mock.calls[0][0] as SyntheticEvent;
+ expect(received.nativeEvent).toEqual({ x: 1 });
+ expect(received.rnIsDirect).toBe(isModern() ? true : undefined);
+ expect(received.target).toBe(isModern() ? map : undefined);
+ });
+
+ test('dispatch overrides a bubbling built-in event type with a direct one', async () => {
+ const onFocus = jest.fn();
+ const onParentFocus = jest.fn();
+ await render(
+ React.createElement(
+ 'RNCustomView',
+ { onFocus: onParentFocus },
+ React.createElement('RNCustomView', { testID: 'target', onFocus }),
+ ),
+ );
+
+ await fireEvent(screen.getByTestId('target'), 'focus', { nativeEvent: {}, dispatch: 'direct' });
+
+ expect(onFocus).toHaveBeenCalledTimes(1);
+ expect(onParentFocus).not.toHaveBeenCalled();
+ });
+
+ test('dispatch overrides a direct built-in event type with a bubbling one (legacy: calls only the nearest handler)', async () => {
+ const onLayout = jest.fn();
+ const onParentLayout = jest.fn();
+ await render(
+ React.createElement(
+ 'RNCustomView',
+ { onLayout: onParentLayout },
+ React.createElement('RNCustomView', { testID: 'target', onLayout }),
+ ),
+ );
+
+ await fireEvent(screen.getByTestId('target'), 'layout', {
+ nativeEvent: {},
+ dispatch: 'bubbling',
+ });
+
+ expect(onLayout).toHaveBeenCalledTimes(1);
+ expect(onParentLayout).toHaveBeenCalledTimes(isModern() ? 1 : 0);
+ });
+
+ test('fireEvent.press() with dispatch fires a native press event with the passed payload', async () => {
+ const onPress = jest.fn();
+ await render(React.createElement('AIRMap', { testID: 'map', onPress }));
+
+ await fireEvent.press(screen.getByTestId('map'), {
+ nativeEvent: { coordinate: { latitude: 1, longitude: 2 } },
+ dispatch: 'bubbling',
+ });
+
+ expect(onPress).toHaveBeenCalledTimes(1);
+ // Modern skips the default touch payload, as the event is the component's own.
+ const { nativeEvent } = onPress.mock.calls[0][0];
+ expect(nativeEvent).toMatchObject({ coordinate: { latitude: 1, longitude: 2 } });
+ expect('pageX' in nativeEvent).toBe(!isModern());
+ });
+
+ test('dispatch on a Pressable calls no Pressability callback (legacy: calls onPress)', async () => {
+ const onPress = jest.fn();
+ await render();
+
+ await fireEvent(screen.getByTestId('target'), 'press', {
+ nativeEvent: {},
+ dispatch: 'bubbling',
+ });
+
+ // Modern dispatches a native `press` event, which `Pressable`'s host has no prop for.
+ expect(onPress).toHaveBeenCalledTimes(isModern() ? 0 : 1);
+ });
+
+ test.each([
+ ['capture', 'string "capture"'],
+ [null, 'null'],
+ [true, 'boolean true'],
+ ])(
+ 'throws when dispatch is %p (legacy: passes it to the handler)',
+ async (dispatch, description) => {
+ const onPageSelected = jest.fn();
+ await render(React.createElement('RNCViewPager', { testID: 'pager', onPageSelected }));
+
+ const error = await getErrorMessage(
+ fireEvent(screen.getByTestId('pager'), 'pageSelected', { nativeEvent: {}, dispatch }),
+ );
+
+ expect(error).toBe(
+ isModern()
+ ? `Unable to fire a "pageSelected" event. Expected "dispatch" to be "direct" or "bubbling", received ${description}.`
+ : undefined,
+ );
+ expect(onPageSelected).toHaveBeenCalledTimes(isModern() ? 0 : 1);
+ },
+ );
+
+ test('dispatches changeText with dispatch as a native event of the component', async () => {
+ const onChangeText = jest.fn();
+ await render(React.createElement('RNCustomInput', { testID: 'input', onChangeText }));
+
+ await fireEvent(screen.getByTestId('input'), 'changeText', {
+ nativeEvent: { text: 'Hello' },
+ dispatch: 'direct',
+ });
+
+ expect(onChangeText).toHaveBeenCalledTimes(1);
+ expect(onChangeText.mock.calls[0][0].nativeEvent).toEqual({ text: 'Hello' });
+ });
+});
+
+describe('Pressability event types', () => {
+ function logCallbacks(calls: string[], id: string) {
+ const log = (name: string) => () => {
+ calls.push(`${id}.${name}`);
+ };
+ return {
+ onPress: log('onPress'),
+ onPressIn: log('onPressIn'),
+ onPressOut: log('onPressOut'),
+ onLongPress: log('onLongPress'),
+ };
+ }
+
+ test.each([
+ ['press', 'onPress'],
+ ['pressIn', 'onPressIn'],
+ ['pressOut', 'onPressOut'],
+ ['longPress', 'onLongPress'],
+ ])('"%s" calls only %s of the responder', async (eventType, name) => {
+ const calls: string[] = [];
+ await render(
+ <>
+
+ Pressable
+
+
+ Touchable
+
+ >,
+ );
+
+ await fireEvent(screen.getByText('Pressable'), eventType, emptyEvent);
+ await fireEvent(screen.getByText('Touchable'), eventType, emptyEvent);
+
+ expect(calls).toEqual([`pressable.${name}`, `touchable.${name}`]);
+ });
+
+ test('passes a responder event with default touch payload (legacy: passes the event object as given)', async () => {
+ const onPressIn = jest.fn();
+ await render(
+
+ Press me
+ ,
+ );
+
+ await fireEvent(screen.getByText('Press me'), 'pressIn', {
+ nativeEvent: { pageX: 20 },
+ timeStamp: 123,
+ });
+
+ const event = onPressIn.mock.calls[0][0];
+ // Modern matches React Native, which derives Pressability events from a touch.
+ expect(event.nativeEvent).toEqual(
+ isModern() ? getTouchPayload(true, { pageX: 20 }) : { pageX: 20 },
+ );
+ expect(event.timeStamp).toBe(123);
+ if (!isModern()) return;
+ expect(event.type).toBe('responderGrant');
+ expect(event.dispatchConfig).toEqual({
+ registrationName: 'onResponderGrant',
+ dependencies: [],
+ });
+ // Not a direct event: the responder system calls the handler without an event dispatch.
+ expect(event.bubbles).toBe(false);
+ expect(event.rnIsDirect).toBe(false);
+ expect(event.target).toBe(screen.getByText('Press me'));
+ });
+
+ test('calls onLongPress without currentTarget (legacy: passes the event object as given)', async () => {
+ let currentTarget: unknown;
+ await render(
+ {
+ currentTarget = event.currentTarget;
+ }}
+ >
+ Press me
+ ,
+ );
+
+ await fireEvent(screen.getByText('Press me'), 'longPress', emptyEvent);
+
+ // Modern matches React Native, where Pressability calls `onLongPress` from a timer, after the
+ // dispatch of the responder grant event.
+ expect(currentTarget).toBe(isModern() ? null : undefined);
+ });
+});
+
+test('fireEvent throws for custom event (onCustomEvent) on composite component (legacy: calls it)', async () => {
const CustomComponent = ({ onCustomEvent }: { onCustomEvent: (data: string) => void }) => (
onCustomEvent('event data')}>
Custom
@@ -1008,11 +2287,20 @@ test('fireEvent fires custom event (onCustomEvent) on composite component', asyn
);
const handler = jest.fn();
await render();
- await fireEvent(screen.getByText('Custom'), 'customEvent', 'event data');
- expect(handler).toHaveBeenCalledWith('event data');
+ const error = await getErrorMessage(
+ fireEvent(screen.getByText('Custom'), 'customEvent', 'event data'),
+ );
+ // Modern matches React Native, which dispatches only native events, to host elements. Legacy
+ // calls any matching prop, also on composite components.
+ expect(error).toBe(
+ isModern()
+ ? `Unable to fire a "customEvent" event. React Native doesn't know "customEvent". For a native event of a third-party component, pass dispatch: "direct" or "bubbling" in the event object, matching its DirectEventHandler or BubblingEventHandler prop type. To call a handler prop directly, use UNSAFE_invokeHandler().`
+ : undefined,
+ );
+ expect(handler.mock.calls).toEqual(isModern() ? [] : [['event data']]);
});
-test('fireEvent fires event with custom prop name (handlePress) on composite component', async () => {
+test('fireEvent throws for custom prop name (handlePress) on composite component (legacy: calls it)', async () => {
const MyButton = ({ handlePress }: { handlePress: () => void }) => (
Button
@@ -1020,15 +2308,15 @@ test('fireEvent fires event with custom prop name (handlePress) on composite com
);
const handler = jest.fn();
await render();
- await fireEvent(screen.getByText('Button'), 'handlePress');
- expect(handler).toHaveBeenCalled();
-});
-
-test('fireEvent returns undefined when handler does not return a value', async () => {
- const handler = jest.fn();
- await render();
- const result = await fireEvent.press(screen.getByTestId('btn'));
- expect(result).toBeUndefined();
+ const error = await getErrorMessage(fireEvent(screen.getByText('Button'), 'handlePress'));
+ // Modern matches React Native, which dispatches only native events, to host elements. Legacy
+ // calls any matching prop, also on composite components.
+ expect(error).toBe(
+ isModern()
+ ? `Unable to fire a "handlePress" event. React Native doesn't know "handlePress". For a native event of a third-party component, pass dispatch: "direct" or "bubbling" in the event object, matching its DirectEventHandler or BubblingEventHandler prop type. To call a handler prop directly, use UNSAFE_invokeHandler().`
+ : undefined,
+ );
+ expect(handler).toHaveBeenCalledTimes(isModern() ? 0 : 1);
});
test('fireEvent calls handler on element when both element and parent have handlers', async () => {
@@ -1048,32 +2336,104 @@ test('fireEvent calls handler on element when both element and parent have handl
test('fireEvent does nothing when element is unmounted', async () => {
const onPress = jest.fn();
+ const onFocus = jest.fn();
+ const onChangeText = jest.fn();
await render(
+
+
,
);
- const element = screen.getByTestId('btn');
+ const button = screen.getByTestId('btn');
+ const input = screen.getByTestId('input');
+ const scrollView = screen.getByTestId('scroll');
await screen.rerender();
- await fireEvent.press(element);
+ await fireEvent.press(button);
+ await fireEvent(input, 'focus', emptyEvent);
+ await fireEvent.changeText(input, 'Hello');
+ await fireEvent.scroll(scrollView, verticalScrollEvent);
+
expect(onPress).not.toHaveBeenCalled();
+ expect(onFocus).not.toHaveBeenCalled();
+ expect(onChangeText).not.toHaveBeenCalled();
+ expect(nativeState.contentOffsetForInstance.get(scrollView)).toBeUndefined();
});
-test('fireEvent does not throw when called with non-existent event name', async () => {
+test.each([
+ ['fireEvent()', (instance: TestInstance) => fireEvent(instance, 'focus', emptyEvent), 'focus'],
+ ['fireEvent.press()', (instance: TestInstance) => fireEvent.press(instance), 'press'],
+ [
+ 'fireEvent.changeText()',
+ (instance: TestInstance) => fireEvent.changeText(instance, 'Hello'),
+ 'changeText',
+ ],
+ ['fireEvent.scroll()', (instance: TestInstance) => fireEvent.scroll(instance), 'scroll'],
+ ['fireEvent.layout()', (instance: TestInstance) => fireEvent.layout(instance), 'layout'],
+])('%s throws without an element', async (_, fire, eventType) => {
+ const error = await getErrorMessage(fire(null as unknown as TestInstance));
+ expect(error).toBe(
+ isModern()
+ ? `Unable to fire a "${eventType}" event. Please provide a host element.`
+ : "Cannot read properties of null (reading 'parent')",
+ );
+});
+
+test('fireEvent throws with non-existent event name (legacy: does nothing)', async () => {
await render();
const element = screen.getByTestId('btn');
- await expect(fireEvent(element, 'nonExistentEvent' as any)).resolves.toBeUndefined();
+ const outcome = await fireEvent(element, 'nonExistentEvent' as any).then(
+ (result) => ({ result }),
+ (error: Error) => ({ error: error.message }),
+ );
+ // React Native never dispatches it, so no handler runs in either. Modern throws to point out
+ // the mistake.
+ expect(outcome).toStrictEqual(
+ isModern()
+ ? {
+ error: `Unable to fire a "nonExistentEvent" event. React Native doesn't know "nonExistentEvent". For a native event of a third-party component, pass dispatch: "direct" or "bubbling" in the event object, matching its DirectEventHandler or BubblingEventHandler prop type. To call a handler prop directly, use UNSAFE_invokeHandler().`,
+ }
+ : { result: undefined },
+ );
});
-test('fireEvent handles handler that throws gracefully', async () => {
+test('fireEvent rejects with the handler error', async () => {
const error = new Error('Handler error');
const onPress = jest.fn(() => {
throw error;
});
- await render();
+ const onFocus = jest.fn(() => {
+ throw error;
+ });
+ await render(
+ <>
+
+
+ >,
+ );
await expect(fireEvent.press(screen.getByTestId('btn'))).rejects.toThrow('Handler error');
+ await expect(fireEvent(screen.getByTestId('input'), 'focus', emptyEvent)).rejects.toThrow(
+ 'Handler error',
+ );
expect(onPress).toHaveBeenCalledTimes(1);
+ expect(onFocus).toHaveBeenCalledTimes(1);
+});
+
+test('fireEvent renders state updates from the handler', async () => {
+ function Counter() {
+ const [count, setCount] = React.useState(0);
+ return (
+ setCount((value) => value + 1)}>
+ Count: {count}
+
+ );
+ }
+
+ await render();
+ await fireEvent(screen.getByTestId('text'), 'press', emptyEvent);
+
+ expect(screen.getByText('Count: 1')).toBeOnTheScreen();
});
describe('disabled elements', () => {
@@ -1129,6 +2489,82 @@ describe('disabled elements', () => {
expect(handleOuterPress).toHaveBeenCalledTimes(1);
});
+ test('bubbles event past disabled Text to the Pressable (legacy: calls onPress of disabled Text)', async () => {
+ const onTextPress = jest.fn();
+ const onPress = jest.fn();
+ await render(
+
+
+ Disabled
+
+ ,
+ );
+
+ await fireEvent.press(screen.getByText('Disabled'));
+
+ // Modern matches `userEvent.press()`, which skips disabled Text. Legacy checks only
+ // responders, and the mocked Text has none.
+ expect(onTextPress).toHaveBeenCalledTimes(isModern() ? 0 : 1);
+ expect(onPress).toHaveBeenCalledTimes(isModern() ? 1 : 0);
+ });
+
+ test.each([
+ ['aria-disabled', { 'aria-disabled': true }],
+ ['accessibilityState.disabled', { accessibilityState: { disabled: true } }],
+ ])(
+ 'bubbles event past Text disabled by %s to the Pressable (legacy: calls onPress of Text)',
+ async (_, disabledProps) => {
+ const onTextPress = jest.fn();
+ const onPress = jest.fn();
+ await render(
+
+
+ Disabled
+
+ ,
+ );
+
+ await fireEvent.press(screen.getByText('Disabled'));
+
+ // Modern matches React Native's `Text`, which falls back to these props when `disabled` is
+ // not set. Legacy checks only responders, and the mocked Text has none.
+ expect(onTextPress).toHaveBeenCalledTimes(isModern() ? 0 : 1);
+ expect(onPress).toHaveBeenCalledTimes(isModern() ? 1 : 0);
+ },
+ );
+
+ test('calls onPress of Text with disabled={false} over aria-disabled', async () => {
+ const onPress = jest.fn();
+ await render(
+
+ Enabled
+ ,
+ );
+
+ await fireEvent.press(screen.getByText('Enabled'));
+
+ // React Native's `Text` reads `disabled` first.
+ expect(onPress).toHaveBeenCalledTimes(1);
+ });
+
+ test('calls onPress of non-editable TextInput without bubbling to the Pressable (legacy: bubbles)', async () => {
+ const onInputPress = jest.fn();
+ const onPress = jest.fn();
+ await render(
+
+
+ ,
+ );
+
+ await fireEvent.press(screen.getByTestId('input'));
+
+ // Modern matches iOS: TextInput's Pressability claims the touch even when not editable, and
+ // calls its `onPress`, as `editable={false}` only stops it from focusing. Legacy skips the
+ // non-editable TextInput and bubbles to the Pressable, as Android does.
+ expect(onInputPress).toHaveBeenCalledTimes(isModern() ? 1 : 0);
+ expect(onPress).toHaveBeenCalledTimes(isModern() ? 0 : 1);
+ });
+
test('ignores custom disabled prop on composite component (only respects native disabled)', async () => {
const TestComponent = ({ onPress }: { onPress: () => void; disabled?: boolean }) => (
@@ -1143,6 +2579,9 @@ describe('disabled elements', () => {
});
describe('unhandled event warning', () => {
+ // Modern `fireEvent` doesn't warn about unhandled events.
+ runInLegacyEventSystem();
+
let warnSpy: jest.SpyInstance;
beforeEach(() => {
@@ -1154,6 +2593,10 @@ describe('unhandled event warning', () => {
warnSpy.mockRestore();
});
+ function getWarnings(): string[] {
+ return warnSpy.mock.calls.map(([message]) => message.split('\n')[0]);
+ }
+
test('warns when the handler is on a disabled element', async () => {
await render(
@@ -1183,6 +2626,26 @@ describe('unhandled event warning', () => {
`);
});
+ test('warns when the handler is on a disabled TouchableOpacity', async () => {
+ await render(
+
+ Trigger
+ ,
+ );
+
+ await fireEvent.press(screen.getByText('Trigger'));
+
+ expect(getWarnings()).toEqual(['Cannot fire the "press" event on a disabled element.']);
+ });
+
+ test('warns when the pressIn callback is on a disabled element', async () => {
+ await render();
+
+ await fireEvent(screen.getByTestId('pressable'), 'pressIn', emptyEvent);
+
+ expect(getWarnings()).toEqual(['Cannot fire the "pressIn" event on a disabled element.']);
+ });
+
test('lists every disabled element the event skipped', async () => {
await render(
@@ -1259,6 +2722,42 @@ describe('unhandled event warning', () => {
`);
});
+ test('warns when no element handles a native event', async () => {
+ await render(
+
+
+ ,
+ );
+
+ await fireEvent(screen.getByTestId('view'), 'focus', emptyEvent);
+
+ expect(getWarnings()).toEqual(['No "onFocus" handler found on the element or its ancestors.']);
+ });
+
+ // Not `pressIn` or `pressOut`: `Pressable` always passes its own, which update the pressed state.
+ test('warns when no element has the longPress callback', async () => {
+ await render();
+
+ await fireEvent(screen.getByTestId('pressable'), 'longPress', emptyEvent);
+
+ expect(getWarnings()).toEqual([
+ 'No "onLongPress" handler found on the element or its ancestors.',
+ ]);
+ });
+
+ test('warns when an ancestor has only a capture handler, as no capture handlers are called', async () => {
+ await render(
+ // Spread because `View` types include `onFocusCapture` only since RN 0.88.
+
+
+ ,
+ );
+
+ await fireEvent(screen.getByTestId('view'), 'focus', emptyEvent);
+
+ expect(getWarnings()).toEqual(['No "onFocus" handler found on the element or its ancestors.']);
+ });
+
test('does not warn when the event bubbles to an enabled parent', async () => {
await render(
@@ -1450,6 +2949,26 @@ describe('unhandled event warning', () => {
expect(warnSpy).not.toHaveBeenCalled();
});
+ test('does not warn when a responder without Pressability claims the touch', async () => {
+ function Draggable() {
+ const { panHandlers } = PanResponder.create({ onStartShouldSetPanResponder: () => true });
+ return (
+
+ Trigger
+
+ );
+ }
+ await render(
+
+
+ ,
+ );
+
+ await fireEvent.press(screen.getByText('Trigger'));
+
+ expect(warnSpy).not.toHaveBeenCalled();
+ });
+
test('warns when the handler is blocked by non-editable TextInput', async () => {
await render();
@@ -1467,15 +2986,29 @@ describe('unhandled event warning', () => {
`);
});
+ test('warns when "press" is fired on non-editable TextInput', async () => {
+ const onPress = jest.fn();
+ await render();
+
+ await fireEvent(screen.getByTestId('input'), 'press', emptyEvent);
+
+ expect(onPress).not.toHaveBeenCalled();
+ expect(warnSpy).toHaveBeenCalledTimes(1);
+ expect(warnSpy.mock.calls[0][0]).toMatch(
+ /^Cannot fire the "press" event on a non-editable TextInput\./,
+ );
+ });
+
test.each([
['focus', 'onFocus'],
['blur', 'onBlur'],
- ['press', 'onPress'],
- ])('warns when "%s" is blocked by non-editable TextInput', async (eventType, handlerName) => {
- await render();
+ ])('warns when "%s" is fired on non-editable TextInput', async (eventType, handlerName) => {
+ const handler = jest.fn();
+ await render();
- await fireEvent(screen.getByTestId('input'), eventType);
+ await fireEvent(screen.getByTestId('input'), eventType, emptyEvent);
+ expect(handler).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy.mock.calls[0][0]).toMatch(
new RegExp(`^Cannot fire the "${eventType}" event on a non-editable TextInput\\.`),
@@ -1491,7 +3024,7 @@ describe('unhandled event warning', () => {
,
);
- await fireEvent(screen.getByTestId('input'), 'focus');
+ await fireEvent(screen.getByTestId('input'), 'focus', emptyEvent);
expect(onFocus).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledTimes(1);
@@ -1507,24 +3040,68 @@ describe('unhandled event warning', () => {
});
test('reports disabled elements when they include a non-editable TextInput', async () => {
+ const onInputPress = jest.fn();
await render(
-
+
,
);
await fireEvent.press(screen.getByTestId('input'));
+ expect(onInputPress).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy.mock.calls[0][0]).toMatch(
/^Cannot fire the "press" event on disabled elements\./,
);
});
- test('does not warn when the event updates native state (uncontrolled TextInput)', async () => {
- await render();
+ test('warns when element has no onLayout handler and layout event has no layout', async () => {
+ await render();
+
+ await fireEvent(screen.getByTestId('view'), 'layout', { nativeEvent: {} });
+
+ expect(warnSpy).toHaveBeenCalledTimes(1);
+ expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(`
+ "No "onLayout" handler found on the element or its ancestors.
+ If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`.
+
+ "
+ `);
+ });
+
+ test('does not warn when the event updates native state', async () => {
+ await render(
+ <>
+
+
+ >,
+ );
await fireEvent.changeText(screen.getByTestId('input'), 'Hello');
+ await fireEvent.scroll(screen.getByTestId('scroll'), verticalScrollEvent);
+
+ expect(warnSpy).not.toHaveBeenCalled();
+ });
+
+ test('does not warn on an unmounted element', async () => {
+ await render(
+
+
+
+
+ ,
+ );
+ const pressable = screen.getByTestId('pressable');
+ const input = screen.getByTestId('input');
+ const view = screen.getByTestId('view');
+ await screen.rerender();
+
+ await fireEvent.press(pressable);
+ await fireEvent.changeText(input, 'Hello');
+ await fireEvent(view, 'focus', emptyEvent);
expect(warnSpy).not.toHaveBeenCalled();
});
@@ -1533,16 +3110,21 @@ describe('unhandled event warning', () => {
configure({ eventDiagnostics: false });
await render(
-
+
Disabled
No handler
+
,
);
await fireEvent.press(screen.getByText('Disabled'));
+ await fireEvent(screen.getByTestId('disabled'), 'pressIn', emptyEvent);
await fireEvent.press(screen.getByText('No handler'));
+ await fireEvent(screen.getByText('No handler'), 'focus', emptyEvent);
await fireEvent.layout(screen.getByText('No handler'));
+ await fireEvent.scroll(screen.getByText('No handler'));
+ await fireEvent.changeText(screen.getByTestId('input'), 'Hello');
expect(warnSpy).not.toHaveBeenCalled();
});
@@ -1560,6 +3142,17 @@ describe('pointerEvents prop', () => {
expect(onPress).not.toHaveBeenCalled();
});
+ test('does not fire inside View with pointerEvents="none" set in style', async () => {
+ const onPress = jest.fn();
+ await render(
+
+
+ ,
+ );
+ await fireEvent.press(screen.getByTestId('btn'));
+ expect(onPress).not.toHaveBeenCalled();
+ });
+
test('does not fire inside View with pointerEvents="box-only"', async () => {
const onPress = jest.fn();
await render(
@@ -1617,7 +3210,7 @@ describe('pointerEvents prop', () => {
test('fires non-pointer events inside View with pointerEvents="box-none"', async () => {
const onLayout = jest.fn();
await render();
- await fireEvent(screen.getByTestId('view'), 'layout');
+ await fireEvent(screen.getByTestId('view'), 'layout', layoutEvent);
expect(onLayout).toHaveBeenCalled();
});
@@ -1627,6 +3220,25 @@ describe('pointerEvents prop', () => {
await fireEvent.press(screen.getByTestId('pressable'));
expect(onPress).toHaveBeenCalled();
});
+
+ test('moves the touch from a child to the Pressable with pointerEvents="box-only" (legacy: passes a stub target)', async () => {
+ const onTextPress = jest.fn();
+ const onPress = jest.fn();
+ await render(
+
+ Press me
+ ,
+ );
+
+ await fireEvent.press(screen.getByText('Press me'));
+
+ expect(onTextPress).not.toHaveBeenCalled();
+ expect(onPress).toHaveBeenCalledTimes(1);
+ // Modern matches React Native, where the touch targets the Pressable.
+ expect(onPress.mock.calls[0][0].target).toEqual(
+ isModern() ? screen.getByTestId('pressable') : {},
+ );
+ });
});
describe('non-editable TextInput', () => {
@@ -1638,7 +3250,7 @@ describe('non-editable TextInput', () => {
return ;
}
- test('blocks touch-related events but allows non-touch events', async () => {
+ test('dispatches focus and submitEditing, but not changeText (legacy: blocks touch-related events)', async () => {
const onFocus = jest.fn();
const onChangeText = jest.fn();
const onSubmitEditing = jest.fn();
@@ -1656,18 +3268,23 @@ describe('non-editable TextInput', () => {
);
const input = screen.getByTestId('input');
- await fireEvent(input, 'focus');
+ await fireEvent(input, 'focus', emptyEvent);
await fireEvent.changeText(input, 'Text');
await fireEvent(input, 'submitEditing', { nativeEvent: { text: 'Text' } });
await fireEvent(input, 'layout', layoutEvent);
- expect(onFocus).not.toHaveBeenCalled();
+ // Legacy matches a device, where a non-editable TextInput can't be focused or submitted.
+ // Modern `fireEvent()` dispatches the event as given, without checking `editable`.
+ expect(onFocus).toHaveBeenCalledTimes(isModern() ? 1 : 0);
expect(onChangeText).not.toHaveBeenCalled();
- expect(onSubmitEditing).not.toHaveBeenCalled();
- expect(onLayout).toHaveBeenCalledWith(layoutEvent);
+ expect(onSubmitEditing).toHaveBeenCalledTimes(isModern() ? 1 : 0);
+ // Modern matches React Native, which passes a `SyntheticEvent`. Legacy passes the event object
+ // as given.
+ expect(onLayout.mock.calls).toEqual([[isModern() ? anySyntheticEvent : layoutEvent]]);
+ expect(onLayout.mock.calls[0][0]).toMatchObject(layoutEvent);
});
- test('blocks touch-related events when firing on nested Text child', async () => {
+ test('bubbles focus and submitEditing from nested Text child, but not layout (legacy: blocks touch-related events)', async () => {
const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {});
const onFocus = jest.fn();
const onChangeText = jest.fn();
@@ -1688,71 +3305,83 @@ describe('non-editable TextInput', () => {
);
const subject = screen.getByText('Nested Text');
- await fireEvent(subject, 'focus');
- await fireEvent(subject, 'onFocus');
- await fireEvent.changeText(subject, 'Text');
+ await fireEvent(subject, 'focus', emptyEvent);
+ const changeTextError = await getErrorMessage(fireEvent.changeText(subject, 'Text'));
await fireEvent(subject, 'submitEditing', {
nativeEvent: { text: 'Text' },
});
- await fireEvent(subject, 'onSubmitEditing', {
- nativeEvent: { text: 'Text' },
- });
await fireEvent(subject, 'layout', layoutEvent);
- await fireEvent(subject, 'onLayout', layoutEvent);
- expect(onFocus).not.toHaveBeenCalled();
+ // Legacy matches a device, where a non-editable TextInput can't be focused or submitted.
+ // Modern `fireEvent()` dispatches the events as given, and they bubble from the Text to the
+ // TextInput.
+ expect(onFocus).toHaveBeenCalledTimes(isModern() ? 1 : 0);
+ expect(onSubmitEditing).toHaveBeenCalledTimes(isModern() ? 1 : 0);
+ // Modern `changeText` accepts only a host TextInput. Legacy walks up to it, and skips it.
+ expect(changeTextError).toBe(
+ isModern()
+ ? 'Unable to fire a "changeText" event. Expected a host "TextInput" element, received "Text".'
+ : undefined,
+ );
expect(onChangeText).not.toHaveBeenCalled();
- expect(onSubmitEditing).not.toHaveBeenCalled();
- // Layout is a direct event, but still bubbles to the parent TextInput with a warning
- expect(onLayout).toHaveBeenCalledTimes(2);
- expect(onLayout).toHaveBeenCalledWith(layoutEvent);
- expect(warnSpy).toHaveBeenCalledTimes(2);
+ // Modern matches React Native: layout is a direct event, so it doesn't reach the parent
+ // TextInput. Legacy still bubbles it, with a warning.
+ expect(onLayout.mock.calls).toEqual(isModern() ? [] : [[layoutEvent]]);
+ expect(warnSpy).toHaveBeenCalledTimes(isModern() ? 0 : 1);
warnSpy.mockRestore();
});
test.each([
['WrappedTextInput', WrappedTextInput],
['DoubleWrappedTextInput', DoubleWrappedTextInput],
- ])('blocks touch-related events on %s', async (_, Component) => {
- const onFocus = jest.fn();
- const onChangeText = jest.fn();
- const onSubmitEditing = jest.fn();
- const onLayout = jest.fn();
-
- await render(
- ,
- );
+ ])(
+ 'dispatches focus and submitEditing on %s, but not changeText (legacy: blocks touch-related events)',
+ async (_, Component) => {
+ const onFocus = jest.fn();
+ const onChangeText = jest.fn();
+ const onSubmitEditing = jest.fn();
+ const onLayout = jest.fn();
- const input = screen.getByTestId('input');
- await fireEvent(input, 'focus');
- await fireEvent.changeText(input, 'Text');
- await fireEvent(input, 'submitEditing', { nativeEvent: { text: 'Text' } });
- await fireEvent(input, 'layout', layoutEvent);
+ await render(
+ ,
+ );
- expect(onFocus).not.toHaveBeenCalled();
- expect(onChangeText).not.toHaveBeenCalled();
- expect(onSubmitEditing).not.toHaveBeenCalled();
- expect(onLayout).toHaveBeenCalledWith(layoutEvent);
- });
+ const input = screen.getByTestId('input');
+ await fireEvent(input, 'focus', emptyEvent);
+ await fireEvent.changeText(input, 'Text');
+ await fireEvent(input, 'submitEditing', { nativeEvent: { text: 'Text' } });
+ await fireEvent(input, 'layout', layoutEvent);
+
+ // Legacy matches a device, where a non-editable TextInput can't be focused or submitted.
+ // Modern `fireEvent()` dispatches the event as given, without checking `editable`.
+ expect(onFocus).toHaveBeenCalledTimes(isModern() ? 1 : 0);
+ expect(onChangeText).not.toHaveBeenCalled();
+ expect(onSubmitEditing).toHaveBeenCalledTimes(isModern() ? 1 : 0);
+ // Modern matches React Native, which passes a `SyntheticEvent`. Legacy passes the event
+ // object as given.
+ expect(onLayout.mock.calls).toEqual([[isModern() ? anySyntheticEvent : layoutEvent]]);
+ expect(onLayout.mock.calls[0][0]).toMatchObject(layoutEvent);
+ },
+ );
test('fires layout event', async () => {
const onLayout = jest.fn();
await render();
- await fireEvent(screen.getByTestId('input'), 'layout');
+ await fireEvent(screen.getByTestId('input'), 'layout', layoutEvent);
expect(onLayout).toHaveBeenCalled();
});
test('fires scroll event', async () => {
const onScroll = jest.fn();
await render();
- await fireEvent(screen.getByTestId('input'), 'scroll');
+ await fireEvent(screen.getByTestId('input'), 'scroll', verticalScrollEvent);
expect(onScroll).toHaveBeenCalled();
});
});
@@ -1784,7 +3413,126 @@ describe('responder system', () => {
expect(handlePress).not.toHaveBeenCalled();
});
- test('fires responderMove on PanResponder component', async () => {
+ test('passes the touch to an ancestor when the responder declines it', async () => {
+ const onViewPress = jest.fn();
+ const onPress = jest.fn();
+ await render(
+
+ false}
+ {...{ testOnly_onPress: onViewPress }}
+ />
+ ,
+ );
+
+ await fireEvent.press(screen.getByTestId('view'));
+
+ expect(onViewPress).not.toHaveBeenCalled();
+ expect(onPress).toHaveBeenCalledTimes(1);
+ });
+
+ test('does not call onPress when a responder without Pressability claims the touch (legacy: calls onPress of the ancestor)', async () => {
+ const onPress = jest.fn();
+ const onPanResponderGrant = jest.fn();
+ const Draggable = () => {
+ const panResponder = React.useRef(
+ PanResponder.create({ onStartShouldSetPanResponder: () => true, onPanResponderGrant }),
+ ).current;
+ return ;
+ };
+ await render(
+
+
+ ,
+ );
+
+ await fireEvent.press(screen.getByTestId('draggable'));
+
+ // Modern matches React Native, where the `PanResponder` keeps the touch from its ancestors.
+ expect(onPress).toHaveBeenCalledTimes(isModern() ? 0 : 1);
+ expect(onPanResponderGrant).not.toHaveBeenCalled();
+ });
+
+ test.each(['press', 'pressIn', 'pressOut', 'longPress'] as const)(
+ 'does not call the Pressable handler when TextInput without one claims "%s" (legacy: calls it)',
+ async (eventType) => {
+ const handler = jest.fn();
+ await render(
+
+
+ ,
+ );
+
+ await fireEvent(screen.getByTestId('input'), eventType, emptyEvent);
+
+ // Modern matches React Native, where the TextInput claims the touch.
+ expect(handler).toHaveBeenCalledTimes(isModern() ? 0 : 1);
+ },
+ );
+
+ test.each(['press', 'pressIn', 'pressOut', 'longPress'] as const)(
+ 'does not call the Pressable handler when Text without one claims "%s" (legacy: calls it)',
+ async (eventType) => {
+ const handler = jest.fn();
+ const textHandlerName = eventType === 'press' ? 'onLongPress' : 'onPress';
+ await render(
+
+ Trigger
+ ,
+ );
+
+ await fireEvent(screen.getByText('Trigger'), eventType, emptyEvent);
+
+ // Modern matches React Native, where a Text with a press handler claims the touch.
+ expect(handler).toHaveBeenCalledTimes(isModern() ? 0 : 1);
+ },
+ );
+
+ test.each([
+ ['press', 'onLongPress'],
+ ['longPress', 'onPress'],
+ ] as const)(
+ 'does not call the parent handler when Pressable without one claims "%s" (legacy: calls it)',
+ async (eventType, innerHandlerName) => {
+ const handler = jest.fn();
+ await render(
+
+
+ Trigger
+
+ ,
+ );
+
+ await fireEvent(screen.getByText('Trigger'), eventType, emptyEvent);
+
+ // Modern matches React Native, where the inner Pressable claims the touch.
+ expect(handler).toHaveBeenCalledTimes(isModern() ? 0 : 1);
+ },
+ );
+
+ test('asks responders from the target to the root, without arguments (legacy: asks only responders with a handler)', async () => {
+ const calls: string[] = [];
+ const onStartShouldSetResponder =
+ (name: string) =>
+ (...args: unknown[]) => {
+ calls.push(`${name} (${args.length} args)`);
+ return false;
+ };
+ await render(
+
+
+ ,
+ );
+
+ await fireEvent.press(screen.getByTestId('child'));
+
+ // Modern matches React Native's responder negotiation. Legacy asks only the responder of the
+ // element with the handler, and neither has one.
+ expect(calls).toEqual(isModern() ? ['child (0 args)', 'parent (0 args)'] : []);
+ });
+
+ test('throws for responderMove on PanResponder component (legacy: calls the handler)', async () => {
const onDrag = jest.fn();
function TestDraggableComponent({ onDrag }: { onDrag: () => void }) {
const responderHandlers = PanResponder.create({
@@ -1798,9 +3546,30 @@ describe('responder system', () => {
);
}
await render();
- await fireEvent(screen.getByText('Trigger'), 'responderMove', {
- touchHistory: { mostRecentTimeStamp: '2', touchBank: [] },
- });
- expect(onDrag).toHaveBeenCalled();
+ const error = await getErrorMessage(
+ fireEvent(screen.getByText('Trigger'), 'responderMove', {
+ touchHistory: { mostRecentTimeStamp: '2', touchBank: [] },
+ }),
+ );
+ // Legacy calls `onResponderMove` directly. On a device, it follows a granted touch with the
+ // responder system's touch history, which modern doesn't simulate, so modern throws.
+ expect(error).toBe(
+ isModern()
+ ? `Unable to fire a "responderMove" event. React Native doesn't know "responderMove". For a native event of a third-party component, pass dispatch: "direct" or "bubbling" in the event object, matching its DirectEventHandler or BubblingEventHandler prop type. To call a handler prop directly, use UNSAFE_invokeHandler().`
+ : undefined,
+ );
+ expect(onDrag).toHaveBeenCalledTimes(isModern() ? 0 : 1);
});
});
+
+test('fireEvent reads the event system on each call', async () => {
+ const onLayout = jest.fn();
+ await render();
+
+ configure({ unstable_eventSystem: 'legacy' });
+ await fireEvent.layout(screen.getByTestId('view'));
+ configure({ unstable_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);
+});
diff --git a/src/events/__tests__/handler.test.tsx b/src/events/__tests__/handler.test.tsx
deleted file mode 100644
index b08ccf496..000000000
--- a/src/events/__tests__/handler.test.tsx
+++ /dev/null
@@ -1,101 +0,0 @@
-import * as React from 'react';
-import { Text, View } from 'react-native';
-
-import { render, screen } from '../..';
-import { getEventHandlerFromProps, normalizeEventType } from '../handler';
-
-test('getEventHandlerFromProps strict mode', async () => {
- const onPress = jest.fn();
- const testOnlyOnPress = jest.fn();
-
- await render(
-
-
- {/* @ts-expect-error Intentionally passing such props */}
-
- {/* @ts-expect-error Intentionally passing such props */}
-
- ,
- );
-
- const regular = screen.getByTestId('regular');
- const testOnly = screen.getByTestId('testOnly');
- const both = screen.getByTestId('both');
-
- expect(getEventHandlerFromProps(regular.props, 'press')).toBe(onPress);
- expect(getEventHandlerFromProps(testOnly.props, 'press')).toBe(testOnlyOnPress);
- expect(getEventHandlerFromProps(both.props, 'press')).toBe(onPress);
-
- expect(getEventHandlerFromProps(regular.props, 'onPress')).toBe(onPress);
- expect(getEventHandlerFromProps(testOnly.props, 'onPress')).toBe(testOnlyOnPress);
- expect(getEventHandlerFromProps(both.props, 'onPress')).toBe(onPress);
-});
-
-test('getEventHandlerFromProps does not treat event names starting with "on" as prefixed', async () => {
- const onOnline = jest.fn();
- // @ts-expect-error Intentionally passing such props
- await render();
-
- expect(getEventHandlerFromProps(screen.getByTestId('view').props, 'online')).toBe(onOnline);
-});
-
-test('getEventHandlerFromProps loose mode', async () => {
- const onPress = jest.fn();
- const testOnlyOnPress = jest.fn();
-
- await render(
-
-
- {/* @ts-expect-error Intentionally passing such props */}
-
- {/* @ts-expect-error Intentionally passing such props */}
-
- ,
- );
-
- const regular = screen.getByTestId('regular');
- const testOnly = screen.getByTestId('testOnly');
- const both = screen.getByTestId('both');
-
- expect(getEventHandlerFromProps(regular.props, 'press', { loose: true })).toBe(onPress);
- expect(getEventHandlerFromProps(testOnly.props, 'press', { loose: true })).toBe(testOnlyOnPress);
- expect(getEventHandlerFromProps(both.props, 'press', { loose: true })).toBe(onPress);
-
- expect(getEventHandlerFromProps(regular.props, 'onPress', { loose: true })).toBe(onPress);
- expect(getEventHandlerFromProps(testOnly.props, 'onPress', { loose: true })).toBe(
- testOnlyOnPress,
- );
- expect(getEventHandlerFromProps(both.props, 'onPress', { loose: true })).toBe(onPress);
-});
-
-test('getEventHandlerFromProps loose mode matches handlers named without the `on*` prefix', async () => {
- const press = jest.fn();
- const testOnlyPress = jest.fn();
-
- await render(
-
- {/* @ts-expect-error Intentionally passing such props */}
-
- {/* @ts-expect-error Intentionally passing such props */}
-
- ,
- );
-
- const regular = screen.getByTestId('regular');
- const testOnly = screen.getByTestId('testOnly');
-
- expect(getEventHandlerFromProps(regular.props, 'press', { loose: true })).toBe(press);
- expect(getEventHandlerFromProps(testOnly.props, 'press', { loose: true })).toBe(testOnlyPress);
-
- expect(getEventHandlerFromProps(regular.props, 'press')).toBeUndefined();
- expect(getEventHandlerFromProps(testOnly.props, 'press')).toBeUndefined();
-});
-
-test('normalizeEventType strips the `on*` prefix', () => {
- expect(normalizeEventType('onLayout')).toBe('layout');
- expect(normalizeEventType('onChangeText')).toBe('changeText');
- expect(normalizeEventType('layout')).toBe('layout');
- expect(normalizeEventType('changeText')).toBe('changeText');
- expect(normalizeEventType('once')).toBe('once');
- expect(normalizeEventType('on')).toBe('on');
-});
diff --git a/src/events/__tests__/invoke-handler.test.tsx b/src/events/__tests__/invoke-handler.test.tsx
new file mode 100644
index 000000000..34979048a
--- /dev/null
+++ b/src/events/__tests__/invoke-handler.test.tsx
@@ -0,0 +1,248 @@
+import * as React from 'react';
+import { FlatList, Pressable, ScrollView, Text, TextInput, View } from 'react-native';
+
+import { render, screen, UNSAFE_invokeHandler } from '../..';
+
+test('calls a handler prop of the element with all arguments', async () => {
+ const onContentSizeChange = jest.fn();
+ await render();
+
+ await UNSAFE_invokeHandler(screen.getByTestId('scroll'), 'onContentSizeChange', 100, 200);
+
+ expect(onContentSizeChange).toHaveBeenCalledTimes(1);
+ expect(onContentSizeChange).toHaveBeenCalledWith(100, 200);
+});
+
+test('calls a handler without arguments', async () => {
+ const onLoadEnd = jest.fn();
+ const props = { onLoadEnd };
+ await render();
+
+ await UNSAFE_invokeHandler(screen.getByTestId('view'), 'onLoadEnd');
+
+ expect(onLoadEnd).toHaveBeenCalledWith();
+});
+
+test('resolves to the return value of the handler', async () => {
+ await render( true} />);
+
+ await expect(
+ UNSAFE_invokeHandler(screen.getByTestId('view'), 'onStartShouldSetResponder'),
+ ).resolves.toBe(true);
+});
+
+test('awaits async handlers and resolves to their value', async () => {
+ const onRefresh = jest.fn(async () => {
+ await Promise.resolve();
+ return 'refreshed';
+ });
+ const props = { onRefresh };
+ await render();
+
+ await expect(UNSAFE_invokeHandler(screen.getByTestId('view'), 'onRefresh')).resolves.toBe(
+ 'refreshed',
+ );
+});
+
+test('calls a handler of a composite component that renders the element', async () => {
+ const onHoverIn = jest.fn();
+ const onHoverOut = jest.fn();
+ await render(
+
+ Button
+ ,
+ );
+
+ const button = screen.getByRole('button');
+ await UNSAFE_invokeHandler(button, 'onHoverIn', { nativeEvent: {} });
+ await UNSAFE_invokeHandler(button, 'onHoverOut', { nativeEvent: {} });
+
+ expect(onHoverIn).toHaveBeenCalledWith({ nativeEvent: {} });
+ expect(onHoverOut).toHaveBeenCalledWith({ nativeEvent: {} });
+});
+
+test('calls any function prop, not only on* handlers', async () => {
+ function Form(_props: { handleSubmitForm: (code: string) => void }) {
+ return ;
+ }
+
+ const handleSubmitForm = jest.fn();
+ await render();
+
+ await UNSAFE_invokeHandler(screen.getByTestId('form'), 'handleSubmitForm', '123456');
+
+ expect(handleSubmitForm).toHaveBeenCalledWith('123456');
+});
+
+test('calls FlatList onContentSizeChange through the list internals', async () => {
+ const onContentSizeChange = jest.fn();
+ await render(
+ {item}}
+ onContentSizeChange={onContentSizeChange}
+ />,
+ );
+
+ await UNSAFE_invokeHandler(screen.getByTestId('list'), 'onContentSizeChange', 300, 600);
+
+ expect(onContentSizeChange).toHaveBeenCalledWith(300, 600);
+});
+
+test('prefers the handler of the element over its composite components', async () => {
+ const onCustom = jest.fn();
+ const onCompositeCustom = jest.fn();
+ function Composite(_props: { onCustom: () => void }) {
+ const hostProps = { onCustom };
+ return ;
+ }
+
+ await render();
+
+ await UNSAFE_invokeHandler(screen.getByTestId('view'), 'onCustom');
+
+ expect(onCustom).toHaveBeenCalledTimes(1);
+ expect(onCompositeCustom).not.toHaveBeenCalled();
+});
+
+test('prefers the nearest composite component', async () => {
+ const onInnerCustom = jest.fn();
+ const onOuterCustom = jest.fn();
+ function Inner(_props: { onCustom: () => void }) {
+ return ;
+ }
+ function Outer(_props: { onCustom: () => void }) {
+ return ;
+ }
+
+ await render();
+
+ await UNSAFE_invokeHandler(screen.getByTestId('view'), 'onCustom');
+
+ expect(onInnerCustom).toHaveBeenCalledTimes(1);
+ expect(onOuterCustom).not.toHaveBeenCalled();
+});
+
+test('does not call handlers of ancestor host elements or their components', async () => {
+ const onParentCustom = jest.fn();
+ const onCompositeCustom = jest.fn();
+ function Composite(props: { onCustom: () => void; children: React.ReactNode }) {
+ const hostProps = { onCustom: onParentCustom };
+ return {props.children};
+ }
+
+ await render(
+
+
+ ,
+ );
+
+ await expect(UNSAFE_invokeHandler(screen.getByTestId('child'), 'onCustom')).rejects.toThrow(
+ 'Unable to invoke "onCustom". No "onCustom" function prop found on the element (View) or the ' +
+ 'components that render it (View).',
+ );
+ expect(onParentCustom).not.toHaveBeenCalled();
+ expect(onCompositeCustom).not.toHaveBeenCalled();
+});
+
+test('calls handlers of elements a device would not deliver events to', async () => {
+ const onChangeText = jest.fn();
+ await render(
+
+
+ ,
+ );
+
+ await UNSAFE_invokeHandler(screen.getByTestId('input'), 'onChangeText', 'Hello');
+
+ expect(onChangeText).toHaveBeenCalledWith('Hello');
+});
+
+test('renders state updates from the handler', async () => {
+ function Subject() {
+ const [isHovered, setIsHovered] = React.useState(false);
+ return (
+ setIsHovered(true)}>
+ {isHovered ? 'Hovered' : 'Idle'}
+
+ );
+ }
+
+ await render();
+
+ await UNSAFE_invokeHandler(screen.getByRole('button'), 'onHoverIn');
+
+ expect(screen.getByText('Hovered')).toBeOnTheScreen();
+});
+
+test('rethrows errors from the handler', async () => {
+ const props = {
+ onCustom: () => {
+ throw new Error('Handler failed');
+ },
+ };
+ await render();
+
+ await expect(UNSAFE_invokeHandler(screen.getByTestId('view'), 'onCustom')).rejects.toThrow(
+ 'Handler failed',
+ );
+});
+
+test('throws when no handler is found, listing the components it checked', async () => {
+ function MyButton() {
+ return (
+
+ Button
+
+ );
+ }
+
+ await render();
+
+ await expect(UNSAFE_invokeHandler(screen.getByRole('button'), 'onHoverIn')).rejects.toThrow(
+ 'Unable to invoke "onHoverIn". No "onHoverIn" function prop found on the element (View) or ' +
+ 'the components that render it (View, Pressable, MyButton).',
+ );
+});
+
+test('throws when the prop is not a function', async () => {
+ const props = { onCustom: 'not a function' };
+ await render();
+
+ await expect(UNSAFE_invokeHandler(screen.getByTestId('view'), 'onCustom')).rejects.toThrow(
+ 'Unable to invoke "onCustom". No "onCustom" function prop found',
+ );
+});
+
+test('throws for an event type instead of a handler prop name', async () => {
+ const onContentSizeChange = jest.fn();
+ await render();
+
+ await expect(
+ UNSAFE_invokeHandler(screen.getByTestId('scroll'), 'contentSizeChange', 100, 200),
+ ).rejects.toThrow(
+ 'Unable to invoke "contentSizeChange". No "contentSizeChange" function prop found',
+ );
+ expect(onContentSizeChange).not.toHaveBeenCalled();
+});
+
+test('throws when the element is unmounted', async () => {
+ const onCustom = jest.fn();
+ const props = { onCustom };
+ await render();
+ const view = screen.getByTestId('view');
+ await screen.rerender(Other);
+
+ await expect(UNSAFE_invokeHandler(view, 'onCustom')).rejects.toThrow(
+ 'Unable to invoke "onCustom". The element is unmounted.',
+ );
+ expect(onCustom).not.toHaveBeenCalled();
+});
+
+test('throws without an element', async () => {
+ // @ts-expect-error Testing invalid input
+ await expect(UNSAFE_invokeHandler(null, 'onCustom')).rejects.toThrow(
+ 'Unable to invoke "onCustom". Please provide a host element.',
+ );
+});
diff --git a/src/events/builders/__tests__/index.test.ts b/src/events/builders/__tests__/index.test.ts
deleted file mode 100644
index 548ed471a..000000000
--- a/src/events/builders/__tests__/index.test.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import * as eventBuilder from '..';
-
-test('re-exports all event builders', () => {
- expect(eventBuilder.buildTouchEvent).toBeInstanceOf(Function);
- expect(eventBuilder.buildResponderGrantEvent).toBeInstanceOf(Function);
- expect(eventBuilder.buildResponderReleaseEvent).toBeInstanceOf(Function);
- expect(eventBuilder.buildFocusEvent).toBeInstanceOf(Function);
- expect(eventBuilder.buildBlurEvent).toBeInstanceOf(Function);
- expect(eventBuilder.buildScrollEvent).toBeInstanceOf(Function);
- expect(eventBuilder.buildTextChangeEvent).toBeInstanceOf(Function);
- expect(eventBuilder.buildKeyPressEvent).toBeInstanceOf(Function);
- expect(eventBuilder.buildSubmitEditingEvent).toBeInstanceOf(Function);
- expect(eventBuilder.buildEndEditingEvent).toBeInstanceOf(Function);
- expect(eventBuilder.buildTextSelectionChangeEvent).toBeInstanceOf(Function);
- expect(eventBuilder.buildContentSizeChangeEvent).toBeInstanceOf(Function);
- expect(eventBuilder.mergeEventProps).toBeInstanceOf(Function);
-});
diff --git a/src/events/builders/__tests__/merge.test.ts b/src/events/builders/__tests__/merge.test.ts
deleted file mode 100644
index eec683e2f..000000000
--- a/src/events/builders/__tests__/merge.test.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { buildTouchEvent } from '../common';
-import { mergeEventProps } from '../merge';
-
-test('returns the same event when no props are passed', () => {
- const event = buildTouchEvent();
- const nativeEvent = { ...event.nativeEvent };
-
- expect(mergeEventProps(event)).toBe(event);
- expect(event.nativeEvent).toEqual(nativeEvent);
-});
-
-test('deep merges nested objects and keeps other default fields', () => {
- const event = mergeEventProps(buildTouchEvent(), { nativeEvent: { pageX: 10, pageY: 20 } });
-
- expect(event.nativeEvent.pageX).toBe(10);
- expect(event.nativeEvent.pageY).toBe(20);
- expect(event.nativeEvent.locationX).toBe(0);
- expect(typeof event.preventDefault).toBe('function');
-});
-
-test('replaces arrays and primitive values instead of merging them', () => {
- const event = mergeEventProps(buildTouchEvent(), {
- nativeEvent: { touches: [{ identifier: 1 }] },
- timeStamp: 42,
- });
-
- expect(event.nativeEvent.touches).toEqual([{ identifier: 1 }]);
- expect(event.timeStamp).toBe(42);
-});
-
-test('adds props that the event does not have', () => {
- const event = mergeEventProps(buildTouchEvent(), { custom: { value: 1 } });
-
- expect(event).toMatchObject({ custom: { value: 1 } });
-});
diff --git a/src/events/builders/__tests__/scroll.test.ts b/src/events/builders/__tests__/scroll.test.ts
deleted file mode 100644
index 7ae6d6c70..000000000
--- a/src/events/builders/__tests__/scroll.test.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import { buildScrollEvent } from '../scroll';
-
-test('buildScrollEvent returns default scroll event', () => {
- const event = buildScrollEvent();
-
- expect(event.nativeEvent).toEqual({
- contentInset: { bottom: 0, left: 0, right: 0, top: 0 },
- contentOffset: { y: 0, x: 0 },
- contentSize: { height: 0, width: 0 },
- layoutMeasurement: { height: 0, width: 0 },
- responderIgnoreScroll: true,
- target: 0,
- velocity: { y: 0, x: 0 },
- });
-});
-
-test('buildScrollEvent uses provided offset', () => {
- const event = buildScrollEvent({ y: 100, x: 50 });
-
- expect(event.nativeEvent.contentOffset).toEqual({ y: 100, x: 50 });
-});
-
-test('buildScrollEvent uses provided options', () => {
- const event = buildScrollEvent(
- { y: 0, x: 0 },
- {
- contentSize: { height: 1000, width: 400 },
- layoutMeasurement: { height: 800, width: 400 },
- },
- );
-
- expect(event.nativeEvent.contentSize).toEqual({ height: 1000, width: 400 });
- expect(event.nativeEvent.layoutMeasurement).toEqual({
- height: 800,
- width: 400,
- });
-});
diff --git a/src/events/builders/__tests__/text.test.ts b/src/events/builders/__tests__/text.test.ts
deleted file mode 100644
index 8970d4060..000000000
--- a/src/events/builders/__tests__/text.test.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-import {
- buildContentSizeChangeEvent,
- buildEndEditingEvent,
- buildKeyPressEvent,
- buildSubmitEditingEvent,
- buildTextChangeEvent,
- buildTextSelectionChangeEvent,
-} from '../text';
-
-test('buildTextChangeEvent returns event with text and selection', () => {
- const event = buildTextChangeEvent('Hello', { start: 5, end: 5 });
-
- expect(event.nativeEvent).toEqual({
- text: 'Hello',
- target: 0,
- eventCount: 0,
- selection: { start: 5, end: 5 },
- });
-});
-
-test('buildKeyPressEvent returns event with key', () => {
- const event = buildKeyPressEvent('a');
-
- expect(event.nativeEvent).toEqual({ key: 'a' });
-});
-
-test('buildSubmitEditingEvent returns event with text', () => {
- const event = buildSubmitEditingEvent('Hello');
-
- expect(event.nativeEvent).toEqual({ text: 'Hello', target: 0 });
-});
-
-test('buildEndEditingEvent returns event with text', () => {
- const event = buildEndEditingEvent('Hello');
-
- expect(event.nativeEvent).toEqual({ text: 'Hello', target: 0 });
-});
-
-test('buildTextSelectionChangeEvent returns event with selection', () => {
- const event = buildTextSelectionChangeEvent({ start: 0, end: 4 });
-
- expect(event.nativeEvent).toEqual({ selection: { start: 0, end: 4 } });
-});
-
-test('buildContentSizeChangeEvent returns event with contentSize', () => {
- const event = buildContentSizeChangeEvent({ width: 100, height: 50 });
-
- expect(event.nativeEvent).toEqual({
- contentSize: { width: 100, height: 50 },
- target: 0,
- });
-});
diff --git a/src/events/builders/common.ts b/src/events/builders/common.ts
deleted file mode 100644
index 64f961129..000000000
--- a/src/events/builders/common.ts
+++ /dev/null
@@ -1,108 +0,0 @@
-import type { LayoutRectangle } from '../types';
-import { baseSyntheticEvent } from './base';
-
-/**
- * Experimental values:
- * - iOS: `{"changedTouches": [[Circular]], "identifier": 1, "locationX": 253, "locationY": 30.333328247070312, "pageX": 273, "pageY": 141.3333282470703, "target": 75, "timestamp": 875928682.0450834, "touches": [[Circular]]}`
- * - Android: `{"changedTouches": [[Circular]], "identifier": 0, "locationX": 160, "locationY": 40.3636360168457, "pageX": 180, "pageY": 140.36363220214844, "target": 53, "targetSurface": -1, "timestamp": 10290805, "touches": [[Circular]]}`
- */
-export function buildTouchEvent() {
- return {
- ...baseSyntheticEvent(),
- nativeEvent: {
- changedTouches: [] as unknown[],
- identifier: 0,
- locationX: 0,
- locationY: 0,
- pageX: 0,
- pageY: 0,
- target: 0,
- timestamp: Date.now(),
- touches: [] as unknown[],
- },
- currentTarget: { measure: () => {} },
- };
-}
-
-export type TouchEvent = ReturnType;
-
-export function buildResponderGrantEvent() {
- return {
- ...buildTouchEvent(),
- dispatchConfig: { registrationName: 'onResponderGrant' },
- };
-}
-
-export function buildResponderReleaseEvent() {
- return {
- ...buildTouchEvent(),
- dispatchConfig: { registrationName: 'onResponderRelease' },
- };
-}
-
-/**
- * Experimental values:
- * - iOS: `{"eventCount": 0, "target": 75, "text": ""}`
- * - Android: `{"target": 53}`
- */
-export function buildFocusEvent() {
- return {
- ...baseSyntheticEvent(),
- nativeEvent: {
- target: 0,
- },
- };
-}
-
-/**
- * Experimental values:
- * - iOS: `{"eventCount": 0, "target": 75, "text": ""}`
- * - Android: `{"target": 53}`
- */
-export function buildBlurEvent() {
- return {
- ...baseSyntheticEvent(),
- nativeEvent: {
- target: 0,
- },
- };
-}
-
-/**
- * Builds an accessibility action event, as delivered to the `onAccessibilityAction`
- * handler when an assistive technology triggers an action.
- *
- * Experimental values:
- * - `{"actionName": "increment"}`
- */
-export function buildAccessibilityActionEvent(actionName: string) {
- return {
- ...baseSyntheticEvent(),
- nativeEvent: {
- actionName,
- },
- };
-}
-
-/**
- * Builds a layout event, as delivered to the `onLayout` handler when an element's
- * size or position is measured by the layout engine.
- *
- * The passed `layout` values are merged onto a zeroed rectangle, so only the
- * fields relevant to the test need to be provided.
- */
-export function buildLayoutEvent(layout?: Partial) {
- return {
- ...baseSyntheticEvent(),
- nativeEvent: {
- // `??` rather than spread, so that explicit `undefined` values fall back to `0`.
- layout: {
- x: layout?.x ?? 0,
- y: layout?.y ?? 0,
- width: layout?.width ?? 0,
- height: layout?.height ?? 0,
- },
- target: 0,
- },
- };
-}
diff --git a/src/events/builders/index.ts b/src/events/builders/index.ts
deleted file mode 100644
index 813d38feb..000000000
--- a/src/events/builders/index.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export * from './common';
-export * from './merge';
-export * from './scroll';
-export * from './text';
diff --git a/src/events/builders/merge.ts b/src/events/builders/merge.ts
deleted file mode 100644
index aa068145e..000000000
--- a/src/events/builders/merge.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import type { EventProps } from '../types';
-
-/**
- * Deep merges custom props into a built event, so tests can override only the fields they need.
- * Nested objects are merged, other values (including arrays) are replaced. Mutates and returns
- * the passed event.
- */
-export function mergeEventProps(event: T, eventProps?: EventProps): T {
- if (eventProps) {
- mergeInto(event as EventProps, eventProps);
- }
-
- return event;
-}
-
-function mergeInto(target: EventProps, source: EventProps) {
- for (const key of Object.keys(source)) {
- const sourceValue = source[key];
- const targetValue = target[key];
- if (isObject(sourceValue) && isObject(targetValue)) {
- mergeInto(targetValue, sourceValue);
- } else {
- target[key] = sourceValue;
- }
- }
-}
-
-function isObject(value: unknown): value is EventProps {
- return value !== null && typeof value === 'object' && !Array.isArray(value);
-}
diff --git a/src/events/builders/scroll.ts b/src/events/builders/scroll.ts
deleted file mode 100644
index a9a799371..000000000
--- a/src/events/builders/scroll.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import type { Point, Size } from '../../types';
-import { baseSyntheticEvent } from './base';
-
-/**
- * Other options for constructing a scroll event.
- */
-export type ScrollEventOptions = {
- contentSize?: Size;
- layoutMeasurement?: Size;
-};
-
-/**
- * Experimental values:
- * - iOS: `{"contentInset": {"bottom": 0, "left": 0, "right": 0, "top": 0}, "contentOffset": {"x": 0, "y": 5.333333333333333}, "contentSize": {"height": 1676.6666259765625, "width": 390}, "layoutMeasurement": {"height": 753, "width": 390}, "zoomScale": 1}`
- * - Android: `{"contentInset": {"bottom": 0, "left": 0, "right": 0, "top": 0}, "contentOffset": {"x": 0, "y": 31.619047164916992}, "contentSize": {"height": 1624.761962890625, "width": 411.4285583496094}, "layoutMeasurement": {"height": 785.5238037109375, "width": 411.4285583496094}, "responderIgnoreScroll": true, "target": 139, "velocity": {"x": -1.3633992671966553, "y": -1.3633992671966553}}`
- */
-export function buildScrollEvent(offset: Point = { y: 0, x: 0 }, options?: ScrollEventOptions) {
- return {
- ...baseSyntheticEvent(),
- nativeEvent: {
- contentInset: { bottom: 0, left: 0, right: 0, top: 0 },
- contentOffset: { y: offset.y, x: offset.x },
- contentSize: {
- height: options?.contentSize?.height ?? 0,
- width: options?.contentSize?.width ?? 0,
- },
- layoutMeasurement: {
- height: options?.layoutMeasurement?.height ?? 0,
- width: options?.layoutMeasurement?.width ?? 0,
- },
- responderIgnoreScroll: true,
- target: 0,
- velocity: { y: 0, x: 0 },
- },
- };
-}
diff --git a/src/events/builders/text.ts b/src/events/builders/text.ts
deleted file mode 100644
index 8b0346484..000000000
--- a/src/events/builders/text.ts
+++ /dev/null
@@ -1,74 +0,0 @@
-import type { Size, TextRange } from '../../types';
-import { baseSyntheticEvent } from './base';
-
-/**
- * Experimental values:
- * - iOS: `{"eventCount": 4, "target": 75, "text": "Test"}`
- * - Android: `{"eventCount": 6, "target": 53, "text": "Tes"}`
- */
-export function buildTextChangeEvent(text: string, { start, end }: TextRange) {
- return {
- ...baseSyntheticEvent(),
- nativeEvent: { text, target: 0, eventCount: 0, selection: { start, end } },
- };
-}
-
-/**
- * Experimental values:
- * - iOS: `{"eventCount": 3, "key": "a", "target": 75}`
- * - Android: `{"key": "a"}`
- */
-export function buildKeyPressEvent(key: string) {
- return {
- ...baseSyntheticEvent(),
- nativeEvent: { key },
- };
-}
-
-/**
- * Experimental values:
- * - iOS: `{"eventCount": 4, "target": 75, "text": "Test"}`
- * - Android: `{"target": 53, "text": "Test"}`
- */
-export function buildSubmitEditingEvent(text: string) {
- return {
- ...baseSyntheticEvent(),
- nativeEvent: { text, target: 0 },
- };
-}
-
-/**
- * Experimental values:
- * - iOS: `{"eventCount": 4, "target": 75, "text": "Test"}`
- * - Android: `{"target": 53, "text": "Test"}`
- */
-export function buildEndEditingEvent(text: string) {
- return {
- ...baseSyntheticEvent(),
- nativeEvent: { text, target: 0 },
- };
-}
-
-/**
- * Experimental values:
- * - iOS: `{"selection": {"end": 4, "start": 4}, "target": 75}`
- * - Android: `{"selection": {"end": 4, "start": 4}}`
- */
-export function buildTextSelectionChangeEvent({ start, end }: TextRange) {
- return {
- ...baseSyntheticEvent(),
- nativeEvent: { selection: { start, end } },
- };
-}
-
-/**
- * Experimental values:
- * - iOS: `{"contentSize": {"height": 21.666666666666668, "width": 11.666666666666666}, "target": 75}`
- * - Android: `{"contentSize": {"height": 61.45454406738281, "width": 352.7272644042969}, "target": 53}`
- */
-export function buildContentSizeChangeEvent({ width, height }: Size) {
- return {
- ...baseSyntheticEvent(),
- nativeEvent: { contentSize: { width, height }, target: 0 },
- };
-}
diff --git a/src/events/create-event.ts b/src/events/create-event.ts
new file mode 100644
index 000000000..58aff016b
--- /dev/null
+++ b/src/events/create-event.ts
@@ -0,0 +1,99 @@
+import { getConfig } from '../config';
+import type { Point, Size, TextRange } from '../types';
+import { wrapNativeEvent } from './legacy/builders/base';
+import type { NativeEventPayload, SyntheticEvent } from './modern/event';
+import { createEvent as createModernEvent } from './modern/event';
+import { getEventTypeConfig } from './modern/event-types';
+import type { ScrollNativeEventOptions } from './shared/payloads';
+import {
+ buildAccessibilityActionNativeEvent,
+ buildBlurNativeEvent,
+ buildContentSizeChangeNativeEvent,
+ buildEndEditingNativeEvent,
+ buildFocusNativeEvent,
+ buildKeyPressNativeEvent,
+ buildScrollNativeEvent,
+ buildSubmitEditingNativeEvent,
+ buildTextChangeNativeEvent,
+ buildTextSelectionChangeNativeEvent,
+} from './shared/payloads';
+
+export type LegacyEvent = ReturnType>;
+
+/**
+ * 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
+ * `SyntheticEvent`. Used by `userEvent`, with `dispatchEvent()`. Unlike modern `createEvent()`,
+ * takes the `nativeEvent` itself, not an init object.
+ *
+ * Callbacks that components call from JavaScript (`changeText`, `pressIn`, `responderGrant`, ...)
+ * aren't native events. Call them with `invokeEventHandler()`.
+ *
+ * @param eventType a native event type without the `on*` prefix, e.g. `focus`
+ */
+export function createEvent(
+ eventType: string,
+ nativeEvent: NativeEventPayload,
+): LegacyEvent | SyntheticEvent {
+ if (getEventTypeConfig(eventType) == null) {
+ throw new Error(`"${eventType}" is not a native event type. Use invokeEventHandler() instead.`);
+ }
+
+ if (getConfig().unstable_eventSystem === 'legacy') {
+ return wrapNativeEvent(nativeEvent);
+ }
+
+ // Not `null`: the event type is known.
+ return createModernEvent(eventType, { nativeEvent }) as SyntheticEvent;
+}
+
+export function buildFocusEvent() {
+ return createEvent('focus', buildFocusNativeEvent());
+}
+
+export function buildBlurEvent() {
+ return createEvent('blur', buildBlurNativeEvent());
+}
+
+export function buildAccessibilityActionEvent(actionName: string) {
+ return createEvent('accessibilityAction', buildAccessibilityActionNativeEvent(actionName));
+}
+
+export function buildScrollEvent(
+ eventType: ScrollEventType,
+ offset?: Point,
+ options?: ScrollNativeEventOptions,
+) {
+ return createEvent(eventType, buildScrollNativeEvent(offset, options));
+}
+
+export function buildTextChangeEvent(text: string, range: TextRange) {
+ return createEvent('change', buildTextChangeNativeEvent(text, range));
+}
+
+export function buildKeyPressEvent(key: string) {
+ return createEvent('keyPress', buildKeyPressNativeEvent(key));
+}
+
+export function buildSubmitEditingEvent(text: string) {
+ return createEvent('submitEditing', buildSubmitEditingNativeEvent(text));
+}
+
+export function buildEndEditingEvent(text: string) {
+ return createEvent('endEditing', buildEndEditingNativeEvent(text));
+}
+
+export function buildTextSelectionChangeEvent(range: TextRange) {
+ return createEvent('selectionChange', buildTextSelectionChangeNativeEvent(range));
+}
+
+export function buildContentSizeChangeEvent(size: Size) {
+ return createEvent('contentSizeChange', buildContentSizeChangeNativeEvent(size));
+}
+
+type ScrollEventType =
+ | 'scroll'
+ | 'scrollBeginDrag'
+ | 'scrollEndDrag'
+ | 'momentumScrollBegin'
+ | 'momentumScrollEnd';
diff --git a/src/events/dispatch-event.ts b/src/events/dispatch-event.ts
new file mode 100644
index 000000000..6f7a3a0a3
--- /dev/null
+++ b/src/events/dispatch-event.ts
@@ -0,0 +1,29 @@
+import type { TestInstance } from 'test-renderer';
+
+import type { LegacyEvent } from './create-event';
+import { dispatchEvent as dispatchModernEvent } from './modern/dispatch';
+import { SyntheticEvent } from './modern/event';
+import { invokeEventHandler } from './shared/invoke-event-handler';
+
+/**
+ * Dispatches an event from `createEvent()` to a host element. Used by `userEvent`.
+ *
+ * - `SyntheticEvent`: dispatched as React Native does (capture and bubble phases for bubbling
+ * events). `press` is a native event type (`topPress`), so it reaches host `onPress` props only,
+ * not `Pressable`'s `onPress`.
+ * - Legacy event object: calls the element's own `on*` prop with it.
+ *
+ * @param eventType a native event type without the `on*` prefix, e.g. `focus`
+ * @returns `true` if a handler was called.
+ */
+export async function dispatchEvent(
+ instance: TestInstance,
+ eventType: string,
+ event: SyntheticEvent | LegacyEvent,
+): Promise {
+ if (event instanceof SyntheticEvent) {
+ return await dispatchModernEvent(instance, event);
+ }
+
+ return await invokeEventHandler(instance, eventType, event);
+}
diff --git a/src/events/dispatch.ts b/src/events/dispatch.ts
deleted file mode 100644
index 400b2c080..000000000
--- a/src/events/dispatch.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import type { TestInstance } from 'test-renderer';
-
-import { act } from '../act';
-import { isInstanceMounted } from '../helpers/component-tree';
-import { getEventHandlerFromProps } from './handler';
-
-/**
- * Basic dispatch event function used by User Event module.
- *
- * @param instance instance to trigger event on
- * @param eventType type of the event
- * @param event event payload(s)
- * @returns `true` if a handler was called.
- */
-export async function dispatchEvent(
- instance: TestInstance,
- eventType: string,
- ...event: unknown[]
-): Promise {
- if (!isInstanceMounted(instance)) {
- return false;
- }
-
- const handler = getEventHandlerFromProps(instance.props, eventType);
- if (!handler) {
- return false;
- }
-
- await act(() => {
- handler(...event);
- });
-
- return true;
-}
diff --git a/src/events/fire-event.ts b/src/events/fire-event.ts
index ee998c9e2..2d0dc1b81 100644
--- a/src/events/fire-event.ts
+++ b/src/events/fire-event.ts
@@ -1,80 +1,124 @@
import type { TestInstance } from 'test-renderer';
-import { act } from '../act';
-import { isInstanceMounted } from '../helpers/component-tree';
-import { isHostScrollView } from '../helpers/host-component-names';
-import { buildLayoutEvent, buildTouchEvent } from './builders/common';
-import { mergeEventProps } from './builders/merge';
-import { buildScrollEvent } from './builders/scroll';
-import { normalizeEventType } from './handler';
-import { nativeState } from './native-state';
-import { findEventHandler } from './propagation';
-import type { EventProps, EventType, LayoutRectangle } from './types';
-import { updateNativeStateFromEvent } from './update-native-state';
-import { warnAboutUnhandledEvent } from './warnings';
-
-async function fireEvent(instance: TestInstance, eventType: EventType, ...data: unknown[]) {
- return await fireEventInternal(instance, { type: eventType, data, bubbles: true });
-}
+import type { EventSystem } from '../config';
+import { getConfig } from '../config';
+import { fireEvent as legacyFireEvent } from './legacy/fire-event';
+import type { CustomEventDispatch, KnownEventType } from './modern/event-types';
+import type { FireEventInit } from './modern/fire-event';
+import { fireEvent as modernFireEvent } from './modern/fire-event';
+import type { EventProps, EventType, LayoutRectangle } from './shared/types';
+
+/**
+ * Settings that users augment to change RNTL's types. `@testing-library/react-native/modern-types`
+ * sets `eventSystem: 'modern'`, so `fireEvent` takes the modern signatures.
+ */
+// eslint-disable-next-line @typescript-eslint/no-empty-object-type
+export interface Register {}
-fireEvent.changeText = async (instance: TestInstance, text: string) =>
- await fireEvent(instance, 'changeText', text);
+type RegisteredEventSystem = Register extends { eventSystem: infer S extends EventSystem }
+ ? S
+ : 'legacy';
-fireEvent.press = async (instance: TestInstance, eventProps?: EventProps) => {
- await fireEvent(instance, 'press', mergeEventProps(buildTouchEvent(), eventProps));
+type LegacyFireEvent = {
+ (instance: TestInstance, eventType: EventType, ...data: unknown[]): Promise;
+ changeText: (instance: TestInstance, text: string) => Promise;
+ press: (instance: TestInstance, eventProps?: EventProps) => Promise;
+ scroll: (instance: TestInstance, eventProps?: EventProps) => Promise;
+ layout: (instance: TestInstance, layout?: Partial) => Promise;
};
-fireEvent.scroll = async (instance: TestInstance, eventProps?: EventProps) => {
- const layoutMeasurement = isHostScrollView(instance)
- ? nativeState.layoutSizeForInstance.get(instance)
- : undefined;
- const event = buildScrollEvent(undefined, { layoutMeasurement });
- await fireEvent(instance, 'scroll', mergeEventProps(event, eventProps));
+type KnownEventName = KnownEventType | `on${Capitalize}`;
+
+type CustomFireEventInit = FireEventInit & { dispatch: CustomEventDispatch };
+
+/** Shown in the type error of `fireEvent(input, 'changeText', 'Hello')`, as the runtime error. */
+type ChangeTextHint = 'Use fireEvent.changeText() or userEvent.type() instead.';
+
+/**
+ * Event object that modern `fireEvent` accepts at runtime: `dispatch` is required for event types
+ * React Native doesn't know. Not distributive, so a union with an unknown event type requires it.
+ */
+type ModernFireEventInit = [T] extends [KnownEventName]
+ ? FireEventInit
+ : [T] extends ['changeText' | 'onChangeText']
+ ? ChangeTextHint | CustomFireEventInit
+ : CustomFireEventInit;
+
+type ModernFireEvent = {
+ (
+ instance: TestInstance,
+ eventType: T,
+ event: ModernFireEventInit,
+ ): Promise;
+ changeText: (instance: TestInstance, text: string) => Promise;
+ press: (instance: TestInstance, event?: FireEventInit) => Promise;
+ scroll: (instance: TestInstance, event?: FireEventInit) => Promise;
+ layout: (instance: TestInstance, layout?: Partial) => Promise;
};
-// Does not bubble and checks only the element's own props, as React Native delivers layout events
-// only to the measured element. This is the intended behavior: `fireEvent(instance, 'layout')`
-// still bubbles (with a deprecation warning) for compatibility, and will match this in the next
-// major version.
-fireEvent.layout = async (instance: TestInstance, layout?: Partial) => {
- await fireEventInternal(instance, {
- type: 'layout',
- data: [buildLayoutEvent(layout)],
- bubbles: false,
- });
+export type FireEvent = RegisteredEventSystem extends 'modern' ? ModernFireEvent : LegacyFireEvent;
+
+/**
+ * Public `fireEvent`. Calls the implementation of the event system selected by
+ * `configure({ unstable_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.
+ */
+async function fireEventImpl(
+ instance: TestInstance,
+ eventType: EventType,
+ ...data: unknown[]
+): Promise {
+ if (isModern()) {
+ return await modernFireEvent(instance, eventType, ...(data as [FireEventInit]));
+ }
+
+ return await legacyFireEvent(instance, eventType, ...data);
+}
+
+fireEventImpl.changeText = async (instance: TestInstance, text: string): Promise => {
+ if (isModern()) {
+ return await modernFireEvent.changeText(instance, text);
+ }
+
+ return await legacyFireEvent.changeText(instance, text);
};
-type FireEventOptions = {
- type: EventType;
- data: unknown[];
- bubbles: boolean;
+fireEventImpl.press = async (instance: TestInstance, eventProps?: EventProps): Promise => {
+ if (isModern()) {
+ await modernFireEvent.press(instance, eventProps as FireEventInit);
+ return;
+ }
+
+ await legacyFireEvent.press(instance, eventProps);
};
-async function fireEventInternal(instance: TestInstance, options: FireEventOptions) {
- const { type, data, bubbles } = options;
- if (!isInstanceMounted(instance)) {
+fireEventImpl.scroll = async (instance: TestInstance, eventProps?: EventProps): Promise => {
+ if (isModern()) {
+ await modernFireEvent.scroll(instance, eventProps as FireEventInit);
return;
}
- // `fireEvent` accepts event types with and without the `on*` prefix.
- const hasUpdatedNativeState = updateNativeStateFromEvent(
- instance,
- normalizeEventType(type),
- data[0],
- );
+ await legacyFireEvent.scroll(instance, eventProps);
+};
- const { handler, skippedTargets } = findEventHandler(instance, type, { bubbles });
- if (!handler) {
- warnAboutUnhandledEvent(instance, type, { skippedTargets, hasUpdatedNativeState });
+fireEventImpl.layout = async (
+ instance: TestInstance,
+ layout?: Partial,
+): Promise => {
+ if (isModern()) {
+ await modernFireEvent.layout(instance, layout);
return;
}
- let returnValue;
- await act(() => {
- returnValue = handler(...data);
- });
+ await legacyFireEvent.layout(instance, layout);
+};
- return returnValue;
+function isModern() {
+ return getConfig().unstable_eventSystem === 'modern';
}
+const fireEvent = fireEventImpl as FireEvent;
+
export { fireEvent };
diff --git a/src/events/handler.ts b/src/events/handler.ts
deleted file mode 100644
index 8e0088942..000000000
--- a/src/events/handler.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-import type { EventHandler } from './types';
-
-export type EventHandlerOptions = {
- /** Include check for event handler named without adding `on*` prefix. */
- loose?: boolean;
-};
-
-export function getEventHandlerFromProps(
- props: Record,
- eventType: string,
- options?: EventHandlerOptions,
-): EventHandler | undefined {
- const handlerName = getEventHandlerName(eventType);
- if (typeof props[handlerName] === 'function') {
- return props[handlerName] as EventHandler;
- }
-
- if (options?.loose && typeof props[eventType] === 'function') {
- return props[eventType] as EventHandler;
- }
-
- if (typeof props[`testOnly_${handlerName}`] === 'function') {
- return props[`testOnly_${handlerName}`] as EventHandler;
- }
-
- if (options?.loose && typeof props[`testOnly_${eventType}`] === 'function') {
- return props[`testOnly_${eventType}`] as EventHandler;
- }
-
- return undefined;
-}
-
-/**
- * Returns the event type without the `on*` prefix, e.g. `onLayout` -> `layout`.
- * Note: `fireEvent` accepts event types with and without the prefix, so use this
- * before comparing event types.
- */
-export function normalizeEventType(eventType: string) {
- if (hasOnPrefix(eventType)) {
- return eventType.charAt(2).toLowerCase() + eventType.slice(3);
- }
-
- return eventType;
-}
-
-export function getEventHandlerName(eventType: string) {
- if (hasOnPrefix(eventType)) {
- return eventType;
- }
-
- return `on${capitalizeFirstLetter(eventType)}`;
-}
-
-function hasOnPrefix(eventType: string) {
- return /^on[A-Z]/.test(eventType);
-}
-
-function capitalizeFirstLetter(str: string) {
- return str.charAt(0).toUpperCase() + str.slice(1);
-}
diff --git a/src/events/index.ts b/src/events/index.ts
deleted file mode 100644
index 117425c85..000000000
--- a/src/events/index.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-export * from './builders/common';
-export * from './builders/scroll';
-export * from './builders/text';
-export * from './dispatch';
-export * from './handler';
-export { isPointerEventEnabled } from './is-enabled';
-export * from './native-state';
-export type { EventWarning } from './warnings';
-export { formatDisabledTargets, getPointerEventsBlockedTargets, logEventWarning } from './warnings';
diff --git a/src/events/invoke-handler.ts b/src/events/invoke-handler.ts
new file mode 100644
index 000000000..69a7bb63d
--- /dev/null
+++ b/src/events/invoke-handler.ts
@@ -0,0 +1,78 @@
+import type { TestInstance } from 'test-renderer';
+
+import { act } from '../act';
+import { isInstanceMounted } from '../helpers/component-tree';
+import { ErrorWithStack } from '../helpers/errors';
+import { getComponentName, getCompositeOwnerFibers } from '../helpers/fiber';
+import type { EventHandler } from './shared/types';
+
+/**
+ * Calls a handler prop with the given arguments, in `act()`, and resolves to its return value.
+ * Escape hatch for callbacks that no event reaches, like `onContentSizeChange(width, height)`, or
+ * `onHoverIn` of `Pressable`, which React Native never calls on iOS or Android by default.
+ *
+ * Looks up the prop on the element, then on the composite components that render it, nearest
+ * first, up to the next host element. Doesn't check whether a device would call the handler:
+ * disabled elements, `pointerEvents` and `editable` are ignored.
+ *
+ * @param handlerName the exact prop name, e.g. `onContentSizeChange`
+ */
+export async function UNSAFE_invokeHandler(
+ instance: TestInstance,
+ handlerName: string,
+ ...args: unknown[]
+): Promise {
+ if (instance == null) {
+ throw new ErrorWithStack(
+ `Unable to invoke "${handlerName}". Please provide a host element.`,
+ UNSAFE_invokeHandler,
+ );
+ }
+
+ if (!isInstanceMounted(instance)) {
+ throw new ErrorWithStack(
+ `Unable to invoke "${handlerName}". The element is unmounted.`,
+ UNSAFE_invokeHandler,
+ );
+ }
+
+ const handler = findHandler(instance, handlerName);
+ if (handler == null) {
+ throw new ErrorWithStack(
+ `Unable to invoke "${handlerName}". No "${handlerName}" function prop found on the element ` +
+ `(${instance.type}) or the components that render it (${formatCompositeNames(instance)}).`,
+ UNSAFE_invokeHandler,
+ );
+ }
+
+ return await act(() => handler(...args));
+}
+
+/** Checks the element's props, then its composite owners' props, up to the next host element. */
+function findHandler(instance: TestInstance, handlerName: string): EventHandler | null {
+ const ownHandler = instance.props[handlerName];
+ if (typeof ownHandler === 'function') {
+ return ownHandler as EventHandler;
+ }
+
+ for (const fiber of getCompositeOwnerFibers(instance)) {
+ const handler: unknown = fiber.memoizedProps?.[handlerName];
+ if (typeof handler === 'function') {
+ return handler as EventHandler;
+ }
+ }
+
+ return null;
+}
+
+/**
+ * E.g. `View, Pressable, MyButton`. Collapses adjacent duplicates, e.g. `memo(forwardRef(Pressable))`
+ * in older React Native versions renders two fibers named `Pressable`.
+ */
+function formatCompositeNames(instance: TestInstance): string {
+ const names = getCompositeOwnerFibers(instance)
+ .map((fiber) => getComponentName(fiber.type))
+ .filter((name) => name != null)
+ .filter((name, index, all) => name !== all[index - 1]);
+ return names.length > 0 ? names.join(', ') : 'none';
+}
diff --git a/src/events/builders/__tests__/base.test.ts b/src/events/legacy/builders/__tests__/base.test.ts
similarity index 76%
rename from src/events/builders/__tests__/base.test.ts
rename to src/events/legacy/builders/__tests__/base.test.ts
index 47ecedc24..761efd3b3 100644
--- a/src/events/builders/__tests__/base.test.ts
+++ b/src/events/legacy/builders/__tests__/base.test.ts
@@ -1,4 +1,4 @@
-import { baseSyntheticEvent } from '../base';
+import { baseSyntheticEvent, wrapNativeEvent } from '../base';
test('returns object with all required properties and default values', () => {
const event = baseSyntheticEvent();
@@ -33,3 +33,12 @@ test('can be spread into other objects', () => {
expect(extendedEvent).toHaveProperty('preventDefault');
expect(extendedEvent.nativeEvent).toEqual({ test: 'value' });
});
+
+test('wrapNativeEvent adds base stubs around the native event', () => {
+ const nativeEvent = { target: 0 };
+ const event = wrapNativeEvent(nativeEvent);
+
+ expect(event.nativeEvent).toBe(nativeEvent);
+ expect(event.timeStamp).toBe(0);
+ expect(typeof event.preventDefault).toBe('function');
+});
diff --git a/src/events/builders/__tests__/common.test.ts b/src/events/legacy/builders/__tests__/common.test.ts
similarity index 54%
rename from src/events/builders/__tests__/common.test.ts
rename to src/events/legacy/builders/__tests__/common.test.ts
index 42066a66d..64bf6a405 100644
--- a/src/events/builders/__tests__/common.test.ts
+++ b/src/events/legacy/builders/__tests__/common.test.ts
@@ -1,11 +1,4 @@
-import {
- buildBlurEvent,
- buildFocusEvent,
- buildLayoutEvent,
- buildResponderGrantEvent,
- buildResponderReleaseEvent,
- buildTouchEvent,
-} from '../common';
+import { buildResponderGrantEvent, buildResponderReleaseEvent, buildTouchEvent } from '../common';
test('buildTouchEvent returns event with touch nativeEvent', () => {
const event = buildTouchEvent();
@@ -42,27 +35,3 @@ test('buildResponderReleaseEvent returns touch event with dispatchConfig', () =>
});
expect(event.nativeEvent).toHaveProperty('touches');
});
-
-test('buildFocusEvent returns event with target', () => {
- const event = buildFocusEvent();
-
- expect(event.nativeEvent).toEqual({ target: 0 });
- expect(event).toHaveProperty('preventDefault');
-});
-
-test('buildBlurEvent returns event with target', () => {
- const event = buildBlurEvent();
-
- expect(event.nativeEvent).toEqual({ target: 0 });
- expect(event).toHaveProperty('preventDefault');
-});
-
-test('buildLayoutEvent returns event with zeroed layout rectangle', () => {
- const event = buildLayoutEvent();
-
- expect(event.nativeEvent).toEqual({
- layout: { x: 0, y: 0, width: 0, height: 0 },
- target: 0,
- });
- expect(event).toHaveProperty('preventDefault');
-});
diff --git a/src/events/builders/base.ts b/src/events/legacy/builders/base.ts
similarity index 85%
rename from src/events/builders/base.ts
rename to src/events/legacy/builders/base.ts
index ff45e9828..11debeca6 100644
--- a/src/events/builders/base.ts
+++ b/src/events/legacy/builders/base.ts
@@ -19,3 +19,7 @@ export function baseSyntheticEvent(): BaseEvent {
timeStamp: 0,
};
}
+
+export function wrapNativeEvent(nativeEvent: T) {
+ return { ...baseSyntheticEvent(), nativeEvent };
+}
diff --git a/src/events/legacy/builders/common.ts b/src/events/legacy/builders/common.ts
new file mode 100644
index 000000000..6cd4c3d6c
--- /dev/null
+++ b/src/events/legacy/builders/common.ts
@@ -0,0 +1,23 @@
+import { buildTouchNativeEvent } from '../../shared/payloads';
+import { wrapNativeEvent } from './base';
+
+export function buildTouchEvent() {
+ return {
+ ...wrapNativeEvent(buildTouchNativeEvent()),
+ currentTarget: { measure: () => {} },
+ };
+}
+
+export function buildResponderGrantEvent() {
+ return {
+ ...buildTouchEvent(),
+ dispatchConfig: { registrationName: 'onResponderGrant' },
+ };
+}
+
+export function buildResponderReleaseEvent() {
+ return {
+ ...buildTouchEvent(),
+ dispatchConfig: { registrationName: 'onResponderRelease' },
+ };
+}
diff --git a/src/events/legacy/fire-event.ts b/src/events/legacy/fire-event.ts
new file mode 100644
index 000000000..d6eb9bfac
--- /dev/null
+++ b/src/events/legacy/fire-event.ts
@@ -0,0 +1,81 @@
+import type { TestInstance } from 'test-renderer';
+
+import { act } from '../../act';
+import { isInstanceMounted } from '../../helpers/component-tree';
+import { normalizeEventType } from '../shared/handler';
+import { mergeEventProps } from '../shared/merge';
+import { buildLayoutNativeEvent, buildScrollNativeEvent } from '../shared/payloads';
+import type { EventProps, EventType, LayoutRectangle } from '../shared/types';
+import {
+ getNativeStateEventProps,
+ updateNativeStateFromEvent,
+} from '../shared/update-native-state';
+import { wrapNativeEvent } from './builders/base';
+import { buildTouchEvent } from './builders/common';
+import { findEventHandler } from './propagation';
+import { warnAboutUnhandledEvent } from './warnings';
+
+async function fireEvent(instance: TestInstance, eventType: EventType, ...data: unknown[]) {
+ return await fireEventInternal(instance, { type: eventType, data, bubbles: true });
+}
+
+fireEvent.changeText = async (instance: TestInstance, text: string) =>
+ await fireEvent(instance, 'changeText', text);
+
+fireEvent.press = async (instance: TestInstance, eventProps?: EventProps) => {
+ await fireEvent(instance, 'press', mergeEventProps(buildTouchEvent(), eventProps));
+};
+
+fireEvent.scroll = async (instance: TestInstance, eventProps?: EventProps) => {
+ const nativeEvent = mergeEventProps(
+ buildScrollNativeEvent(),
+ getNativeStateEventProps(instance, 'scroll'),
+ );
+ await fireEvent(instance, 'scroll', mergeEventProps(wrapNativeEvent(nativeEvent), eventProps));
+};
+
+// Does not bubble and checks only the element's own props, as React Native delivers layout events
+// only to the measured element. This is the intended behavior: `fireEvent(instance, 'layout')`
+// still bubbles (with a deprecation warning) for compatibility, and will match this in the next
+// major version.
+fireEvent.layout = async (instance: TestInstance, layout?: Partial) => {
+ await fireEventInternal(instance, {
+ type: 'layout',
+ data: [wrapNativeEvent(buildLayoutNativeEvent(layout))],
+ bubbles: false,
+ });
+};
+
+type FireEventOptions = {
+ type: EventType;
+ data: unknown[];
+ bubbles: boolean;
+};
+
+async function fireEventInternal(instance: TestInstance, options: FireEventOptions) {
+ const { type, data, bubbles } = options;
+ if (!isInstanceMounted(instance)) {
+ return;
+ }
+
+ const hasUpdatedNativeState = updateNativeStateFromEvent(
+ instance,
+ normalizeEventType(type),
+ data[0],
+ );
+
+ const { handler, skippedTargets } = findEventHandler(instance, type, { bubbles });
+ if (!handler) {
+ warnAboutUnhandledEvent(instance, type, { skippedTargets, hasUpdatedNativeState });
+ return;
+ }
+
+ let returnValue;
+ await act(() => {
+ returnValue = handler(...data);
+ });
+
+ return returnValue;
+}
+
+export { fireEvent };
diff --git a/src/events/is-enabled.ts b/src/events/legacy/is-enabled.ts
similarity index 50%
rename from src/events/is-enabled.ts
rename to src/events/legacy/is-enabled.ts
index 0aef969a9..87d30dffe 100644
--- a/src/events/is-enabled.ts
+++ b/src/events/legacy/is-enabled.ts
@@ -1,27 +1,8 @@
-import { StyleSheet } from 'react-native';
import type { TestInstance } from 'test-renderer';
-import { isHostTextInput } from '../helpers/host-component-names';
-import { isEditableTextInput } from '../helpers/text-input';
-
-/**
- * pointerEvents controls whether the View can be the target of touch events.
- * 'auto': The View and its children can be the target of touch events.
- * 'none': The View is never the target of touch events.
- * 'box-none': The View is never the target of touch events but its subviews can be
- * 'box-only': The view can be the target of touch events but its subviews cannot be
- * see the official react native doc https://reactnative.dev/docs/view#pointerevents */
-export function isPointerEventEnabled(instance: TestInstance): boolean {
- return getPointerEventsBlocker(instance) == null;
-}
-
-/**
- * Returns the element whose `pointerEvents` prevents the instance from being the target of
- * touch events: the instance itself or one of its ancestors. Returns `null` if nothing blocks it.
- */
-export function getPointerEventsBlocker(instance: TestInstance): TestInstance | null {
- return findPointerEventsBlocker(instance, false);
-}
+import { isHostTextInput } from '../../helpers/host-component-names';
+import { isEditableTextInput } from '../../helpers/text-input';
+import { isPointerEventEnabled } from '../shared/pointer-events';
export function isTouchResponder(instance: TestInstance) {
return Boolean(instance.props.onStartShouldSetResponder) || isHostTextInput(instance);
@@ -73,21 +54,3 @@ export function isEventEnabled(
return touchStart === undefined && touchMove === undefined;
}
-
-function findPointerEventsBlocker(instance: TestInstance, isParent: boolean): TestInstance | null {
- // Check both props.pointerEvents and props.style.pointerEvents
- const pointerEvents =
- instance?.props.pointerEvents ?? StyleSheet.flatten(instance?.props.style)?.pointerEvents;
-
- const parentCondition = isParent ? pointerEvents === 'box-only' : pointerEvents === 'box-none';
-
- if (pointerEvents === 'none' || parentCondition) {
- return instance;
- }
-
- if (!instance.parent) {
- return null;
- }
-
- return findPointerEventsBlocker(instance.parent, true);
-}
diff --git a/src/events/propagation.ts b/src/events/legacy/propagation.ts
similarity index 81%
rename from src/events/propagation.ts
rename to src/events/legacy/propagation.ts
index 34396a31e..4bfab30fe 100644
--- a/src/events/propagation.ts
+++ b/src/events/legacy/propagation.ts
@@ -1,15 +1,16 @@
-import type { Fiber, TestInstance } from 'test-renderer';
+import type { TestInstance } from 'test-renderer';
+import { getCompositeOwnerFibers } from '../../helpers/fiber';
import {
isHostImage,
isHostModal,
isHostScrollView,
isHostText,
isHostTextInput,
-} from '../helpers/host-component-names';
-import { getEventHandlerFromProps, normalizeEventType } from './handler';
+} from '../../helpers/host-component-names';
+import { getEventHandlerByEventType, normalizeEventType } from '../shared/handler';
+import type { EventHandler } from '../shared/types';
import { isEventEnabled, isTouchResponder } from './is-enabled';
-import type { EventHandler } from './types';
import { warnAboutBubblingDirectEvent } from './warnings';
export type FindEventHandlerOptions = {
@@ -39,7 +40,7 @@ export function findEventHandler(
options: FindEventHandlerOptions,
): FindEventHandlerResult {
if (!options.bubbles) {
- const handler = getEventHandlerFromProps(instance.props, eventType, { loose: true });
+ const handler = getEventHandlerByEventType(instance.props, eventType, { loose: true });
return { handler: handler ?? null, skippedTargets: [] };
}
@@ -74,8 +75,8 @@ function findBubblingHandlerOwner(
const touchResponder = isTouchResponder(instance) ? instance : nearestTouchResponder;
const handler =
- getEventHandlerFromProps(instance.props, eventType, { loose: true }) ??
- findEventHandlerFromFiber(instance.unstable_fiber, eventType);
+ getEventHandlerByEventType(instance.props, eventType, { loose: true }) ??
+ findCompositeEventHandler(instance, eventType);
if (handler) {
if (isEventEnabled(instance, normalizeEventType(eventType), touchResponder)) {
return { owner: { handler, instance }, skippedTargets };
@@ -153,23 +154,16 @@ function isDirectEvent(instance: TestInstance, eventType: string) {
return false;
}
-function findEventHandlerFromFiber(fiber: Fiber | null, eventType: string): EventHandler | null {
- // Container fibers have memoizedProps set to null
- if (!fiber?.memoizedProps) {
- return null;
- }
-
- const handler = getEventHandlerFromProps(fiber.memoizedProps, eventType, {
- loose: true,
- });
- if (handler) {
- return handler;
- }
-
- // No parent fiber or we reached another host element
- if (fiber.return === null || typeof fiber.return.type === 'string') {
- return null;
+/** Checks the props of the composite components that render the element, nearest first. */
+function findCompositeEventHandler(instance: TestInstance, eventType: string): EventHandler | null {
+ for (const fiber of getCompositeOwnerFibers(instance)) {
+ const handler = getEventHandlerByEventType(fiber.memoizedProps ?? {}, eventType, {
+ loose: true,
+ });
+ if (handler) {
+ return handler;
+ }
}
- return findEventHandlerFromFiber(fiber.return, eventType);
+ return null;
}
diff --git a/src/events/legacy/warnings.ts b/src/events/legacy/warnings.ts
new file mode 100644
index 000000000..b1c3faa68
--- /dev/null
+++ b/src/events/legacy/warnings.ts
@@ -0,0 +1,92 @@
+import redent from 'redent';
+import type { TestInstance } from 'test-renderer';
+
+import { getConfig } from '../../config';
+import { computeAriaDisabled } from '../../helpers/accessibility';
+import { formatElement } from '../../helpers/format-element';
+import { logger } from '../../helpers/logger';
+import { getEventHandlerName, normalizeEventType } from '../shared/handler';
+import type { EventWarning } from '../shared/warnings';
+import {
+ formatDisabledTargets,
+ getPointerEventsBlockedTargets,
+ logEventWarning,
+} from '../shared/warnings';
+import { isEventBlockableByPointerEvents } from './is-enabled';
+
+type UnhandledEventInfo = {
+ skippedTargets: TestInstance[];
+ hasUpdatedNativeState: boolean;
+};
+
+/** Warns when no handler ran because the target is disabled or nothing handles the event. */
+export function warnAboutUnhandledEvent(
+ instance: TestInstance,
+ eventType: string,
+ info: UnhandledEventInfo,
+) {
+ if (!getConfig().eventDiagnostics) {
+ return;
+ }
+
+ const warning = getUnhandledEventWarning(instance, eventType, info);
+ if (warning != null) {
+ logEventWarning(warning);
+ }
+}
+
+/**
+ * Warns when `fireEvent` bubbles a direct event from the fired element up to `owner`. React Native
+ * delivers direct events only to the emitting element, so the event should be fired on `owner`.
+ */
+export function warnAboutBubblingDirectEvent(eventType: string, owner: TestInstance) {
+ logger.warn(
+ `fireEvent: "${eventType}" does not bubble in React Native. fireEvent will stop bubbling it in the next major version. ` +
+ `Fire it on:\n\n${redent(formatElement(owner), 2)}`,
+ );
+}
+
+function getUnhandledEventWarning(
+ instance: TestInstance,
+ eventType: string,
+ { skippedTargets, hasUpdatedNativeState }: UnhandledEventInfo,
+): EventWarning | null {
+ if (skippedTargets.length === 0) {
+ // The event still had an effect, e.g. `changeText` on an uncontrolled TextInput updates its value.
+ if (hasUpdatedNativeState) {
+ return null;
+ }
+
+ const handlerName = getEventHandlerName(eventType);
+ return {
+ message: `No "${handlerName}" handler found on the element or its ancestors.`,
+ elements: [instance],
+ };
+ }
+
+ // `pointerEvents` is checked first: it blocks the event even if the element is enabled.
+ const blocked = isEventBlockableByPointerEvents(normalizeEventType(eventType))
+ ? getPointerEventsBlockedTargets(skippedTargets)
+ : null;
+ if (blocked != null) {
+ return {
+ message:
+ blocked.elements.length === 1
+ ? `Cannot fire the "${eventType}" event on an element blocked by pointerEvents.`
+ : `Cannot fire the "${eventType}" event on elements blocked by pointerEvents.`,
+ ...blocked,
+ };
+ }
+
+ // `computeAriaDisabled` also covers non-editable `TextInput`. A responder declining the touch
+ // is a deliberate way of blocking events, so it doesn't warn.
+ const disabledTargets = skippedTargets.filter(computeAriaDisabled);
+ if (disabledTargets.length === 0) {
+ return null;
+ }
+
+ return {
+ message: `Cannot fire the "${eventType}" event on ${formatDisabledTargets(disabledTargets)}.`,
+ elements: disabledTargets,
+ };
+}
diff --git a/src/events/modern/__tests__/dispatch.test.tsx b/src/events/modern/__tests__/dispatch.test.tsx
new file mode 100644
index 000000000..6e5667795
--- /dev/null
+++ b/src/events/modern/__tests__/dispatch.test.tsx
@@ -0,0 +1,528 @@
+import * as React from 'react';
+import { Text, TextInput, View } from 'react-native';
+import type { TestInstance } from 'test-renderer';
+
+import { render, screen } from '../../..';
+import { dispatchEvent } from '../dispatch';
+import type { NativeEventPayload, SyntheticEvent } from '../event';
+import { createEvent } from '../event';
+
+function createKnownEvent(eventType: string, nativeEvent?: NativeEventPayload): SyntheticEvent {
+ const event = createEvent(eventType, { nativeEvent });
+ if (event == null) {
+ throw new Error(`Unknown event type: ${eventType}`);
+ }
+
+ return event;
+}
+
+type HandlerProps = Record void>;
+
+type Call = {
+ prop: string;
+ currentTarget: string | undefined;
+ target: string | undefined;
+ eventPhase: number;
+};
+
+function logHandlers(calls: Call[], id: string, propNames: string[]): HandlerProps {
+ return Object.fromEntries(
+ propNames.map((propName) => [
+ propName,
+ (event: SyntheticEvent) => {
+ calls.push({
+ prop: `${id}.${propName}`,
+ currentTarget: event.currentTarget?.props.testID,
+ target: event.target?.props.testID,
+ eventPhase: event.eventPhase,
+ });
+ },
+ ]),
+ );
+}
+
+/** Lets handler props be spread on `View`, whose types don't include all of them. */
+function handlerProps(props: HandlerProps): HandlerProps {
+ return props;
+}
+
+function getProps(calls: Call[]) {
+ return calls.map((call) => call.prop);
+}
+
+function renderNestedViews(propNames: string[], calls: Call[]) {
+ return render(
+
+
+
+
+ ,
+ );
+}
+
+describe('bubbling events', () => {
+ test('run capture phase from the root, then bubble phase to the root', async () => {
+ const calls: Call[] = [];
+ await renderNestedViews(['onFocus', 'onFocusCapture'], calls);
+
+ await dispatchEvent(screen.getByTestId('target'), createKnownEvent('focus'));
+
+ expect(calls).toEqual([
+ { prop: 'root.onFocusCapture', currentTarget: 'root', target: 'target', eventPhase: 1 },
+ { prop: 'parent.onFocusCapture', currentTarget: 'parent', target: 'target', eventPhase: 1 },
+ { prop: 'target.onFocusCapture', currentTarget: 'target', target: 'target', eventPhase: 2 },
+ { prop: 'target.onFocus', currentTarget: 'target', target: 'target', eventPhase: 2 },
+ { prop: 'parent.onFocus', currentTarget: 'parent', target: 'target', eventPhase: 3 },
+ { prop: 'root.onFocus', currentTarget: 'root', target: 'target', eventPhase: 3 },
+ ]);
+ });
+
+ test('call handlers on all host ancestors, skipping elements without handler', async () => {
+ const onSubmitEditing = jest.fn();
+ await render(
+
+
+
+
+ ,
+ );
+
+ await dispatchEvent(
+ screen.getByTestId('input'),
+ createKnownEvent('submitEditing', { text: 'Hello' }),
+ );
+
+ expect(onSubmitEditing).toHaveBeenCalledTimes(1);
+ expect(onSubmitEditing.mock.calls[0][0].nativeEvent).toEqual({ text: 'Hello' });
+ });
+
+ test('stop when a capture handler stops propagation', async () => {
+ const calls: Call[] = [];
+ await render(
+
+ {
+ calls.push({ prop: 'parent.onFocusCapture' } as Call);
+ event.stopPropagation();
+ },
+ })}
+ >
+
+
+ ,
+ );
+
+ await dispatchEvent(screen.getByTestId('target'), createKnownEvent('focus'));
+
+ expect(getProps(calls)).toEqual(['parent.onFocusCapture']);
+ });
+
+ test.each(['stopPropagation', 'stopImmediatePropagation'] as const)(
+ 'stop when a bubble handler calls %s()',
+ async (method) => {
+ const calls: Call[] = [];
+ await render(
+
+ {
+ calls.push({ prop: 'parent.onFocus' } as Call);
+ event[method]();
+ },
+ })}
+ >
+
+
+ ,
+ );
+
+ await dispatchEvent(screen.getByTestId('target'), createKnownEvent('focus'));
+
+ expect(getProps(calls)).toEqual(['target.onFocus', 'parent.onFocus']);
+ },
+ );
+
+ test('with skipBubbling run capture phase, but call only the target when bubbling', async () => {
+ const calls: Call[] = [];
+ await renderNestedViews(['onPointerEnter', 'onPointerEnterCapture'], calls);
+
+ await dispatchEvent(screen.getByTestId('target'), createKnownEvent('pointerEnter'));
+
+ expect(getProps(calls)).toEqual([
+ 'root.onPointerEnterCapture',
+ 'parent.onPointerEnterCapture',
+ 'target.onPointerEnterCapture',
+ 'target.onPointerEnter',
+ ]);
+ });
+});
+
+describe('direct events', () => {
+ test('call only the target, without capture phase', async () => {
+ const calls: Call[] = [];
+ await renderNestedViews(['onLayout', 'onLayoutCapture'], calls);
+
+ await dispatchEvent(screen.getByTestId('target'), createKnownEvent('layout'));
+
+ expect(calls).toEqual([
+ { prop: 'target.onLayout', currentTarget: 'target', target: 'target', eventPhase: 2 },
+ ]);
+ });
+
+ test('do not reach ancestors when the target has no handler', async () => {
+ const onScroll = jest.fn();
+ await render(
+
+
+ ,
+ );
+
+ await dispatchEvent(screen.getByTestId('target'), createKnownEvent('scroll'));
+
+ expect(onScroll).not.toHaveBeenCalled();
+ });
+});
+
+describe('afterTargetHandler', () => {
+ test('runs at the target right after its bubble handler, before ancestors', async () => {
+ const calls: Call[] = [];
+ await renderNestedViews(['onFocus', 'onFocusCapture'], calls);
+ const target = screen.getByTestId('target');
+ const afterTargetHandler = jest.fn((_target: TestInstance, event: SyntheticEvent) => {
+ calls.push({
+ prop: 'afterTargetHandler',
+ currentTarget: event.currentTarget?.props.testID,
+ target: event.target?.props.testID,
+ eventPhase: event.eventPhase,
+ });
+ });
+
+ await dispatchEvent(target, createKnownEvent('focus'), { afterTargetHandler });
+
+ expect(getProps(calls)).toEqual([
+ 'root.onFocusCapture',
+ 'parent.onFocusCapture',
+ 'target.onFocusCapture',
+ 'target.onFocus',
+ 'afterTargetHandler',
+ 'parent.onFocus',
+ 'root.onFocus',
+ ]);
+ expect(calls[4]).toEqual({
+ prop: 'afterTargetHandler',
+ currentTarget: 'target',
+ target: 'target',
+ eventPhase: 2,
+ });
+ expect(afterTargetHandler.mock.calls[0][0]).toBe(target);
+ });
+
+ test('runs for direct events and when the target has no handler', async () => {
+ await render();
+ const afterTargetHandler = jest.fn();
+
+ await dispatchEvent(screen.getByTestId('target'), createKnownEvent('layout'), {
+ afterTargetHandler,
+ });
+
+ expect(afterTargetHandler).toHaveBeenCalledTimes(1);
+ });
+
+ test('is skipped when a capture handler stops propagation', async () => {
+ await render(
+ event.stopPropagation() })}>
+
+ ,
+ );
+ const afterTargetHandler = jest.fn();
+
+ await dispatchEvent(screen.getByTestId('target'), createKnownEvent('focus'), {
+ afterTargetHandler,
+ });
+
+ expect(afterTargetHandler).not.toHaveBeenCalled();
+ });
+
+ test('runs when the target bubble handler stops propagation', async () => {
+ const onParentFocus = jest.fn();
+ await render(
+
+ event.stopImmediatePropagation() })}
+ />
+ ,
+ );
+ const afterTargetHandler = jest.fn();
+
+ await dispatchEvent(screen.getByTestId('target'), createKnownEvent('focus'), {
+ afterTargetHandler,
+ });
+
+ expect(afterTargetHandler).toHaveBeenCalledTimes(1);
+ expect(onParentFocus).not.toHaveBeenCalled();
+ });
+
+ test('is skipped when the target handler throws, and its own error is rethrown', async () => {
+ const onParentFocus = jest.fn();
+ await render(
+
+ {
+ throw new Error('Target error');
+ },
+ })}
+ />
+ ,
+ );
+ const afterTargetHandler = jest.fn();
+
+ await expect(
+ dispatchEvent(screen.getByTestId('target'), createKnownEvent('focus'), {
+ afterTargetHandler,
+ }),
+ ).rejects.toThrow('Target error');
+ expect(afterTargetHandler).not.toHaveBeenCalled();
+ expect(onParentFocus).toHaveBeenCalledTimes(1);
+
+ await expect(
+ dispatchEvent(screen.getByTestId('target'), createKnownEvent('blur'), {
+ afterTargetHandler: () => {
+ throw new Error('After target error');
+ },
+ }),
+ ).rejects.toThrow('After target error');
+ });
+
+ test('does not count as a called handler', async () => {
+ await render();
+
+ expect(
+ await dispatchEvent(screen.getByTestId('target'), createKnownEvent('focus'), {
+ afterTargetHandler: () => {},
+ }),
+ ).toBe(false);
+ });
+});
+
+test('does not call props of composite components', async () => {
+ const onFocus = jest.fn();
+ const Box = (_props: { onFocus: () => void }) => ;
+ await render();
+
+ await dispatchEvent(screen.getByTestId('target'), createKnownEvent('focus'));
+
+ expect(onFocus).not.toHaveBeenCalled();
+});
+
+test('calls testOnly_ props in both phases when there is no regular prop', async () => {
+ const calls: Call[] = [];
+ await render(
+
+
+ ,
+ );
+
+ await dispatchEvent(screen.getByTestId('target'), createKnownEvent('focus'));
+
+ expect(getProps(calls)).toEqual(['parent.testOnly_onFocusCapture', 'target.onFocus']);
+});
+
+test('does nothing when the target is unmounted', async () => {
+ const onFocus = jest.fn();
+ await render();
+ const target = screen.getByTestId('target');
+ // A new key remounts the host element, so `target` is detached.
+ await screen.rerender();
+
+ const result = await dispatchEvent(target, createKnownEvent('focus'));
+
+ expect(result).toBe(false);
+ expect(onFocus).not.toHaveBeenCalled();
+});
+
+test('passes React Native event object to handlers', async () => {
+ const onPointerUp = jest.fn();
+ await render();
+ const target = screen.getByTestId('target');
+ const payload = { pageX: 10, pageY: 20, timestamp: 1234 };
+
+ await dispatchEvent(target, createKnownEvent('pointerUp', payload));
+
+ const event: SyntheticEvent = onPointerUp.mock.calls[0][0];
+ expect(event.type).toBe('pointerup');
+ expect(event.bubbles).toBe(true);
+ expect(event.cancelable).toBe(true);
+ expect(event.isTrusted).toBe(true);
+ expect(event.nativeEvent).toBe(payload);
+ expect(event.timeStamp).toBe(1234);
+ expect(event.target).toBe(target);
+ expect(event.dispatchConfig).toEqual({
+ phasedRegistrationNames: { bubbled: 'onPointerUp', captured: 'onPointerUpCapture' },
+ });
+});
+
+test('passes direct event object to handlers', async () => {
+ const onLayout = jest.fn();
+ await render();
+
+ await dispatchEvent(
+ screen.getByTestId('target'),
+ createKnownEvent('layout', { layout: { width: 100 } }),
+ );
+
+ const event: SyntheticEvent = onLayout.mock.calls[0][0];
+ expect(event.type).toBe('layout');
+ expect(event.bubbles).toBe(false);
+ expect(event.rnIsDirect).toBe(true);
+ expect(event.dispatchConfig).toEqual({ registrationName: 'onLayout' });
+});
+
+test('exposes composed path during dispatch and resets the event after it', async () => {
+ let composedPath: TestInstance[] = [];
+ const onFocus = jest.fn((event: SyntheticEvent) => {
+ composedPath = event.composedPath();
+ });
+ await render(
+
+
+ ,
+ );
+
+ await dispatchEvent(screen.getByTestId('target'), createKnownEvent('focus'));
+
+ expect(composedPath.map((node) => node.props.testID)).toEqual(['target', 'root']);
+ const event: SyntheticEvent = onFocus.mock.calls[0][0];
+ expect(event.currentTarget).toBeNull();
+ expect(event.eventPhase).toBe(0);
+ expect(event.composedPath()).toEqual([]);
+ expect(event.isPropagationStopped()).toBe(false);
+ expect(event.target).toBe(screen.getByTestId('target'));
+});
+
+test('calls handlers with the current element as `this`', async () => {
+ const onFocus = jest.fn();
+ await render();
+
+ await dispatchEvent(screen.getByTestId('target'), createKnownEvent('focus'));
+
+ expect(onFocus.mock.contexts[0]).toBe(screen.getByTestId('target'));
+});
+
+test('returns whether a handler on the path was called', async () => {
+ await render(
+ {} })}>
+
+
+
+
+ ,
+ );
+
+ expect(await dispatchEvent(screen.getByTestId('nested'), createKnownEvent('focus'))).toBe(true);
+ expect(await dispatchEvent(screen.getByTestId('target'), createKnownEvent('layout'))).toBe(false);
+});
+
+test('keeps defaultPrevented after a handler prevents default', async () => {
+ await render(
+ event.preventDefault() })}
+ />,
+ );
+ const event = createKnownEvent('focus');
+
+ await dispatchEvent(screen.getByTestId('target'), event);
+
+ expect(event.defaultPrevented).toBe(true);
+});
+
+test('rethrows the first handler error after calling all handlers', async () => {
+ const onRootFocus = jest.fn();
+ await render(
+
+ {
+ throw new Error('Parent error');
+ },
+ })}
+ >
+ {
+ throw new Error('Target error');
+ },
+ })}
+ />
+
+ ,
+ );
+
+ await expect(
+ dispatchEvent(screen.getByTestId('target'), createKnownEvent('focus')),
+ ).rejects.toThrow('Target error');
+ expect(onRootFocus).toHaveBeenCalledTimes(1);
+});
+
+test('renders state updates from other handlers when a handler throws', async () => {
+ function Subject() {
+ const [count, setCount] = React.useState(0);
+ return (
+ setCount((value) => value + 1) })}>
+ {
+ throw new Error('Target error');
+ },
+ })}
+ />
+ Count: {count}
+
+ );
+ }
+ await render();
+
+ await expect(
+ dispatchEvent(screen.getByTestId('target'), createKnownEvent('focus')),
+ ).rejects.toThrow('Target error');
+ expect(screen.getByText('Count: 1')).toBeOnTheScreen();
+});
+
+test('renders state updates from all handlers once', async () => {
+ let renderCount = 0;
+ function Counter() {
+ const [parentCount, setParentCount] = React.useState(0);
+ const [targetCount, setTargetCount] = React.useState(0);
+ renderCount += 1;
+
+ return (
+ setParentCount((count) => count + 1) })}>
+ setTargetCount((count) => count + 1) })}
+ >
+ {`${parentCount}:${targetCount}`}
+
+
+ );
+ }
+ await render();
+ const initialRenderCount = renderCount;
+
+ await dispatchEvent(screen.getByTestId('target'), createKnownEvent('focus'));
+
+ expect(screen.getByText('1:1')).toBeTruthy();
+ expect(renderCount - initialRenderCount).toBe(1);
+});
diff --git a/src/events/modern/__tests__/event-types.test.ts b/src/events/modern/__tests__/event-types.test.ts
new file mode 100644
index 000000000..17b5f089a
--- /dev/null
+++ b/src/events/modern/__tests__/event-types.test.ts
@@ -0,0 +1,159 @@
+import { createEvent } from '../event';
+import {
+ BUBBLING_EVENT_TYPES,
+ DIRECT_EVENT_TYPES,
+ getCustomEventTypeConfig,
+ getEventTypeConfig,
+ SKIP_BUBBLING_EVENT_TYPES,
+} from '../event-types';
+
+type ViewConfig = {
+ bubblingEventTypes?: Record<
+ string,
+ { phasedRegistrationNames: { bubbled: string; captured: string; skipBubbling?: boolean } }
+ >;
+ directEventTypes?: Record;
+};
+
+const viewConfigModules = [
+ 'react-native/Libraries/NativeComponent/BaseViewConfig.ios',
+ 'react-native/Libraries/NativeComponent/BaseViewConfig.android',
+ 'react-native/Libraries/Components/DrawerAndroid/AndroidDrawerLayoutNativeComponent',
+ 'react-native/Libraries/Components/RefreshControl/AndroidSwipeRefreshLayoutNativeComponent',
+ 'react-native/Libraries/Components/RefreshControl/PullToRefreshViewNativeComponent',
+ 'react-native/Libraries/Components/ScrollView/ScrollViewNativeComponent',
+ 'react-native/Libraries/Components/Switch/AndroidSwitchNativeComponent',
+ 'react-native/Libraries/Components/Switch/SwitchNativeComponent',
+ 'react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent',
+ 'react-native/Libraries/Components/TextInput/RCTMultilineTextInputNativeComponent',
+ 'react-native/Libraries/Components/TextInput/RCTSingelineTextInputNativeComponent',
+ 'react-native/Libraries/Image/ImageViewNativeComponent',
+ 'react-native/Libraries/Modal/RCTModalHostViewNativeComponent',
+];
+
+// Missing in older React Native versions.
+const optionalViewConfigModules = [
+ // Added in React Native 0.81.
+ 'react-native/src/private/components/virtualview/VirtualViewNativeComponent',
+];
+
+// Not exported by React Native, so listed by hand in `event-types.ts`.
+const manualDirectEventTypes = ['textLayout'];
+
+function loadReactNativeEventTypes() {
+ const bubbling = new Set();
+ const skipBubbling = new Set();
+ const direct = new Set(manualDirectEventTypes);
+
+ for (const viewConfig of loadViewConfigs()) {
+ for (const [topLevelType, config] of Object.entries(viewConfig.bubblingEventTypes ?? {})) {
+ const eventType = toEventType(topLevelType);
+ // Prop names derived from the event type match React Native's.
+ expect(getDispatchConfig(eventType)).toEqual({
+ phasedRegistrationNames: config.phasedRegistrationNames,
+ });
+ bubbling.add(eventType);
+ if (config.phasedRegistrationNames.skipBubbling) {
+ skipBubbling.add(eventType);
+ }
+ }
+
+ for (const [topLevelType, config] of Object.entries(viewConfig.directEventTypes ?? {})) {
+ // Gesture handler events: registered without `top`, not dispatched by React Native.
+ if (!topLevelType.startsWith('top')) {
+ continue;
+ }
+
+ const eventType = toEventType(topLevelType);
+ expect(getDispatchConfig(eventType)).toEqual({
+ registrationName: config.registrationName,
+ });
+ direct.add(eventType);
+ }
+ }
+
+ return { bubbling, skipBubbling, direct };
+}
+
+function loadViewConfigs(): ViewConfig[] {
+ const modules = viewConfigModules.map((modulePath) => jest.requireActual(modulePath));
+ for (const modulePath of optionalViewConfigModules) {
+ try {
+ modules.push(jest.requireActual(modulePath));
+ } catch {
+ // Not available in the installed React Native version.
+ }
+ }
+
+ return modules.map((module) => module.__INTERNAL_VIEW_CONFIG ?? module.default);
+}
+
+function toEventType(topLevelType: string) {
+ return topLevelType.charAt(3).toLowerCase() + topLevelType.slice(4);
+}
+
+function getDispatchConfig(eventType: string) {
+ return createEvent(eventType)?.dispatchConfig;
+}
+
+// The lists are the union across supported React Native versions, so older versions declare only a
+// subset of them (e.g. `keyDown` and `keyUp` were added in React Native 0.84).
+test('event types cover React Native view configs', () => {
+ const reactNative = loadReactNativeEventTypes();
+
+ expect(BUBBLING_EVENT_TYPES).toEqual(expect.arrayContaining([...reactNative.bubbling]));
+ expect(SKIP_BUBBLING_EVENT_TYPES).toEqual(expect.arrayContaining([...reactNative.skipBubbling]));
+ expect(DIRECT_EVENT_TYPES).toEqual(expect.arrayContaining([...reactNative.direct]));
+});
+
+test('getEventTypeConfig() returns config of bubbling event', () => {
+ expect(getEventTypeConfig('pointerUp')).toEqual({
+ kind: 'bubbling',
+ handlerName: 'onPointerUp',
+ captureHandlerName: 'onPointerUpCapture',
+ skipBubbling: false,
+ });
+});
+
+test('getEventTypeConfig() returns config of bubbling event with skipBubbling', () => {
+ expect(getEventTypeConfig('pointerEnter')).toEqual({
+ kind: 'bubbling',
+ handlerName: 'onPointerEnter',
+ captureHandlerName: 'onPointerEnterCapture',
+ skipBubbling: true,
+ });
+});
+
+test('getEventTypeConfig() returns config of direct event', () => {
+ expect(getEventTypeConfig('layout')).toEqual({ kind: 'direct', handlerName: 'onLayout' });
+});
+
+test('getEventTypeConfig() returns config of direct event with non-standard handler name', () => {
+ expect(getEventTypeConfig('safeAreaInsetsChange')).toEqual({
+ kind: 'direct',
+ handlerName: 'experimental_onSafeAreaInsetsChange',
+ });
+});
+
+test('getEventTypeConfig() returns null for events unknown to React Native', () => {
+ expect(getEventTypeConfig('changeText')).toBeNull();
+ expect(getEventTypeConfig('pressIn')).toBeNull();
+ expect(getEventTypeConfig('onFocus')).toBeNull();
+ expect(getEventTypeConfig('toString')).toBeNull();
+});
+
+test('getCustomEventTypeConfig() returns config of direct custom event', () => {
+ expect(getCustomEventTypeConfig('pageSelected', 'direct')).toEqual({
+ kind: 'direct',
+ handlerName: 'onPageSelected',
+ });
+});
+
+test('getCustomEventTypeConfig() returns config of bubbling custom event', () => {
+ expect(getCustomEventTypeConfig('customGesture', 'bubbling')).toEqual({
+ kind: 'bubbling',
+ handlerName: 'onCustomGesture',
+ captureHandlerName: 'onCustomGestureCapture',
+ skipBubbling: false,
+ });
+});
diff --git a/src/events/modern/__tests__/event.test.ts b/src/events/modern/__tests__/event.test.ts
new file mode 100644
index 000000000..5a0eb0acd
--- /dev/null
+++ b/src/events/modern/__tests__/event.test.ts
@@ -0,0 +1,227 @@
+import { format } from 'pretty-format';
+import type { TestInstance } from 'test-renderer';
+
+import { createEvent, getEventInternals, SyntheticEvent } from '../event';
+import type { EventTypeConfig } from '../event-types';
+
+const typeConfig: EventTypeConfig = { kind: 'direct', handlerName: 'onFocus' };
+
+afterEach(() => {
+ jest.restoreAllMocks();
+});
+
+test('exposes init values', () => {
+ const nativeEvent = { target: 1 };
+ const event = new SyntheticEvent(
+ 'layout',
+ { cancelable: true, timeStamp: 123 },
+ nativeEvent,
+ typeConfig,
+ );
+
+ expect(event.type).toBe('layout');
+ expect(event.bubbles).toBe(false);
+ expect(event.cancelable).toBe(true);
+ expect(event.rnIsDirect).toBe(true);
+ expect(event.timeStamp).toBe(123);
+ expect(event.nativeEvent).toBe(nativeEvent);
+ expect(event.dispatchConfig).toEqual({ registrationName: 'onFocus' });
+ expect(event.composed).toBe(false);
+ // Only `dispatchEvent()` marks events as trusted.
+ expect(event.isTrusted).toBe(false);
+});
+
+test('takes bubbles, rnIsDirect and dispatchConfig over the type config', () => {
+ const dispatchConfig = { registrationName: 'onResponderGrant', dependencies: [] };
+ const event = new SyntheticEvent(
+ 'responderGrant',
+ { bubbles: false, rnIsDirect: false },
+ {},
+ typeConfig,
+ dispatchConfig,
+ );
+
+ expect(event.bubbles).toBe(false);
+ expect(event.rnIsDirect).toBe(false);
+ expect(event.dispatchConfig).toBe(dispatchConfig);
+});
+
+test('has no target and phase outside of dispatch', () => {
+ const event = new SyntheticEvent('focus', {}, {}, typeConfig);
+
+ expect(event.target).toBeNull();
+ expect(event.currentTarget).toBeNull();
+ expect(event.eventPhase).toBe(SyntheticEvent.NONE);
+ expect(event.composedPath()).toEqual([]);
+});
+
+test('keeps dispatch fields out of printed and compared events', () => {
+ const event = new SyntheticEvent('focus', { timeStamp: 1 }, {}, typeConfig);
+ const target = { type: 'View', props: { testID: 'target' } } as unknown as TestInstance;
+ getEventInternals(event).target = target;
+
+ expect(event.target).toBe(target);
+ expect(event).toEqual(new SyntheticEvent('focus', { timeStamp: 1 }, {}, typeConfig));
+ expect(format(event)).not.toContain('target');
+});
+
+test('defaults timeStamp to performance.now()', () => {
+ jest.spyOn(performance, 'now').mockReturnValue(456);
+
+ const event = new SyntheticEvent('focus', {}, {}, typeConfig);
+
+ expect(event.timeStamp).toBe(456);
+});
+
+test('preventDefault() marks cancelable event as default prevented', () => {
+ const event = new SyntheticEvent('focus', { cancelable: true }, {}, typeConfig);
+
+ event.preventDefault();
+
+ expect(event.defaultPrevented).toBe(true);
+ expect(event.isDefaultPrevented()).toBe(true);
+});
+
+test('preventDefault() does nothing on non-cancelable event', () => {
+ const event = new SyntheticEvent('focus', {}, {}, typeConfig);
+
+ event.preventDefault();
+
+ expect(event.defaultPrevented).toBe(false);
+ expect(event.isDefaultPrevented()).toBe(false);
+});
+
+test.each(['stopPropagation', 'stopImmediatePropagation'] as const)(
+ '%s() stops propagation',
+ (method) => {
+ const event = new SyntheticEvent('focus', {}, {}, typeConfig);
+ expect(event.isPropagationStopped()).toBe(false);
+
+ event[method]();
+
+ expect(event.isPropagationStopped()).toBe(true);
+ expect(event.cancelBubble).toBe(true);
+ },
+);
+
+test('cancelBubble can only be set to true', () => {
+ const event = new SyntheticEvent('focus', {}, {}, typeConfig);
+
+ event.cancelBubble = true;
+ event.cancelBubble = false;
+
+ expect(event.isPropagationStopped()).toBe(true);
+});
+
+test('persist() is a no-op', () => {
+ const event = new SyntheticEvent('focus', {}, {}, typeConfig);
+
+ expect(() => event.persist()).not.toThrow();
+});
+
+test('defines event phase constants on class and prototype', () => {
+ const event = new SyntheticEvent('focus', {}, {}, typeConfig);
+
+ expect([
+ SyntheticEvent.NONE,
+ SyntheticEvent.CAPTURING_PHASE,
+ SyntheticEvent.AT_TARGET,
+ SyntheticEvent.BUBBLING_PHASE,
+ ]).toEqual([0, 1, 2, 3]);
+ expect([event.NONE, event.CAPTURING_PHASE, event.AT_TARGET, event.BUBBLING_PHASE]).toEqual([
+ 0, 1, 2, 3,
+ ]);
+ expect(Object.keys(event)).not.toContain('NONE');
+});
+
+describe('createEvent()', () => {
+ test('creates bubbling event', () => {
+ const nativeEvent = { pageX: 10, timestamp: 1234 };
+
+ const event = createEvent('pointerUp', { nativeEvent });
+
+ expect(event).toBeInstanceOf(SyntheticEvent);
+ expect(event?.type).toBe('pointerup');
+ expect(event?.bubbles).toBe(true);
+ expect(event?.cancelable).toBe(true);
+ expect(event?.rnIsDirect).toBe(false);
+ expect(event?.nativeEvent).toBe(nativeEvent);
+ expect(event?.timeStamp).toBe(1234);
+ expect(event?.dispatchConfig).toEqual({
+ phasedRegistrationNames: { bubbled: 'onPointerUp', captured: 'onPointerUpCapture' },
+ });
+ });
+
+ test('creates non-bubbling event for skipBubbling event types', () => {
+ const event = createEvent('pointerEnter');
+
+ expect(event?.bubbles).toBe(false);
+ expect(event?.rnIsDirect).toBe(false);
+ expect(event?.dispatchConfig).toEqual({
+ phasedRegistrationNames: {
+ bubbled: 'onPointerEnter',
+ captured: 'onPointerEnterCapture',
+ skipBubbling: true,
+ },
+ });
+ });
+
+ test('creates direct event', () => {
+ const event = createEvent('layout');
+
+ expect(event?.type).toBe('layout');
+ expect(event?.bubbles).toBe(false);
+ expect(event?.rnIsDirect).toBe(true);
+ expect(event?.dispatchConfig).toEqual({ registrationName: 'onLayout' });
+ });
+
+ test('defaults nativeEvent to an empty object', () => {
+ expect(createEvent('focus')?.nativeEvent).toEqual({});
+ });
+
+ test.each([
+ [{ nativeEvent: { timeStamp: 1 } }, 1],
+ [{ nativeEvent: { timestamp: 2 } }, 2],
+ [{ nativeEvent: { timeStamp: 1, timestamp: 2 } }, 1],
+ [{ nativeEvent: { timeStamp: 1, timestamp: 2 }, timeStamp: 3 }, 3],
+ [{ nativeEvent: { timestamp: 'later' } }, 456],
+ ])('takes timeStamp from %p', (init, timeStamp) => {
+ jest.spyOn(performance, 'now').mockReturnValue(456);
+
+ expect(createEvent('focus', init)?.timeStamp).toBe(timeStamp);
+ });
+
+ test('returns null for events unknown to React Native', () => {
+ expect(createEvent('changeText', { nativeEvent: { text: 'Hello' } })).toBeNull();
+ });
+
+ test('creates direct custom event with dispatch', () => {
+ const nativeEvent = { position: 1 };
+
+ const event = createEvent('pageSelected', { nativeEvent, dispatch: 'direct' });
+
+ expect(event?.type).toBe('pageselected');
+ expect(event?.bubbles).toBe(false);
+ expect(event?.rnIsDirect).toBe(true);
+ expect(event?.nativeEvent).toBe(nativeEvent);
+ expect(event?.dispatchConfig).toEqual({ registrationName: 'onPageSelected' });
+ });
+
+ test('creates bubbling custom event with dispatch', () => {
+ const event = createEvent('customGesture', { dispatch: 'bubbling' });
+
+ expect(event?.bubbles).toBe(true);
+ expect(event?.rnIsDirect).toBe(false);
+ expect(event?.dispatchConfig).toEqual({
+ phasedRegistrationNames: { bubbled: 'onCustomGesture', captured: 'onCustomGestureCapture' },
+ });
+ });
+
+ test('overrides the dispatch of events known to React Native with dispatch', () => {
+ const event = createEvent('focus', { dispatch: 'direct' });
+
+ expect(event?.bubbles).toBe(false);
+ expect(event?.rnIsDirect).toBe(true);
+ expect(event?.dispatchConfig).toEqual({ registrationName: 'onFocus' });
+ });
+});
diff --git a/src/events/modern/__tests__/pressability.test.tsx b/src/events/modern/__tests__/pressability.test.tsx
new file mode 100644
index 000000000..34fe2188f
--- /dev/null
+++ b/src/events/modern/__tests__/pressability.test.tsx
@@ -0,0 +1,181 @@
+import * as React from 'react';
+import { Pressable, Text, TouchableOpacity, View } from 'react-native';
+import type { TestInstance } from 'test-renderer';
+
+import { render, screen } from '../../..';
+import { getEventHandlerName } from '../../shared/handler';
+import type { SyntheticEvent } from '../event';
+import { createEvent } from '../event';
+import type { PressabilityEventType } from '../event-types';
+import { PRESSABILITY_EVENT_TYPES } from '../event-types';
+import { dispatchPressabilityEvent, findResponder, getTouchPath } from '../pressability';
+
+function createTouchEvent() {
+ return createEvent('touchEnd', { nativeEvent: { pageX: 10 } }) as SyntheticEvent;
+}
+
+/** Touches the element and calls one callback of the touch responder, as modern `fireEvent`. */
+async function touch(
+ instance: TestInstance,
+ eventType: PressabilityEventType,
+ event: SyntheticEvent = createTouchEvent(),
+) {
+ const path = getTouchPath(instance);
+ const responder = findResponder(path);
+ return responder != null
+ ? (await dispatchPressabilityEvent(responder, [eventType], event, path[0])).length > 0
+ : false;
+}
+
+function logCallbacks(calls: string[], id: string) {
+ const log = (name: string) => () => {
+ calls.push(`${id}.${name}`);
+ };
+ return {
+ onPress: log('onPress'),
+ onPressIn: log('onPressIn'),
+ onPressOut: log('onPressOut'),
+ onLongPress: log('onLongPress'),
+ };
+}
+
+test.each(PRESSABILITY_EVENT_TYPES)(
+ '%s calls only Pressable and TouchableOpacity callback',
+ async (eventType) => {
+ const name = getEventHandlerName(eventType);
+ const calls: string[] = [];
+ await render(
+ <>
+
+ Pressable
+
+
+ Touchable
+
+ >,
+ );
+
+ await touch(screen.getByText('Pressable'), eventType);
+ await touch(screen.getByText('Touchable'), eventType);
+
+ expect(calls).toEqual([`pressable.${name}`, `touchable.${name}`]);
+ },
+);
+
+test.each(PRESSABILITY_EVENT_TYPES)(
+ '%s calls only host callback, incl. testOnly_ props',
+ async (eventType) => {
+ const name = getEventHandlerName(eventType);
+ const calls: string[] = [];
+ await render(
+ <>
+ Text
+ calls.push(`view.testOnly_${name}`) }}
+ />
+ >,
+ );
+
+ await touch(screen.getByText('Text'), eventType);
+ await touch(screen.getByTestId('view'), eventType);
+
+ expect(calls).toEqual([`text.${name}`, `view.testOnly_${name}`]);
+ },
+);
+
+test('passes the event, with the hit target and the responder', async () => {
+ const onPressIn = jest.fn((event) => ({
+ currentTarget: event.currentTarget,
+ target: event.target,
+ }));
+ await render(
+
+ Press me
+ ,
+ );
+ const event = createTouchEvent();
+
+ await touch(screen.getByText('Press me'), 'pressIn', event);
+
+ expect(onPressIn).toHaveBeenCalledWith(event);
+ expect(onPressIn.mock.results[0].value).toEqual({
+ currentTarget: screen.getByTestId('pressable'),
+ target: screen.getByText('Press me'),
+ });
+});
+
+test('host element with another press callback claims the touch, but gets nothing', async () => {
+ const onPressIn = jest.fn();
+ await render(
+
+ Press me
+ ,
+ );
+
+ await touch(screen.getByText('Press me'), 'pressIn');
+
+ expect(onPressIn).not.toHaveBeenCalled();
+});
+
+test('responder without the callback keeps the touch from its ancestors', async () => {
+ const onLongPress = jest.fn();
+ await render(
+
+
+ Press me
+
+ ,
+ );
+
+ await touch(screen.getByText('Press me'), 'longPress');
+
+ expect(onLongPress).not.toHaveBeenCalled();
+});
+
+test('does not call callbacks of disabled Pressable', async () => {
+ const calls: string[] = [];
+ await render(
+
+ Press me
+ ,
+ );
+
+ for (const eventType of PRESSABILITY_EVENT_TYPES) {
+ await touch(screen.getByText('Press me'), eventType);
+ }
+
+ expect(calls).toEqual([]);
+});
+
+test('does not call callbacks of Text disabled during the touch', async () => {
+ const onPress = jest.fn();
+ function DisabledOnPressIn() {
+ const [disabled, setDisabled] = React.useState(false);
+ return (
+ setDisabled(true)} onPress={onPress}>
+ Press me
+
+ );
+ }
+ await render();
+ const path = getTouchPath(screen.getByText('Press me'));
+ const responder = findResponder(path) as TestInstance;
+
+ await dispatchPressabilityEvent(responder, ['pressIn'], createTouchEvent(), path[0]);
+ const result = await dispatchPressabilityEvent(responder, ['press'], createTouchEvent(), path[0]);
+
+ expect(result).toEqual([]);
+ expect(onPress).not.toHaveBeenCalled();
+});
+
+test('returns whether the responder had the callback', async () => {
+ await render(
+
+ Press me
+ ,
+ );
+
+ expect(await touch(screen.getByText('Press me'), 'press')).toBe(true);
+ expect(await touch(screen.getByText('Press me'), 'longPress')).toBe(false);
+});
diff --git a/src/events/modern/__tests__/responder-event.test.ts b/src/events/modern/__tests__/responder-event.test.ts
new file mode 100644
index 000000000..c34fd3b08
--- /dev/null
+++ b/src/events/modern/__tests__/responder-event.test.ts
@@ -0,0 +1,86 @@
+import { SyntheticEvent } from '../event';
+import { createPressabilityEvent, ResponderEvent } from '../responder-event';
+
+afterEach(() => {
+ jest.restoreAllMocks();
+});
+
+const touch = {
+ identifier: 0,
+ locationX: 0,
+ locationY: 0,
+ pageX: 0,
+ pageY: 0,
+ target: 0,
+ timestamp: 456,
+};
+
+test.each(['pressIn', 'longPress'] as const)('%s gets the responder grant event', (eventType) => {
+ jest.spyOn(performance, 'now').mockReturnValue(456);
+
+ const event = createPressabilityEvent(eventType);
+
+ expect(event).toBeInstanceOf(ResponderEvent);
+ expect(event).toBeInstanceOf(SyntheticEvent);
+ expect(event.type).toBe('responderGrant');
+ expect(event.dispatchConfig).toEqual({ registrationName: 'onResponderGrant', dependencies: [] });
+ expect(event.nativeEvent).toEqual({ ...touch, changedTouches: [touch], touches: [touch] });
+});
+
+test.each(['pressOut', 'press'] as const)('%s gets the responder release event', (eventType) => {
+ jest.spyOn(performance, 'now').mockReturnValue(456);
+
+ const event = createPressabilityEvent(eventType);
+
+ expect(event.type).toBe('responderRelease');
+ expect(event.dispatchConfig).toEqual({
+ registrationName: 'onResponderRelease',
+ dependencies: ['topTouchCancel', 'topTouchEnd'],
+ });
+ expect(event.nativeEvent).toEqual({ ...touch, changedTouches: [touch], touches: [] });
+});
+
+test('creates the event as React Native responder system does', () => {
+ jest.spyOn(performance, 'now').mockReturnValue(456);
+
+ const event = createPressabilityEvent('press', { nativeEvent: { timestamp: 123 } });
+
+ expect(event.bubbles).toBe(false);
+ expect(event.cancelable).toBe(true);
+ expect(event.composed).toBe(false);
+ expect(event.rnIsDirect).toBe(false);
+ expect(event.isTrusted).toBe(false);
+ // From `performance.now()`, not the native timestamp.
+ expect(event.timeStamp).toBe(456);
+ expect(event.target).toBeNull();
+ expect(event.currentTarget).toBeNull();
+ expect(event.eventPhase).toBe(SyntheticEvent.NONE);
+ expect(event.composedPath()).toEqual([]);
+});
+
+test('takes timeStamp from init', () => {
+ expect(createPressabilityEvent('press', { timeStamp: 123 }).timeStamp).toBe(123);
+});
+
+test('fills the touch arrays from the passed nativeEvent', () => {
+ const event = createPressabilityEvent('pressIn', {
+ nativeEvent: { pageX: 20, pageY: 30, timestamp: 1 },
+ });
+
+ const expectedTouch = { ...touch, pageX: 20, pageY: 30, timestamp: 1 };
+ expect(event.nativeEvent.changedTouches).toEqual([expectedTouch]);
+ expect(event.nativeEvent.touches).toEqual([expectedTouch]);
+ // Separate objects, as on a device.
+ expect(event.nativeEvent.touches).not.toBe(event.nativeEvent.changedTouches);
+});
+
+test('keeps the touch arrays of the passed nativeEvent', () => {
+ const touches = [{ identifier: 1, pageX: 5, pageY: 5, timestamp: 1 }];
+
+ const event = createPressabilityEvent('pressOut', {
+ nativeEvent: { changedTouches: touches, touches },
+ });
+
+ expect(event.nativeEvent.changedTouches).toBe(touches);
+ expect(event.nativeEvent.touches).toBe(touches);
+});
diff --git a/src/events/modern/dispatch.ts b/src/events/modern/dispatch.ts
new file mode 100644
index 000000000..898a802a4
--- /dev/null
+++ b/src/events/modern/dispatch.ts
@@ -0,0 +1,182 @@
+import type { TestInstance } from 'test-renderer';
+
+import { act } from '../../act';
+import { isInstanceMounted } from '../../helpers/component-tree';
+import { getEventHandlerByName } from '../shared/handler';
+import type { EventPhase } from './event';
+import { getEventInternals, SyntheticEvent } from './event';
+import type { BubblingEventTypeConfig, DirectEventTypeConfig } from './event-types';
+
+/**
+ * Dispatches an event from `createEvent()` as React Native does for native events
+ * (`src/private/renderer/events/dispatchNativeEvent.js`, then `dispatch()` in
+ * `src/private/webapis/dom/events/EventTarget.js`):
+ *
+ * - Direct events: only the target's `on*` prop. See `dispatchDirectEvent()`.
+ * - Bubbling events: capture phase from the root to the target (`on*Capture`), then bubble phase
+ * back to the root (`on*`). See `dispatchBubblingEvent()`.
+ *
+ * Only host elements are on the path, so composite props are never called. A `testOnly_` prop
+ * (`testOnly_onFocus`) is used when the element has no regular one. Every handler on the
+ * path runs until one stops propagation. The first handler error is rethrown after all handlers
+ * ran. One `act()` wraps the dispatch, as React Native batches updates for the whole event.
+ *
+ * Not implemented yet: responder negotiation, which React Native runs first for touch, scroll and
+ * selection change events.
+ *
+ * @returns `true` if a handler was called. Check `event.defaultPrevented` for `preventDefault()`.
+ */
+export async function dispatchEvent(
+ target: TestInstance,
+ event: SyntheticEvent,
+ options?: DispatchOptions,
+): Promise {
+ if (!isInstanceMounted(target)) {
+ return false;
+ }
+
+ const config = getEventInternals(event).typeConfig;
+ const state: DispatchState = { hasCalledHandler: false, firstError: null };
+
+ await act(() => {
+ getEventInternals(event).isTrusted = true;
+ getEventInternals(event).target = target;
+ if (config.kind === 'direct') {
+ dispatchDirectEvent(event, target, config, state, options?.afterTargetHandler);
+ } else {
+ dispatchBubblingEvent(event, target, config, state, options?.afterTargetHandler);
+ }
+
+ resetEvent(event);
+ });
+
+ // After `act()`, which would skip rendering other handlers' updates if its callback threw.
+ if (state.firstError != null) {
+ throw state.firstError.error;
+ }
+
+ return state.hasCalledHandler;
+}
+
+export type DispatchOptions = {
+ /**
+ * Runs at the target, right after the target's own handler, like a composite component wrapping
+ * the host prop: `TextInput` passes `onChange={_onChange}`, which calls `props.onChange(event)`,
+ * then `props.onChangeText(text)`. Skipped when propagation stopped before the target, or when
+ * the target's handler threw. Runs when the target has no handler.
+ */
+ afterTargetHandler?: (target: TestInstance, event: SyntheticEvent) => void;
+};
+
+/** Clears the event's internals, as the event can still be read after the dispatch. */
+function resetEvent(event: SyntheticEvent) {
+ getEventInternals(event).eventPhase = SyntheticEvent.NONE;
+ getEventInternals(event).currentTarget = null;
+ getEventInternals(event).composedPath = [];
+ getEventInternals(event).stopPropagation = false;
+}
+
+type DispatchState = {
+ hasCalledHandler: boolean;
+ /** Wrapped, so a thrown `undefined` is rethrown too. */
+ firstError: { error: unknown } | null;
+};
+
+/** Calls only the target's handler: no capture phase, no ancestors. */
+function dispatchDirectEvent(
+ event: SyntheticEvent,
+ target: TestInstance,
+ config: DirectEventTypeConfig,
+ state: DispatchState,
+ afterTargetHandler: DispatchOptions['afterTargetHandler'],
+) {
+ getEventInternals(event).composedPath = [target];
+ callHandler(
+ event,
+ target,
+ config.handlerName,
+ SyntheticEvent.AT_TARGET,
+ state,
+ afterTargetHandler,
+ );
+}
+
+/**
+ * Capture phase from the root down to the target, then bubble phase from the target back up to the
+ * root. `skipBubbling` events (`pointerEnter`, `pointerLeave`) run the full capture phase, but
+ * stop the bubble phase at the target.
+ */
+function dispatchBubblingEvent(
+ event: SyntheticEvent,
+ target: TestInstance,
+ config: BubblingEventTypeConfig,
+ state: DispatchState,
+ afterTargetHandler: DispatchOptions['afterTargetHandler'],
+) {
+ const path = getAncestorPath(target);
+ getEventInternals(event).composedPath = path;
+
+ // Capture: root first, target last.
+ for (let i = path.length - 1; i >= 0 && !event.isPropagationStopped(); i -= 1) {
+ const phase = i === 0 ? SyntheticEvent.AT_TARGET : SyntheticEvent.CAPTURING_PHASE;
+ callHandler(event, path[i], config.captureHandlerName, phase, state);
+ }
+
+ // Bubble: target first, root last.
+ const bubblePath = config.skipBubbling ? [target] : path;
+ for (let i = 0; i < bubblePath.length && !event.isPropagationStopped(); i += 1) {
+ const isTarget = i === 0;
+ callHandler(
+ event,
+ bubblePath[i],
+ config.handlerName,
+ isTarget ? SyntheticEvent.AT_TARGET : SyntheticEvent.BUBBLING_PHASE,
+ state,
+ isTarget ? afterTargetHandler : undefined,
+ );
+ }
+}
+
+/** Target first, then host ancestors. The root container is not an element, so it's excluded. */
+function getAncestorPath(target: TestInstance): TestInstance[] {
+ const path: TestInstance[] = [];
+ let current: TestInstance | null = target;
+ while (current?.parent != null) {
+ path.push(current);
+ current = current.parent;
+ }
+
+ return path;
+}
+
+function callHandler(
+ event: SyntheticEvent,
+ node: TestInstance,
+ handlerName: string,
+ phase: EventPhase,
+ state: DispatchState,
+ afterHandler?: DispatchOptions['afterTargetHandler'],
+) {
+ getEventInternals(event).eventPhase = phase;
+ getEventInternals(event).currentTarget = node;
+
+ const handler = getEventHandlerByName(node.props, handlerName);
+ if (handler == null && afterHandler == null) {
+ return;
+ }
+
+ try {
+ if (handler != null) {
+ state.hasCalledHandler = true;
+ handler.call(node, event);
+ }
+
+ // In the same `try`, as a component's wrapper doesn't reach its own code after the prop throws.
+ afterHandler?.(node, event);
+ } catch (error) {
+ // Keep dispatching, as React Native (`handleListenerError()` in `EventTarget.js`) and DOM do.
+ if (state.firstError == null) {
+ state.firstError = { error };
+ }
+ }
+}
diff --git a/src/events/modern/event-types.ts b/src/events/modern/event-types.ts
new file mode 100644
index 000000000..1218d6251
--- /dev/null
+++ b/src/events/modern/event-types.ts
@@ -0,0 +1,193 @@
+import { getEventHandlerName } from '../shared/handler';
+
+/**
+ * Event types known to React Native, without the `top` prefix (`focus`, not `topFocus`).
+ *
+ * `@react-native/jest-preset` mocks native components, so React Native's view config registry stays
+ * empty in Jest. These are static copies of the union of `BaseViewConfig.{ios,android}.js` and the
+ * built-in components' view configs. `__tests__/event-types.test.ts` checks them against the
+ * installed `react-native`.
+ */
+
+export const BUBBLING_EVENT_TYPES = [
+ 'blur',
+ 'change',
+ 'click',
+ 'endEditing',
+ 'focus',
+ 'gotPointerCapture',
+ 'keyDown',
+ 'keyPress',
+ 'keyUp',
+ 'lostPointerCapture',
+ 'pointerCancel',
+ 'pointerDown',
+ 'pointerEnter',
+ 'pointerLeave',
+ 'pointerMove',
+ 'pointerOut',
+ 'pointerOver',
+ 'pointerUp',
+ 'press',
+ 'select',
+ 'submitEditing',
+ 'touchCancel',
+ 'touchEnd',
+ 'touchMove',
+ 'touchStart',
+] as const;
+
+export type BubblingEventType = (typeof BUBBLING_EVENT_TYPES)[number];
+
+/** Bubbling events that run the capture phase as usual, but call only the target when bubbling. */
+export const SKIP_BUBBLING_EVENT_TYPES: readonly BubblingEventType[] = [
+ 'pointerEnter',
+ 'pointerLeave',
+];
+
+export const DIRECT_EVENT_TYPES = [
+ 'accessibilityAction',
+ 'accessibilityEscape',
+ 'accessibilityTap',
+ 'changeSync',
+ 'contentSizeChange',
+ 'dismiss',
+ 'drawerClose',
+ 'drawerOpen',
+ 'drawerSlide',
+ 'drawerStateChanged',
+ 'error',
+ 'keyPressSync',
+ 'layout',
+ 'load',
+ 'loadEnd',
+ 'loadStart',
+ 'loadingError',
+ 'loadingFinish',
+ 'loadingStart',
+ 'magicTap',
+ 'message',
+ 'modeChange',
+ 'momentumScrollBegin',
+ 'momentumScrollEnd',
+ 'orientationChange',
+ 'partialLoad',
+ 'progress',
+ 'refresh',
+ 'requestClose',
+ 'safeAreaInsetsChange',
+ 'scroll',
+ 'scrollBeginDrag',
+ 'scrollEndDrag',
+ 'scrollToTop',
+ 'selectionChange',
+ 'show',
+ // From the inline view config in `TextNativeComponent.js`, which is not exported.
+ 'textLayout',
+] as const;
+
+export type DirectEventType = (typeof DIRECT_EVENT_TYPES)[number];
+
+/**
+ * Pressability callbacks (`onPress`, `onPressIn`, ...) that modern `fireEvent` calls. They are not
+ * native events: Pressability calls them on the touch responder from responder events, so
+ * `dispatchPressabilityEvent()` calls them instead of `dispatchEvent()`. `press` is also a native
+ * event type, but `fireEvent` calls Pressability's `onPress` for it.
+ */
+export const PRESSABILITY_EVENT_TYPES = ['press', 'pressIn', 'pressOut', 'longPress'] as const;
+
+export type PressabilityEventType = (typeof PRESSABILITY_EVENT_TYPES)[number];
+
+export function isPressabilityEventType(eventType: string): eventType is PressabilityEventType {
+ return includes(PRESSABILITY_EVENT_TYPES, eventType);
+}
+
+/** Event types modern `fireEvent` fires without `dispatch`. */
+export type KnownEventType = BubblingEventType | DirectEventType | PressabilityEventType;
+
+/** Direct events whose handler prop doesn't follow the `on` + event type pattern. */
+const DIRECT_EVENT_HANDLER_NAMES: Readonly> = {
+ // Added in React Native 0.89.
+ safeAreaInsetsChange: 'experimental_onSafeAreaInsetsChange',
+};
+
+/** Capture phase from the root to the target, then bubble phase back to the root. */
+export type BubblingEventTypeConfig = {
+ kind: 'bubbling';
+ /** Bubble phase prop, e.g. `onFocus`. */
+ handlerName: string;
+ /** Capture phase prop, e.g. `onFocusCapture`. */
+ captureHandlerName: string;
+ skipBubbling: boolean;
+};
+
+/** Calls only the target's prop, without the capture phase. */
+export type DirectEventTypeConfig = {
+ kind: 'direct';
+ /** e.g. `onLayout` */
+ handlerName: string;
+};
+
+/** How React Native dispatches an event type, and which handler props it calls. */
+export type EventTypeConfig = BubblingEventTypeConfig | DirectEventTypeConfig;
+
+/**
+ * How a native component dispatches an event type React Native doesn't know, as its codegen spec
+ * declares the handler prop: `DirectEventHandler` or `BubblingEventHandler`.
+ */
+export const CUSTOM_EVENT_DISPATCHES = ['direct', 'bubbling'] as const;
+
+export type CustomEventDispatch = (typeof CUSTOM_EVENT_DISPATCHES)[number];
+
+/**
+ * Returns how React Native dispatches the event type (e.g. `focus`), or `null` if React Native
+ * doesn't know it.
+ */
+export function getEventTypeConfig(eventType: string): EventTypeConfig | null {
+ if (includes(BUBBLING_EVENT_TYPES, eventType)) {
+ return getBubblingEventTypeConfig(eventType, includes(SKIP_BUBBLING_EVENT_TYPES, eventType));
+ }
+
+ if (includes(DIRECT_EVENT_TYPES, eventType)) {
+ return {
+ kind: 'direct',
+ handlerName: DIRECT_EVENT_HANDLER_NAMES[eventType] ?? getEventHandlerName(eventType),
+ };
+ }
+
+ return null;
+}
+
+/**
+ * Returns the config React Native builds for an event type a native component declares, e.g. a
+ * third-party component's `pageSelected`. As codegen generates it (`GenerateViewConfigJs.js`), the
+ * handler props follow the event type (`onPageSelected`, `onPageSelectedCapture`), and bubbling
+ * events always bubble.
+ */
+export function getCustomEventTypeConfig(
+ eventType: string,
+ dispatch: CustomEventDispatch,
+): EventTypeConfig {
+ if (dispatch === 'bubbling') {
+ return getBubblingEventTypeConfig(eventType, false);
+ }
+
+ return { kind: 'direct', handlerName: getEventHandlerName(eventType) };
+}
+
+function getBubblingEventTypeConfig(
+ eventType: string,
+ skipBubbling: boolean,
+): BubblingEventTypeConfig {
+ const handlerName = getEventHandlerName(eventType);
+ return {
+ kind: 'bubbling',
+ handlerName,
+ captureHandlerName: `${handlerName}Capture`,
+ skipBubbling,
+ };
+}
+
+function includes(eventTypes: readonly string[], eventType: string): boolean {
+ return eventTypes.includes(eventType);
+}
diff --git a/src/events/modern/event.ts b/src/events/modern/event.ts
new file mode 100644
index 000000000..cf0983d9e
--- /dev/null
+++ b/src/events/modern/event.ts
@@ -0,0 +1,279 @@
+import type { TestInstance } from 'test-renderer';
+
+import type { CustomEventDispatch, EventTypeConfig } from './event-types';
+import { getCustomEventTypeConfig, getEventTypeConfig } from './event-types';
+
+export type NativeEventPayload = Record;
+
+/**
+ * React Native's `DispatchConfig`, exposed as `event.dispatchConfig`. React's legacy event system
+ * calls handler props "registration names".
+ */
+export type DispatchConfig =
+ | { phasedRegistrationNames: { bubbled: string; captured: string; skipBubbling?: boolean } }
+ | { registrationName: string; dependencies?: readonly string[] };
+
+export type CreateEventInit = {
+ /** Passed to handlers as `event.nativeEvent`. Defaults to `{}`. */
+ nativeEvent?: NativeEventPayload;
+ /**
+ * The event's `timeStamp`. Defaults to the native timestamp, as in React Native
+ * (`nativeEvent.timeStamp ?? nativeEvent.timestamp`), then to `performance.now()`.
+ */
+ timeStamp?: number;
+ /**
+ * How to dispatch an event type React Native doesn't know, e.g. a third-party component's
+ * `pageSelected`. Overrides React Native's dispatch for event types it knows.
+ */
+ dispatch?: CustomEventDispatch;
+};
+
+/** `bubbles` and `rnIsDirect` default to what the type config says. */
+type SyntheticEventInit = {
+ bubbles?: boolean;
+ cancelable?: boolean;
+ rnIsDirect?: boolean;
+ timeStamp?: number;
+};
+
+const NONE = 0;
+const CAPTURING_PHASE = 1;
+const AT_TARGET = 2;
+const BUBBLING_PHASE = 3;
+
+export type EventPhase =
+ | typeof NONE
+ | typeof CAPTURING_PHASE
+ | typeof AT_TARGET
+ | typeof BUBBLING_PHASE;
+
+/**
+ * Event fields that the dispatch reads or updates, as in React Native's `EventInternals`. Stored
+ * under a non-enumerable symbol key, so printed and compared events don't include the element tree.
+ */
+type EventInternals = {
+ readonly typeConfig: EventTypeConfig;
+ target: TestInstance | null;
+ currentTarget: TestInstance | null;
+ eventPhase: EventPhase;
+ composedPath: TestInstance[];
+ stopPropagation: boolean;
+ isTrusted: boolean;
+};
+
+const EVENT_INTERNALS = Symbol('eventInternals');
+
+/**
+ * Event object passed to handlers by the modern event system.
+ *
+ * Mirrors React Native's `LegacySyntheticEvent`, which extends the W3C `Event`
+ * (`src/private/renderer/events/LegacySyntheticEvent.js`,
+ * `src/private/webapis/dom/events/Event.js`).
+ * `target` and `currentTarget` are host elements, the same objects that queries return.
+ */
+export class SyntheticEvent {
+ static readonly NONE = NONE;
+ static readonly CAPTURING_PHASE = CAPTURING_PHASE;
+ static readonly AT_TARGET = AT_TARGET;
+ static readonly BUBBLING_PHASE = BUBBLING_PHASE;
+
+ private readonly _type: string;
+ private readonly _bubbles: boolean;
+ private readonly _cancelable: boolean;
+ private readonly _rnIsDirect: boolean;
+ private readonly _timeStamp: number;
+ private readonly _nativeEvent: NativeEventPayload;
+ private readonly _dispatchConfig: DispatchConfig;
+ private _defaultPrevented = false;
+
+ constructor(
+ type: string,
+ init: SyntheticEventInit,
+ nativeEvent: NativeEventPayload,
+ typeConfig: EventTypeConfig,
+ dispatchConfig: DispatchConfig = toDispatchConfig(typeConfig),
+ ) {
+ this._type = type;
+ this._bubbles = init.bubbles ?? (typeConfig.kind === 'bubbling' && !typeConfig.skipBubbling);
+ this._cancelable = Boolean(init.cancelable);
+ this._rnIsDirect = init.rnIsDirect ?? typeConfig.kind === 'direct';
+ this._timeStamp = init.timeStamp ?? performance.now();
+ this._nativeEvent = nativeEvent;
+ this._dispatchConfig = dispatchConfig;
+ Object.defineProperty(this, EVENT_INTERNALS, {
+ value: {
+ typeConfig,
+ target: null,
+ currentTarget: null,
+ eventPhase: NONE,
+ composedPath: [],
+ stopPropagation: false,
+ isTrusted: false,
+ } satisfies EventInternals,
+ });
+ }
+
+ // Getters, so the constants live on the prototype as in React Native, not on each event.
+ get NONE(): typeof NONE {
+ return NONE;
+ }
+
+ get CAPTURING_PHASE(): typeof CAPTURING_PHASE {
+ return CAPTURING_PHASE;
+ }
+
+ get AT_TARGET(): typeof AT_TARGET {
+ return AT_TARGET;
+ }
+
+ get BUBBLING_PHASE(): typeof BUBBLING_PHASE {
+ return BUBBLING_PHASE;
+ }
+
+ get type(): string {
+ return this._type;
+ }
+
+ get bubbles(): boolean {
+ return this._bubbles;
+ }
+
+ /** React Native never creates composed events. */
+ get composed(): boolean {
+ return false;
+ }
+
+ get cancelable(): boolean {
+ return this._cancelable;
+ }
+
+ /** Direct events are dispatched only to the target, without the capture phase. */
+ get rnIsDirect(): boolean {
+ return this._rnIsDirect;
+ }
+
+ get timeStamp(): number {
+ return this._timeStamp;
+ }
+
+ get nativeEvent(): NativeEventPayload {
+ return this._nativeEvent;
+ }
+
+ get dispatchConfig(): DispatchConfig {
+ return this._dispatchConfig;
+ }
+
+ get defaultPrevented(): boolean {
+ return this._defaultPrevented;
+ }
+
+ get target(): TestInstance | null {
+ return getEventInternals(this).target;
+ }
+
+ get currentTarget(): TestInstance | null {
+ return getEventInternals(this).currentTarget;
+ }
+
+ get eventPhase(): EventPhase {
+ return getEventInternals(this).eventPhase;
+ }
+
+ /** Set by `dispatchEvent()`, as React Native's native event dispatch marks events as trusted. */
+ get isTrusted(): boolean {
+ return getEventInternals(this).isTrusted;
+ }
+
+ get cancelBubble(): boolean {
+ return getEventInternals(this).stopPropagation;
+ }
+
+ set cancelBubble(value: boolean) {
+ if (value) {
+ getEventInternals(this).stopPropagation = true;
+ }
+ }
+
+ composedPath(): TestInstance[] {
+ return getEventInternals(this).composedPath.slice();
+ }
+
+ preventDefault(): void {
+ if (this._cancelable) {
+ this._defaultPrevented = true;
+ }
+ }
+
+ stopPropagation(): void {
+ getEventInternals(this).stopPropagation = true;
+ }
+
+ /** Same as `stopPropagation()`, as each element has a single prop handler per phase. */
+ stopImmediatePropagation(): void {
+ getEventInternals(this).stopPropagation = true;
+ }
+
+ /** No-op: React Native no longer pools events. */
+ persist(): void {}
+
+ isDefaultPrevented(): boolean {
+ return this.defaultPrevented;
+ }
+
+ isPropagationStopped(): boolean {
+ return this.cancelBubble;
+ }
+}
+
+/**
+ * Creates the event React Native would create for a native event of this type, ready for
+ * `dispatchEvent()`. Like Testing Library's DOM `createEvent()`.
+ *
+ * `init.dispatch` overrides React Native's config for the event type, as a native component can
+ * declare its own event with a built-in name, e.g. a direct `press`.
+ *
+ * @param eventType e.g. `focus` or `pointerUp`
+ * @returns `null` for events unknown to React Native without `init.dispatch`, as React Native drops
+ * them.
+ */
+export function createEvent(eventType: string, init: CreateEventInit = {}): SyntheticEvent | null {
+ const config =
+ init.dispatch != null
+ ? getCustomEventTypeConfig(eventType, init.dispatch)
+ : getEventTypeConfig(eventType);
+ if (config == null) {
+ return null;
+ }
+
+ const nativeEvent = init.nativeEvent ?? {};
+ const nativeTimeStamp = nativeEvent.timeStamp ?? nativeEvent.timestamp;
+ const timeStamp =
+ init.timeStamp ?? (typeof nativeTimeStamp === 'number' ? nativeTimeStamp : undefined);
+ return new SyntheticEvent(
+ // React Native's event type is the lowercased name, e.g. `pointerup`.
+ eventType.toLowerCase(),
+ { cancelable: true, timeStamp },
+ nativeEvent,
+ config,
+ );
+}
+
+/** @internal */
+export function getEventInternals(event: SyntheticEvent): EventInternals {
+ return (event as unknown as { [EVENT_INTERNALS]: EventInternals })[EVENT_INTERNALS];
+}
+
+function toDispatchConfig(config: EventTypeConfig): DispatchConfig {
+ if (config.kind === 'direct') {
+ return { registrationName: config.handlerName };
+ }
+
+ return {
+ phasedRegistrationNames: {
+ bubbled: config.handlerName,
+ captured: config.captureHandlerName,
+ ...(config.skipBubbling ? { skipBubbling: true } : {}),
+ },
+ };
+}
diff --git a/src/events/modern/fire-event-utils.ts b/src/events/modern/fire-event-utils.ts
new file mode 100644
index 000000000..c23e9e368
--- /dev/null
+++ b/src/events/modern/fire-event-utils.ts
@@ -0,0 +1,266 @@
+import type { TestInstance } from 'test-renderer';
+
+import { ErrorWithStack } from '../../helpers/errors';
+import { isHostTextInput } from '../../helpers/host-component-names';
+import { logger } from '../../helpers/logger';
+import { hasOnPrefix, normalizeEventType } from '../shared/handler';
+import type { CreateEventInit } from './event';
+import { SyntheticEvent } from './event';
+import {
+ CUSTOM_EVENT_DISPATCHES,
+ getEventTypeConfig,
+ isPressabilityEventType,
+} from './event-types';
+
+const ALLOWED_INIT_KEYS = ['nativeEvent', 'timeStamp', 'dispatch'];
+
+const EVENT_KEYS = new Set(
+ Object.getOwnPropertyNames(SyntheticEvent.prototype).filter((key) => key !== 'constructor'),
+);
+
+/** What to use instead of event types that only the legacy system or `userEvent` can fire. */
+const UNKNOWN_EVENT_TYPE_ALTERNATIVES: Record = {
+ changeText: 'fireEvent.changeText() or userEvent.type()',
+};
+
+/**
+ * Warns for the deprecated `on` prefix (`onPress`), which modern `fireEvent` still strips, as legacy
+ * `fireEvent` does. To be removed in the next major release.
+ */
+export function warnOnPrefix(eventType: string): void {
+ if (!hasOnPrefix(eventType)) {
+ return;
+ }
+
+ const unprefixedType = normalizeEventType(eventType);
+ logger.warn(
+ `Passing event types with the "on" prefix ("${eventType}") to fireEvent is deprecated and ` +
+ 'will be removed in the next major release. ' +
+ `Use "${unprefixedType}" instead, e.g. fireEvent(element, '${unprefixedType}', { nativeEvent: {} }).`,
+ );
+}
+
+/**
+ * Throws for event types React Native doesn't know, such as `changeText` or custom prop names,
+ * except Pressability event types (`pressIn`, ...), and event types passed with `dispatch`.
+ * React Native drops unknown events, so dropping them here would call no handler, and a test
+ * asserting that a handler wasn't called would pass for the wrong reason.
+ *
+ * `dispatch` is required, not defaulted to `direct`: a legacy call such as
+ * `fireEvent(list, 'endReached', ...)` would otherwise pass a `SyntheticEvent` to `onEndReached`,
+ * which `FlatList` calls from JavaScript, but the Jest mock of `ScrollView` puts on the host.
+ *
+ * @param init the event object, checked by `validateEventInit()` afterwards
+ * @param callsite the function to remove from the error stack, e.g. `fireEvent`
+ */
+export function ensureEventType(
+ eventType: string,
+ init: unknown,
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
+ callsite: Function,
+): void {
+ if (isKnownEventType(eventType)) {
+ return;
+ }
+
+ // Custom event type: the caller declares how it dispatches.
+ if (isPlainObject(init) && init.dispatch !== undefined) {
+ return;
+ }
+
+ const alternative = UNKNOWN_EVENT_TYPE_ALTERNATIVES[eventType];
+ if (alternative != null) {
+ throw new ErrorWithStack(
+ `Unable to fire a "${eventType}" event. React Native doesn't dispatch "${eventType}" ` +
+ `natively. Use ${alternative} instead.`,
+ callsite,
+ );
+ }
+
+ throw new ErrorWithStack(
+ `Unable to fire a "${eventType}" event. React Native doesn't know "${eventType}". ` +
+ `For a native event of a third-party component, pass dispatch: "direct" or "bubbling" ` +
+ 'in the event object, matching its DirectEventHandler or BubblingEventHandler prop type. ' +
+ 'To call a handler prop directly, use UNSAFE_invokeHandler().',
+ callsite,
+ );
+}
+
+/**
+ * Throws unless exactly one argument follows the event type in modern `fireEvent`, as React Native
+ * handlers receive a single event. Catches legacy calls passing several handler arguments.
+ *
+ * @param callsite the function to remove from the error stack, e.g. `fireEvent`
+ */
+export function ensureSingleEventArg(
+ eventType: string,
+ args: unknown[],
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
+ callsite: Function,
+): void {
+ if (args.length === 0) {
+ throw new ErrorWithStack(
+ `Unable to fire a "${eventType}" event. Please provide an event object, ` +
+ `e.g. fireEvent(element, '${eventType}', { nativeEvent: {} }).`,
+ callsite,
+ );
+ }
+
+ if (args.length > 1) {
+ throw new ErrorWithStack(
+ `Unable to fire a "${eventType}" event. Expected a single event object, ` +
+ `received ${args.length} arguments. To call a handler prop with custom arguments, ` +
+ 'use UNSAFE_invokeHandler().',
+ callsite,
+ );
+ }
+}
+
+/**
+ * @param callsite the function to remove from the error stack, e.g. `fireEvent`
+ */
+export function validateEventInit(
+ eventType: string,
+ init: unknown,
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
+ callsite: Function,
+): CreateEventInit {
+ if (!isPlainObject(init)) {
+ throw new ErrorWithStack(
+ `Unable to fire a "${eventType}" event. Expected an event object, ` +
+ `received ${describeValue(init)}.`,
+ callsite,
+ );
+ }
+
+ const unknownKeys = Object.keys(init).filter((key) => !ALLOWED_INIT_KEYS.includes(key));
+ if (unknownKeys.length > 0) {
+ throw new ErrorWithStack(
+ `Unable to fire a "${eventType}" event. Unsupported event object keys: ` +
+ `${formatKeys(unknownKeys)}. ${describeUnknownKeys(unknownKeys)}`,
+ callsite,
+ );
+ }
+
+ if (init.nativeEvent !== undefined && !isPlainObject(init.nativeEvent)) {
+ throw new ErrorWithStack(
+ `Unable to fire a "${eventType}" event. Expected "nativeEvent" to be an object, ` +
+ `received ${describeValue(init.nativeEvent)}.`,
+ callsite,
+ );
+ }
+
+ if (
+ init.timeStamp !== undefined &&
+ (typeof init.timeStamp !== 'number' || !Number.isFinite(init.timeStamp))
+ ) {
+ throw new ErrorWithStack(
+ `Unable to fire a "${eventType}" event. Expected "timeStamp" to be a finite number, ` +
+ `received ${describeValue(init.timeStamp)}.`,
+ callsite,
+ );
+ }
+
+ if (
+ init.dispatch !== undefined &&
+ !(CUSTOM_EVENT_DISPATCHES as readonly unknown[]).includes(init.dispatch)
+ ) {
+ throw new ErrorWithStack(
+ `Unable to fire a "${eventType}" event. Expected "dispatch" to be "direct" or "bubbling", ` +
+ `received ${describeValue(init.dispatch)}.`,
+ callsite,
+ );
+ }
+
+ return init as CreateEventInit;
+}
+
+/**
+ * Validates the arguments of `fireEvent.changeText`: a host `TextInput`, as only `TextInput` calls
+ * `onChangeText`, and a string.
+ *
+ * @param callsite the function to remove from the error stack, e.g. `fireEvent.changeText`
+ */
+export function validateChangeTextArgs(
+ instance: TestInstance,
+ text: unknown,
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
+ callsite: Function,
+): asserts text is string {
+ if (!isHostTextInput(instance)) {
+ throw new ErrorWithStack(
+ `Unable to fire a "changeText" event. Expected a host "TextInput" element, ` +
+ `received "${instance.type}".`,
+ callsite,
+ );
+ }
+
+ if (typeof text !== 'string') {
+ throw new ErrorWithStack(
+ `Unable to fire a "changeText" event. Expected text to be a string, ` +
+ `received ${describeValue(text)}.`,
+ callsite,
+ );
+ }
+}
+
+function isKnownEventType(eventType: string): boolean {
+ return getEventTypeConfig(eventType) != null || isPressabilityEventType(eventType);
+}
+
+/**
+ * Explains why each key can't be passed. Dropping keys silently, as DOM event constructors do,
+ * would leave handlers reading `undefined` with no explanation.
+ */
+function describeUnknownKeys(keys: string[]): string {
+ const eventKeys = keys.filter((key) => EVENT_KEYS.has(key));
+ const otherKeys = keys.filter((key) => !EVENT_KEYS.has(key));
+
+ const reasons: string[] = [];
+ if (eventKeys.length > 0) {
+ reasons.push(`${formatKeys(eventKeys)} ${isOrAre(eventKeys)} provided by the event.`);
+ }
+
+ if (otherKeys.length > 0) {
+ reasons.push(`Pass ${formatKeys(otherKeys)} in "nativeEvent" instead.`);
+ }
+
+ return reasons.join(' ');
+}
+
+function formatKeys(keys: string[]): string {
+ return keys.map((key) => `"${key}"`).join(', ');
+}
+
+function isOrAre(keys: string[]): string {
+ return keys.length === 1 ? 'is' : 'are';
+}
+
+function isPlainObject(value: unknown): value is Record {
+ if (value === null || typeof value !== 'object') {
+ return false;
+ }
+
+ const prototype = Object.getPrototypeOf(value);
+ return prototype === Object.prototype || prototype === null;
+}
+
+function describeValue(value: unknown): string {
+ if (value == null) {
+ return String(value);
+ }
+
+ if (Array.isArray(value)) {
+ return 'an array';
+ }
+
+ if (typeof value === 'object') {
+ return `an instance of ${value.constructor?.name ?? 'an unknown class'}`;
+ }
+
+ if (typeof value === 'function') {
+ return 'a function';
+ }
+
+ return typeof value === 'string' ? `string "${value}"` : `${typeof value} ${String(value)}`;
+}
diff --git a/src/events/modern/fire-event.ts b/src/events/modern/fire-event.ts
new file mode 100644
index 000000000..108983ef9
--- /dev/null
+++ b/src/events/modern/fire-event.ts
@@ -0,0 +1,208 @@
+import type { TestInstance } from 'test-renderer';
+
+import { isInstanceMounted } from '../../helpers/component-tree';
+import { ErrorWithStack } from '../../helpers/errors';
+import { isEditableTextInput } from '../../helpers/text-input';
+import { getEventHandlerByEventType, normalizeEventType } from '../shared/handler';
+import { mergeEventProps } from '../shared/merge';
+import {
+ buildLayoutNativeEvent,
+ buildScrollNativeEvent,
+ buildTextChangeNativeEvent,
+} from '../shared/payloads';
+import type { LayoutRectangle } from '../shared/types';
+import {
+ getNativeStateEventProps,
+ updateNativeStateFromEvent,
+} from '../shared/update-native-state';
+import type { DispatchOptions } from './dispatch';
+import { dispatchEvent } from './dispatch';
+import type { CreateEventInit, NativeEventPayload } from './event';
+import { createEvent } from './event';
+import type { PressabilityEventType } from './event-types';
+import { isPressabilityEventType } from './event-types';
+import {
+ ensureEventType,
+ ensureSingleEventArg,
+ validateChangeTextArgs,
+ validateEventInit,
+ warnOnPrefix,
+} from './fire-event-utils';
+import { dispatchPressabilityEvent, findResponder, getTouchPath } from './pressability';
+import { createPressabilityEvent } from './responder-event';
+
+/**
+ * Event object passed to modern `fireEvent`. Handlers receive a `SyntheticEvent` from
+ * `createEvent()`, with `nativeEvent` as its `nativeEvent`. `dispatch` is required for event types
+ * React Native doesn't know, and overrides React Native's dispatch for the others.
+ */
+export type FireEventInit = CreateEventInit;
+
+/**
+ * Fires an event on a host element: `createEvent()`, then `dispatchEvent()`, like Testing Library's
+ * DOM `fireEvent(element, event)`. The event type is needed to find the event's dispatch config.
+ *
+ * Pressability event types (`press`, `pressIn`, `pressOut`, `longPress`) call the callback of the
+ * element that becomes the touch responder instead, as `fireEvent.press()` does.
+ *
+ * Exactly one event object is required, as React Native handlers receive a single event. Event
+ * types React Native doesn't know need `dispatch: 'direct' | 'bubbling'` in the event object, as
+ * declared by the native component, e.g. a third-party component's `pageSelected`. Without it they
+ * throw, as do `changeText` and custom prop names.
+ *
+ * `dispatch` also overrides React Native's dispatch for known event types, for a native component
+ * that declares its own event with a built-in name, e.g. a map's `BubblingEventHandler` `onPress`.
+ * Pressability event types are then dispatched as native events, skipping Pressability.
+ *
+ * Resolves to nothing, unlike DOM `fireEvent`: React Native has no default actions that
+ * `preventDefault()` could cancel. Handlers can read `event.defaultPrevented` themselves.
+ *
+ * @param eventType without the `on*` prefix, as React Native names it, e.g. `focus`. The prefix
+ * (`onFocus`) is deprecated and logs a warning.
+ */
+export async function fireEvent(
+ instance: TestInstance,
+ eventType: string,
+ ...args: [event: FireEventInit]
+): Promise {
+ const normalizedType = normalizeEventType(eventType);
+ ensureInstance(instance, normalizedType, fireEvent);
+ // Before the event object, as `fireEvent(input, 'changeText', 'Hello')` needs the hint more.
+ ensureEventType(normalizedType, args[0], fireEvent);
+ ensureSingleEventArg(normalizedType, args, fireEvent);
+ const init = validateEventInit(normalizedType, args[0], fireEvent);
+ // After the checks, so a call that throws doesn't also warn.
+ warnOnPrefix(eventType);
+ if (isPressabilityEventType(normalizedType) && init.dispatch == null) {
+ await firePressabilityEvent(instance, normalizedType, init);
+ return;
+ }
+
+ await fireEventInternal(instance, normalizedType, init);
+}
+
+/**
+ * Changes the text of a `TextInput` as a device does: fires a `change` event with the new text,
+ * and calls `onChangeText(text)` right after the input's own `onChange`, as `TextInput` does from
+ * its `onChange` wrapper. Ancestors receive the bubbling `onChange`, but never `onChangeText`.
+ * A non-editable `TextInput` receives no events, as on a device.
+ */
+fireEvent.changeText = async (instance: TestInstance, text: string): Promise => {
+ ensureInstance(instance, 'changeText', fireEvent.changeText);
+ validateChangeTextArgs(instance, text, fireEvent.changeText);
+ if (!isEditableTextInput(instance)) {
+ return;
+ }
+
+ const selection = { start: text.length, end: text.length };
+ const payload = buildTextChangeNativeEvent(text, selection);
+ const dispatchOptions: DispatchOptions = {
+ afterTargetHandler: (target) => {
+ getEventHandlerByEventType(target.props, 'changeText')?.(text);
+ },
+ };
+
+ await fireEventInternal(instance, 'change', {}, payload, dispatchOptions);
+};
+
+/**
+ * Presses the element: calls `onPress` of the element that becomes the touch responder, as a tap
+ * on a device does. `Pressable` and `Touchable*` get it from their Pressability config, hosts with
+ * their own `onPress` (mocked `Text`, `testOnly_onPress`) directly. Disabled elements and
+ * responders that decline the touch pass it on to their ancestors. A `TextInput` always keeps the
+ * touch, even without `onPress`. `pointerEvents` moves the touch to the nearest ancestor it doesn't
+ * block. See `getTouchPath()`, `findResponder()` and `dispatchPressabilityEvent()`.
+ *
+ * Only `onPress` is called. Use `userEvent.press()` for `onPressIn`, `onPressOut` and timing.
+ *
+ * The passed `nativeEvent` is deep merged onto a default touch payload. With `dispatch`, fires a
+ * native `press` event with the passed `nativeEvent` instead, as `fireEvent()` does.
+ */
+fireEvent.press = async (instance: TestInstance, event: FireEventInit = {}): Promise => {
+ ensureInstance(instance, 'press', fireEvent.press);
+ const init = validateEventInit('press', event, fireEvent.press);
+ if (init.dispatch != null) {
+ await fireEventInternal(instance, 'press', init);
+ return;
+ }
+
+ await firePressabilityEvent(instance, 'press', init);
+};
+
+/**
+ * Fires a `scroll` event. The passed `nativeEvent` is deep merged onto a default scroll payload,
+ * whose `layoutMeasurement` is the `ScrollView`'s size from its last `layout` event.
+ */
+fireEvent.scroll = async (instance: TestInstance, event: FireEventInit = {}): Promise => {
+ ensureInstance(instance, 'scroll', fireEvent.scroll);
+ const init = validateEventInit('scroll', event, fireEvent.scroll);
+ await fireEventInternal(instance, 'scroll', init, buildScrollNativeEvent());
+};
+
+/** Fires a `layout` event. The passed `layout` is merged onto a zeroed rectangle. */
+fireEvent.layout = async (
+ instance: TestInstance,
+ layout?: Partial,
+): Promise => {
+ ensureInstance(instance, 'layout', fireEvent.layout);
+ await fireEventInternal(instance, 'layout', {}, buildLayoutNativeEvent(layout));
+};
+
+/**
+ * Expects an event type without the `on*` prefix, that React Native knows or that has `dispatch`.
+ *
+ * @param basePayload the payload a device would send. Native state, like a `ScrollView`'s
+ * size, is merged onto it, then the passed `nativeEvent` is deep merged on top. Without it, the
+ * passed `nativeEvent` is used as is.
+ */
+async function fireEventInternal(
+ instance: TestInstance,
+ eventType: string,
+ init: CreateEventInit,
+ basePayload?: NativeEventPayload,
+ dispatchOptions?: DispatchOptions,
+): Promise {
+ const nativeEvent = basePayload
+ ? mergeEventProps(basePayload, getNativeStateEventProps(instance, eventType), init.nativeEvent)
+ : init.nativeEvent;
+ const event = createEvent(eventType, { ...init, nativeEvent });
+ if (event == null || !isInstanceMounted(instance)) {
+ return;
+ }
+
+ // Before the dispatch, as a device updates its native views before emitting the event.
+ updateNativeStateFromEvent(instance, eventType, event);
+ await dispatchEvent(instance, event, dispatchOptions);
+}
+
+/** Touches the element and calls one Pressability callback of the touch responder. */
+async function firePressabilityEvent(
+ instance: TestInstance,
+ eventType: PressabilityEventType,
+ init: CreateEventInit,
+): Promise {
+ const event = createPressabilityEvent(eventType, init);
+ if (!isInstanceMounted(instance)) {
+ return;
+ }
+
+ const path = getTouchPath(instance);
+ const responder = findResponder(path);
+ if (responder != null) {
+ await dispatchPressabilityEvent(responder, [eventType], event, path[0]);
+ }
+}
+
+function ensureInstance(
+ instance: TestInstance,
+ eventType: string,
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
+ callsite: Function,
+) {
+ if (instance == null) {
+ throw new ErrorWithStack(
+ `Unable to fire a "${eventType}" event. Please provide a host element.`,
+ callsite,
+ );
+ }
+}
diff --git a/src/events/modern/pressability.ts b/src/events/modern/pressability.ts
new file mode 100644
index 000000000..a5167beee
--- /dev/null
+++ b/src/events/modern/pressability.ts
@@ -0,0 +1,200 @@
+import type { TestInstance } from 'test-renderer';
+
+import { act } from '../../act';
+import { isInstanceMounted } from '../../helpers/component-tree';
+import { isHostTextInput } from '../../helpers/host-component-names';
+import { isDisabledHostText } from '../../helpers/text';
+import {
+ getEventHandlerByName,
+ getEventHandlerName,
+ hasPressEventHandler,
+} from '../shared/handler';
+import { getPointerEventsBlocker } from '../shared/pointer-events';
+import type { EventHandler } from '../shared/types';
+import type { SyntheticEvent } from './event';
+import { getEventInternals } from './event';
+import type { PressabilityEventType } from './event-types';
+
+/** Callbacks of React Native's `PressabilityConfig` that `dispatchPressabilityEvent()` calls. */
+type PressabilityCallbackName = `on${Capitalize}`;
+
+/** The part of React Native's `PressabilityConfig` that RNTL reads. */
+type PressabilityConfig = {
+ [Name in PressabilityCallbackName]?: EventHandler | null;
+};
+
+type DispatchState = {
+ /** Wrapped, so a thrown `undefined` is rethrown too. */
+ firstError: { error: unknown } | null;
+};
+
+/**
+ * Calls Pressability callbacks (`onPress` for `press`, `onPressIn` for `pressIn`, ...) of the
+ * touch responder from `findResponder()`, in order: from its `PressabilityConfig` for `Pressable` and
+ * `Touchable*`, which Pressability exposes in tests as
+ * `onStartShouldSetResponder.testOnly_pressabilityConfig()`, or its own `onPress*` prop (or the
+ * `testOnly_` variant) for mocked hosts. Responders without Pressability, like `PanResponder`, get
+ * nothing.
+ *
+ * Doesn't negotiate the responder: callers do it once per touch, as React Native does on touch
+ * start, so a touch's callbacks all go to the same responder.
+ *
+ * One `act()` wraps the calls, as React Native batches updates for the whole responder event, so
+ * `onPress` sees the state from before `onPressOut`, not its updates. The callbacks are looked up
+ * before the first call. A callback error stops the calls and is rethrown after `act()`. As in
+ * React Native's responder system, which calls handlers directly instead of dispatching the event,
+ * `event.target` is the hit target, `event.currentTarget` is the responder during each call, and
+ * `eventPhase` and `composedPath()` stay unset. `onLongPress` gets a `null` `currentTarget`, as
+ * Pressability calls it from a timer, after the dispatch.
+ *
+ * Known limitation: `testOnly_pressabilityConfig` exists only when `NODE_ENV` is `test`, which
+ * Jest sets by default. Without it, `Pressable` and `Touchable*` claim the touch but get nothing.
+ *
+ * @param eventTypes the callbacks to call, in order, e.g. `['pressOut', 'press']` on touch end.
+ * @param event from `createPressabilityEvent()`, passed to each callback. React Native passes the
+ * same event to `onPressIn` and `onLongPress`, and another one to `onPressOut` and `onPress`.
+ * @param hitTarget the first element of the touch path from `getTouchPath()`.
+ * @returns the event types whose callback was called.
+ */
+export async function dispatchPressabilityEvent(
+ responder: TestInstance,
+ eventTypes: readonly PressabilityEventType[],
+ event: SyntheticEvent,
+ hitTarget: TestInstance,
+): Promise {
+ if (!isInstanceMounted(responder)) {
+ return [];
+ }
+
+ const calls = eventTypes.flatMap((eventType) => {
+ const callback = getPressabilityCallback(responder, eventType);
+ return callback != null ? [{ eventType, callback }] : [];
+ });
+ if (calls.length === 0) {
+ return [];
+ }
+
+ const state: DispatchState = { firstError: null };
+ const calledEventTypes: PressabilityEventType[] = [];
+ await act(() => {
+ getEventInternals(event).target = hitTarget;
+ for (const { eventType, callback } of calls) {
+ getEventInternals(event).currentTarget = eventType === 'longPress' ? null : responder;
+ calledEventTypes.push(eventType);
+ try {
+ callback.call(responder, event);
+ } catch (error) {
+ state.firstError = { error };
+ break;
+ }
+ }
+
+ getEventInternals(event).currentTarget = null;
+ });
+
+ // After `act()`, which would skip rendering state updates if its callback threw.
+ if (state.firstError != null) {
+ throw state.firstError.error;
+ }
+
+ return calledEventTypes;
+}
+
+/**
+ * Hit testing: the elements a touch on the element goes through, from the hit target to the root.
+ * The hit target is the element, or its nearest ancestor when `pointerEvents` blocks it. Empty
+ * when `pointerEvents` blocks every element.
+ */
+export function getTouchPath(target: TestInstance): TestInstance[] {
+ let hitTarget: TestInstance | null = 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;
+}
+
+/**
+ * Responder negotiation, as React Native's `ResponderEventPlugin` does on touch start:
+ * `onStartShouldSetResponder` from the hit target to the root. The first one returning `true` wins.
+ * A disabled `Pressable` declines, so the touch goes on to its ancestors. Called without
+ * arguments, as in legacy `fireEvent` and `userEvent`.
+ *
+ * The Jest preset mocks `Text` and `TextInput`, so their host elements get `onPress*` props
+ * instead of responder props. A host element with any of these props (or their `testOnly_`
+ * variants) takes part in the negotiation as if it had Pressability. A host `TextInput` always
+ * claims the touch, as its Pressability does on a device, even without these props. It calls its
+ * callbacks even when not editable, as on iOS, where `editable={false}` only stops it from
+ * focusing. Known difference from Android: there, a non-editable `TextInput` is disabled and never
+ * the touch target, so the touch goes to its ancestors. A disabled `Text` declines the touch.
+ *
+ * Not implemented yet: the capture phase (`onStartShouldSetResponderCapture`), as `PanResponder`
+ * reads `touchHistory` there, which only the responder system's events have.
+ *
+ * @param path the touch path from `getTouchPath()`.
+ * @returns the first element on the path that claims the touch, or `null` when none does.
+ */
+export function findResponder(path: TestInstance[]): TestInstance | null {
+ for (const node of path) {
+ const handler = getEventHandlerByName(node.props, 'onStartShouldSetResponder');
+ if (handler != null ? handler.call(node) : claimsTouchWithoutResponder(node)) {
+ return node;
+ }
+ }
+
+ return null;
+}
+
+/**
+ * The responder's Pressability callback for the event type, e.g. `onLongPress` for `longPress`, or
+ * `undefined` when it has none.
+ *
+ * Ignores `disabled` of `Pressable` and `Touchable*`: Pressability checks it only in
+ * `onStartShouldSetResponder`, so one disabled during a touch, e.g. by `onPressIn`, still gets
+ * `onPressOut` and `onPress`. A disabled `Text` gets nothing, as on a device, where it stops
+ * rendering its Pressability handlers.
+ */
+export function getPressabilityCallback(
+ responder: TestInstance,
+ eventType: PressabilityEventType,
+): EventHandler | undefined {
+ const callbackName = getEventHandlerName(eventType) as PressabilityCallbackName;
+ const config = getPressabilityConfig(responder);
+ // Mocked hosts, like `Text` and `TextInput`, have their own `onPress*` props instead.
+ if (config == null) {
+ return isDisabledHostText(responder)
+ ? undefined
+ : getEventHandlerByName(responder.props, callbackName);
+ }
+
+ return config[callbackName] ?? undefined;
+}
+
+/** Config of `Pressable` and `Touchable*`, or `null` for elements without Pressability. */
+function getPressabilityConfig(instance: TestInstance): PressabilityConfig | null {
+ const getConfig: unknown = instance.props.onStartShouldSetResponder?.testOnly_pressabilityConfig;
+ return typeof getConfig === 'function' ? (getConfig() as PressabilityConfig) : null;
+}
+
+/**
+ * For mocked hosts. `TextInput` claims the touch whatever its props, as on a device, where its
+ * Pressability config has no `disabled`.
+ */
+function claimsTouchWithoutResponder(instance: TestInstance): boolean {
+ if (isHostTextInput(instance)) {
+ return true;
+ }
+
+ if (isDisabledHostText(instance)) {
+ return false;
+ }
+
+ return hasPressEventHandler(instance.props);
+}
diff --git a/src/events/modern/responder-event.ts b/src/events/modern/responder-event.ts
new file mode 100644
index 000000000..e2e66931f
--- /dev/null
+++ b/src/events/modern/responder-event.ts
@@ -0,0 +1,90 @@
+import { mergeEventProps } from '../shared/merge';
+import { buildTouchNativeEvent } from '../shared/payloads';
+import type { CreateEventInit, NativeEventPayload } from './event';
+import { SyntheticEvent } from './event';
+import type { PressabilityEventType } from './event-types';
+
+/** The dispatch configs React Native's responder system passes to `ResponderEvent`. */
+const RESPONDER_DISPATCH_CONFIGS = {
+ responderGrant: { registrationName: 'onResponderGrant', dependencies: [] },
+ responderRelease: {
+ registrationName: 'onResponderRelease',
+ dependencies: ['topTouchCancel', 'topTouchEnd'],
+ },
+} as const;
+
+type ResponderEventType = keyof typeof RESPONDER_DISPATCH_CONFIGS;
+
+/**
+ * Event object of responder handlers (`onResponderGrant`, ...), which Pressability passes on to
+ * its callbacks. Mirrors React Native's `ResponderEvent`
+ * (`src/private/renderer/events/ResponderEvent.js`). The responder system creates it in
+ * JavaScript and calls the handler directly, so unlike native events it is neither direct nor
+ * trusted, has no event phase or composed path, and its `timeStamp` is `performance.now()`.
+ */
+export class ResponderEvent extends SyntheticEvent {
+ constructor(
+ type: ResponderEventType,
+ init: { timeStamp?: number },
+ nativeEvent: NativeEventPayload,
+ ) {
+ const dispatchConfig = RESPONDER_DISPATCH_CONFIGS[type];
+ super(
+ type,
+ { bubbles: false, cancelable: true, rnIsDirect: false, timeStamp: init.timeStamp },
+ nativeEvent,
+ { kind: 'direct', handlerName: dispatchConfig.registrationName },
+ dispatchConfig,
+ );
+ }
+}
+
+/**
+ * Creates the event Pressability passes to the callback, ready for `dispatchPressabilityEvent()`.
+ * As in React Native, `onPressIn` and `onLongPress` get the `responderGrant` event (touch start),
+ * and `onPressOut` and `onPress` the `responderRelease` event (touch end).
+ *
+ * `nativeEvent` is deep merged onto a default touch payload. As on a device, `changedTouches` has
+ * the touch, and `touches` has it on touch start and is empty on touch end, unless `nativeEvent`
+ * sets them.
+ */
+export function createPressabilityEvent(
+ eventType: PressabilityEventType,
+ init: CreateEventInit = {},
+): ResponderEvent {
+ const isTouchStart = eventType === 'pressIn' || eventType === 'longPress';
+ const nativeEvent = buildResponderNativeEvent(isTouchStart, init.nativeEvent);
+ return new ResponderEvent(
+ isTouchStart ? 'responderGrant' : 'responderRelease',
+ { timeStamp: init.timeStamp },
+ nativeEvent,
+ );
+}
+
+/**
+ * React Native's touch payload (`touchEventPayload()` in `TouchEventEmitter.cpp`): the changed
+ * touch's fields, plus the touch arrays. On the New Architecture, `timestamp` uses the clock of
+ * `performance.now()`.
+ */
+function buildResponderNativeEvent(
+ isTouchStart: boolean,
+ init: NativeEventPayload | undefined,
+): NativeEventPayload {
+ const nativeEvent: NativeEventPayload = mergeEventProps(
+ buildTouchNativeEvent(),
+ { timestamp: performance.now() },
+ init,
+ );
+
+ const touch = { ...nativeEvent };
+ delete touch.changedTouches;
+ delete touch.touches;
+ if (init == null || !Object.hasOwn(init, 'changedTouches')) {
+ nativeEvent.changedTouches = [{ ...touch }];
+ }
+ if (init == null || !Object.hasOwn(init, 'touches')) {
+ nativeEvent.touches = isTouchStart ? [{ ...touch }] : [];
+ }
+
+ return nativeEvent;
+}
diff --git a/src/events/shared/__tests__/handler.test.tsx b/src/events/shared/__tests__/handler.test.tsx
new file mode 100644
index 000000000..052cc75bb
--- /dev/null
+++ b/src/events/shared/__tests__/handler.test.tsx
@@ -0,0 +1,119 @@
+import * as React from 'react';
+import { Text, View } from 'react-native';
+
+import { render, screen } from '../../..';
+import { getEventHandlerByEventType, getEventHandlerByName, normalizeEventType } from '../handler';
+
+test('getEventHandlerByEventType strict mode', async () => {
+ const onPress = jest.fn();
+ const testOnlyOnPress = jest.fn();
+
+ await render(
+
+
+ {/* @ts-expect-error Intentionally passing such props */}
+
+ {/* @ts-expect-error Intentionally passing such props */}
+
+ ,
+ );
+
+ const regular = screen.getByTestId('regular');
+ const testOnly = screen.getByTestId('testOnly');
+ const both = screen.getByTestId('both');
+
+ expect(getEventHandlerByEventType(regular.props, 'press')).toBe(onPress);
+ expect(getEventHandlerByEventType(testOnly.props, 'press')).toBe(testOnlyOnPress);
+ expect(getEventHandlerByEventType(both.props, 'press')).toBe(onPress);
+
+ expect(getEventHandlerByEventType(regular.props, 'onPress')).toBe(onPress);
+ expect(getEventHandlerByEventType(testOnly.props, 'onPress')).toBe(testOnlyOnPress);
+ expect(getEventHandlerByEventType(both.props, 'onPress')).toBe(onPress);
+});
+
+test('getEventHandlerByEventType does not treat event names starting with "on" as prefixed', async () => {
+ const onOnline = jest.fn();
+ // @ts-expect-error Intentionally passing such props
+ await render();
+
+ expect(getEventHandlerByEventType(screen.getByTestId('view').props, 'online')).toBe(onOnline);
+});
+
+test('getEventHandlerByEventType loose mode', async () => {
+ const onPress = jest.fn();
+ const testOnlyOnPress = jest.fn();
+
+ await render(
+
+
+ {/* @ts-expect-error Intentionally passing such props */}
+
+ {/* @ts-expect-error Intentionally passing such props */}
+
+ ,
+ );
+
+ const regular = screen.getByTestId('regular');
+ const testOnly = screen.getByTestId('testOnly');
+ const both = screen.getByTestId('both');
+
+ expect(getEventHandlerByEventType(regular.props, 'press', { loose: true })).toBe(onPress);
+ expect(getEventHandlerByEventType(testOnly.props, 'press', { loose: true })).toBe(
+ testOnlyOnPress,
+ );
+ expect(getEventHandlerByEventType(both.props, 'press', { loose: true })).toBe(onPress);
+
+ expect(getEventHandlerByEventType(regular.props, 'onPress', { loose: true })).toBe(onPress);
+ expect(getEventHandlerByEventType(testOnly.props, 'onPress', { loose: true })).toBe(
+ testOnlyOnPress,
+ );
+ expect(getEventHandlerByEventType(both.props, 'onPress', { loose: true })).toBe(onPress);
+});
+
+test('getEventHandlerByEventType loose mode matches handlers named without the `on*` prefix', async () => {
+ const press = jest.fn();
+ const testOnlyPress = jest.fn();
+
+ await render(
+
+ {/* @ts-expect-error Intentionally passing such props */}
+
+ {/* @ts-expect-error Intentionally passing such props */}
+
+ ,
+ );
+
+ const regular = screen.getByTestId('regular');
+ const testOnly = screen.getByTestId('testOnly');
+
+ expect(getEventHandlerByEventType(regular.props, 'press', { loose: true })).toBe(press);
+ expect(getEventHandlerByEventType(testOnly.props, 'press', { loose: true })).toBe(testOnlyPress);
+
+ expect(getEventHandlerByEventType(regular.props, 'press')).toBeUndefined();
+ expect(getEventHandlerByEventType(testOnly.props, 'press')).toBeUndefined();
+});
+
+test('normalizeEventType strips the `on*` prefix', () => {
+ expect(normalizeEventType('onLayout')).toBe('layout');
+ expect(normalizeEventType('onChangeText')).toBe('changeText');
+ expect(normalizeEventType('layout')).toBe('layout');
+ expect(normalizeEventType('changeText')).toBe('changeText');
+ expect(normalizeEventType('once')).toBe('once');
+ expect(normalizeEventType('on')).toBe('on');
+});
+
+test('getEventHandlerByName returns function props with the exact name or its testOnly_ variant', () => {
+ const onFocus = jest.fn();
+ const testOnlyOnBlur = jest.fn();
+ const props = {
+ onFocus,
+ testOnly_onFocus: jest.fn(),
+ testOnly_onBlur: testOnlyOnBlur,
+ onLayout: 'not a function',
+ };
+
+ expect(getEventHandlerByName(props, 'onFocus')).toBe(onFocus);
+ expect(getEventHandlerByName(props, 'onBlur')).toBe(testOnlyOnBlur);
+ expect(getEventHandlerByName(props, 'focus')).toBeUndefined();
+ expect(getEventHandlerByName(props, 'onLayout')).toBeUndefined();
+});
diff --git a/src/events/shared/__tests__/invoke-event-handler.test.tsx b/src/events/shared/__tests__/invoke-event-handler.test.tsx
new file mode 100644
index 000000000..7793bb29c
--- /dev/null
+++ b/src/events/shared/__tests__/invoke-event-handler.test.tsx
@@ -0,0 +1,69 @@
+import * as React from 'react';
+import { ScrollView, Text, TextInput, View } from 'react-native';
+
+import { render, screen } from '../../..';
+import { invokeEventHandler } from '../invoke-event-handler';
+
+test('calls the element handler with all params', async () => {
+ const onContentSizeChange = jest.fn();
+ await render();
+
+ const result = await invokeEventHandler(
+ screen.getByTestId('scroll'),
+ 'contentSizeChange',
+ 100,
+ 200,
+ );
+
+ expect(result).toBe(true);
+ expect(onContentSizeChange).toHaveBeenCalledWith(100, 200);
+});
+
+test('calls testOnly_ handlers', async () => {
+ const onChangeText = jest.fn();
+ const testOnlyProps = { testOnly_onChangeText: onChangeText };
+ await render();
+
+ expect(await invokeEventHandler(screen.getByTestId('view'), 'changeText', 'Hello')).toBe(true);
+ expect(onChangeText).toHaveBeenCalledWith('Hello');
+});
+
+test('does not call ancestor handlers', async () => {
+ const onParentChangeText = jest.fn();
+ const parentProps = { onChangeText: onParentChangeText };
+ await render(
+
+
+ ,
+ );
+
+ expect(await invokeEventHandler(screen.getByTestId('input'), 'changeText', 'Hello')).toBe(false);
+ expect(onParentChangeText).not.toHaveBeenCalled();
+});
+
+test('returns false when the element is unmounted', async () => {
+ const onChangeText = jest.fn();
+ await render();
+ const input = screen.getByTestId('input');
+ await screen.rerender();
+
+ expect(await invokeEventHandler(input, 'changeText', 'Hello')).toBe(false);
+ expect(onChangeText).not.toHaveBeenCalled();
+});
+
+test('renders state updates from the handler', async () => {
+ function Subject() {
+ const [text, setText] = React.useState('');
+ return (
+ <>
+
+ Text: {text}
+ >
+ );
+ }
+ await render();
+
+ await invokeEventHandler(screen.getByTestId('input'), 'changeText', 'Hello');
+
+ expect(screen.getByText('Text: Hello')).toBeOnTheScreen();
+});
diff --git a/src/events/shared/__tests__/merge.test.ts b/src/events/shared/__tests__/merge.test.ts
new file mode 100644
index 000000000..848e30b07
--- /dev/null
+++ b/src/events/shared/__tests__/merge.test.ts
@@ -0,0 +1,63 @@
+import { mergeEventProps } from '../merge';
+
+function buildEvent() {
+ return {
+ nativeEvent: { pageX: 0, pageY: 0, locationX: 0, touches: [] as unknown[] },
+ preventDefault: () => {},
+ timeStamp: 0,
+ };
+}
+
+test('returns the same event when no props are passed', () => {
+ const event = buildEvent();
+ const nativeEvent = { ...event.nativeEvent };
+
+ expect(mergeEventProps(event)).toBe(event);
+ expect(event.nativeEvent).toEqual(nativeEvent);
+});
+
+test('deep merges nested objects and keeps other default fields', () => {
+ const event = mergeEventProps(buildEvent(), { nativeEvent: { pageX: 10, pageY: 20 } });
+
+ expect(event.nativeEvent.pageX).toBe(10);
+ expect(event.nativeEvent.pageY).toBe(20);
+ expect(event.nativeEvent.locationX).toBe(0);
+ expect(typeof event.preventDefault).toBe('function');
+});
+
+test('replaces arrays and primitive values instead of merging them', () => {
+ const event = mergeEventProps(buildEvent(), {
+ nativeEvent: { touches: [{ identifier: 1 }] },
+ timeStamp: 42,
+ });
+
+ expect(event.nativeEvent.touches).toEqual([{ identifier: 1 }]);
+ expect(event.timeStamp).toBe(42);
+});
+
+test('adds props that the event does not have', () => {
+ const event = mergeEventProps(buildEvent(), { custom: { value: 1 } });
+
+ expect(event).toMatchObject({ custom: { value: 1 } });
+});
+
+test('merges multiple props in order, skipping undefined ones', () => {
+ const event = mergeEventProps(
+ buildEvent(),
+ { nativeEvent: { pageX: 10, pageY: 20 } },
+ undefined,
+ { nativeEvent: { pageX: 30 } },
+ );
+
+ expect(event.nativeEvent).toMatchObject({ pageX: 30, pageY: 20, locationX: 0 });
+});
+
+test('does not pollute Object.prototype through a __proto__ key', () => {
+ const props = JSON.parse(
+ '{"__proto__": {"polluted": true}, "nativeEvent": {"__proto__": {"polluted": true}}}',
+ );
+
+ mergeEventProps(buildEvent(), props);
+
+ expect(({} as Record).polluted).toBeUndefined();
+});
diff --git a/src/events/shared/__tests__/payloads.test.ts b/src/events/shared/__tests__/payloads.test.ts
new file mode 100644
index 000000000..b18d59bc8
--- /dev/null
+++ b/src/events/shared/__tests__/payloads.test.ts
@@ -0,0 +1,105 @@
+import {
+ buildAccessibilityActionNativeEvent,
+ buildBlurNativeEvent,
+ buildContentSizeChangeNativeEvent,
+ buildEndEditingNativeEvent,
+ buildFocusNativeEvent,
+ buildKeyPressNativeEvent,
+ buildLayoutNativeEvent,
+ buildScrollNativeEvent,
+ buildSubmitEditingNativeEvent,
+ buildTextChangeNativeEvent,
+ buildTextSelectionChangeNativeEvent,
+ buildTouchNativeEvent,
+} from '../payloads';
+
+test('buildTouchNativeEvent returns touch payload', () => {
+ expect(buildTouchNativeEvent()).toEqual({
+ changedTouches: [],
+ identifier: 0,
+ locationX: 0,
+ locationY: 0,
+ pageX: 0,
+ pageY: 0,
+ target: 0,
+ timestamp: expect.any(Number),
+ touches: [],
+ });
+});
+
+test('buildFocusNativeEvent and buildBlurNativeEvent return target', () => {
+ expect(buildFocusNativeEvent()).toEqual({ target: 0 });
+ expect(buildBlurNativeEvent()).toEqual({ target: 0 });
+});
+
+test('buildAccessibilityActionNativeEvent returns action name', () => {
+ expect(buildAccessibilityActionNativeEvent('increment')).toEqual({ actionName: 'increment' });
+});
+
+test('buildLayoutNativeEvent merges layout onto zeroed rectangle', () => {
+ expect(buildLayoutNativeEvent()).toEqual({
+ layout: { x: 0, y: 0, width: 0, height: 0 },
+ target: 0,
+ });
+ expect(buildLayoutNativeEvent({ width: 100, x: undefined })).toEqual({
+ layout: { x: 0, y: 0, width: 100, height: 0 },
+ target: 0,
+ });
+});
+
+test('buildScrollNativeEvent returns default scroll payload', () => {
+ expect(buildScrollNativeEvent()).toEqual({
+ contentInset: { bottom: 0, left: 0, right: 0, top: 0 },
+ contentOffset: { y: 0, x: 0 },
+ contentSize: { height: 0, width: 0 },
+ layoutMeasurement: { height: 0, width: 0 },
+ responderIgnoreScroll: true,
+ target: 0,
+ velocity: { y: 0, x: 0 },
+ });
+});
+
+test('buildScrollNativeEvent uses provided offset and options', () => {
+ const nativeEvent = buildScrollNativeEvent(
+ { y: 100, x: 50 },
+ {
+ contentSize: { height: 1000, width: 400 },
+ layoutMeasurement: { height: 800, width: 400 },
+ },
+ );
+
+ expect(nativeEvent.contentOffset).toEqual({ y: 100, x: 50 });
+ expect(nativeEvent.contentSize).toEqual({ height: 1000, width: 400 });
+ expect(nativeEvent.layoutMeasurement).toEqual({ height: 800, width: 400 });
+});
+
+test('buildTextChangeNativeEvent returns text and selection', () => {
+ expect(buildTextChangeNativeEvent('Hello', { start: 5, end: 5 })).toEqual({
+ text: 'Hello',
+ target: 0,
+ eventCount: 0,
+ selection: { start: 5, end: 5 },
+ });
+});
+
+test('buildKeyPressNativeEvent returns key', () => {
+ expect(buildKeyPressNativeEvent('a')).toEqual({ key: 'a' });
+});
+
+test('buildSubmitEditingNativeEvent and buildEndEditingNativeEvent return text', () => {
+ expect(buildSubmitEditingNativeEvent('Hello')).toEqual({ text: 'Hello', target: 0 });
+ expect(buildEndEditingNativeEvent('Hello')).toEqual({ text: 'Hello', target: 0 });
+});
+
+test('buildTextSelectionChangeNativeEvent returns selection', () => {
+ expect(buildTextSelectionChangeNativeEvent({ start: 0, end: 4 })).toEqual({
+ selection: { start: 0, end: 4 },
+ });
+});
+
+test('buildContentSizeChangeNativeEvent returns content size', () => {
+ expect(buildContentSizeChangeNativeEvent({ width: 100, height: 50 })).toEqual({
+ contentSize: { width: 100, height: 50 },
+ target: 0,
+ });
+});
diff --git a/src/events/shared/handler.ts b/src/events/shared/handler.ts
new file mode 100644
index 000000000..67021f936
--- /dev/null
+++ b/src/events/shared/handler.ts
@@ -0,0 +1,80 @@
+import type { EventHandler } from './types';
+
+type EventHandlerOptions = {
+ /** Include check for event handler named without adding `on*` prefix. */
+ loose?: boolean;
+};
+
+export function getEventHandlerByEventType(
+ props: Record,
+ eventType: string,
+ options?: EventHandlerOptions,
+): EventHandler | undefined {
+ const eventHandlerName = getEventHandlerName(eventType);
+ return (
+ getEventHandlerByName(props, eventHandlerName) ??
+ (options?.loose ? getEventHandlerByName(props, eventType) : undefined)
+ );
+}
+
+/**
+ * Whether the props include a Pressability callback (`onPress`, `onPressIn`, `onPressOut` or
+ * `onLongPress`), or its `testOnly_` variant.
+ */
+export function hasPressEventHandler(props: Record): boolean {
+ return ['press', 'pressIn', 'pressOut', 'longPress'].some(
+ (eventType) => getEventHandlerByEventType(props, eventType) != null,
+ );
+}
+
+/**
+ * Returns the handler prop with exactly the given name (e.g. `onFocusCapture`), or else its
+ * `testOnly_` variant (`testOnly_onFocusCapture`), which lets tests handle events on host elements.
+ */
+export function getEventHandlerByName(
+ props: Record,
+ eventHandlerName: string,
+): EventHandler | undefined {
+ return (
+ getFunctionProp(props, eventHandlerName) ??
+ getFunctionProp(props, `testOnly_${eventHandlerName}`)
+ );
+}
+
+/**
+ * Returns the event type without the `on*` prefix, e.g. `onLayout` -> `layout`.
+ * Note: `fireEvent` accepts event types with and without the prefix, so use this
+ * before comparing event types.
+ */
+export function normalizeEventType(eventType: string) {
+ if (hasOnPrefix(eventType)) {
+ return eventType.charAt(2).toLowerCase() + eventType.slice(3);
+ }
+
+ return eventType;
+}
+
+export function getEventHandlerName(eventType: string) {
+ if (hasOnPrefix(eventType)) {
+ return eventType;
+ }
+
+ return `on${capitalizeFirstLetter(eventType)}`;
+}
+
+function getFunctionProp(
+ props: Record,
+ propName: string,
+): EventHandler | undefined {
+ const handler = props[propName];
+ return typeof handler === 'function' ? (handler as EventHandler) : undefined;
+}
+
+/** Whether the event type is a handler prop name, e.g. `onLayout`, not `layout` or `once`. */
+export function hasOnPrefix(eventType: string) {
+ return /^on[A-Z]/.test(eventType);
+}
+
+function capitalizeFirstLetter(str: string) {
+ return str.charAt(0).toUpperCase() + str.slice(1);
+}
diff --git a/src/events/shared/invoke-event-handler.ts b/src/events/shared/invoke-event-handler.ts
new file mode 100644
index 000000000..d8bdd1bee
--- /dev/null
+++ b/src/events/shared/invoke-event-handler.ts
@@ -0,0 +1,35 @@
+import type { TestInstance } from 'test-renderer';
+
+import { act } from '../../act';
+import { isInstanceMounted } from '../../helpers/component-tree';
+import { getEventHandlerByEventType } from './handler';
+
+/**
+ * Calls the element's own handler prop for the event type (`onChangeText`, or
+ * `testOnly_onChangeText`) with the given params, in `act()`. For callbacks that components call
+ * from JavaScript instead of dispatching native events, e.g. `ScrollView`'s
+ * `onContentSizeChange(width, height)`.
+ *
+ * @param eventType without the `on*` prefix, e.g. `changeText`
+ * @returns `true` if a handler was called.
+ */
+export async function invokeEventHandler(
+ instance: TestInstance,
+ eventType: string,
+ ...params: unknown[]
+): Promise {
+ if (!isInstanceMounted(instance)) {
+ return false;
+ }
+
+ const handler = getEventHandlerByEventType(instance.props, eventType);
+ if (!handler) {
+ return false;
+ }
+
+ await act(() => {
+ handler(...params);
+ });
+
+ return true;
+}
diff --git a/src/events/shared/merge.ts b/src/events/shared/merge.ts
new file mode 100644
index 000000000..de9cfce52
--- /dev/null
+++ b/src/events/shared/merge.ts
@@ -0,0 +1,33 @@
+import { isObject } from '../../helpers/object';
+import type { EventProps } from './types';
+
+/**
+ * Deep merges custom props into a built event, so tests can override only the fields they need.
+ * Props are merged in order, so later ones win. Nested objects are merged, other values (including
+ * arrays) are replaced. Mutates and returns the passed event.
+ */
+export function mergeEventProps(
+ event: T,
+ ...eventProps: Array
+): T {
+ for (const props of eventProps) {
+ if (props) {
+ mergeInto(event as EventProps, props);
+ }
+ }
+
+ return event;
+}
+
+function mergeInto(target: EventProps, source: EventProps) {
+ for (const key of Object.keys(source)) {
+ const sourceValue = source[key];
+ const targetValue = target[key];
+ // Only recurse into the target's own objects, so a `__proto__` key can't reach `Object.prototype`.
+ if (Object.hasOwn(target, key) && isObject(sourceValue) && isObject(targetValue)) {
+ mergeInto(targetValue, sourceValue);
+ } else {
+ target[key] = sourceValue;
+ }
+ }
+}
diff --git a/src/events/native-state.ts b/src/events/shared/native-state.ts
similarity index 87%
rename from src/events/native-state.ts
rename to src/events/shared/native-state.ts
index c4b405c7c..501840bb3 100644
--- a/src/events/native-state.ts
+++ b/src/events/shared/native-state.ts
@@ -1,13 +1,13 @@
import type { TestInstance } from 'test-renderer';
-import type { Point, Size } from '../types';
+import type { Point, Size } from '../../types';
/**
* Simulated native state for unmanaged controls.
*
* Values from `value` props (managed controls) should take precedence over these values.
*/
-export type NativeState = {
+type NativeState = {
valueForInstance: WeakMap;
contentOffsetForInstance: WeakMap;
layoutSizeForInstance: WeakMap;
diff --git a/src/events/shared/payloads.ts b/src/events/shared/payloads.ts
new file mode 100644
index 000000000..efa2f15f1
--- /dev/null
+++ b/src/events/shared/payloads.ts
@@ -0,0 +1,154 @@
+import type { Point, Size, TextRange } from '../../types';
+import type { LayoutRectangle } from './types';
+
+/**
+ * `nativeEvent` payloads, matching what React Native sends on a device. Both event systems use them:
+ * legacy wraps them in a stub event (`legacy/builders/`), modern in a `SyntheticEvent`.
+ */
+
+/**
+ * Experimental values:
+ * - iOS: `{"changedTouches": [[Circular]], "identifier": 1, "locationX": 253, "locationY": 30.333328247070312, "pageX": 273, "pageY": 141.3333282470703, "target": 75, "timestamp": 875928682.0450834, "touches": [[Circular]]}`
+ * - Android: `{"changedTouches": [[Circular]], "identifier": 0, "locationX": 160, "locationY": 40.3636360168457, "pageX": 180, "pageY": 140.36363220214844, "target": 53, "targetSurface": -1, "timestamp": 10290805, "touches": [[Circular]]}`
+ */
+export function buildTouchNativeEvent() {
+ return {
+ changedTouches: [] as unknown[],
+ identifier: 0,
+ locationX: 0,
+ locationY: 0,
+ pageX: 0,
+ pageY: 0,
+ target: 0,
+ timestamp: Date.now(),
+ touches: [] as unknown[],
+ };
+}
+
+/**
+ * Experimental values:
+ * - iOS: `{"eventCount": 0, "target": 75, "text": ""}`
+ * - Android: `{"target": 53}`
+ */
+export function buildFocusNativeEvent() {
+ return { target: 0 };
+}
+
+/**
+ * Experimental values:
+ * - iOS: `{"eventCount": 0, "target": 75, "text": ""}`
+ * - Android: `{"target": 53}`
+ */
+export function buildBlurNativeEvent() {
+ return { target: 0 };
+}
+
+/**
+ * Experimental values:
+ * - `{"actionName": "increment"}`
+ */
+export function buildAccessibilityActionNativeEvent(actionName: string) {
+ return { actionName };
+}
+
+/**
+ * Delivered to `onLayout` when the layout engine measures an element. The passed `layout` values
+ * are merged onto a zeroed rectangle, so only the fields relevant to the test need to be provided.
+ */
+export function buildLayoutNativeEvent(layout?: Partial) {
+ return {
+ // `??` rather than spread, so that explicit `undefined` values fall back to `0`.
+ layout: {
+ x: layout?.x ?? 0,
+ y: layout?.y ?? 0,
+ width: layout?.width ?? 0,
+ height: layout?.height ?? 0,
+ },
+ target: 0,
+ };
+}
+
+export type ScrollNativeEventOptions = {
+ contentSize?: Size;
+ layoutMeasurement?: Size;
+};
+
+/**
+ * Experimental values:
+ * - iOS: `{"contentInset": {"bottom": 0, "left": 0, "right": 0, "top": 0}, "contentOffset": {"x": 0, "y": 5.333333333333333}, "contentSize": {"height": 1676.6666259765625, "width": 390}, "layoutMeasurement": {"height": 753, "width": 390}, "zoomScale": 1}`
+ * - Android: `{"contentInset": {"bottom": 0, "left": 0, "right": 0, "top": 0}, "contentOffset": {"x": 0, "y": 31.619047164916992}, "contentSize": {"height": 1624.761962890625, "width": 411.4285583496094}, "layoutMeasurement": {"height": 785.5238037109375, "width": 411.4285583496094}, "responderIgnoreScroll": true, "target": 139, "velocity": {"x": -1.3633992671966553, "y": -1.3633992671966553}}`
+ */
+export function buildScrollNativeEvent(
+ offset: Point = { y: 0, x: 0 },
+ options?: ScrollNativeEventOptions,
+) {
+ return {
+ contentInset: { bottom: 0, left: 0, right: 0, top: 0 },
+ contentOffset: { y: offset.y, x: offset.x },
+ contentSize: {
+ height: options?.contentSize?.height ?? 0,
+ width: options?.contentSize?.width ?? 0,
+ },
+ layoutMeasurement: {
+ height: options?.layoutMeasurement?.height ?? 0,
+ width: options?.layoutMeasurement?.width ?? 0,
+ },
+ responderIgnoreScroll: true,
+ target: 0,
+ velocity: { y: 0, x: 0 },
+ };
+}
+
+/**
+ * Experimental values:
+ * - iOS: `{"eventCount": 4, "target": 75, "text": "Test"}`
+ * - Android: `{"eventCount": 6, "target": 53, "text": "Tes"}`
+ */
+export function buildTextChangeNativeEvent(text: string, { start, end }: TextRange) {
+ return { text, target: 0, eventCount: 0, selection: { start, end } };
+}
+
+/**
+ * Experimental values:
+ * - iOS: `{"eventCount": 3, "key": "a", "target": 75}`
+ * - Android: `{"key": "a"}`
+ */
+export function buildKeyPressNativeEvent(key: string) {
+ return { key };
+}
+
+/**
+ * Experimental values:
+ * - iOS: `{"eventCount": 4, "target": 75, "text": "Test"}`
+ * - Android: `{"target": 53, "text": "Test"}`
+ */
+export function buildSubmitEditingNativeEvent(text: string) {
+ return { text, target: 0 };
+}
+
+/**
+ * Experimental values:
+ * - iOS: `{"eventCount": 4, "target": 75, "text": "Test"}`
+ * - Android: `{"target": 53, "text": "Test"}`
+ */
+export function buildEndEditingNativeEvent(text: string) {
+ return { text, target: 0 };
+}
+
+/**
+ * Experimental values:
+ * - iOS: `{"selection": {"end": 4, "start": 4}, "target": 75}`
+ * - Android: `{"selection": {"end": 4, "start": 4}}`
+ */
+export function buildTextSelectionChangeNativeEvent({ start, end }: TextRange) {
+ return { selection: { start, end } };
+}
+
+/**
+ * Experimental values:
+ * - iOS: `{"contentSize": {"height": 21.666666666666668, "width": 11.666666666666666}, "target": 75}`
+ * - Android: `{"contentSize": {"height": 61.45454406738281, "width": 352.7272644042969}, "target": 53}`
+ */
+export function buildContentSizeChangeNativeEvent({ width, height }: Size) {
+ return { contentSize: { width, height }, target: 0 };
+}
diff --git a/src/events/shared/pointer-events.ts b/src/events/shared/pointer-events.ts
new file mode 100644
index 000000000..3c0ad3e01
--- /dev/null
+++ b/src/events/shared/pointer-events.ts
@@ -0,0 +1,39 @@
+import { StyleSheet } from 'react-native';
+import type { TestInstance } from 'test-renderer';
+
+/**
+ * pointerEvents controls whether the View can be the target of touch events.
+ * 'auto': The View and its children can be the target of touch events.
+ * 'none': The View is never the target of touch events.
+ * 'box-none': The View is never the target of touch events but its subviews can be
+ * 'box-only': The view can be the target of touch events but its subviews cannot be
+ * see the official react native doc https://reactnative.dev/docs/view#pointerevents */
+export function isPointerEventEnabled(instance: TestInstance): boolean {
+ return getPointerEventsBlocker(instance) == null;
+}
+
+/**
+ * Returns the element whose `pointerEvents` prevents the instance from being the target of
+ * touch events: the instance itself or one of its ancestors. Returns `null` if nothing blocks it.
+ */
+export function getPointerEventsBlocker(instance: TestInstance): TestInstance | null {
+ return findPointerEventsBlocker(instance, false);
+}
+
+function findPointerEventsBlocker(instance: TestInstance, isParent: boolean): TestInstance | null {
+ // Check both props.pointerEvents and props.style.pointerEvents
+ const pointerEvents =
+ instance?.props.pointerEvents ?? StyleSheet.flatten(instance?.props.style)?.pointerEvents;
+
+ const parentCondition = isParent ? pointerEvents === 'box-only' : pointerEvents === 'box-none';
+
+ if (pointerEvents === 'none' || parentCondition) {
+ return instance;
+ }
+
+ if (!instance.parent) {
+ return null;
+ }
+
+ return findPointerEventsBlocker(instance.parent, true);
+}
diff --git a/src/events/types.ts b/src/events/shared/types.ts
similarity index 93%
rename from src/events/types.ts
rename to src/events/shared/types.ts
index d01d50f8d..aee809036 100644
--- a/src/events/types.ts
+++ b/src/events/shared/types.ts
@@ -6,7 +6,7 @@ import type {
ViewProps,
} from 'react-native';
-import type { StringWithAutocomplete } from '../types';
+import type { StringWithAutocomplete } from '../../types';
export type EventHandler = (...args: unknown[]) => unknown;
diff --git a/src/events/update-native-state.ts b/src/events/shared/update-native-state.ts
similarity index 61%
rename from src/events/update-native-state.ts
rename to src/events/shared/update-native-state.ts
index 739905380..998c3fb6b 100644
--- a/src/events/update-native-state.ts
+++ b/src/events/shared/update-native-state.ts
@@ -1,9 +1,10 @@
import type { TestInstance } from 'test-renderer';
-import { isHostScrollView } from '../helpers/host-component-names';
-import { isEditableTextInput } from '../helpers/text-input';
-import type { Point, Size } from '../types';
+import { isHostScrollView } from '../../helpers/host-component-names';
+import { isEditableTextInput } from '../../helpers/text-input';
+import type { Point, Size } from '../../types';
import { nativeState } from './native-state';
+import type { EventProps } from './types';
const SCROLL_EVENTS = [
'scroll',
@@ -29,6 +30,14 @@ export function updateNativeStateFromEvent(
return true;
}
+ if (eventType === 'change' && isEditableTextInput(instance)) {
+ const text = tryGetText(value);
+ if (text != null) {
+ nativeState.valueForInstance.set(instance, text);
+ return true;
+ }
+ }
+
if (SCROLL_EVENTS.includes(eventType) && isHostScrollView(instance)) {
const contentOffset = tryGetContentOffset(value);
if (contentOffset) {
@@ -48,6 +57,37 @@ export function updateNativeStateFromEvent(
return false;
}
+/**
+ * Returns `nativeEvent` fields a device fills from native state, to merge onto a default payload.
+ * Expects event type without the `on*` prefix (see `normalizeEventType`).
+ */
+export function getNativeStateEventProps(
+ instance: TestInstance,
+ eventType: string,
+): EventProps | undefined {
+ if (SCROLL_EVENTS.includes(eventType) && isHostScrollView(instance)) {
+ const layoutSize = nativeState.layoutSizeForInstance.get(instance);
+ // Copied, so that merging passed props onto the payload doesn't change native state.
+ return layoutSize ? { layoutMeasurement: { ...layoutSize } } : undefined;
+ }
+
+ return undefined;
+}
+
+function tryGetText(event: unknown): string | null {
+ try {
+ // @ts-expect-error: try to extract text from the event value
+ const text = event?.nativeEvent?.text;
+ if (typeof text === 'string') {
+ return text;
+ }
+ } catch {
+ // Do nothing
+ }
+
+ return null;
+}
+
function tryGetContentOffset(event: unknown): Point | null {
try {
// @ts-expect-error: try to extract contentOffset from the event value
diff --git a/src/events/shared/warnings.ts b/src/events/shared/warnings.ts
new file mode 100644
index 000000000..35ff74266
--- /dev/null
+++ b/src/events/shared/warnings.ts
@@ -0,0 +1,65 @@
+import redent from 'redent';
+import type { TestInstance } from 'test-renderer';
+
+import { formatElement, formatJson } from '../../helpers/format-element';
+import { isHostTextInput } from '../../helpers/host-component-names';
+import { logger } from '../../helpers/logger';
+import { isEditableTextInput } from '../../helpers/text-input';
+import { getPointerEventsBlocker } from './pointer-events';
+
+export type EventWarning = {
+ message: string;
+ elements: TestInstance[];
+ /** Elements whose `pointerEvents` blocked the event. */
+ pointerEventsBlockers?: TestInstance[];
+};
+
+export function logEventWarning({ message, elements, pointerEventsBlockers = [] }: EventWarning) {
+ const header =
+ `${message}\n` +
+ 'If this is intentional, you can disable this warning via `configure({ eventDiagnostics: false })`.';
+ const elementBlocks = elements
+ .map((element) => element.toJSON())
+ .filter((json) => json != null)
+ .map((json) => redent(formatJson(json), 2));
+ // Blockers are often large containers, so they are printed without their children.
+ const blockerBlocks = [...new Set(pointerEventsBlockers)].map((blocker) =>
+ redent(formatElement(blocker), 2),
+ );
+ const blockerSection = blockerBlocks.length > 0 ? ['Blocked by:', ...blockerBlocks] : [];
+ logger.warn([header, ...elementBlocks, ...blockerSection].join('\n\n'));
+}
+
+/**
+ * Returns the targets that `pointerEvents` blocks along with their blockers, or `null` if it
+ * blocks none of them.
+ *
+ * @param targets Skipped elements, nearest first.
+ */
+export function getPointerEventsBlockedTargets(
+ targets: TestInstance[],
+): Pick | null {
+ const elements: TestInstance[] = [];
+ const pointerEventsBlockers: TestInstance[] = [];
+ for (const target of targets) {
+ const blocker = getPointerEventsBlocker(target);
+ if (blocker != null) {
+ elements.push(target);
+ pointerEventsBlockers.push(blocker);
+ }
+ }
+
+ return elements.length > 0 ? { elements, pointerEventsBlockers } : null;
+}
+
+/**
+ * Describes the elements that `computeAriaDisabled` treats as disabled, e.g. "a disabled element".
+ * Non-editable `TextInput`s are called non-editable, which is what users set (`editable={false}`).
+ */
+export function formatDisabledTargets(targets: TestInstance[]): string {
+ if (targets.every((target) => isHostTextInput(target) && !isEditableTextInput(target))) {
+ return targets.length === 1 ? 'a non-editable TextInput' : 'non-editable TextInputs';
+ }
+
+ return targets.length === 1 ? 'a disabled element' : 'disabled elements';
+}
diff --git a/src/events/warnings.ts b/src/events/warnings.ts
deleted file mode 100644
index 14e758618..000000000
--- a/src/events/warnings.ts
+++ /dev/null
@@ -1,147 +0,0 @@
-import redent from 'redent';
-import type { TestInstance } from 'test-renderer';
-
-import { getConfig } from '../config';
-import { computeAriaDisabled } from '../helpers/accessibility';
-import { formatElement, formatJson } from '../helpers/format-element';
-import { isHostTextInput } from '../helpers/host-component-names';
-import { logger } from '../helpers/logger';
-import { isEditableTextInput } from '../helpers/text-input';
-import { getEventHandlerName, normalizeEventType } from './handler';
-import { getPointerEventsBlocker, isEventBlockableByPointerEvents } from './is-enabled';
-
-type UnhandledEventInfo = {
- skippedTargets: TestInstance[];
- hasUpdatedNativeState: boolean;
-};
-
-export type EventWarning = {
- message: string;
- elements: TestInstance[];
- /** Elements whose `pointerEvents` blocked the event. */
- pointerEventsBlockers?: TestInstance[];
-};
-
-/**
- * Warns when no handler ran because the target is disabled or nothing handles the event.
- */
-export function warnAboutUnhandledEvent(
- instance: TestInstance,
- eventType: string,
- info: UnhandledEventInfo,
-) {
- if (!getConfig().eventDiagnostics) {
- return;
- }
-
- const warning = getUnhandledEventWarning(instance, eventType, info);
- if (warning != null) {
- logEventWarning(warning);
- }
-}
-
-/**
- * Warns when `fireEvent` bubbles a direct event from the fired element up to `owner`. React Native
- * delivers direct events only to the emitting element, so the event should be fired on `owner`.
- */
-export function warnAboutBubblingDirectEvent(eventType: string, owner: TestInstance) {
- logger.warn(
- `fireEvent: "${eventType}" does not bubble in React Native. fireEvent will stop bubbling it in the next major version. ` +
- `Fire it on:\n\n${redent(formatElement(owner), 2)}`,
- );
-}
-
-export function logEventWarning({ message, elements, pointerEventsBlockers = [] }: EventWarning) {
- const header =
- `${message}\n` +
- 'If this is intentional, you can disable this warning via `configure({ eventDiagnostics: false })`.';
- const elementBlocks = elements
- .map((element) => element.toJSON())
- .filter((json) => json != null)
- .map((json) => redent(formatJson(json), 2));
- // Blockers are often large containers, so they are printed without their children.
- const blockerBlocks = [...new Set(pointerEventsBlockers)].map((blocker) =>
- redent(formatElement(blocker), 2),
- );
- const blockerSection = blockerBlocks.length > 0 ? ['Blocked by:', ...blockerBlocks] : [];
- logger.warn([header, ...elementBlocks, ...blockerSection].join('\n\n'));
-}
-
-/**
- * Returns the targets that `pointerEvents` blocks along with their blockers, or `null` if it
- * blocks none of them.
- *
- * @param targets Skipped elements, nearest first.
- */
-export function getPointerEventsBlockedTargets(
- targets: TestInstance[],
-): Pick | null {
- const elements: TestInstance[] = [];
- const pointerEventsBlockers: TestInstance[] = [];
- for (const target of targets) {
- const blocker = getPointerEventsBlocker(target);
- if (blocker != null) {
- elements.push(target);
- pointerEventsBlockers.push(blocker);
- }
- }
-
- return elements.length > 0 ? { elements, pointerEventsBlockers } : null;
-}
-
-/**
- * Describes the elements that `computeAriaDisabled` treats as disabled, e.g. "a disabled element".
- * Non-editable `TextInput`s are called non-editable, which is what users set (`editable={false}`).
- */
-export function formatDisabledTargets(targets: TestInstance[]): string {
- if (targets.every((target) => isHostTextInput(target) && !isEditableTextInput(target))) {
- return targets.length === 1 ? 'a non-editable TextInput' : 'non-editable TextInputs';
- }
-
- return targets.length === 1 ? 'a disabled element' : 'disabled elements';
-}
-
-function getUnhandledEventWarning(
- instance: TestInstance,
- eventType: string,
- { skippedTargets, hasUpdatedNativeState }: UnhandledEventInfo,
-): EventWarning | null {
- if (skippedTargets.length === 0) {
- // The event still had an effect, e.g. `changeText` on an uncontrolled TextInput updates its value.
- if (hasUpdatedNativeState) {
- return null;
- }
-
- const handlerName = getEventHandlerName(eventType);
- return {
- message: `No "${handlerName}" handler found on the element or its ancestors.`,
- elements: [instance],
- };
- }
-
- // `pointerEvents` is checked first: it blocks the event even if the element is enabled.
- const blocked = isEventBlockableByPointerEvents(normalizeEventType(eventType))
- ? getPointerEventsBlockedTargets(skippedTargets)
- : null;
- if (blocked != null) {
- return {
- message:
- blocked.elements.length === 1
- ? `Cannot fire the "${eventType}" event on an element blocked by pointerEvents.`
- : `Cannot fire the "${eventType}" event on elements blocked by pointerEvents.`,
- ...blocked,
- };
- }
-
- // `computeAriaDisabled` also covers non-editable `TextInput`. A responder declining the touch
- // is a deliberate way of blocking events, so it doesn't warn.
- const disabledTargets = skippedTargets.filter(computeAriaDisabled);
- if (disabledTargets.length === 0) {
- return null;
- }
-
- return {
- message: `Cannot fire the "${eventType}" event on ${formatDisabledTargets(disabledTargets)}.`,
- elements: disabledTargets,
- };
-}
diff --git a/src/helpers/__tests__/fiber.test.tsx b/src/helpers/__tests__/fiber.test.tsx
new file mode 100644
index 000000000..08e487114
--- /dev/null
+++ b/src/helpers/__tests__/fiber.test.tsx
@@ -0,0 +1,62 @@
+import React from 'react';
+import { View } from 'react-native';
+
+import { render, screen } from '../..';
+import { getComponentName, getCompositeOwnerFibers } from '../fiber';
+
+function Inner() {
+ return ;
+}
+
+const Memoized = React.memo(Inner);
+
+describe('getCompositeOwnerFibers()', () => {
+ it('returns composites up to the next host element, nearest first', async () => {
+ function Outer() {
+ return ;
+ }
+
+ await render(
+
+
+ ,
+ );
+
+ const names = getCompositeOwnerFibers(screen.getByTestId('view')).map((fiber) =>
+ getComponentName(fiber.type),
+ );
+ expect(names).toEqual(['View', 'Inner', 'Outer']);
+ });
+});
+
+describe('getComponentName()', () => {
+ it('returns the name of a function component', () => {
+ expect(getComponentName(Inner)).toBe('Inner');
+ });
+
+ it('prefers displayName', () => {
+ function Named() {
+ return null;
+ }
+ Named.displayName = 'CustomName';
+ expect(getComponentName(Named)).toBe('CustomName');
+ });
+
+ it('unwraps memo() and forwardRef()', () => {
+ expect(getComponentName(React.memo(Inner))).toBe('Inner');
+ expect(getComponentName(React.forwardRef(Inner))).toBe('Inner');
+ });
+
+ it('prefers displayName of the wrapper', () => {
+ const Wrapped = React.memo(Inner);
+ Wrapped.displayName = 'Wrapped';
+ expect(getComponentName(Wrapped)).toBe('Wrapped');
+ });
+
+ it('returns null for unnamed or unknown types', () => {
+ expect(getComponentName(() => null)).toBeNull();
+ expect(getComponentName('View')).toBeNull();
+ expect(getComponentName(null)).toBeNull();
+ expect(getComponentName({})).toBeNull();
+ });
+});
diff --git a/src/helpers/fiber.ts b/src/helpers/fiber.ts
new file mode 100644
index 000000000..845237f40
--- /dev/null
+++ b/src/helpers/fiber.ts
@@ -0,0 +1,30 @@
+import type { Fiber, TestInstance } from 'test-renderer';
+
+/** Fibers of the composite components that render the element, nearest first. */
+export function getCompositeOwnerFibers(instance: TestInstance): Fiber[] {
+ const fibers: Fiber[] = [];
+ let fiber = instance.unstable_fiber?.return ?? null;
+ // Stops at the next host element, or at the root container, which has no parent.
+ while (fiber?.return != null && typeof fiber.type !== 'string') {
+ fibers.push(fiber);
+ fiber = fiber.return;
+ }
+
+ return fibers;
+}
+
+/** Display name of a component type, e.g. `Pressable`, unwrapping `memo()` and `forwardRef()`. */
+export function getComponentName(type: unknown): string | null {
+ if (typeof type === 'function') {
+ const component = type as { displayName?: string; name?: string };
+ return component.displayName || component.name || null;
+ }
+
+ if (type != null && typeof type === 'object') {
+ // `memo()` and `forwardRef()` wrap the component in `type` or `render`.
+ const wrapper = type as { displayName?: string; type?: unknown; render?: unknown };
+ return wrapper.displayName || getComponentName(wrapper.type ?? wrapper.render);
+ }
+
+ return null;
+}
diff --git a/src/helpers/object.ts b/src/helpers/object.ts
index cb517873c..b82a24343 100644
--- a/src/helpers/object.ts
+++ b/src/helpers/object.ts
@@ -9,7 +9,7 @@ export function pick(object: T, keys: (keyof T)[]): Partial
return result;
}
-function isObject(value: unknown): value is Record {
+export function isObject(value: unknown): value is Record {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
diff --git a/src/helpers/text-input.ts b/src/helpers/text-input.ts
index d053e0570..d97254ab4 100644
--- a/src/helpers/text-input.ts
+++ b/src/helpers/text-input.ts
@@ -1,6 +1,6 @@
import type { TestInstance } from 'test-renderer';
-import { nativeState } from '../events/native-state';
+import { nativeState } from '../events/shared/native-state';
import { isHostTextInput } from './host-component-names';
export function isEditableTextInput(instance: TestInstance) {
diff --git a/src/helpers/text.ts b/src/helpers/text.ts
new file mode 100644
index 000000000..d1566b837
--- /dev/null
+++ b/src/helpers/text.ts
@@ -0,0 +1,17 @@
+import type { TestInstance } from 'test-renderer';
+
+import { isHostText } from './host-component-names';
+
+/**
+ * Whether the element is a host `Text` that React Native's `Text` disables. As in React Native,
+ * `disabled={false}` wins over `aria-disabled`. The Jest preset mocks `Text`, so its host element
+ * gets these props unchanged.
+ */
+export function isDisabledHostText(instance: TestInstance): boolean {
+ if (!isHostText(instance)) {
+ return false;
+ }
+
+ const { props } = instance;
+ return (props.disabled ?? props['aria-disabled'] ?? props.accessibilityState?.disabled) === true;
+}
diff --git a/src/modern-types.ts b/src/modern-types.ts
new file mode 100644
index 000000000..586f056bb
--- /dev/null
+++ b/src/modern-types.ts
@@ -0,0 +1,13 @@
+/**
+ * Switches `fireEvent` types to the modern event system. Users import it as
+ * `@testing-library/react-native/modern-types`, next to `configure({ unstable_eventSystem: 'modern' })`.
+ *
+ * Not part of the main tsconfig program, as the augmentation would apply to all tests.
+ */
+export {};
+
+declare module './events/fire-event' {
+ interface Register {
+ eventSystem: 'modern';
+ }
+}
diff --git a/src/pure.ts b/src/pure.ts
index 377c1f678..b339d2828 100644
--- a/src/pure.ts
+++ b/src/pure.ts
@@ -1,6 +1,7 @@
export { act } from './act';
export { cleanup } from './cleanup';
export { fireEvent } from './events/fire-event';
+export { UNSAFE_invokeHandler } from './events/invoke-handler';
export { render } from './render';
export { waitFor } from './wait-for';
export { waitForElementToBeRemoved } from './wait-for-element-to-be-removed';
@@ -15,5 +16,5 @@ export { userEvent } from './user-event';
export type { RenderOptions, RenderResult, DebugFunction } from './render';
export type { RenderHookOptions, RenderHookResult } from './render-hook';
-export type { Config } from './config';
+export type { Config, EventSystem } from './config';
export type { UserEventConfig } from './user-event';
diff --git a/src/test-utils/event-serializer.ts b/src/test-utils/event-serializer.ts
new file mode 100644
index 000000000..69cc0420e
--- /dev/null
+++ b/src/test-utils/event-serializer.ts
@@ -0,0 +1,29 @@
+import type { Config, Printer, Refs } from 'pretty-format';
+
+import { isObject } from '../helpers/object';
+import { EventEntry } from './events';
+
+/**
+ * Prints `createEventLogger()` entries with only the `nativeEvent` of event payloads, so
+ * `userEvent` snapshots are the same with legacy event objects and modern `SyntheticEvent`s.
+ * Other payloads, e.g. the text for `changeText`, are printed as they are.
+ */
+export function test(value: unknown): boolean {
+ return value instanceof EventEntry;
+}
+
+export function serialize(
+ entry: EventEntry,
+ config: Config,
+ indentation: string,
+ depth: number,
+ refs: Refs,
+ printer: Printer,
+): string {
+ const { name, payload } = entry;
+ const printedPayload =
+ isObject(payload) && isObject(payload.nativeEvent)
+ ? { nativeEvent: payload.nativeEvent }
+ : payload;
+ return printer({ name, payload: printedPayload }, config, indentation, depth, refs);
+}
diff --git a/src/test-utils/event-system.ts b/src/test-utils/event-system.ts
new file mode 100644
index 000000000..64fc86046
--- /dev/null
+++ b/src/test-utils/event-system.ts
@@ -0,0 +1,15 @@
+import { configure, getConfig } from '../config';
+
+export function isModernEventSystem() {
+ return getConfig().unstable_eventSystem === 'modern';
+}
+
+/**
+ * Runs tests in the legacy event system in both Jest projects, for legacy behavior the modern
+ * event system doesn't have. Call it at the top of a test file, or in a `describe()`.
+ */
+export function runInLegacyEventSystem() {
+ beforeEach(() => {
+ configure({ unstable_eventSystem: 'legacy' });
+ });
+}
diff --git a/src/test-utils/events.ts b/src/test-utils/events.ts
index 3f7c6d5d2..22a349055 100644
--- a/src/test-utils/events.ts
+++ b/src/test-utils/events.ts
@@ -1,18 +1,16 @@
-export interface EventEntry {
- name: string;
- payload: any;
+/** Logged handler call. A class, so `event-serializer.ts` can recognize it with `instanceof`. */
+export class EventEntry {
+ constructor(
+ readonly name: string,
+ readonly payload: any,
+ ) {}
}
export function createEventLogger() {
const events: EventEntry[] = [];
const logEvent = (name: string) => {
return (event: unknown) => {
- const eventEntry: EventEntry = {
- name,
- payload: event,
- };
-
- events.push(eventEntry);
+ events.push(new EventEntry(name, event));
};
};
diff --git a/src/user-event/__tests__/__snapshots__/clear.test.tsx.snap b/src/user-event/__tests__/__snapshots__/clear.test.tsx.snap
index 3146bd80a..ff29f7d5b 100644
--- a/src/user-event/__tests__/__snapshots__/clear.test.tsx.snap
+++ b/src/user-event/__tests__/__snapshots__/clear.test.tsx.snap
@@ -5,64 +5,33 @@ exports[`clear() supports basic case: value: "Hello! 1`] = `
{
"name": "focus",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"target": 0,
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "selectionChange",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"selection": {
"end": 6,
"start": 0,
},
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "keyPress",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"key": "Backspace",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "change",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"eventCount": 0,
"selection": {
@@ -72,11 +41,6 @@ exports[`clear() supports basic case: value: "Hello! 1`] = `
"target": 0,
"text": "",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
@@ -86,56 +50,29 @@ exports[`clear() supports basic case: value: "Hello! 1`] = `
{
"name": "selectionChange",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"selection": {
"end": 0,
"start": 0,
},
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "endEditing",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"target": 0,
"text": "",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "blur",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"target": 0,
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
]
@@ -146,64 +83,33 @@ exports[`clear() supports defaultValue prop: defaultValue: "Hello Default!" 1`]
{
"name": "focus",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"target": 0,
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "selectionChange",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"selection": {
"end": 14,
"start": 0,
},
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "keyPress",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"key": "Backspace",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "change",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"eventCount": 0,
"selection": {
@@ -213,11 +119,6 @@ exports[`clear() supports defaultValue prop: defaultValue: "Hello Default!" 1`]
"target": 0,
"text": "",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
@@ -227,56 +128,29 @@ exports[`clear() supports defaultValue prop: defaultValue: "Hello Default!" 1`]
{
"name": "selectionChange",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"selection": {
"end": 0,
"start": 0,
},
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "endEditing",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"target": 0,
"text": "",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "blur",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"target": 0,
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
]
@@ -287,64 +161,33 @@ exports[`clear() supports multiline: value: "Hello World!\\nHow are you?" multil
{
"name": "focus",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"target": 0,
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "selectionChange",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"selection": {
"end": 25,
"start": 0,
},
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "keyPress",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"key": "Backspace",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "change",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"eventCount": 0,
"selection": {
@@ -354,11 +197,6 @@ exports[`clear() supports multiline: value: "Hello World!\\nHow are you?" multil
"target": 0,
"text": "",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
@@ -368,30 +206,17 @@ exports[`clear() supports multiline: value: "Hello World!\\nHow are you?" multil
{
"name": "selectionChange",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"selection": {
"end": 0,
"start": 0,
},
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "contentSizeChange",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"contentSize": {
"height": 16,
@@ -399,46 +224,23 @@ exports[`clear() supports multiline: value: "Hello World!\\nHow are you?" multil
},
"target": 0,
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "endEditing",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"target": 0,
"text": "",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "blur",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"target": 0,
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
]
@@ -453,19 +255,10 @@ exports[`clear() works when not all events have handlers 1`] = `
{
"name": "endEditing",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"target": 0,
"text": "",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
]
diff --git a/src/user-event/__tests__/__snapshots__/paste.test.tsx.snap b/src/user-event/__tests__/__snapshots__/paste.test.tsx.snap
index 619cfd17e..acc470a3a 100644
--- a/src/user-event/__tests__/__snapshots__/paste.test.tsx.snap
+++ b/src/user-event/__tests__/__snapshots__/paste.test.tsx.snap
@@ -5,47 +5,25 @@ exports[`paste() paste on empty text input 1`] = `
{
"name": "focus",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"target": 0,
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "selectionChange",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"selection": {
"end": 0,
"start": 0,
},
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "change",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"eventCount": 0,
"selection": {
@@ -55,11 +33,6 @@ exports[`paste() paste on empty text input 1`] = `
"target": 0,
"text": "Hi!",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
@@ -69,56 +42,29 @@ exports[`paste() paste on empty text input 1`] = `
{
"name": "selectionChange",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"selection": {
"end": 3,
"start": 3,
},
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "endEditing",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"target": 0,
"text": "Hi!",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "blur",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"target": 0,
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
]
@@ -129,47 +75,25 @@ exports[`paste() paste on filled text input 1`] = `
{
"name": "focus",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"target": 0,
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "selectionChange",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"selection": {
"end": 6,
"start": 0,
},
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "change",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"eventCount": 0,
"selection": {
@@ -179,11 +103,6 @@ exports[`paste() paste on filled text input 1`] = `
"target": 0,
"text": "Hi!",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
@@ -193,56 +112,29 @@ exports[`paste() paste on filled text input 1`] = `
{
"name": "selectionChange",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"selection": {
"end": 3,
"start": 3,
},
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "endEditing",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"target": 0,
"text": "Hi!",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "blur",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"target": 0,
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
]
@@ -253,47 +145,25 @@ exports[`paste() supports defaultValue prop: defaultValue: "Hello Default!" 1`]
{
"name": "focus",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"target": 0,
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "selectionChange",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"selection": {
"end": 14,
"start": 0,
},
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "change",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"eventCount": 0,
"selection": {
@@ -303,11 +173,6 @@ exports[`paste() supports defaultValue prop: defaultValue: "Hello Default!" 1`]
"target": 0,
"text": "Hi!",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
@@ -317,56 +182,29 @@ exports[`paste() supports defaultValue prop: defaultValue: "Hello Default!" 1`]
{
"name": "selectionChange",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"selection": {
"end": 3,
"start": 3,
},
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "endEditing",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"target": 0,
"text": "Hi!",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "blur",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"target": 0,
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
]
@@ -377,47 +215,25 @@ exports[`paste() supports multiline: value: "Hello World!\\nHow are you?" multil
{
"name": "focus",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"target": 0,
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "selectionChange",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"selection": {
"end": 25,
"start": 0,
},
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "change",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"eventCount": 0,
"selection": {
@@ -427,11 +243,6 @@ exports[`paste() supports multiline: value: "Hello World!\\nHow are you?" multil
"target": 0,
"text": "Hi!",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
@@ -441,30 +252,17 @@ exports[`paste() supports multiline: value: "Hello World!\\nHow are you?" multil
{
"name": "selectionChange",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"selection": {
"end": 3,
"start": 3,
},
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "contentSizeChange",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"contentSize": {
"height": 16,
@@ -472,46 +270,23 @@ exports[`paste() supports multiline: value: "Hello World!\\nHow are you?" multil
},
"target": 0,
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "endEditing",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"target": 0,
"text": "Hi!",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "blur",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"target": 0,
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
]
@@ -526,19 +301,10 @@ exports[`paste() works when not all events have handlers 1`] = `
{
"name": "endEditing",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"target": 0,
"text": "Hi!",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
]
diff --git a/src/user-event/__tests__/clear.test.tsx b/src/user-event/__tests__/clear.test.tsx
index 5f34d442b..84a152a92 100644
--- a/src/user-event/__tests__/clear.test.tsx
+++ b/src/user-event/__tests__/clear.test.tsx
@@ -3,6 +3,7 @@ import type { TextInputProps } from 'react-native';
import { TextInput, View } from 'react-native';
import { render, screen, userEvent } from '../..';
+import { isModernEventSystem } from '../../test-utils/event-system';
import { createEventLogger, getEventsNames } from '../../test-utils/events';
beforeEach(() => {
@@ -39,7 +40,7 @@ async function renderTextInputWithToolkit(props: TextInputProps = {}) {
}
describe('clear()', () => {
- it('supports basic case', async () => {
+ test('supports basic case', async () => {
jest.spyOn(Date, 'now').mockImplementation(() => 100100100100);
const { textInput, events } = await renderTextInputWithToolkit({
value: 'Hello!',
@@ -62,7 +63,7 @@ describe('clear()', () => {
expect(events).toMatchSnapshot('value: "Hello!');
});
- it.each(['modern', 'legacy'])('works with %s fake timers', async (type) => {
+ test.each(['modern', 'legacy'])('works with %s fake timers', async (type) => {
jest.useFakeTimers({ legacyFakeTimers: type === 'legacy' });
const { textInput, events } = await renderTextInputWithToolkit({
value: 'Hello!',
@@ -83,7 +84,7 @@ describe('clear()', () => {
]);
});
- it('supports defaultValue prop', async () => {
+ test('supports defaultValue prop', async () => {
const { textInput, events } = await renderTextInputWithToolkit({
defaultValue: 'Hello Default!',
});
@@ -105,7 +106,7 @@ describe('clear()', () => {
expect(events).toMatchSnapshot('defaultValue: "Hello Default!"');
});
- it('does respect editable prop', async () => {
+ test('does respect editable prop', async () => {
const { textInput } = await renderTextInputWithToolkit({
value: 'Hello!',
editable: false,
@@ -117,7 +118,7 @@ describe('clear()', () => {
expect(textInput).toHaveDisplayValue('Hello!');
});
- it('does respect pointer-events prop', async () => {
+ test('does respect pointer-events prop', async () => {
const { textInput } = await renderTextInputWithToolkit({
value: 'Hello!',
pointerEvents: 'none',
@@ -129,7 +130,7 @@ describe('clear()', () => {
expect(textInput).toHaveDisplayValue('Hello!');
});
- it('supports multiline', async () => {
+ test('supports multiline', async () => {
const { textInput, events } = await renderTextInputWithToolkit({
value: 'Hello World!\nHow are you?',
multiline: true,
@@ -153,7 +154,7 @@ describe('clear()', () => {
expect(events).toMatchSnapshot('value: "Hello World!\nHow are you?" multiline: true,');
});
- it('works when not all events have handlers', async () => {
+ test('works when not all events have handlers', async () => {
const { events, logEvent } = createEventLogger();
await render(
{
expect(events).toMatchSnapshot();
});
- it('does NOT work on View', async () => {
+ test('does NOT work on View', async () => {
await render();
const user = userEvent.setup();
@@ -185,19 +186,19 @@ describe('clear()', () => {
// View that ignores props type checking
const AnyView = View as React.ComponentType;
- it('does NOT bubble up', async () => {
- const parentHandler = jest.fn();
+ test('bubbles up only native bubbling events (legacy: bubbles none)', async () => {
+ const { events, logEvent } = createEventLogger();
await render(
,
@@ -205,10 +206,12 @@ describe('clear()', () => {
const user = userEvent.setup();
await user.clear(screen.getByTestId('input'));
- expect(parentHandler).not.toHaveBeenCalled();
+ // These bubble to the parent in React Native.
+ const modernEvents = ['focus', 'keyPress', 'change', 'endEditing', 'blur'];
+ expect(getEventsNames(events)).toEqual(isModernEventSystem() ? modernEvents : []);
});
- it('sets native state value for unmanaged text inputs', async () => {
+ test('sets native state value for unmanaged text inputs', async () => {
await render();
const user = userEvent.setup();
diff --git a/src/user-event/__tests__/event-diagnostics.test.tsx b/src/user-event/__tests__/event-diagnostics.test.tsx
index b82e80638..cdd972bd5 100644
--- a/src/user-event/__tests__/event-diagnostics.test.tsx
+++ b/src/user-event/__tests__/event-diagnostics.test.tsx
@@ -4,6 +4,7 @@ import { Pressable, ScrollView, Text, TextInput, View } from 'react-native';
import { render, screen, userEvent } from '../..';
import { configure } from '../../config';
import { logger } from '../../helpers/logger';
+import { isModernEventSystem } from '../../test-utils/event-system';
let warnSpy: jest.SpyInstance;
@@ -67,15 +68,20 @@ test('warns when no element handles the press', async () => {
`);
});
-test('warns when none of the dispatched events has a handler', async () => {
- // `longPress` dispatches `pressIn`, `longPress` and `pressOut`, but not `press`.
+test('calls onPress on long press without onLongPress (legacy: warns that none of the dispatched events has a handler)', async () => {
+ // Legacy `longPress` dispatches `pressIn`, `longPress` and `pressOut`, but not `press`. As
+ // Pressability, modern `longPress` calls `onPress` when there is no `onLongPress`.
+ const onPress = jest.fn();
// @ts-expect-error Host View does not declare `onPress`.
- await render();
+ await render();
const user = userEvent.setup();
await user.longPress(screen.getByTestId('view'));
- expect(warnSpy).toHaveBeenCalledTimes(1);
+ expect(onPress).toHaveBeenCalledTimes(isModernEventSystem() ? 1 : 0);
+ expect(warnSpy).toHaveBeenCalledTimes(isModernEventSystem() ? 0 : 1);
+ if (isModernEventSystem()) return;
+
expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(`
"longPress() interaction did not call any event handlers. The element has no handler for the "pressIn", "longPress" or "pressOut" events.
If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`.
@@ -182,17 +188,67 @@ test('warns when clearing or pasting into a non-editable TextInput', async () =>
);
});
-test('warns when pressing a non-editable TextInput', async () => {
+test('does not warn when pressing a non-editable TextInput (legacy: warns it is non-editable)', async () => {
+ const onPressIn = jest.fn();
+ await render();
+ const user = userEvent.setup();
+
+ await user.press(screen.getByTestId('input'));
+
+ // Modern matches iOS, where `editable={false}` only stops TextInput from focusing.
+ const isModern = isModernEventSystem();
+ expect(onPressIn).toHaveBeenCalledTimes(isModern ? 1 : 0);
+ expect(warnSpy).toHaveBeenCalledTimes(isModern ? 0 : 1);
+ if (isModern) return;
+ expect(warnSpy.mock.calls[0][0]).toMatch(
+ /^press\(\) interaction did not call any event handlers\. Cannot interact with a non-editable TextInput\./,
+ );
+});
+
+test('warns when pressing a TextInput without handlers inside a Pressable (legacy: calls onPress of the Pressable)', async () => {
+ const onPress = jest.fn();
await render(
- ,
+
+
+ ,
);
const user = userEvent.setup();
await user.press(screen.getByTestId('input'));
- expect(warnSpy).toHaveBeenCalledTimes(1);
+ // Modern matches React Native: the TextInput claims the touch, so the warning names it.
+ const isModern = isModernEventSystem();
+ expect(onPress).toHaveBeenCalledTimes(isModern ? 0 : 1);
+ expect(warnSpy).toHaveBeenCalledTimes(isModern ? 1 : 0);
+ if (!isModern) return;
+ expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(`
+ "press() interaction did not call any event handlers. The element has no handler for the "pressIn", "pressOut" or "press" events.
+ If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`.
+
+ "
+ `);
+});
+
+test('warns when pressing a non-editable TextInput without handlers inside a Pressable (legacy: calls onPress of the Pressable)', async () => {
+ const onPress = jest.fn();
+ await render(
+
+
+ ,
+ );
+ const user = userEvent.setup();
+
+ await user.press(screen.getByTestId('input'));
+
+ // Modern matches iOS, where a non-editable TextInput claims the touch too.
+ const isModern = isModernEventSystem();
+ expect(onPress).toHaveBeenCalledTimes(isModern ? 0 : 1);
+ expect(warnSpy).toHaveBeenCalledTimes(isModern ? 1 : 0);
+ if (!isModern) return;
expect(warnSpy.mock.calls[0][0]).toMatch(
- /^press\(\) interaction did not call any event handlers\. Cannot interact with a non-editable TextInput\./,
+ /^press\(\) interaction did not call any event handlers\. The element has no handler for the "pressIn", "pressOut" or "press" events\./,
);
});
diff --git a/src/user-event/__tests__/paste.test.tsx b/src/user-event/__tests__/paste.test.tsx
index d49395b58..a2079a4e0 100644
--- a/src/user-event/__tests__/paste.test.tsx
+++ b/src/user-event/__tests__/paste.test.tsx
@@ -3,6 +3,7 @@ import type { TextInputProps } from 'react-native';
import { TextInput, View } from 'react-native';
import { render, screen, userEvent } from '../..';
+import { isModernEventSystem } from '../../test-utils/event-system';
import { createEventLogger, getEventsNames } from '../../test-utils/events';
beforeEach(() => {
@@ -39,7 +40,7 @@ async function renderTextInputWithToolkit(props: TextInputProps = {}) {
}
describe('paste()', () => {
- it('paste on empty text input', async () => {
+ test('paste on empty text input', async () => {
jest.spyOn(Date, 'now').mockImplementation(() => 100100100100);
const { textInput, events } = await renderTextInputWithToolkit();
@@ -59,7 +60,7 @@ describe('paste()', () => {
expect(events).toMatchSnapshot();
});
- it('paste on filled text input', async () => {
+ test('paste on filled text input', async () => {
jest.spyOn(Date, 'now').mockImplementation(() => 100100100100);
const { textInput, events } = await renderTextInputWithToolkit({
value: 'Hello!',
@@ -81,7 +82,7 @@ describe('paste()', () => {
expect(events).toMatchSnapshot();
});
- it.each(['modern', 'legacy'])('works with %s fake timers', async (type) => {
+ test.each(['modern', 'legacy'])('works with %s fake timers', async (type) => {
jest.useFakeTimers({ legacyFakeTimers: type === 'legacy' });
const { textInput, events } = await renderTextInputWithToolkit({
value: 'Hello!',
@@ -101,7 +102,7 @@ describe('paste()', () => {
]);
});
- it('supports defaultValue prop', async () => {
+ test('supports defaultValue prop', async () => {
const { textInput, events } = await renderTextInputWithToolkit({
defaultValue: 'Hello Default!',
});
@@ -122,7 +123,7 @@ describe('paste()', () => {
expect(events).toMatchSnapshot('defaultValue: "Hello Default!"');
});
- it('does respect editable prop', async () => {
+ test('does respect editable prop', async () => {
const { textInput } = await renderTextInputWithToolkit({
value: 'Hello!',
editable: false,
@@ -134,7 +135,7 @@ describe('paste()', () => {
expect(textInput).toHaveDisplayValue('Hello!');
});
- it('does respect pointer-events prop', async () => {
+ test('does respect pointer-events prop', async () => {
const { textInput } = await renderTextInputWithToolkit({
value: 'Hello!',
pointerEvents: 'none',
@@ -146,7 +147,7 @@ describe('paste()', () => {
expect(textInput).toHaveDisplayValue('Hello!');
});
- it('supports multiline', async () => {
+ test('supports multiline', async () => {
const { textInput, events } = await renderTextInputWithToolkit({
value: 'Hello World!\nHow are you?',
multiline: true,
@@ -169,7 +170,7 @@ describe('paste()', () => {
expect(events).toMatchSnapshot('value: "Hello World!\nHow are you?" multiline: true,');
});
- it('works when not all events have handlers', async () => {
+ test('works when not all events have handlers', async () => {
const { events, logEvent } = createEventLogger();
await render(
{
expect(events).toMatchSnapshot();
});
- it('does NOT work on View', async () => {
+ test('does NOT work on View', async () => {
await render();
const user = userEvent.setup();
@@ -201,19 +202,19 @@ describe('paste()', () => {
// View that ignores props type checking
const AnyView = View as React.ComponentType;
- it('does NOT bubble up', async () => {
- const parentHandler = jest.fn();
+ test('bubbles up only native bubbling events (legacy: bubbles none)', async () => {
+ const { events, logEvent } = createEventLogger();
await render(
,
@@ -221,10 +222,12 @@ describe('paste()', () => {
const user = userEvent.setup();
await user.paste(screen.getByTestId('input'), 'Hi!');
- expect(parentHandler).not.toHaveBeenCalled();
+ // These bubble to the parent in React Native.
+ const modernEvents = ['focus', 'change', 'endEditing', 'blur'];
+ expect(getEventsNames(events)).toEqual(isModernEventSystem() ? modernEvents : []);
});
- it('sets native state value for unmanaged text inputs', async () => {
+ test('sets native state value for unmanaged text inputs', async () => {
await render();
const user = userEvent.setup();
diff --git a/src/user-event/accessibility-action/accessibility-action.ts b/src/user-event/accessibility-action/accessibility-action.ts
index 85f94329e..8f0b49697 100644
--- a/src/user-event/accessibility-action/accessibility-action.ts
+++ b/src/user-event/accessibility-action/accessibility-action.ts
@@ -1,7 +1,7 @@
import type { AccessibilityActionInfo } from 'react-native';
import type { TestInstance } from 'test-renderer';
-import { buildAccessibilityActionEvent } from '../../events';
+import { buildAccessibilityActionEvent } from '../../events/create-event';
import { computeAriaDisabled } from '../../helpers/accessibility';
import { isTestInstance } from '../../helpers/component-tree';
import { ErrorWithStack } from '../../helpers/errors';
diff --git a/src/user-event/clear.ts b/src/user-event/clear.ts
index 407c58359..f542b5f0c 100644
--- a/src/user-event/clear.ts
+++ b/src/user-event/clear.ts
@@ -5,8 +5,8 @@ import {
buildEndEditingEvent,
buildFocusEvent,
buildTextSelectionChangeEvent,
- isPointerEventEnabled,
-} from '../events';
+} from '../events/create-event';
+import { isPointerEventEnabled } from '../events/shared/pointer-events';
import { ErrorWithStack } from '../helpers/errors';
import { isHostTextInput } from '../helpers/host-component-names';
import { getTextInputValue, isEditableTextInput } from '../helpers/text-input';
diff --git a/src/user-event/legacy/press.ts b/src/user-event/legacy/press.ts
new file mode 100644
index 000000000..5452412bd
--- /dev/null
+++ b/src/user-event/legacy/press.ts
@@ -0,0 +1,171 @@
+import type { TestInstance } from 'test-renderer';
+
+import { act } from '../../act';
+import {
+ buildResponderGrantEvent,
+ buildResponderReleaseEvent,
+} from '../../events/legacy/builders/common';
+import { hasPressEventHandler } from '../../events/shared/handler';
+import { isPointerEventEnabled } from '../../events/shared/pointer-events';
+import { isHostText, isHostTextInput } from '../../helpers/host-component-names';
+import type { UserEventConfig } from '../setup';
+import type { Interaction } from '../utils';
+import { wait } from '../utils';
+
+// These are constants defined in the React Native repo
+// See: https://github.com/facebook/react-native/blob/50e38cc9f1e6713228a91ad50f426c4f65e65e1a/packages/react-native/Libraries/Pressability/Pressability.js#L264
+const DEFAULT_MIN_PRESS_DURATION = 130;
+const DEFAULT_LONG_PRESS_DELAY_MS = 500;
+
+export type EmitPressOptions = {
+ type: 'press' | 'longPress';
+ /**
+ * How long the touch lasts. Defaults to Pressability's long press delay (500 ms) for
+ * `longPress()`, and for `press()` to its minimum press duration (130 ms) on a touch responder,
+ * or the `delay` option of `userEvent.setup()` on a mocked element.
+ */
+ duration?: number;
+};
+
+/**
+ * Presses the element from `findPressTarget()`: calls a mocked element's `onPress*` props directly,
+ * or a touch responder's `onResponderGrant` and `onResponderRelease`, so Pressability's timers
+ * decide which callbacks to call.
+ */
+export async function emitPressEvents(
+ config: UserEventConfig,
+ interaction: Interaction,
+ instance: TestInstance,
+ options: EmitPressOptions,
+) {
+ // Hold a long press for Pressability's long press delay, so its timer calls `onLongPress`.
+ const resolvedOptions =
+ options.type === 'longPress'
+ ? { ...options, duration: options.duration ?? DEFAULT_LONG_PRESS_DELAY_MS }
+ : options;
+
+ const targetKind = findPressTarget(interaction, instance);
+ if (targetKind === 'mocked-direct') {
+ await emitMockedDirectPressEvents(config, interaction, resolvedOptions);
+ } else if (targetKind === 'touch-responder') {
+ await emitTouchResponderPressEvents(config, interaction, resolvedOptions);
+ }
+}
+
+/**
+ * What kind of element receives the press:
+ * - `'mocked-direct'`: a host element with `onPress*` props (or their `testOnly_` variants), called
+ * directly. Real host elements never get these props, as Pressability turns them into responder
+ * props, but mocks do: the Jest preset's `mockComponent()` passes all props of `Text` and
+ * `TextInput` on to a host element.
+ * - `'touch-responder'`: an element whose `onStartShouldSetResponder` accepts the touch, like the
+ * host element of `Pressable` or `TouchableOpacity`.
+ */
+type PressTargetKind = 'mocked-direct' | 'touch-responder';
+
+/**
+ * Finds the element that receives the press, from the instance up to the root, and sets it as
+ * `interaction.target`: the nearest enabled mocked element (`'mocked-direct'`), or touch
+ * responder (`'touch-responder'`). Elements that could handle the press, but are disabled or block
+ * touches, are added to `interaction.skippedTargets`.
+ *
+ * A non-editable `TextInput` is skipped, and a `TextInput` without `onPress*` props passes the
+ * press on to its ancestors. Modern `findPressTarget()` keeps it, as React Native does.
+ *
+ * @returns what kind of element the target is, or `null` when no element receives the press.
+ */
+function findPressTarget(interaction: Interaction, instance: TestInstance): PressTargetKind | null {
+ let current: TestInstance | null = instance;
+ while (current != null) {
+ if (isEnabledHostElement(current) && hasPressEventHandler(current.props)) {
+ interaction.target = current;
+ return 'mocked-direct';
+ }
+
+ if (isEnabledTouchResponder(current)) {
+ interaction.target = current;
+ return 'touch-responder';
+ }
+
+ // The element could handle the press, but is disabled or blocks touches.
+ if (hasPressEventHandler(current.props) || current.props.onStartShouldSetResponder) {
+ interaction.skippedTargets.push(current);
+ }
+
+ current = current.parent;
+ }
+
+ return null;
+}
+
+function isEnabledHostElement(instance: TestInstance) {
+ if (!isPointerEventEnabled(instance)) {
+ return false;
+ }
+
+ if (isHostText(instance)) {
+ return instance.props.disabled !== true;
+ }
+
+ if (isHostTextInput(instance)) {
+ return instance.props.editable !== false;
+ }
+
+ return true;
+}
+
+function isEnabledTouchResponder(instance: TestInstance) {
+ return isPointerEventEnabled(instance) && instance.props.onStartShouldSetResponder?.();
+}
+
+/**
+ * Calls the target's `onPress*` props in the order Pressability calls them. Like Pressability,
+ * calls them only on the target, so `onPress*` props of its ancestors are not called.
+ */
+async function emitMockedDirectPressEvents(
+ config: UserEventConfig,
+ interaction: Interaction,
+ options: EmitPressOptions,
+) {
+ await wait(config);
+ await interaction.dispatchTouchEvent('pressIn');
+
+ await wait(config, options.duration);
+
+ // Long press events are emitted before `pressOut`.
+ if (options.type === 'longPress') {
+ await interaction.dispatchTouchEvent('longPress');
+ }
+
+ await interaction.dispatchTouchEvent('pressOut');
+
+ // Regular press events are emitted after `pressOut` according to the React Native docs.
+ // See: https://reactnative.dev/docs/pressable#onpress
+ // Experimentally for very short presses (< 130ms) `press` events are actually emitted before `onPressOut`, but
+ // we will ignore that as in reality most pressed would be above the 130ms threshold.
+ if (options.type === 'press') {
+ await interaction.dispatchTouchEvent('press');
+ }
+}
+
+async function emitTouchResponderPressEvents(
+ config: UserEventConfig,
+ interaction: Interaction,
+ options: EmitPressOptions,
+) {
+ await wait(config);
+
+ await interaction.invokeEventHandler('responderGrant', buildResponderGrantEvent());
+
+ const duration = options.duration ?? DEFAULT_MIN_PRESS_DURATION;
+ await wait(config, duration);
+
+ await interaction.invokeEventHandler('responderRelease', buildResponderReleaseEvent());
+
+ // React Native will wait for minimal delay of DEFAULT_MIN_PRESS_DURATION
+ // before emitting the `pressOut` event. We need to wait here, so that
+ // `press()` function does not return before that.
+ if (DEFAULT_MIN_PRESS_DURATION - duration > 0) {
+ await act(() => wait(config, DEFAULT_MIN_PRESS_DURATION - duration));
+ }
+}
diff --git a/src/user-event/modern/press.ts b/src/user-event/modern/press.ts
new file mode 100644
index 000000000..d1d2c8f32
--- /dev/null
+++ b/src/user-event/modern/press.ts
@@ -0,0 +1,110 @@
+import type { TestInstance } from 'test-renderer';
+
+import type { SyntheticEvent } from '../../events/modern/event';
+import type { PressabilityEventType } from '../../events/modern/event-types';
+import {
+ dispatchPressabilityEvent,
+ findResponder,
+ getPressabilityCallback,
+ getTouchPath,
+} from '../../events/modern/pressability';
+import { createPressabilityEvent } from '../../events/modern/responder-event';
+import { hasPressEventHandler } from '../../events/shared/handler';
+import type { UserEventConfig } from '../setup';
+import type { Interaction } from '../utils';
+import { wait } from '../utils';
+
+export type EmitPressOptions = {
+ type: 'press' | 'longPress';
+ /** How long the touch lasts. Defaults to the `delay` option of `userEvent.setup()`. */
+ duration?: number;
+};
+
+/**
+ * Presses the element as a touch on a device does: finds the touch responder as modern
+ * `fireEvent.press()` does, then calls its Pressability callbacks in the order Pressability calls
+ * them, each with a `SyntheticEvent`:
+ *
+ * 1. `onPressIn` on touch start.
+ * 2. `onLongPress`, for `longPress()`.
+ * 3. `onPressOut` on touch end.
+ * 4. `onPress`, unless `onLongPress` was called.
+ *
+ * As in React Native, `onPressIn` and `onLongPress` get the same event (`responderGrant`, created
+ * when the touch starts), and `onPressOut` and `onPress` another one (`responderRelease`, created
+ * when it ends).
+ *
+ * Calls the callbacks directly instead of the responder's `onResponderGrant` and
+ * `onResponderRelease`, so Pressability's timers aren't run. The action, not the touch duration,
+ * decides whether it is a long press: `longPress()` calls `onLongPress` whatever the responder's
+ * `delayLongPress`, and `press()` never does. The touch lasts `duration`, only so timers in the
+ * component can run. `onPressOut` comes right on touch end, even for touches shorter than
+ * `minPressDuration` (130 ms), and `delayPressIn` and `delayPressOut` are ignored. Responders
+ * without Pressability, like `PanResponder`, get nothing.
+ */
+export async function emitPressEvents(
+ config: UserEventConfig,
+ interaction: Interaction,
+ instance: TestInstance,
+ options: EmitPressOptions,
+) {
+ const path = getTouchPath(instance);
+ const responder = findPressTarget(interaction, instance, path);
+ if (responder == null) {
+ return;
+ }
+
+ /** Calls the callbacks in one `act()`, as React Native calls them from one responder event. */
+ const dispatch = async (eventTypes: PressabilityEventType[], event: SyntheticEvent) => {
+ const calledEventTypes = await dispatchPressabilityEvent(responder, eventTypes, event, path[0]);
+ for (const eventType of eventTypes) {
+ interaction.recordEvent(eventType, calledEventTypes.includes(eventType));
+ }
+ };
+
+ await wait(config);
+ const pressStartEvent = createPressabilityEvent('pressIn');
+ await dispatch(['pressIn'], pressStartEvent);
+
+ await wait(config, options.duration);
+ const isLongPress = options.type === 'longPress';
+ if (isLongPress) {
+ await dispatch(['longPress'], pressStartEvent);
+ }
+
+ // Pressability skips `onPress` after a long press only when there is an `onLongPress`.
+ const isPressCanceledByLongPress =
+ isLongPress && getPressabilityCallback(responder, 'longPress') != null;
+ const pressEndEvent = createPressabilityEvent('pressOut');
+ await dispatch(isPressCanceledByLongPress ? ['pressOut'] : ['pressOut', 'press'], pressEndEvent);
+}
+
+/**
+ * Sets `interaction.target` to the touch responder, the element modern `fireEvent.press()` calls.
+ * Elements between the pressed element and the responder that could handle the press, but are
+ * disabled or block touches, are added to `interaction.skippedTargets`.
+ *
+ * @returns the responder, or `null` when no element receives the press.
+ */
+function findPressTarget(
+ interaction: Interaction,
+ instance: TestInstance,
+ path: TestInstance[],
+): TestInstance | null {
+ const touchResponder = findResponder(path);
+
+ let current: TestInstance | null = instance;
+ while (current != null && current !== touchResponder) {
+ if (hasPressEventHandler(current.props) || current.props.onStartShouldSetResponder) {
+ interaction.skippedTargets.push(current);
+ }
+
+ current = current.parent;
+ }
+
+ if (touchResponder != null) {
+ interaction.target = touchResponder;
+ }
+
+ return touchResponder;
+}
diff --git a/src/user-event/paste.ts b/src/user-event/paste.ts
index 49f63c022..aee7128a7 100644
--- a/src/user-event/paste.ts
+++ b/src/user-event/paste.ts
@@ -7,9 +7,9 @@ import {
buildFocusEvent,
buildTextChangeEvent,
buildTextSelectionChangeEvent,
- isPointerEventEnabled,
- nativeState,
-} from '../events';
+} from '../events/create-event';
+import { nativeState } from '../events/shared/native-state';
+import { isPointerEventEnabled } from '../events/shared/pointer-events';
import { ErrorWithStack } from '../helpers/errors';
import { isHostTextInput } from '../helpers/host-component-names';
import { getTextInputValue, isEditableTextInput } from '../helpers/text-input';
@@ -49,7 +49,7 @@ export async function paste(
const rangeAfter = { start: text.length, end: text.length };
await interaction.dispatchEvent('change', buildTextChangeEvent(text, rangeAfter));
- await interaction.dispatchEvent('changeText', text);
+ await interaction.invokeEventHandler('changeText', text);
await interaction.dispatchEvent('selectionChange', buildTextSelectionChangeEvent(rangeAfter));
// According to the docs only multiline TextInput emits contentSizeChange event
diff --git a/src/user-event/press/__tests__/__snapshots__/longPress.test.tsx.snap b/src/user-event/press/__tests__/__snapshots__/longPress.test.tsx.snap
deleted file mode 100644
index a6b2a3190..000000000
--- a/src/user-event/press/__tests__/__snapshots__/longPress.test.tsx.snap
+++ /dev/null
@@ -1,131 +0,0 @@
-// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
-
-exports[`userEvent.longPress with fake timers calls onLongPress if the delayLongPress is the default one 1`] = `
-[
- {
- "name": "longPress",
- "payload": {
- "currentTarget": {
- "measure": [Function],
- },
- "dispatchConfig": {
- "registrationName": "onResponderGrant",
- },
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
- "nativeEvent": {
- "changedTouches": [],
- "identifier": 0,
- "locationX": 0,
- "locationY": 0,
- "pageX": 0,
- "pageY": 0,
- "target": 0,
- "timestamp": 0,
- "touches": [],
- },
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
- },
- },
-]
-`;
-
-exports[`userEvent.longPress with fake timers works on Pressable 1`] = `
-[
- {
- "name": "pressIn",
- "payload": {
- "currentTarget": {
- "measure": [Function],
- },
- "dispatchConfig": {
- "registrationName": "onResponderGrant",
- },
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
- "nativeEvent": {
- "changedTouches": [],
- "identifier": 0,
- "locationX": 0,
- "locationY": 0,
- "pageX": 0,
- "pageY": 0,
- "target": 0,
- "timestamp": 0,
- "touches": [],
- },
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
- },
- },
- {
- "name": "longPress",
- "payload": {
- "currentTarget": {
- "measure": [Function],
- },
- "dispatchConfig": {
- "registrationName": "onResponderGrant",
- },
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
- "nativeEvent": {
- "changedTouches": [],
- "identifier": 0,
- "locationX": 0,
- "locationY": 0,
- "pageX": 0,
- "pageY": 0,
- "target": 0,
- "timestamp": 0,
- "touches": [],
- },
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
- },
- },
- {
- "name": "pressOut",
- "payload": {
- "currentTarget": {
- "measure": [Function],
- },
- "dispatchConfig": {
- "registrationName": "onResponderRelease",
- },
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
- "nativeEvent": {
- "changedTouches": [],
- "identifier": 0,
- "locationX": 0,
- "locationY": 0,
- "pageX": 0,
- "pageY": 0,
- "target": 0,
- "timestamp": 500,
- "touches": [],
- },
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
- },
- },
-]
-`;
diff --git a/src/user-event/press/__tests__/__snapshots__/press.test.tsx.snap b/src/user-event/press/__tests__/__snapshots__/press.test.tsx.snap
deleted file mode 100644
index 7f3d902ef..000000000
--- a/src/user-event/press/__tests__/__snapshots__/press.test.tsx.snap
+++ /dev/null
@@ -1,96 +0,0 @@
-// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
-
-exports[`userEvent.press with fake timers works on Pressable 1`] = `
-[
- {
- "name": "pressIn",
- "payload": {
- "currentTarget": {
- "measure": [Function],
- },
- "dispatchConfig": {
- "registrationName": "onResponderGrant",
- },
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
- "nativeEvent": {
- "changedTouches": [],
- "identifier": 0,
- "locationX": 0,
- "locationY": 0,
- "pageX": 0,
- "pageY": 0,
- "target": 0,
- "timestamp": 0,
- "touches": [],
- },
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
- },
- },
- {
- "name": "pressOut",
- "payload": {
- "currentTarget": {
- "measure": [Function],
- },
- "dispatchConfig": {
- "registrationName": "onResponderRelease",
- },
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
- "nativeEvent": {
- "changedTouches": [],
- "identifier": 0,
- "locationX": 0,
- "locationY": 0,
- "pageX": 0,
- "pageY": 0,
- "target": 0,
- "timestamp": 130,
- "touches": [],
- },
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
- },
- },
- {
- "name": "press",
- "payload": {
- "currentTarget": {
- "measure": [Function],
- },
- "dispatchConfig": {
- "registrationName": "onResponderRelease",
- },
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
- "nativeEvent": {
- "changedTouches": [],
- "identifier": 0,
- "locationX": 0,
- "locationY": 0,
- "pageX": 0,
- "pageY": 0,
- "target": 0,
- "timestamp": 130,
- "touches": [],
- },
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
- },
- },
-]
-`;
diff --git a/src/user-event/press/__tests__/longPress.real-timers.test.tsx b/src/user-event/press/__tests__/longPress.real-timers.test.tsx
index 6fad2e043..a85871261 100644
--- a/src/user-event/press/__tests__/longPress.real-timers.test.tsx
+++ b/src/user-event/press/__tests__/longPress.real-timers.test.tsx
@@ -3,6 +3,7 @@ import { Pressable, Text, TouchableHighlight, TouchableOpacity } from 'react-nat
import { userEvent } from '../..';
import { render, screen } from '../../..';
+import { isModernEventSystem } from '../../../test-utils/event-system';
import { createEventLogger, getEventsNames } from '../../../test-utils/events';
describe('userEvent.longPress with real timers', () => {
@@ -106,7 +107,7 @@ describe('userEvent.longPress with real timers', () => {
expect(mockOnPress).not.toHaveBeenCalled();
});
- test('does not calls onLongPress when duration is lesser than specified delayLongPress', async () => {
+ test('calls onLongPress whatever the delayLongPress (legacy: does not call it when duration is lesser than delayLongPress)', async () => {
const mockOnLongPress = jest.fn();
const mockOnPress = jest.fn();
const user = userEvent.setup();
@@ -118,8 +119,9 @@ describe('userEvent.longPress with real timers', () => {
);
await user.longPress(screen.getByText('press me'));
- expect(mockOnLongPress).not.toHaveBeenCalled();
- expect(mockOnPress).toHaveBeenCalledTimes(1);
+ // Modern `longPress()` doesn't wait for Pressability's long press timer.
+ expect(mockOnLongPress).toHaveBeenCalledTimes(isModernEventSystem() ? 1 : 0);
+ expect(mockOnPress).toHaveBeenCalledTimes(isModernEventSystem() ? 0 : 1);
});
test('does not calls onPress when onLongPress is called', async () => {
diff --git a/src/user-event/press/__tests__/longPress.test.tsx b/src/user-event/press/__tests__/longPress.test.tsx
index f836eeafd..d17851a7b 100644
--- a/src/user-event/press/__tests__/longPress.test.tsx
+++ b/src/user-event/press/__tests__/longPress.test.tsx
@@ -3,6 +3,7 @@ import { Pressable, Text, TouchableHighlight, TouchableOpacity, View } from 'rea
import { userEvent } from '../..';
import { render, screen } from '../../..';
+import { isModernEventSystem } from '../../../test-utils/event-system';
import { createEventLogger, getEventsNames } from '../../../test-utils/events';
describe('userEvent.longPress with fake timers', () => {
@@ -11,7 +12,7 @@ describe('userEvent.longPress with fake timers', () => {
jest.setSystemTime(0);
});
- test('works on Pressable', async () => {
+ test('works on Pressable without holding the touch (legacy: holds it for 500 ms)', async () => {
const { events, logEvent } = createEventLogger();
const user = userEvent.setup();
@@ -27,7 +28,71 @@ describe('userEvent.longPress with fake timers', () => {
await user.longPress(screen.getByTestId('pressable'));
expect(getEventsNames(events)).toEqual(['pressIn', 'longPress', 'pressOut']);
- expect(events).toMatchSnapshot();
+ const touch = {
+ identifier: 0,
+ locationX: 0,
+ locationY: 0,
+ pageX: 0,
+ pageY: 0,
+ target: 0,
+ timestamp: 0,
+ };
+ // Modern matches React Native, which passes the touch in the touch arrays.
+ expect(events[0].payload.nativeEvent).toEqual({
+ ...touch,
+ changedTouches: isModernEventSystem() ? [touch] : [],
+ touches: isModernEventSystem() ? [touch] : [],
+ });
+ // Legacy holds the touch for Pressability's long press delay (500 ms).
+ expect(events.map((event) => event.payload.nativeEvent.timestamp)).toEqual([
+ 0,
+ 0,
+ isModernEventSystem() ? 0 : 500,
+ ]);
+ });
+
+ test('passes the responder grant event to onPressIn and onLongPress, the release event to onPressOut and onPress', async () => {
+ const { events, logEvent } = createEventLogger();
+ const user = userEvent.setup();
+
+ await render(
+ ,
+ );
+
+ await user.longPress(screen.getByTestId('pressable'));
+ await user.press(screen.getByTestId('pressable'));
+ const [pressIn, longPress, pressOut, , pressOut2, press] = events.map((e) => e.payload);
+ expect(longPress).toBe(pressIn);
+ expect(pressIn.dispatchConfig).toMatchObject({ registrationName: 'onResponderGrant' });
+ expect(pressOut.dispatchConfig).toMatchObject({ registrationName: 'onResponderRelease' });
+ expect(press).toBe(pressOut2);
+ });
+
+ test('calls onLongPress without currentTarget (legacy: stub events)', async () => {
+ const currentTargets: unknown[] = [];
+ const user = userEvent.setup();
+
+ await render(
+ {
+ currentTargets.push(event.currentTarget);
+ }}
+ />,
+ );
+
+ await user.longPress(screen.getByTestId('pressable'));
+ if (!isModernEventSystem()) return;
+
+ // Modern matches React Native, where Pressability calls `onLongPress` from a timer, after the
+ // dispatch of the responder grant event.
+ expect(currentTargets).toEqual([null]);
});
test('works on TouchableOpacity', async () => {
@@ -101,7 +166,7 @@ describe('userEvent.longPress with fake timers', () => {
);
await user.longPress(screen.getByText('press me'));
- expect(events).toMatchSnapshot();
+ expect(getEventsNames(events)).toEqual(['longPress']);
});
test('calls onLongPress when duration is greater than specified delayLongPress', async () => {
@@ -123,7 +188,7 @@ describe('userEvent.longPress with fake timers', () => {
expect(mockOnPress).not.toHaveBeenCalled();
});
- test('does not calls onLongPress when duration is lesser than specified delayLongPress', async () => {
+ test('calls onLongPress whatever the delayLongPress (legacy: does not call it when duration is lesser than delayLongPress)', async () => {
const mockOnLongPress = jest.fn();
const mockOnPress = jest.fn();
const user = userEvent.setup();
@@ -135,8 +200,9 @@ describe('userEvent.longPress with fake timers', () => {
);
await user.longPress(screen.getByText('press me'));
- expect(mockOnLongPress).not.toHaveBeenCalled();
- expect(mockOnPress).toHaveBeenCalledTimes(1);
+ // Modern `longPress()` doesn't wait for Pressability's long press timer.
+ expect(mockOnLongPress).toHaveBeenCalledTimes(isModernEventSystem() ? 1 : 0);
+ expect(mockOnPress).toHaveBeenCalledTimes(isModernEventSystem() ? 0 : 1);
});
test('does not calls onPress when onLongPress is called', async () => {
@@ -169,7 +235,7 @@ describe('userEvent.longPress with fake timers', () => {
expect(mockOnLongPress).toHaveBeenCalled();
});
- test('longPress accepts custom duration', async () => {
+ test('calls onLongPress after custom duration (legacy: calls onPress instead when shorter than delayLongPress)', async () => {
const { events, logEvent } = createEventLogger();
const user = userEvent.setup();
@@ -184,6 +250,13 @@ describe('userEvent.longPress with fake timers', () => {
);
await user.longPress(screen.getByTestId('pressable'), { duration: 50 });
- expect(getEventsNames(events)).toEqual(['pressIn', 'press', 'pressOut']);
+ // Legacy runs Pressability, whose long press timer doesn't fire within 50 ms, and which delays
+ // `onPressOut` for touches shorter than its `minPressDuration` (130 ms).
+ expect(getEventsNames(events)).toEqual(
+ isModernEventSystem()
+ ? ['pressIn', 'longPress', 'pressOut']
+ : ['pressIn', 'press', 'pressOut'],
+ );
+ expect(events.at(-1)?.payload.nativeEvent.timestamp).toBe(50);
});
});
diff --git a/src/user-event/press/__tests__/press.real-timers.test.tsx b/src/user-event/press/__tests__/press.real-timers.test.tsx
index db7be70c4..674005fd0 100644
--- a/src/user-event/press/__tests__/press.real-timers.test.tsx
+++ b/src/user-event/press/__tests__/press.real-timers.test.tsx
@@ -11,6 +11,7 @@ import {
import { userEvent } from '../..';
import { render, screen } from '../../..';
+import { isModernEventSystem } from '../../../test-utils/event-system';
import { createEventLogger, getEventsNames } from '../../../test-utils/events';
// Typical event order is pressIn, pressOut, press
@@ -272,7 +273,7 @@ describe('userEvent.press with real timers', () => {
expect(events).toEqual([]);
});
- test('does not call onPressIn and onPressOut on non editable TextInput', async () => {
+ test('calls onPressIn and onPressOut on non editable TextInput (legacy: calls nothing)', async () => {
const { events, logEvent } = createEventLogger();
await render(
@@ -284,7 +285,8 @@ describe('userEvent.press with real timers', () => {
/>,
);
await userEvent.press(screen.getByPlaceholderText('email'));
- expect(events).toEqual([]);
+ // Modern matches iOS, where `editable={false}` only stops TextInput from focusing.
+ expect(getEventsNames(events)).toEqual(isModernEventSystem() ? ['pressIn', 'pressOut'] : []);
});
test('does not call onPressIn and onPressOut on TextInput with pointer events disabled', async () => {
diff --git a/src/user-event/press/__tests__/press.test.tsx b/src/user-event/press/__tests__/press.test.tsx
index 7ab2eb80a..990afe05f 100644
--- a/src/user-event/press/__tests__/press.test.tsx
+++ b/src/user-event/press/__tests__/press.test.tsx
@@ -1,4 +1,5 @@
import * as React from 'react';
+import type { GestureResponderEvent } from 'react-native';
import {
Button,
Pressable,
@@ -11,6 +12,8 @@ import {
import { userEvent } from '../..';
import { render, screen } from '../../..';
+import { SyntheticEvent } from '../../../events/modern/event';
+import { isModernEventSystem } from '../../../test-utils/event-system';
import { createEventLogger, getEventsNames } from '../../../test-utils/events';
describe('userEvent.press with fake timers', () => {
@@ -19,7 +22,7 @@ describe('userEvent.press with fake timers', () => {
jest.setSystemTime(0);
});
- test('works on Pressable', async () => {
+ test('works on Pressable without holding the touch (legacy: holds it for 130 ms)', async () => {
const { events, logEvent } = createEventLogger();
const user = userEvent.setup();
@@ -35,7 +38,53 @@ describe('userEvent.press with fake timers', () => {
await user.press(screen.getByTestId('pressable'));
expect(getEventsNames(events)).toEqual(['pressIn', 'pressOut', 'press']);
- expect(events).toMatchSnapshot();
+ const touch = {
+ identifier: 0,
+ locationX: 0,
+ locationY: 0,
+ pageX: 0,
+ pageY: 0,
+ target: 0,
+ timestamp: 0,
+ };
+ // Modern matches React Native, which passes the touch in the touch arrays.
+ expect(events[0].payload.nativeEvent).toEqual({
+ ...touch,
+ changedTouches: isModernEventSystem() ? [touch] : [],
+ touches: isModernEventSystem() ? [touch] : [],
+ });
+ // Legacy holds the touch for Pressability's `minPressDuration`.
+ const releaseTime = isModernEventSystem() ? 0 : 130;
+ expect(events.map((event) => event.payload.nativeEvent.timestamp)).toEqual([
+ 0,
+ releaseTime,
+ releaseTime,
+ ]);
+ });
+
+ test('renders state updates from onPressOut after onPress, as one touch end', async () => {
+ const calls: string[] = [];
+ function Counter() {
+ const [count, setCount] = React.useState(0);
+ calls.push(`render ${count}`);
+ return (
+ {
+ calls.push('pressOut');
+ setCount((value) => value + 1);
+ }}
+ onPress={() => calls.push(`press sees ${count}`)}
+ testID="pressable"
+ />
+ );
+ }
+
+ const user = userEvent.setup();
+ await render();
+ calls.length = 0;
+
+ await user.press(screen.getByTestId('pressable'));
+ expect(calls).toEqual(['pressOut', 'press sees 0', 'render 1']);
});
test('works on TouchableOpacity', async () => {
@@ -82,6 +131,82 @@ describe('userEvent.press with fake timers', () => {
expect(getEventsNames(events)).toEqual(['pressIn', 'pressOut', 'press']);
});
+ test('passes a SyntheticEvent with the touch of onPressIn to onPress of Text (legacy: a stub event)', async () => {
+ const { events, logEvent } = createEventLogger();
+
+ await render(
+
+ press me
+ ,
+ );
+
+ await userEvent.press(screen.getByText('press me'));
+ const [pressInEvent, pressEvent] = events.map((event) => event.payload);
+ // The touch has ended by `onPress`, so it's no longer in `touches`.
+ expect(pressEvent.nativeEvent).toEqual({ ...pressInEvent.nativeEvent, touches: [] });
+ expect(pressEvent instanceof SyntheticEvent).toBe(isModernEventSystem());
+ if (isModernEventSystem()) return;
+
+ expect(pressEvent).toMatchObject({
+ target: {},
+ currentTarget: { measure: expect.any(Function) },
+ });
+ });
+
+ test('passes events with the Pressable as currentTarget to its callbacks (legacy: stub events)', async () => {
+ const currentTargets: unknown[] = [];
+ const logCurrentTarget = (event: GestureResponderEvent) => {
+ currentTargets.push(event.currentTarget);
+ };
+
+ await render(
+
+ press me
+ ,
+ );
+
+ await userEvent.press(screen.getByText('press me'));
+ const pressable = screen.getByTestId('pressable');
+ expect(currentTargets.map((target) => target === pressable)).toEqual(
+ Array(3).fill(isModernEventSystem()),
+ );
+ });
+
+ test('does not call onPress of the Text around pressed Text', async () => {
+ const onPressOuter = jest.fn();
+ const onPressInner = jest.fn();
+
+ await render(
+
+ outer inner
+ ,
+ );
+
+ await userEvent.press(screen.getByText('inner'));
+ expect(onPressInner).toHaveBeenCalledTimes(1);
+ expect(onPressOuter).not.toHaveBeenCalled();
+ });
+
+ test('does not call testOnly_onPress of the View around pressed View', async () => {
+ const onPressOuter = jest.fn();
+ const onPressInner = jest.fn();
+
+ await render(
+
+
+ ,
+ );
+
+ await userEvent.press(screen.getByTestId('inner'));
+ expect(onPressInner).toHaveBeenCalledTimes(1);
+ expect(onPressOuter).not.toHaveBeenCalled();
+ });
+
test('works on TextInput', async () => {
const { events, logEvent } = createEventLogger();
@@ -141,6 +266,32 @@ describe('userEvent.press with fake timers', () => {
expect(events).toEqual([]);
});
+ test('finishes the press on Pressable disabled by onPressIn', async () => {
+ const { events, logEvent } = createEventLogger();
+ function BusyPressable() {
+ const [busy, setBusy] = React.useState(false);
+ return (
+ {
+ logEvent('pressIn')(event);
+ setBusy(true);
+ }}
+ onPressOut={logEvent('pressOut')}
+ onPress={logEvent('press')}
+ testID="pressable"
+ />
+ );
+ }
+
+ const user = userEvent.setup();
+ await render();
+ await user.press(screen.getByTestId('pressable'));
+
+ // React Native checks `disabled` only when the touch starts.
+ expect(getEventsNames(events)).toEqual(['pressIn', 'pressOut', 'press']);
+ });
+
test('does not call press when pointer events is none', async () => {
const { events, logEvent } = createEventLogger();
const user = userEvent.setup();
@@ -218,6 +369,29 @@ describe('userEvent.press with fake timers', () => {
expect(getEventsNames(events)).toEqual(['pressIn', 'pressOut', 'press']);
});
+ test('calls press on pressable with pointer events box-none from its child (legacy: does not call)', async () => {
+ const { events, logEvent } = createEventLogger();
+ const user = userEvent.setup();
+
+ await render(
+
+ Trigger
+ ,
+ );
+ await user.press(screen.getByText('Trigger'));
+
+ // Modern matches React Native: `box-none` only stops hit testing on the `Pressable` itself, so
+ // its child is the hit target and the `Pressable` still claims the touch as the responder.
+ expect(getEventsNames(events)).toEqual(
+ isModernEventSystem() ? ['pressIn', 'pressOut', 'press'] : [],
+ );
+ });
+
test('crawls up in the tree to find an element that responds to touch events', async () => {
const mockOnPress = jest.fn();
@@ -281,6 +455,46 @@ describe('userEvent.press with fake timers', () => {
expect(events).toEqual([]);
});
+ test.each([
+ ['aria-disabled', { 'aria-disabled': true }],
+ ['accessibilityState.disabled', { accessibilityState: { disabled: true } }],
+ ])('does not trigger on Text disabled by %s (legacy: triggers)', async (_, disabledProps) => {
+ const { events, logEvent } = createEventLogger();
+
+ await render(
+
+ press me
+ ,
+ );
+ await userEvent.press(screen.getByText('press me'));
+
+ // Modern matches React Native's `Text`, which falls back to these props when `disabled` is
+ // not set. Legacy checks only `disabled`.
+ expect(getEventsNames(events)).toEqual(
+ isModernEventSystem() ? [] : ['pressIn', 'pressOut', 'press'],
+ );
+ });
+
+ test('triggers on Text with disabled={false} over aria-disabled', async () => {
+ const { events, logEvent } = createEventLogger();
+
+ await render(
+
+ press me
+ ,
+ );
+ await userEvent.press(screen.getByText('press me'));
+
+ // React Native's `Text` reads `disabled` first.
+ expect(getEventsNames(events)).toEqual(['press']);
+ });
+
test('does not trigger on Text with disabled pointer events', async () => {
const { events, logEvent } = createEventLogger();
@@ -301,7 +515,41 @@ describe('userEvent.press with fake timers', () => {
expect(events).toEqual([]);
});
- test('does not call onPressIn and onPressOut on non editable TextInput', async () => {
+ test('does not call onPress of the Pressable around TextInput (legacy: calls it for TextInput without onPress* props)', async () => {
+ const { events, logEvent } = createEventLogger();
+
+ await render(
+
+
+
+ ,
+ );
+
+ // Modern matches React Native: TextInput's Pressability always claims the touch, even without
+ // `onPress*` props, so it never reaches the Pressable.
+ await userEvent.press(screen.getByPlaceholderText('plain'));
+ await userEvent.press(screen.getByPlaceholderText('with pressIn'));
+ expect(getEventsNames(events)).toEqual(
+ isModernEventSystem() ? ['inputPressIn'] : ['press', 'inputPressIn'],
+ );
+ });
+
+ test('calls onPressIn of non editable TextInput, not onPress of the Pressable around it (legacy: the reverse)', async () => {
+ const { events, logEvent } = createEventLogger();
+
+ await render(
+
+
+ ,
+ );
+
+ await userEvent.press(screen.getByPlaceholderText('email'));
+ // Modern matches iOS, where `editable={false}` only stops TextInput from focusing. Legacy
+ // skips it, as Android does.
+ expect(getEventsNames(events)).toEqual(isModernEventSystem() ? ['inputPressIn'] : ['press']);
+ });
+
+ test('calls onPressIn and onPressOut on non editable TextInput (legacy: calls nothing)', async () => {
const { events, logEvent } = createEventLogger();
await render(
@@ -314,7 +562,8 @@ describe('userEvent.press with fake timers', () => {
);
await userEvent.press(screen.getByPlaceholderText('email'));
- expect(events).toEqual([]);
+ // Modern matches iOS, where `editable={false}` only stops TextInput from focusing.
+ expect(getEventsNames(events)).toEqual(isModernEventSystem() ? ['pressIn', 'pressOut'] : []);
});
test('does not call onPressIn and onPressOut on TextInput with pointer events disabled', async () => {
diff --git a/src/user-event/press/press.ts b/src/user-event/press/press.ts
index 8d9745935..1fe25f3d3 100644
--- a/src/user-event/press/press.ts
+++ b/src/user-event/press/press.ts
@@ -1,23 +1,13 @@
import type { TestInstance } from 'test-renderer';
-import { act } from '../../act';
-import {
- buildResponderGrantEvent,
- buildResponderReleaseEvent,
- buildTouchEvent,
- getEventHandlerFromProps,
- isPointerEventEnabled,
-} from '../../events';
+import { getConfig } from '../../config';
import { isTestInstance } from '../../helpers/component-tree';
import { ErrorWithStack } from '../../helpers/errors';
-import { isHostText, isHostTextInput } from '../../helpers/host-component-names';
+import { emitPressEvents as emitLegacyPressEvents } from '../legacy/press';
+import type { EmitPressOptions } from '../modern/press';
+import { emitPressEvents as emitModernPressEvents } from '../modern/press';
import type { UserEventConfig, UserEventInstance } from '../setup';
-import { Interaction, wait, warnAboutUnhandledInteraction } from '../utils';
-
-// These are constants defined in the React Native repo
-// See: https://github.com/facebook/react-native/blob/50e38cc9f1e6713228a91ad50f426c4f65e65e1a/packages/react-native/Libraries/Pressability/Pressability.js#L264
-export const DEFAULT_MIN_PRESS_DURATION = 130;
-export const DEFAULT_LONG_PRESS_DELAY_MS = 500;
+import { Interaction, warnAboutUnhandledInteraction } from '../utils';
export interface PressOptions {
duration?: number;
@@ -29,9 +19,7 @@ export async function press(this: UserEventInstance, instance: TestInstance): Pr
}
const interaction = new Interaction('press', instance);
- await basePress(this.config, interaction, instance, {
- type: 'press',
- });
+ await emitPressEvents(this.config, interaction, instance, { type: 'press' });
warnAboutUnhandledInteraction(interaction);
}
@@ -45,124 +33,22 @@ export async function longPress(
}
const interaction = new Interaction('longPress', instance);
- await basePress(this.config, interaction, instance, {
+ await emitPressEvents(this.config, interaction, instance, {
type: 'longPress',
- duration: options?.duration ?? DEFAULT_LONG_PRESS_DELAY_MS,
+ duration: options?.duration,
});
warnAboutUnhandledInteraction(interaction);
}
-interface BasePressOptions {
- type: 'press' | 'longPress';
- duration?: number;
-}
-
-const basePress = async (
+async function emitPressEvents(
config: UserEventConfig,
interaction: Interaction,
instance: TestInstance,
- options: BasePressOptions,
-): Promise => {
- if (isEnabledHostElement(instance) && hasPressEventHandler(instance)) {
- interaction.target = instance;
- await emitDirectPressEvents(config, interaction, options);
- return;
- }
-
- if (isEnabledTouchResponder(instance)) {
- interaction.target = instance;
- await emitPressabilityPressEvents(config, interaction, options);
- return;
- }
-
- // The element could handle the press, but is disabled or blocks touches.
- if (hasPressEventHandler(instance) || instance.props.onStartShouldSetResponder) {
- interaction.skippedTargets.push(instance);
- }
-
- if (!instance.parent) {
- return;
- }
-
- await basePress(config, interaction, instance.parent, options);
-};
-
-function isEnabledHostElement(instance: TestInstance) {
- if (!isPointerEventEnabled(instance)) {
- return false;
- }
-
- if (isHostText(instance)) {
- return instance.props.disabled !== true;
- }
-
- if (isHostTextInput(instance)) {
- return instance.props.editable !== false;
- }
-
- return true;
-}
-
-function isEnabledTouchResponder(instance: TestInstance) {
- return isPointerEventEnabled(instance) && instance.props.onStartShouldSetResponder?.();
-}
-
-function hasPressEventHandler(instance: TestInstance) {
- return (
- getEventHandlerFromProps(instance.props, 'press') ||
- getEventHandlerFromProps(instance.props, 'longPress') ||
- getEventHandlerFromProps(instance.props, 'pressIn') ||
- getEventHandlerFromProps(instance.props, 'pressOut')
- );
-}
-
-/**
- * Dispatches a press event sequence for host instances that have `onPress*` event handlers.
- */
-async function emitDirectPressEvents(
- config: UserEventConfig,
- interaction: Interaction,
- options: BasePressOptions,
-) {
- await wait(config);
- await interaction.dispatchEvent('pressIn', buildTouchEvent());
-
- await wait(config, options.duration);
-
- // Long press events are emitted before `pressOut`.
- if (options.type === 'longPress') {
- await interaction.dispatchEvent('longPress', buildTouchEvent());
- }
-
- await interaction.dispatchEvent('pressOut', buildTouchEvent());
-
- // Regular press events are emitted after `pressOut` according to the React Native docs.
- // See: https://reactnative.dev/docs/pressable#onpress
- // Experimentally for very short presses (< 130ms) `press` events are actually emitted before `onPressOut`, but
- // we will ignore that as in reality most pressed would be above the 130ms threshold.
- if (options.type === 'press') {
- await interaction.dispatchEvent('press', buildTouchEvent());
- }
-}
-
-async function emitPressabilityPressEvents(
- config: UserEventConfig,
- interaction: Interaction,
- options: BasePressOptions,
-) {
- await wait(config);
-
- await interaction.dispatchEvent('responderGrant', buildResponderGrantEvent());
-
- const duration = options.duration ?? DEFAULT_MIN_PRESS_DURATION;
- await wait(config, duration);
-
- await interaction.dispatchEvent('responderRelease', buildResponderReleaseEvent());
-
- // React Native will wait for minimal delay of DEFAULT_MIN_PRESS_DURATION
- // before emitting the `pressOut` event. We need to wait here, so that
- // `press()` function does not return before that.
- if (DEFAULT_MIN_PRESS_DURATION - duration > 0) {
- await act(() => wait(config, DEFAULT_MIN_PRESS_DURATION - duration));
+ options: EmitPressOptions,
+): Promise {
+ if (getConfig().unstable_eventSystem === 'modern') {
+ await emitModernPressEvents(config, interaction, instance, options);
+ } else {
+ await emitLegacyPressEvents(config, interaction, instance, options);
}
}
diff --git a/src/user-event/scroll/__tests__/__snapshots__/scroll-to-flat-list.test.tsx.snap b/src/user-event/scroll/__tests__/__snapshots__/scroll-to-flat-list.test.tsx.snap
index ba7a21e4d..a0506014a 100644
--- a/src/user-event/scroll/__tests__/__snapshots__/scroll-to-flat-list.test.tsx.snap
+++ b/src/user-event/scroll/__tests__/__snapshots__/scroll-to-flat-list.test.tsx.snap
@@ -5,10 +5,6 @@ exports[`scrollTo() with FlatList supports vertical drag scroll: scrollTo({ y: 1
{
"name": "scrollBeginDrag",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"contentInset": {
"bottom": 0,
@@ -35,20 +31,11 @@ exports[`scrollTo() with FlatList supports vertical drag scroll: scrollTo({ y: 1
"y": 0,
},
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "scroll",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"contentInset": {
"bottom": 0,
@@ -75,20 +62,11 @@ exports[`scrollTo() with FlatList supports vertical drag scroll: scrollTo({ y: 1
"y": 0,
},
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "scroll",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"contentInset": {
"bottom": 0,
@@ -115,20 +93,11 @@ exports[`scrollTo() with FlatList supports vertical drag scroll: scrollTo({ y: 1
"y": 0,
},
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "scroll",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"contentInset": {
"bottom": 0,
@@ -155,20 +124,11 @@ exports[`scrollTo() with FlatList supports vertical drag scroll: scrollTo({ y: 1
"y": 0,
},
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "scroll",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"contentInset": {
"bottom": 0,
@@ -195,20 +155,11 @@ exports[`scrollTo() with FlatList supports vertical drag scroll: scrollTo({ y: 1
"y": 0,
},
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "scrollEndDrag",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"contentInset": {
"bottom": 0,
@@ -235,11 +186,6 @@ exports[`scrollTo() with FlatList supports vertical drag scroll: scrollTo({ y: 1
"y": 0,
},
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
]
diff --git a/src/user-event/scroll/__tests__/__snapshots__/scroll-to.test.tsx.snap b/src/user-event/scroll/__tests__/__snapshots__/scroll-to.test.tsx.snap
index b12ed6bae..cbf7df20f 100644
--- a/src/user-event/scroll/__tests__/__snapshots__/scroll-to.test.tsx.snap
+++ b/src/user-event/scroll/__tests__/__snapshots__/scroll-to.test.tsx.snap
@@ -5,10 +5,6 @@ exports[`scrollTo() supports vertical drag scroll: scrollTo({ y: 100 }) 1`] = `
{
"name": "scrollBeginDrag",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"contentInset": {
"bottom": 0,
@@ -35,20 +31,11 @@ exports[`scrollTo() supports vertical drag scroll: scrollTo({ y: 100 }) 1`] = `
"y": 0,
},
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "scroll",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"contentInset": {
"bottom": 0,
@@ -75,20 +62,11 @@ exports[`scrollTo() supports vertical drag scroll: scrollTo({ y: 100 }) 1`] = `
"y": 0,
},
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "scroll",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"contentInset": {
"bottom": 0,
@@ -115,20 +93,11 @@ exports[`scrollTo() supports vertical drag scroll: scrollTo({ y: 100 }) 1`] = `
"y": 0,
},
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "scroll",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"contentInset": {
"bottom": 0,
@@ -155,20 +124,11 @@ exports[`scrollTo() supports vertical drag scroll: scrollTo({ y: 100 }) 1`] = `
"y": 0,
},
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "scroll",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"contentInset": {
"bottom": 0,
@@ -195,20 +155,11 @@ exports[`scrollTo() supports vertical drag scroll: scrollTo({ y: 100 }) 1`] = `
"y": 0,
},
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "scrollEndDrag",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"contentInset": {
"bottom": 0,
@@ -235,11 +186,6 @@ exports[`scrollTo() supports vertical drag scroll: scrollTo({ y: 100 }) 1`] = `
"y": 0,
},
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
]
diff --git a/src/user-event/scroll/scroll-to.ts b/src/user-event/scroll/scroll-to.ts
index 6d581d6ef..811b83956 100644
--- a/src/user-event/scroll/scroll-to.ts
+++ b/src/user-event/scroll/scroll-to.ts
@@ -1,7 +1,8 @@
import { stringify } from 'jest-matcher-utils';
import type { TestInstance } from 'test-renderer';
-import { buildScrollEvent, nativeState } from '../../events';
+import { buildScrollEvent } from '../../events/create-event';
+import { nativeState } from '../../events/shared/native-state';
import { ErrorWithStack } from '../../helpers/errors';
import { isHostScrollView } from '../../helpers/host-component-names';
import { pick } from '../../helpers/object';
@@ -55,7 +56,8 @@ export async function scrollTo(
layoutMeasurement: options.layoutMeasurement ?? nativeState.layoutSizeForInstance.get(instance),
};
- await interaction.dispatchEvent(
+ // Not dispatched: `ScrollView` calls `onContentSizeChange(width, height)` from JavaScript.
+ await interaction.invokeEventHandler(
'contentSizeChange',
options.contentSize?.width ?? 0,
options.contentSize?.height ?? 0,
@@ -100,18 +102,24 @@ async function emitDragScrollEvents(
await wait(config);
await interaction.dispatchEvent(
'scrollBeginDrag',
- buildScrollEvent(scrollSteps[0], scrollOptions),
+ buildScrollEvent('scrollBeginDrag', scrollSteps[0], scrollOptions),
);
// Native emits `scroll` for every drag step, including the last; `scrollEndDrag` repeats its offset.
for (let i = 1; i < scrollSteps.length; i += 1) {
await wait(config);
- await interaction.dispatchEvent('scroll', buildScrollEvent(scrollSteps[i], scrollOptions));
+ await interaction.dispatchEvent(
+ 'scroll',
+ buildScrollEvent('scroll', scrollSteps[i], scrollOptions),
+ );
}
await wait(config);
const lastStep = scrollSteps.at(-1);
- await interaction.dispatchEvent('scrollEndDrag', buildScrollEvent(lastStep, scrollOptions));
+ await interaction.dispatchEvent(
+ 'scrollEndDrag',
+ buildScrollEvent('scrollEndDrag', lastStep, scrollOptions),
+ );
}
async function emitMomentumScrollEvents(
@@ -127,18 +135,24 @@ async function emitMomentumScrollEvents(
await wait(config);
await interaction.dispatchEvent(
'momentumScrollBegin',
- buildScrollEvent(scrollSteps[0], scrollOptions),
+ buildScrollEvent('momentumScrollBegin', scrollSteps[0], scrollOptions),
);
// Native emits `scroll` for every momentum step, including the last; `momentumScrollEnd` repeats its offset.
for (let i = 1; i < scrollSteps.length; i += 1) {
await wait(config);
- await interaction.dispatchEvent('scroll', buildScrollEvent(scrollSteps[i], scrollOptions));
+ await interaction.dispatchEvent(
+ 'scroll',
+ buildScrollEvent('scroll', scrollSteps[i], scrollOptions),
+ );
}
await wait(config);
const lastStep = scrollSteps.at(-1);
- await interaction.dispatchEvent('momentumScrollEnd', buildScrollEvent(lastStep, scrollOptions));
+ await interaction.dispatchEvent(
+ 'momentumScrollEnd',
+ buildScrollEvent('momentumScrollEnd', lastStep, scrollOptions),
+ );
}
function ensureScrollViewDirection(instance: TestInstance, options: ScrollToOptions) {
diff --git a/src/user-event/type/__tests__/__snapshots__/type-managed.test.tsx.snap b/src/user-event/type/__tests__/__snapshots__/type-managed.test.tsx.snap
index 0e39d81ef..f42de1c40 100644
--- a/src/user-event/type/__tests__/__snapshots__/type-managed.test.tsx.snap
+++ b/src/user-event/type/__tests__/__snapshots__/type-managed.test.tsx.snap
@@ -5,12 +5,6 @@ exports[`type() for managed TextInput supports basic case: input: "Wow" 1`] = `
{
"name": "pressIn",
"payload": {
- "currentTarget": {
- "measure": [Function],
- },
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"changedTouches": [],
"identifier": 0,
@@ -22,39 +16,19 @@ exports[`type() for managed TextInput supports basic case: input: "Wow" 1`] = `
"timestamp": 100100100100,
"touches": [],
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "focus",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"target": 0,
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "pressOut",
"payload": {
- "currentTarget": {
- "measure": [Function],
- },
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"changedTouches": [],
"identifier": 0,
@@ -66,37 +40,19 @@ exports[`type() for managed TextInput supports basic case: input: "Wow" 1`] = `
"timestamp": 100100100100,
"touches": [],
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "keyPress",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"key": "W",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "change",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"eventCount": 0,
"selection": {
@@ -106,11 +62,6 @@ exports[`type() for managed TextInput supports basic case: input: "Wow" 1`] = `
"target": 0,
"text": "W",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
@@ -120,47 +71,25 @@ exports[`type() for managed TextInput supports basic case: input: "Wow" 1`] = `
{
"name": "selectionChange",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"selection": {
"end": 1,
"start": 1,
},
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "keyPress",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"key": "o",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "change",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"eventCount": 0,
"selection": {
@@ -170,11 +99,6 @@ exports[`type() for managed TextInput supports basic case: input: "Wow" 1`] = `
"target": 0,
"text": "Wo",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
@@ -184,47 +108,25 @@ exports[`type() for managed TextInput supports basic case: input: "Wow" 1`] = `
{
"name": "selectionChange",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"selection": {
"end": 2,
"start": 2,
},
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "keyPress",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"key": "w",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "change",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"eventCount": 0,
"selection": {
@@ -234,11 +136,6 @@ exports[`type() for managed TextInput supports basic case: input: "Wow" 1`] = `
"target": 0,
"text": "Wow",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
@@ -248,56 +145,29 @@ exports[`type() for managed TextInput supports basic case: input: "Wow" 1`] = `
{
"name": "selectionChange",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"selection": {
"end": 3,
"start": 3,
},
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "endEditing",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"target": 0,
"text": "Wow",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "blur",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"target": 0,
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
]
@@ -308,12 +178,6 @@ exports[`type() for managed TextInput supports rejecting TextInput: input: "ABC"
{
"name": "pressIn",
"payload": {
- "currentTarget": {
- "measure": [Function],
- },
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"changedTouches": [],
"identifier": 0,
@@ -325,39 +189,19 @@ exports[`type() for managed TextInput supports rejecting TextInput: input: "ABC"
"timestamp": 100100100100,
"touches": [],
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "focus",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"target": 0,
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "pressOut",
"payload": {
- "currentTarget": {
- "measure": [Function],
- },
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"changedTouches": [],
"identifier": 0,
@@ -369,37 +213,19 @@ exports[`type() for managed TextInput supports rejecting TextInput: input: "ABC"
"timestamp": 100100100100,
"touches": [],
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "keyPress",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"key": "A",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "change",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"eventCount": 0,
"selection": {
@@ -409,11 +235,6 @@ exports[`type() for managed TextInput supports rejecting TextInput: input: "ABC"
"target": 0,
"text": "XXXA",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
@@ -423,47 +244,25 @@ exports[`type() for managed TextInput supports rejecting TextInput: input: "ABC"
{
"name": "selectionChange",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"selection": {
"end": 4,
"start": 4,
},
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "keyPress",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"key": "B",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "change",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"eventCount": 0,
"selection": {
@@ -473,11 +272,6 @@ exports[`type() for managed TextInput supports rejecting TextInput: input: "ABC"
"target": 0,
"text": "XXXB",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
@@ -487,47 +281,25 @@ exports[`type() for managed TextInput supports rejecting TextInput: input: "ABC"
{
"name": "selectionChange",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"selection": {
"end": 4,
"start": 4,
},
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "keyPress",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"key": "C",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "change",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"eventCount": 0,
"selection": {
@@ -537,11 +309,6 @@ exports[`type() for managed TextInput supports rejecting TextInput: input: "ABC"
"target": 0,
"text": "XXXC",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
@@ -551,56 +318,29 @@ exports[`type() for managed TextInput supports rejecting TextInput: input: "ABC"
{
"name": "selectionChange",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"selection": {
"end": 4,
"start": 4,
},
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "endEditing",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"target": 0,
"text": "XXX",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "blur",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"target": 0,
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
]
diff --git a/src/user-event/type/__tests__/__snapshots__/type.test.tsx.snap b/src/user-event/type/__tests__/__snapshots__/type.test.tsx.snap
index aee07c7ee..3d7c370d6 100644
--- a/src/user-event/type/__tests__/__snapshots__/type.test.tsx.snap
+++ b/src/user-event/type/__tests__/__snapshots__/type.test.tsx.snap
@@ -5,12 +5,6 @@ exports[`type() supports backspace: input: "{Backspace}a", defaultValue: "xxx" 1
{
"name": "pressIn",
"payload": {
- "currentTarget": {
- "measure": [Function],
- },
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"changedTouches": [],
"identifier": 0,
@@ -22,39 +16,19 @@ exports[`type() supports backspace: input: "{Backspace}a", defaultValue: "xxx" 1
"timestamp": 100100100100,
"touches": [],
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "focus",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"target": 0,
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "pressOut",
"payload": {
- "currentTarget": {
- "measure": [Function],
- },
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"changedTouches": [],
"identifier": 0,
@@ -66,37 +40,19 @@ exports[`type() supports backspace: input: "{Backspace}a", defaultValue: "xxx" 1
"timestamp": 100100100100,
"touches": [],
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "keyPress",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"key": "Backspace",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "change",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"eventCount": 0,
"selection": {
@@ -106,11 +62,6 @@ exports[`type() supports backspace: input: "{Backspace}a", defaultValue: "xxx" 1
"target": 0,
"text": "xx",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
@@ -120,47 +71,25 @@ exports[`type() supports backspace: input: "{Backspace}a", defaultValue: "xxx" 1
{
"name": "selectionChange",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"selection": {
"end": 2,
"start": 2,
},
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "keyPress",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"key": "a",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "change",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"eventCount": 0,
"selection": {
@@ -170,11 +99,6 @@ exports[`type() supports backspace: input: "{Backspace}a", defaultValue: "xxx" 1
"target": 0,
"text": "xxa",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
@@ -184,56 +108,29 @@ exports[`type() supports backspace: input: "{Backspace}a", defaultValue: "xxx" 1
{
"name": "selectionChange",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"selection": {
"end": 3,
"start": 3,
},
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "endEditing",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"target": 0,
"text": "xxa",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "blur",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"target": 0,
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
]
@@ -244,12 +141,6 @@ exports[`type() supports basic case: input: "abc" 1`] = `
{
"name": "pressIn",
"payload": {
- "currentTarget": {
- "measure": [Function],
- },
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"changedTouches": [],
"identifier": 0,
@@ -261,39 +152,19 @@ exports[`type() supports basic case: input: "abc" 1`] = `
"timestamp": 100100100100,
"touches": [],
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "focus",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"target": 0,
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "pressOut",
"payload": {
- "currentTarget": {
- "measure": [Function],
- },
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"changedTouches": [],
"identifier": 0,
@@ -305,37 +176,19 @@ exports[`type() supports basic case: input: "abc" 1`] = `
"timestamp": 100100100100,
"touches": [],
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "keyPress",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"key": "a",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "change",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"eventCount": 0,
"selection": {
@@ -345,11 +198,6 @@ exports[`type() supports basic case: input: "abc" 1`] = `
"target": 0,
"text": "a",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
@@ -359,47 +207,25 @@ exports[`type() supports basic case: input: "abc" 1`] = `
{
"name": "selectionChange",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"selection": {
"end": 1,
"start": 1,
},
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "keyPress",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"key": "b",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "change",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"eventCount": 0,
"selection": {
@@ -409,11 +235,6 @@ exports[`type() supports basic case: input: "abc" 1`] = `
"target": 0,
"text": "ab",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
@@ -423,47 +244,25 @@ exports[`type() supports basic case: input: "abc" 1`] = `
{
"name": "selectionChange",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"selection": {
"end": 2,
"start": 2,
},
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "keyPress",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"key": "c",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "change",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"eventCount": 0,
"selection": {
@@ -473,11 +272,6 @@ exports[`type() supports basic case: input: "abc" 1`] = `
"target": 0,
"text": "abc",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
@@ -487,56 +281,29 @@ exports[`type() supports basic case: input: "abc" 1`] = `
{
"name": "selectionChange",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"selection": {
"end": 3,
"start": 3,
},
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "endEditing",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"target": 0,
"text": "abc",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "blur",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"target": 0,
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
]
@@ -547,12 +314,6 @@ exports[`type() supports defaultValue prop: input: "ab", defaultValue: "xxx" 1`]
{
"name": "pressIn",
"payload": {
- "currentTarget": {
- "measure": [Function],
- },
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"changedTouches": [],
"identifier": 0,
@@ -564,39 +325,19 @@ exports[`type() supports defaultValue prop: input: "ab", defaultValue: "xxx" 1`]
"timestamp": 100100100100,
"touches": [],
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "focus",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"target": 0,
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "pressOut",
"payload": {
- "currentTarget": {
- "measure": [Function],
- },
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"changedTouches": [],
"identifier": 0,
@@ -608,37 +349,19 @@ exports[`type() supports defaultValue prop: input: "ab", defaultValue: "xxx" 1`]
"timestamp": 100100100100,
"touches": [],
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "keyPress",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"key": "a",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "change",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"eventCount": 0,
"selection": {
@@ -648,11 +371,6 @@ exports[`type() supports defaultValue prop: input: "ab", defaultValue: "xxx" 1`]
"target": 0,
"text": "xxxa",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
@@ -662,47 +380,25 @@ exports[`type() supports defaultValue prop: input: "ab", defaultValue: "xxx" 1`]
{
"name": "selectionChange",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"selection": {
"end": 4,
"start": 4,
},
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "keyPress",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"key": "b",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "change",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"eventCount": 0,
"selection": {
@@ -712,11 +408,6 @@ exports[`type() supports defaultValue prop: input: "ab", defaultValue: "xxx" 1`]
"target": 0,
"text": "xxxab",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
@@ -726,56 +417,29 @@ exports[`type() supports defaultValue prop: input: "ab", defaultValue: "xxx" 1`]
{
"name": "selectionChange",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"selection": {
"end": 5,
"start": 5,
},
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "endEditing",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"target": 0,
"text": "xxxab",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "blur",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"target": 0,
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
]
@@ -786,12 +450,6 @@ exports[`type() supports multiline: input: "{Enter}\\n", multiline: true 1`] = `
{
"name": "pressIn",
"payload": {
- "currentTarget": {
- "measure": [Function],
- },
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"changedTouches": [],
"identifier": 0,
@@ -803,39 +461,19 @@ exports[`type() supports multiline: input: "{Enter}\\n", multiline: true 1`] = `
"timestamp": 100100100100,
"touches": [],
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "focus",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"target": 0,
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "pressOut",
"payload": {
- "currentTarget": {
- "measure": [Function],
- },
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"changedTouches": [],
"identifier": 0,
@@ -847,37 +485,19 @@ exports[`type() supports multiline: input: "{Enter}\\n", multiline: true 1`] = `
"timestamp": 100100100100,
"touches": [],
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "keyPress",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"key": "Enter",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "change",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"eventCount": 0,
"selection": {
@@ -888,11 +508,6 @@ exports[`type() supports multiline: input: "{Enter}\\n", multiline: true 1`] = `
"text": "
",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
@@ -903,30 +518,17 @@ exports[`type() supports multiline: input: "{Enter}\\n", multiline: true 1`] = `
{
"name": "selectionChange",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"selection": {
"end": 1,
"start": 1,
},
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "contentSizeChange",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"contentSize": {
"height": 32,
@@ -934,37 +536,19 @@ exports[`type() supports multiline: input: "{Enter}\\n", multiline: true 1`] = `
},
"target": 0,
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "keyPress",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"key": "Enter",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "change",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"eventCount": 0,
"selection": {
@@ -976,11 +560,6 @@ exports[`type() supports multiline: input: "{Enter}\\n", multiline: true 1`] = `
",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
@@ -992,30 +571,17 @@ exports[`type() supports multiline: input: "{Enter}\\n", multiline: true 1`] = `
{
"name": "selectionChange",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"selection": {
"end": 2,
"start": 2,
},
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "contentSizeChange",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"contentSize": {
"height": 48,
@@ -1023,48 +589,25 @@ exports[`type() supports multiline: input: "{Enter}\\n", multiline: true 1`] = `
},
"target": 0,
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "endEditing",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"target": 0,
"text": "
",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
{
"name": "blur",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"target": 0,
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
]
@@ -1087,19 +630,10 @@ exports[`type() works when not all events have handlers: input: "abc" 1`] = `
{
"name": "endEditing",
"payload": {
- "currentTarget": {},
- "isDefaultPrevented": [Function],
- "isPersistent": [Function],
- "isPropagationStopped": [Function],
"nativeEvent": {
"target": 0,
"text": "abc",
},
- "persist": [Function],
- "preventDefault": [Function],
- "stopPropagation": [Function],
- "target": {},
- "timeStamp": 0,
},
},
]
diff --git a/src/user-event/type/__tests__/type.test.tsx b/src/user-event/type/__tests__/type.test.tsx
index 23f0405df..5e6f05513 100644
--- a/src/user-event/type/__tests__/type.test.tsx
+++ b/src/user-event/type/__tests__/type.test.tsx
@@ -4,6 +4,7 @@ import { TextInput, View } from 'react-native';
import { userEvent } from '../..';
import { render, screen } from '../../..';
+import { isModernEventSystem } from '../../../test-utils/event-system';
import { createEventLogger, getEventsNames, lastEventPayload } from '../../../test-utils/events';
beforeEach(() => {
@@ -37,7 +38,7 @@ async function renderTextInputWithToolkit(props: TextInputProps = {}) {
}
describe('type()', () => {
- it('supports basic case', async () => {
+ test('supports basic case', async () => {
jest.spyOn(Date, 'now').mockImplementation(() => 100100100100);
const { events } = await renderTextInputWithToolkit();
@@ -67,7 +68,7 @@ describe('type()', () => {
expect(events).toMatchSnapshot('input: "abc"');
});
- it('includes caret selection in the change event nativeEvent', async () => {
+ test('includes caret selection in the change event nativeEvent', async () => {
const { events } = await renderTextInputWithToolkit();
const user = userEvent.setup();
@@ -84,7 +85,7 @@ describe('type()', () => {
});
});
- it('drives components that read the caret from change event (regression)', async () => {
+ test('drives components that read the caret from change event (regression)', async () => {
const onChange = jest.fn();
function MaskedInput() {
@@ -117,7 +118,7 @@ describe('type()', () => {
expect(screen.getByTestId('masked-input').props.value).toBe('abc');
});
- it.each(['modern', 'legacy'])('works with %s fake timers', async (type) => {
+ test.each(['modern', 'legacy'])('works with %s fake timers', async (type) => {
jest.useFakeTimers({ legacyFakeTimers: type === 'legacy' });
const { events } = await renderTextInputWithToolkit();
@@ -145,7 +146,7 @@ describe('type()', () => {
]);
});
- it('supports defaultValue prop', async () => {
+ test('supports defaultValue prop', async () => {
const { events } = await renderTextInputWithToolkit({
defaultValue: 'xxx',
});
@@ -172,7 +173,7 @@ describe('type()', () => {
expect(events).toMatchSnapshot('input: "ab", defaultValue: "xxx"');
});
- it('does respect editable prop', async () => {
+ test('does respect editable prop', async () => {
const { events } = await renderTextInputWithToolkit({
editable: false,
});
@@ -183,7 +184,7 @@ describe('type()', () => {
expect(getEventsNames(events)).toEqual([]);
});
- it('supports backspace', async () => {
+ test('supports backspace', async () => {
const { events } = await renderTextInputWithToolkit({
defaultValue: 'xxx',
});
@@ -210,7 +211,7 @@ describe('type()', () => {
expect(events).toMatchSnapshot('input: "{Backspace}a", defaultValue: "xxx"');
});
- it('supports multiline', async () => {
+ test('supports multiline', async () => {
const { events } = await renderTextInputWithToolkit({
multiline: true,
});
@@ -265,7 +266,7 @@ describe('type()', () => {
});
});
- it('triggers submit event with `submitEditing: true`', async () => {
+ test('triggers submit event with `submitEditing: true`', async () => {
const { events } = await renderTextInputWithToolkit();
const user = userEvent.setup();
@@ -293,7 +294,7 @@ describe('type()', () => {
});
});
- it('works when not all events have handlers', async () => {
+ test('works when not all events have handlers', async () => {
const { events, logEvent } = createEventLogger();
await render(
{
expect(events).toMatchSnapshot('input: "abc"');
});
- it('does NOT work on View', async () => {
+ test('does NOT work on View', async () => {
await render();
const user = userEvent.setup();
@@ -330,19 +331,19 @@ describe('type()', () => {
// View that ignores props type checking
const AnyView = View as React.ComponentType;
- it('does NOT bubble up', async () => {
- const parentHandler = jest.fn();
+ test('bubbles up only native bubbling events (legacy: bubbles none)', async () => {
+ const { events, logEvent } = createEventLogger();
await render(
,
@@ -350,10 +351,17 @@ describe('type()', () => {
const user = userEvent.setup();
await user.type(screen.getByTestId('input'), 'abc');
- expect(parentHandler).not.toHaveBeenCalled();
+ // These bubble to the parent in React Native.
+ const modernEvents = [
+ 'focus',
+ ...['keyPress', 'change', 'keyPress', 'change', 'keyPress', 'change'],
+ 'endEditing',
+ 'blur',
+ ];
+ expect(getEventsNames(events)).toEqual(isModernEventSystem() ? modernEvents : []);
});
- it('supports direct access', async () => {
+ test('supports direct access', async () => {
const { events, logEvent } = createEventLogger();
await render(
{
});
// See: https://github.com/callstack/react-native-testing-library/issues/1588
- it('can call "persist()" on "onKeyPress" event handler', async () => {
+ test('can call "persist()" on "onKeyPress" event handler', async () => {
const handleKeyPress = jest.fn();
await render(
{
expect(handleKeyPress).toHaveBeenCalledTimes(3);
});
- it('respects the "maxLength" prop', async () => {
+ test('respects the "maxLength" prop', async () => {
const { events } = await renderTextInputWithToolkit({ maxLength: 2 });
const user = userEvent.setup();
@@ -425,7 +433,7 @@ describe('type()', () => {
});
});
- it('unmanaged text inputs preserve their native state', async () => {
+ test('unmanaged text inputs preserve their native state', async () => {
await render();
const user = userEvent.setup();
@@ -439,7 +447,7 @@ describe('type()', () => {
expect(input).toHaveDisplayValue('Hello World');
});
- it('skips blur and endEditing events when `skipBlur: true`', async () => {
+ test('skips blur and endEditing events when `skipBlur: true`', async () => {
const { events } = await renderTextInputWithToolkit();
const user = userEvent.setup();
diff --git a/src/user-event/type/type.ts b/src/user-event/type/type.ts
index cd78d2be3..a10c9710f 100644
--- a/src/user-event/type/type.ts
+++ b/src/user-event/type/type.ts
@@ -9,10 +9,9 @@ import {
buildSubmitEditingEvent,
buildTextChangeEvent,
buildTextSelectionChangeEvent,
- buildTouchEvent,
- isPointerEventEnabled,
- nativeState,
-} from '../../events';
+} from '../../events/create-event';
+import { nativeState } from '../../events/shared/native-state';
+import { isPointerEventEnabled } from '../../events/shared/pointer-events';
import { ErrorWithStack } from '../../helpers/errors';
import { isHostTextInput } from '../../helpers/host-component-names';
import { getTextInputValue, isEditableTextInput } from '../../helpers/text-input';
@@ -49,14 +48,14 @@ export async function type(
const keys = parseKeys(text);
if (!options?.skipPress) {
- await interaction.dispatchEvent('pressIn', buildTouchEvent());
+ await interaction.dispatchTouchEvent('pressIn');
}
await interaction.dispatchEvent('focus', buildFocusEvent());
if (!options?.skipPress) {
await wait(this.config);
- await interaction.dispatchEvent('pressOut', buildTouchEvent());
+ await interaction.dispatchTouchEvent('pressOut');
}
for (const key of keys) {
@@ -122,7 +121,7 @@ export async function emitTypingEvents(
};
await interaction.dispatchEvent('change', buildTextChangeEvent(text, selectionRange));
- await interaction.dispatchEvent('changeText', text);
+ await interaction.invokeEventHandler('changeText', text);
await interaction.dispatchEvent('selectionChange', buildTextSelectionChangeEvent(selectionRange));
// According to the docs only multiline TextInput emits contentSizeChange event
diff --git a/src/user-event/utils/interaction.ts b/src/user-event/utils/interaction.ts
index 7ea2f7b3f..defd4bf67 100644
--- a/src/user-event/utils/interaction.ts
+++ b/src/user-event/utils/interaction.ts
@@ -1,6 +1,10 @@
import type { TestInstance } from 'test-renderer';
-import { dispatchEvent } from '../../events';
+import type { LegacyEvent } from '../../events/create-event';
+import { dispatchEvent } from '../../events/dispatch-event';
+import { buildTouchEvent } from '../../events/legacy/builders/common';
+import type { SyntheticEvent } from '../../events/modern/event';
+import { invokeEventHandler } from '../../events/shared/invoke-event-handler';
/**
* Tracks what a single `userEvent` interaction did, so `warnAboutUnhandledInteraction`
@@ -23,8 +27,27 @@ export class Interaction {
public target: TestInstance,
) {}
- async dispatchEvent(eventName: string, ...event: unknown[]) {
- const hasCalledHandler = await dispatchEvent(this.target, eventName, ...event);
+ /** Dispatches a native event from `createEvent()`, e.g. `buildFocusEvent()`. */
+ async dispatchEvent(eventName: string, event: LegacyEvent | SyntheticEvent) {
+ const hasCalledHandler = await dispatchEvent(this.target, eventName, event);
+ this.recordEvent(eventName, hasCalledHandler);
+ }
+
+ /**
+ * Calls a touch callback that `Pressable` calls from JavaScript (`pressIn`, `pressOut`,
+ * `longPress`, `press`) with a touch event, the way Pressability does.
+ */
+ async dispatchTouchEvent(eventName: string) {
+ await this.invokeEventHandler(eventName, buildTouchEvent());
+ }
+
+ /**
+ * Calls the target's own handler with the given params, for callbacks that components call
+ * from JavaScript instead of dispatching events, e.g. `onChangeText(text)` or `ScrollView`'s
+ * `onContentSizeChange(width, height)`.
+ */
+ async invokeEventHandler(eventName: string, ...params: unknown[]) {
+ const hasCalledHandler = await invokeEventHandler(this.target, eventName, ...params);
this.recordEvent(eventName, hasCalledHandler);
}
diff --git a/src/user-event/utils/warnings.ts b/src/user-event/utils/warnings.ts
index 1625c92cf..afa1a36bc 100644
--- a/src/user-event/utils/warnings.ts
+++ b/src/user-event/utils/warnings.ts
@@ -1,10 +1,10 @@
import { getConfig } from '../../config';
-import type { EventWarning } from '../../events';
+import type { EventWarning } from '../../events/shared/warnings';
import {
formatDisabledTargets,
getPointerEventsBlockedTargets,
logEventWarning,
-} from '../../events';
+} from '../../events/shared/warnings';
import { computeAriaDisabled } from '../../helpers/accessibility';
import type { Interaction } from './interaction';
diff --git a/tsconfig.json b/tsconfig.json
index 746bb86db..10282f768 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -16,5 +16,9 @@
"outDir": "dist"
},
"include": ["src/**/*"],
- "exclude": ["src/__tests__/react-19_2/**/*"]
+ "exclude": [
+ "src/__tests__/react-19_2/**/*",
+ "src/__tests__/modern-types/**/*",
+ "src/modern-types.ts"
+ ]
}
diff --git a/tsconfig.modern-types.json b/tsconfig.modern-types.json
new file mode 100644
index 000000000..d5179bc09
--- /dev/null
+++ b/tsconfig.modern-types.json
@@ -0,0 +1,5 @@
+{
+ "extends": "./tsconfig.json",
+ "include": ["src/__tests__/modern-types/**/*"],
+ "exclude": []
+}
diff --git a/website/docs/14.x/docs/api/events/_meta.json b/website/docs/14.x/docs/api/events/_meta.json
index 363342aa2..18633f700 100644
--- a/website/docs/14.x/docs/api/events/_meta.json
+++ b/website/docs/14.x/docs/api/events/_meta.json
@@ -1,4 +1,5 @@
[
{ "type": "file", "name": "user-event", "label": "User Event" },
- { "type": "file", "name": "fire-event", "label": "Fire Event" }
+ { "type": "file", "name": "fire-event", "label": "Fire Event" },
+ { "type": "file", "name": "modern-event-system", "label": "Modern Event System" }
]
diff --git a/website/docs/14.x/docs/api/events/fire-event.mdx b/website/docs/14.x/docs/api/events/fire-event.mdx
index b00e8f3d3..bdc2b3141 100644
--- a/website/docs/14.x/docs/api/events/fire-event.mdx
+++ b/website/docs/14.x/docs/api/events/fire-event.mdx
@@ -211,3 +211,47 @@ await render();
await fireEvent.layout(screen.getByTestId('box'), { width: 320, height: 80 });
```
+
+## `UNSAFE_invokeHandler` {#unsafe-invoke-handler}
+
+_Added in v14.2.0_
+
+```ts
+function UNSAFE_invokeHandler(
+ instance: TestInstance,
+ handlerName: string,
+ ...args: unknown[]
+): Promise;
+```
+
+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](/docs/api/events/user-event) 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(
+ {item}}
+ 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);
+```
diff --git a/website/docs/14.x/docs/api/events/modern-event-system.mdx b/website/docs/14.x/docs/api/events/modern-event-system.mdx
new file mode 100644
index 000000000..c9d6070e4
--- /dev/null
+++ b/website/docs/14.x/docs/api/events/modern-event-system.mdx
@@ -0,0 +1,20 @@
+# Modern Event System
+
+## How to enable the modern event system
+
+Add these two lines to your Jest setup file:
+
+```ts title=jest-setup.ts
+import { configure } from '@testing-library/react-native';
+// Type-checks `fireEvent` arguments for the modern event system.
+import '@testing-library/react-native/modern-types';
+
+// Switches `fireEvent` and `userEvent` to the modern event system.
+configure({ unstable_eventSystem: 'modern' });
+```
+
+Make sure your `tsconfig.json` includes the setup file. If it doesn't, TypeScript won't see the import, and you won't get the stricter checks.
+
+## Event types
+
+Pass event types without the `on` prefix: `'focus'`, not `'onFocus'`. The prefix still works but logs a deprecation warning, and will stop working in the next major release.