diff --git a/contributing/event-dispatch.md b/contributing/event-dispatch.md index db90c0c6b..f5d09203d 100644 --- a/contributing/event-dispatch.md +++ b/contributing/event-dispatch.md @@ -2,24 +2,41 @@ 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 `eventSystem` config option. -| 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 `'modern'` event system (`src/events/modern/`), still in progress, follows the W3C [`EventTarget`](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget) dispatch that React Native enables by default from 0.88 (the `enableNativeEventTargetEventDispatching` feature flag; `src/private/renderer/events/dispatchNativeEvent.js` and `src/private/webapis/dom/events/EventTarget.js` in `node_modules/react-native`). The path holds only host elements. Bubbling events run a capture phase from the root to the target (`on*Capture` props), then a bubble phase back up (`on*` props), and direct events reach only the target. Event objects have the W3C `Event` fields (`eventPhase`, `currentTarget`, `composedPath()`, `stopPropagation()`, `preventDefault()`) next to the legacy synthetic event API (`nativeEvent`, `persist()`, `isDefaultPrevented()`). Check changes to modern dispatch against these upstream files, not React's legacy plugin system. -`src/user-event/` is a separate module on top of `src/events/` and imports it only through `src/events/index.ts`. +RNTL uses this dispatch with every supported React Native version (`>=0.78`), including those before 0.88, which still dispatch through React's legacy plugin system. Prop names, handler order and the synthetic event API are the same on both paths, so tests on older versions get the 0.88 behavior and the W3C fields on top. The modern event system doesn't check the React Native version or its feature flag. See [the 0.88 alignment research](research/rn-0.88-w3c-event-dispatch-alignment.md) for where RNTL matches React Native and where it still differs, e.g. responder negotiation for fired touch events and `addEventListener()` listeners. + +The files in `src/events/legacy/`: + +| 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` drops or moves events a device wouldn't emit from the element (`getEmittingTarget()` in `src/events/modern/emitting-target.ts`), before updating native state, so a dropped `change` leaves it alone. Touch and pointer events (`touchStart`, `pointerDown`, `click`, ...) go to the hit target, found with the same `getTouchPath()` as `press`, so `pointerEvents` moves them to an ancestor or drops them, and `event.target` is the hit target. `display: none` on the element or an ancestor drops them instead of moving them, as a device lays out a hidden view with no frame (iOS doesn't mount it, Android makes it `INVISIBLE`), so no touch can land in its subtree. Text editing events (`focus`, `blur`, `change`, `submitEditing`, `keyPress`, ...) go to the nearest host `TextInput`, as a nested `Text` isn't a native view, and are dropped when it isn't editable. `selectionChange` isn't one of them, as iOS emits it for a non-editable multiline `TextInput`. `change` of a disabled `Switch` is dropped. `disabled` doesn't block touch and pointer events, as on iOS, where only Pressability checks it; on Android, a disabled view is never the hit target, which RNTL doesn't model. Events with `dispatch` keep their target, as the native component declaring them decides when to emit them. The checks aren't in `dispatchEvent()`, which `userEvent` also uses and which stays simple; `userEvent` actions do their own checks. Events are dropped silently, as modern `fireEvent` has no `eventDiagnostics` warnings yet. + +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`'s event object is optional and defaults to `{}`, as the handler gets a `SyntheticEvent` either way. `ensureAtMostOneEventArg()` rejects only extra arguments, which catch legacy calls passing several handler arguments. Event types React Native doesn't know still throw without one, as they need `dispatch`. Touch events (`touchStart`, `touchMove`, `touchEnd`, `touchCancel`) get a default touch payload from `buildTouchPayload()` (`src/events/modern/touch-payload.ts`), the one Pressability callbacks get, with the passed `nativeEvent` deep merged onto it. Other event types, and events with `dispatch`, get the passed `nativeEvent` as is. + +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 +46,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().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..3291319d2 100644 --- a/contributing/native-events.md +++ b/contributing/native-events.md @@ -2,7 +2,9 @@ In React Native, some events **bubble** up to parent elements and others are **direct**, meaning only the element that emitted them receives them. `fireEvent` should behave the same way. -Today, `fireEvent` still bubbles direct events, with a warning (see [Known gaps](#known-gaps)). Only `fireEvent.layout()` does not bubble. The rules live in `isDirectEvent()` in `src/events/propagation.ts`. +In the `'legacy'` event system, the default, `fireEvent` still bubbles direct events, with a warning (see [Known gaps](#known-gaps)). Only `fireEvent.layout()` does not bubble. The rules live in `isDirectEvent()` in `src/events/legacy/propagation.ts`. + +The `'modern'` event system dispatches as React Native 0.88 does, through its W3C `EventTarget` dispatch: direct events reach only the target, and bubbling events run capture and bubble phases. It does so on every supported React Native version, including those before 0.88. Bubbling and direct event types come from `src/events/modern/event-types.ts`. See [Event dispatch](event-dispatch.md). ## Which events are which 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..44ef48dea 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({ eventSystem })`), so `jest.config.js` has two projects: + +- `legacy` runs all tests with the default `'legacy'` event system. +- `modern` runs all tests again with `'modern'` (`jest-setup-modern.ts`). + +Both projects share the same snapshots. `createEventLogger()` entries print only the `nativeEvent` of event payloads (`src/test-utils/event-serializer.ts`), so a snapshot is the same for a legacy event object and a modern `SyntheticEvent`, and a difference between the two systems fails the snapshot. Use `--selectProjects legacy` or `--selectProjects modern` to run one of them. + +When a test expects different behavior in the two systems, e.g. events bubbling to a parent, branch on `getConfig().eventSystem` inside the test instead of skipping it, with a short comment saying which system matches React Native. Name the test after the modern behavior, which aims to match React Native, with the legacy difference in a `(legacy: ...)` suffix, e.g. `does not bubble $name (legacy: bubbles with a warning)`. Prefer ternaries in the expectations (`toHaveBeenCalledTimes(isModern ? 0 : 1)`). For assertions only one system can make, like a legacy warning message, end the test with `if (isModern) return;` before them (`jest/no-conditional-expect` forbids `expect()` inside `if` blocks). Generic `fireEvent(element, eventType, event)` calls that run in both systems pass an event object, e.g. `{ nativeEvent: {} }`, as modern `fireEvent` requires one. `src/events/__tests__/fire-event.test.tsx` runs the public `fireEvent` this way. + +Tests that can't run in the modern event system at all can call `runInLegacyEventSystem()` from `src/test-utils/event-system.ts` at the top of the file (or in a `describe()`). They then run in the legacy event system in both projects. + +`fireEvent` types follow the legacy signatures. Importing `@testing-library/react-native/modern-types` (`src/modern-types.ts`) switches them to the modern ones, for the whole TypeScript program. So `tsconfig.json` excludes it, and `src/__tests__/modern-types/` type-checks the modern signatures in their own program (`tsconfig.modern-types.json`, run by `yarn typecheck`). There, calls the types reject use `// @ts-expect-error` and also assert that they throw at runtime. diff --git a/docs/api/configuration.md b/docs/api/configuration.md index 322d52550..3c33a9069 100644 --- a/docs/api/configuration.md +++ b/docs/api/configuration.md @@ -13,6 +13,9 @@ type Config = { /** Warn when `fireEvent` or a `userEvent` interaction calls no handler. Off by default. */ eventDiagnostics: boolean; + /** Event system used by `fireEvent` and `userEvent`. Defaults to `'legacy'`. */ + eventSystem: 'legacy' | 'modern'; + /** Default options for `debug` helper. */ defaultDebugOptions?: Partial; }; @@ -56,6 +59,19 @@ No warning is logged when the event updates native state, e.g. `fireEvent.change configure({ eventDiagnostics: true }); ``` +### `eventSystem` option + +Event system used by [`fireEvent`](./fire-event.md), [`userEvent`](./user-event.md) and [`UNSAFE_invokeHandler`](./fire-event.md#unsafe-invoke-handler). Defaults to `'legacy'`. + +- `'legacy'`: the simplified event system RNTL has used from the beginning. +- `'modern'` (recommended): simulates React Native's event dispatch, with direct and bubbling events and `SyntheticEvent` objects. + +```ts +configure({ eventSystem: 'modern' }); +``` + +See [Modern Event System](/docs/api/events/modern-event-system) for the differences and how to enable it. + ### `defaultDebugOptions` option Default [debug options](#debug) to be used when calling `debug()`. These default options will be overridden by the ones you specify directly when calling `debug()`. 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..5526da7f2 --- /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({ eventSystem: 'modern' }); +}); diff --git a/jest.config.js b/jest.config.js index b47157dd6..b5a21f24c 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({ 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/refs/react-native b/refs/react-native index db42fda57..e04aaf62f 160000 --- a/refs/react-native +++ b/refs/react-native @@ -1 +1 @@ -Subproject commit db42fda57495eecb35fa2fa8f09a0f6c42ec2434 +Subproject commit e04aaf62f70491c803f0e0653075ec088c7c88f4 diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 1fecd4f33..ff4517054 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().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, + eventSystem: 'legacy', }); }); +test('configure() sets eventSystem', () => { + configure({ eventSystem: 'modern' }); + expect(getConfig().eventSystem).toEqual('modern'); + + configure({ asyncUtilTimeout: 2000 }); + expect(getConfig().eventSystem).toEqual('modern'); + + resetToDefaults(); + expect(getConfig().eventSystem).toEqual('legacy'); +}); + test('resetToDefaults() resets config to defaults', () => { configure({ asyncUtilTimeout: 5000, @@ -66,6 +79,7 @@ test('does not warn when only valid options are passed', () => { defaultIncludeHiddenElements: true, defaultDebugOptions: { message: 'test' }, defaultHidden: false, + eventSystem: 'modern', }); expect(_console.warn).not.toHaveBeenCalled(); 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..8ff338f78 --- /dev/null +++ b/src/__tests__/modern-types/fire-event.test.tsx @@ -0,0 +1,108 @@ +import '../../modern-types'; + +import * as React from 'react'; +import { Pressable, ScrollView, TextInput, View } from 'react-native'; + +import { fireEvent, render, screen } from '../..'; +import { configure } from '../../config'; +import { _console } from '../../helpers/logger'; + +// Type-checked by `tsconfig.modern-types.json`, where `modern-types` switches `fireEvent` to the +// modern signatures. Calls the types reject also throw at runtime. + +beforeEach(() => { + configure({ eventSystem: 'modern' }); +}); + +const emptyEvent = { nativeEvent: {} }; + +test('accepts known event types, with or without the deprecated "on" prefix', async () => { + const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); + const onFocus = jest.fn(); + const onLayout = jest.fn(); + await render(); + const input = screen.getByTestId('input'); + + await fireEvent(input, 'focus', emptyEvent); + await fireEvent(input, 'onLayout', { nativeEvent: { layout: { width: 100 } }, timeStamp: 1 }); + // The event object is optional. + await fireEvent(input, 'focus'); + await fireEvent(input, 'onLayout'); + + expect(onFocus).toHaveBeenCalledTimes(2); + expect(onLayout).toHaveBeenCalledTimes(2); + expect(warnSpy).toHaveBeenCalledTimes(2); + warnSpy.mockRestore(); +}); + +test('accepts custom event types with dispatch', async () => { + const onPageSelected = jest.fn(); + const onChangeText = jest.fn(); + await render(); + 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 the event object with `dispatch` is required for these event types + await expect(fireEvent(pager, 'pageSelected')).rejects.toThrow(); + // @ts-expect-error `changeText` is not a native event type, the type error suggests alternatives + await expect(fireEvent(pager, 'changeText', 'Hello')).rejects.toThrow(); + // @ts-expect-error `changeText` is not a native event type, the type error suggests alternatives + await expect(fireEvent(pager, 'onChangeText', emptyEvent)).rejects.toThrow(); + // @ts-expect-error unsupported `dispatch` value + await expect(fireEvent(pager, 'pageSelected', { dispatch: 'capture' })).rejects.toThrow(); +}); + +test('rejects anything but at most one event object', async () => { + await render(); + const view = screen.getByTestId('view'); + + // @ts-expect-error more than one argument + await expect(fireEvent(view, 'focus', emptyEvent, emptyEvent)).rejects.toThrow(); + // @ts-expect-error not an event object + await expect(fireEvent(view, 'focus', 'event')).rejects.toThrow(); + // @ts-expect-error `nativeEvent` must be an object + await expect(fireEvent(view, 'focus', { nativeEvent: 1 })).rejects.toThrow(); + // @ts-expect-error unsupported event object key + await expect(fireEvent(view, 'focus', { nativeEvent: {}, target: view })).rejects.toThrow(); + // @ts-expect-error unsupported event object key + await expect(fireEvent.press(view, { pageX: 20 })).rejects.toThrow(); +}); diff --git a/src/config.ts b/src/config.ts index b19cb18cf..5b099c0cd 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,12 @@ export type Config = { */ eventDiagnostics: boolean; + /** + * Event system used by `fireEvent` and `userEvent`. `'legacy'` is the current simplified + * implementation. `'modern'` follows React Native's event dispatch. Defaults to `'legacy'`. + */ + eventSystem: EventSystem; + /** Default options for `debug` helper. */ defaultDebugOptions?: Partial; }; @@ -31,6 +39,7 @@ const defaultConfig: Config = { asyncUtilTimeout: 1000, defaultIncludeHiddenElements: false, eventDiagnostics: false, + eventSystem: 'legacy', }; let config = { ...defaultConfig }; @@ -45,6 +54,7 @@ export function configure(options: Partial) { defaultHidden, defaultIncludeHiddenElements, eventDiagnostics, + eventSystem, ...rest } = options; @@ -59,6 +69,7 @@ export function configure(options: Partial) { defaultDebugOptions, defaultIncludeHiddenElements: resolvedDefaultIncludeHiddenElements, eventDiagnostics: eventDiagnostics ?? config.eventDiagnostics, + eventSystem: eventSystem ?? config.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..de4867822 --- /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({ 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({ 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({ 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({ eventSystem: 'legacy' }); + const legacyEvent = build(); + configure({ 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 67a40615e..99a6a6c5e 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -8,6 +8,7 @@ import { PanResponder, Pressable, ScrollView, + Switch, Text, TextInput, TouchableHighlight, @@ -16,45 +17,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' }); + + expect(onOnline).toHaveBeenCalledTimes(1); + expect(getDeprecationWarnings()).toHaveLength(0); + }); + + describe('legacy', () => { + runInLegacyEventSystem(); - await fireEvent(screen.getByTestId('view'), 'onPress'); - expect(press).not.toHaveBeenCalled(); - expect(testOnlyPress).not.toHaveBeenCalled(); + 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'), 'press'); - expect(press).toHaveBeenCalledTimes(1); + 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(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 +242,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 +334,251 @@ 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.each([ + ['without an event object', []], + ['with an undefined event object', [undefined]], + ])('fires %s (legacy: calls the handler without arguments)', async (_, args) => { + const onFocus = jest.fn(); + await render(); + + await fireEvent(screen.getByTestId('input'), 'focus', ...args); + + // Modern matches React Native, where handlers always receive an event. + expect(onFocus.mock.calls).toEqual(isModern() ? [[anySyntheticEvent]] : [args]); + expect(onFocus.mock.calls[0][0]?.nativeEvent).toEqual(isModern() ? {} : undefined); + }); + + test('fires Pressability events without an event object (legacy: calls the callback without arguments)', async () => { + const onPressIn = jest.fn(); + await render(); + + await fireEvent(screen.getByTestId('pressable'), 'pressIn'); + + expect(onPressIn.mock.calls).toEqual(isModern() ? [[anySyntheticEvent]] : [[]]); + }); + + test('throws without an event object for unknown event types (legacy: calls the handler without arguments)', async () => { + const onPageSelected = jest.fn(); + await render(); + + const error = await getErrorMessage(fireEvent(screen.getByTestId('pager'), 'pageSelected')); + + // Modern needs `dispatch` in the event object to know how to dispatch the event. + expect(error).toBe( + isModern() + ? `Unable to fire a "pageSelected" event. React Native doesn't know "pageSelected". ` + + '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(onPageSelected.mock.calls).toEqual(isModern() ? [] : [[]]); + }); + + test.each([ + ['text', 'string "text"'], + [42, 'number 42'], + [null, 'null'], + [[{ 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 +586,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 +596,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 +669,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 +711,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,57 +811,345 @@ 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('updates native state when fired with `on*` prefixed name', async () => { + test('does not fire on read-only TextInput', async () => { + const onChange = jest.fn(); + const onChangeText = jest.fn(); + await render( + , + ); + const input = screen.getByTestId('input'); + + 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('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('does not fire on read-only TextInput', async () => { - const onChangeText = jest.fn(); - await render(); - const input = screen.getByTestId('input'); - await fireEvent.changeText(input, 'new text'); - expect(onChangeText).not.toHaveBeenCalled(); - expect(nativeState.valueForInstance.get(input)).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( @@ -252,38 +1158,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], @@ -302,48 +1217,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 () => { @@ -362,18 +1286,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'], @@ -385,7 +1297,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, @@ -410,6 +1325,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( @@ -506,11 +1433,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'); @@ -522,32 +1449,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], @@ -608,7 +1543,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( @@ -618,10 +1553,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(); }); @@ -636,26 +1572,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( @@ -842,7 +1758,6 @@ describe('direct events', () => { ), ]; - // Builds one case per event type, passing the matching `on*` handler prop to `ui`. function buildDirectEventCases( source: string, eventTypes: string[], @@ -855,15 +1770,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', @@ -871,145 +1790,523 @@ 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( + + + , + ); + + const nativeEvent = { layout: { x: 0, y: 0, width: 100, height: 50 } }; + await fireEvent(screen.getByTestId('target'), 'layout', { nativeEvent }); + + expect(onLayout).toHaveBeenCalledTimes(1); + expect(onLayout.mock.calls[0][0].nativeEvent).toBe(nativeEvent); + expect(onParentLayout).not.toHaveBeenCalled(); + }); + + 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: + + + " + `); + }); + + test.each([true, false])( + 'does not bubble regardless of eventDiagnostics (%s) (legacy: always warns about bubbling)', + async (eventDiagnostics) => { + configure({ eventDiagnostics }); + await render( + {}}> + + , + ); + + await fireEvent.scroll(screen.getByTestId('child')); + + // 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('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 }) => ( + + + + ); + await render(); + + await fireEvent.scroll(screen.getByTestId('child')); + + // 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 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 }) => ( + + + + + + ); + await render(); + + await fireEvent.scroll(screen.getByTestId('child')); + + // 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 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 }) => ( + + Card + + ); + await render(); + + await fireEvent(screen.getByText('Card'), 'load', emptyEvent); + await fireEvent(screen.getByText('Card'), 'show', emptyEvent); + + // 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 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 + + Content + , + ); + + await fireEvent(screen.getByText('Content'), 'load', emptyEvent); + + // 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 bubble load event to ImageBackground handler (legacy: bubbles without a warning)', async () => { + const onLoad = jest.fn(); + await render( + + Caption + , + ); + + await fireEvent(screen.getByText('Caption'), 'load', emptyEvent); + + // 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(); + }); +}); + +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('child'), 'onMomentumScrollEnd'); + await fireEvent(screen.getByTestId('target'), 'focus', { nativeEvent: {}, dispatch: 'direct' }); - expect(onMomentumScrollEnd).toHaveBeenCalledTimes(1); - expect(warnSpy).toHaveBeenCalledTimes(1); + expect(onFocus).toHaveBeenCalledTimes(1); + expect(onParentFocus).not.toHaveBeenCalled(); }); - test('warns about stopping bubbling in the next major version', async () => { + 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.scroll(screen.getByTestId('child')); - - 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: + await fireEvent(screen.getByTestId('target'), 'layout', { + nativeEvent: {}, + dispatch: 'bubbling', + }); - - " - `); + expect(onLayout).toHaveBeenCalledTimes(1); + expect(onParentLayout).toHaveBeenCalledTimes(isModern() ? 1 : 0); }); - test.each([true, false])( - 'warns about bubbling regardless of eventDiagnostics (%s)', - async (eventDiagnostics) => { - configure({ eventDiagnostics }); - await render( - {}}> - - , - ); + 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.scroll(screen.getByTestId('child')); + await fireEvent.press(screen.getByTestId('map'), { + nativeEvent: { coordinate: { latitude: 1, longitude: 2 } }, + dispatch: 'bubbling', + }); - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy.mock.calls[0][0]).toContain('"scroll" does not bubble in React Native'); - }, - ); + 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('warns when handler is on composite component above the emitting element', async () => { - const onScroll = jest.fn(); - const Screen = (_props: { onScroll: () => void }) => ( - - - - ); - await render(); + test('dispatch on a Pressable calls no Pressability callback (legacy: calls onPress)', async () => { + const onPress = jest.fn(); + await render(); - await fireEvent.scroll(screen.getByTestId('child')); + await fireEvent(screen.getByTestId('target'), 'press', { + nativeEvent: {}, + dispatch: 'bubbling', + }); - expect(onScroll).toHaveBeenCalledTimes(1); - expect(warnSpy).toHaveBeenCalledTimes(1); + // Modern dispatches a native `press` event, which `Pressable`'s host has no prop for. + expect(onPress).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 () => { - const onScroll = jest.fn(); - const Screen = (_props: { onScroll: () => void }) => ( - - - - - - ); - await render(); + 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 }), + ); - await fireEvent.scroll(screen.getByTestId('child')); + 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); + }, + ); - expect(onScroll).toHaveBeenCalledTimes(1); - expect(warnSpy).not.toHaveBeenCalled(); + 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' }); }); +}); - test('does not warn when bubbling to composite component handler', async () => { - const onLoad = jest.fn(); - const onShow = jest.fn(); - const Card = (_props: { onLoad: () => void; onShow: () => void }) => ( - - Card - +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 render(); - await fireEvent(screen.getByText('Card'), 'load'); - await fireEvent(screen.getByText('Card'), 'show'); + await fireEvent(screen.getByText('Pressable'), eventType, emptyEvent); + await fireEvent(screen.getByText('Touchable'), eventType, emptyEvent); - expect(onLoad).toHaveBeenCalledTimes(1); - expect(onShow).toHaveBeenCalledTimes(1); - expect(warnSpy).not.toHaveBeenCalled(); + expect(calls).toEqual([`pressable.${name}`, `touchable.${name}`]); }); - test('does not warn when bubbling to host element that does not emit the event', async () => { - const onLoad = jest.fn(); + test('passes a responder event with default touch payload (legacy: passes the event object as given)', async () => { + const onPressIn = jest.fn(); await render( - // @ts-expect-error View does not have onLoad prop - - Content - , + + Press me + , ); - await fireEvent(screen.getByText('Content'), 'load'); + await fireEvent(screen.getByText('Press me'), 'pressIn', { + nativeEvent: { pageX: 20 }, + timeStamp: 123, + }); - expect(onLoad).toHaveBeenCalledTimes(1); - expect(warnSpy).not.toHaveBeenCalled(); + 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('does not warn when bubbling load event to ImageBackground handler', async () => { - const onLoad = jest.fn(); + test('calls onLongPress without currentTarget (legacy: passes the event object as given)', async () => { + let currentTarget: unknown; await render( - - Caption - , + { + currentTarget = event.currentTarget; + }} + > + Press me + , ); - await fireEvent(screen.getByText('Caption'), 'load'); + await fireEvent(screen.getByText('Press me'), 'longPress', emptyEvent); - expect(onLoad).toHaveBeenCalledTimes(1); - expect(warnSpy).not.toHaveBeenCalled(); + // 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 fires custom event (onCustomEvent) on composite component', async () => { +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 @@ -1017,11 +2314,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 @@ -1029,15 +2335,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 () => { @@ -1057,32 +2363,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.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 does not throw when called with non-existent event name', async () => { +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', () => { @@ -1138,6 +2516,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 }) => ( @@ -1152,6 +2606,9 @@ describe('disabled elements', () => { }); describe('unhandled event warning', () => { + // Modern `fireEvent` doesn't warn about unhandled events. + runInLegacyEventSystem(); + let warnSpy: jest.SpyInstance; beforeEach(() => { @@ -1163,6 +2620,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( @@ -1192,6 +2653,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( @@ -1268,6 +2749,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( @@ -1459,6 +2976,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(); @@ -1476,15 +3013,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\\.`), @@ -1500,7 +3051,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); @@ -1516,24 +3067,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.changeText(screen.getByTestId('input'), 'Hello'); + await fireEvent.press(pressable); + await fireEvent.changeText(input, 'Hello'); + await fireEvent(view, 'focus', emptyEvent); expect(warnSpy).not.toHaveBeenCalled(); }); @@ -1542,16 +3137,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(); }); @@ -1569,6 +3169,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( @@ -1626,7 +3237,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(); }); @@ -1636,6 +3247,361 @@ 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('touch events', () => { + test.each([ + ['touchStart', true], + ['touchMove', true], + ['touchEnd', false], + ['touchCancel', false], + ] as const)( + '"%s" gets a default touch payload (legacy: passes the event object as given)', + async (eventType, isActive) => { + const handler = jest.fn(); + await render(); + + await fireEvent(screen.getByTestId('view'), eventType); + + // Modern matches React Native, where `touches` has the touch until it ends. + expect(handler.mock.calls).toEqual(isModern() ? [[anySyntheticEvent]] : [[]]); + expect(handler.mock.calls[0][0]?.nativeEvent).toEqual( + isModern() ? getTouchPayload(isActive) : undefined, + ); + }, + ); + + test('deep merges the passed nativeEvent onto the default touch payload (legacy: passes it as given)', async () => { + const onTouchMove = jest.fn(); + await render(); + + await fireEvent(screen.getByTestId('view'), 'touchMove', { + nativeEvent: { pageX: 110, pageY: 120 }, + }); + + expect(onTouchMove.mock.calls[0][0].nativeEvent).toEqual( + isModern() ? getTouchPayload(true, { pageX: 110, pageY: 120 }) : { pageX: 110, pageY: 120 }, + ); + }); + + test('keeps touch arrays of the passed nativeEvent', async () => { + const onTouchEnd = jest.fn(); + await render(); + const touches = [{ identifier: 1, pageX: 5, pageY: 5 }]; + + await fireEvent(screen.getByTestId('view'), 'touchEnd', { nativeEvent: { touches } }); + + expect(onTouchEnd.mock.calls[0][0].nativeEvent.touches).toEqual(touches); + expect(onTouchEnd.mock.calls[0][0].nativeEvent.changedTouches).toEqual( + isModern() ? [defaultTouch] : undefined, + ); + }); + + test('takes timeStamp from the touch timestamp', async () => { + const onTouchStart = jest.fn(); + await render(); + + const nowSpy = jest.spyOn(performance, 'now').mockReturnValue(456); + await fireEvent(screen.getByTestId('view'), 'touchStart', {}); + nowSpy.mockRestore(); + + // Legacy passes the event object as given. + expect(onTouchStart.mock.calls[0][0].nativeEvent?.timestamp).toBe(isModern() ? 456 : undefined); + expect(onTouchStart.mock.calls[0][0].timeStamp).toBe(isModern() ? 456 : undefined); + }); + + test('passes the nativeEvent as given with dispatch', async () => { + const onTouchStart = jest.fn(); + await render(); + + await fireEvent(screen.getByTestId('view'), 'touchStart', { + nativeEvent: { pageX: 1 }, + dispatch: 'direct', + }); + + expect(onTouchStart.mock.calls[0][0].nativeEvent).toEqual({ pageX: 1 }); + }); + + // Drag detection from the touch handlers of a `Pressable`, as in Expensify's + // `ToggleDistanceUnitButton`. Its tests fire `touchEnd` without an event object. + function DragAwareButton({ onPress }: { onPress: () => void }) { + const start = React.useRef<{ x: number; y: number } | null>(null); + return ( + { + start.current = { x: event.nativeEvent.pageX, y: event.nativeEvent.pageY }; + }} + onTouchMove={(event) => { + if ( + start.current != null && + (Math.abs(event.nativeEvent.pageX - start.current.x) > 1 || + Math.abs(event.nativeEvent.pageY - start.current.y) > 1) + ) { + start.current = null; + } + }} + onTouchEnd={() => { + if (start.current != null) { + onPress(); + } + start.current = null; + }} + /> + ); + } + + test('runs a tap from touch handlers', async () => { + const onPress = jest.fn(); + await render(); + const button = screen.getByTestId('button'); + + await fireEvent(button, 'touchStart', { nativeEvent: { pageX: 100, pageY: 100 } }); + await fireEvent(button, 'touchEnd'); + + expect(onPress).toHaveBeenCalledTimes(1); + }); + + test('runs a drag from touch handlers', async () => { + const onPress = jest.fn(); + await render(); + const button = screen.getByTestId('button'); + + await fireEvent(button, 'touchStart', { nativeEvent: { pageX: 100, pageY: 100 } }); + await fireEvent(button, 'touchMove', { nativeEvent: { pageX: 110, pageY: 110 } }); + await fireEvent(button, 'touchEnd'); + + expect(onPress).not.toHaveBeenCalled(); + }); +}); + +describe('touch event hit testing', () => { + test('moves touchStart from View with pointerEvents="none" to its parent (legacy: fires on the View)', async () => { + const onParentTouchStart = jest.fn(); + const onTouchStart = jest.fn(); + await render( + + + , + ); + + await fireEvent(screen.getByTestId('view'), 'touchStart', emptyEvent); + + // Modern matches React Native, where hit testing skips the View, so the touch targets its + // parent. Legacy blocks only `press`. + expect(onTouchStart).toHaveBeenCalledTimes(isModern() ? 0 : 1); + expect(onParentTouchStart).toHaveBeenCalledTimes(isModern() ? 1 : 0); + expect(onParentTouchStart.mock.calls[0]?.[0].target).toBe( + isModern() ? screen.getByTestId('parent') : undefined, + ); + }); + + test('drops touchStart when pointerEvents="none" blocks every element (legacy: fires on the View)', async () => { + const onTouchStart = jest.fn(); + await render(); + + await fireEvent(screen.getByTestId('view'), 'touchStart', emptyEvent); + + expect(onTouchStart).toHaveBeenCalledTimes(isModern() ? 0 : 1); + }); + + test('drops touchStart on View with pointerEvents="box-none" (legacy: fires on the View)', async () => { + const onTouchStart = jest.fn(); + await render(); + + await fireEvent(screen.getByTestId('view'), 'touchStart', emptyEvent); + + // Modern matches React Native: the View is never the touch target, and has no ancestor. + expect(onTouchStart).toHaveBeenCalledTimes(isModern() ? 0 : 1); + }); + + test('fires touchStart on child of View with pointerEvents="box-none" (legacy: calls only the child)', async () => { + const onParentTouchStart = jest.fn(); + const onTouchStart = jest.fn(); + await render( + + + , + ); + + await fireEvent(screen.getByTestId('child'), 'touchStart', emptyEvent); + + // Modern matches React Native: the child is the target, and the event bubbles to the parent. + expect(onTouchStart).toHaveBeenCalledTimes(1); + expect(onParentTouchStart).toHaveBeenCalledTimes(isModern() ? 1 : 0); + expect(onTouchStart.mock.calls[0]?.[0].target).toBe( + isModern() ? screen.getByTestId('child') : undefined, + ); + }); + + test('moves touchStart from child to View with pointerEvents="box-only" (legacy: fires on the child)', async () => { + const onParentTouchStart = jest.fn(); + const onTouchStart = jest.fn(); + await render( + + + , + ); + + await fireEvent(screen.getByTestId('child'), 'touchStart', emptyEvent); + + expect(onTouchStart).toHaveBeenCalledTimes(isModern() ? 0 : 1); + expect(onParentTouchStart).toHaveBeenCalledTimes(isModern() ? 1 : 0); + expect(onParentTouchStart.mock.calls[0]?.[0].target).toBe( + isModern() ? screen.getByTestId('parent') : undefined, + ); + }); + + test('moves pointerDown from View with pointerEvents="none" to its parent (legacy: fires on the View)', async () => { + const onParentPointerDown = jest.fn(); + const onPointerDown = jest.fn(); + await render( + + + , + ); + + await fireEvent(screen.getByTestId('view'), 'pointerDown', emptyEvent); + + expect(onPointerDown).toHaveBeenCalledTimes(isModern() ? 0 : 1); + expect(onParentPointerDown).toHaveBeenCalledTimes(isModern() ? 1 : 0); + }); + + test('fires touch event with dispatch on View with pointerEvents="none"', async () => { + const onTouchStart = jest.fn(); + await render(); + + // A native component declaring its own event decides when to emit it. + await fireEvent(screen.getByTestId('view'), 'touchStart', { + nativeEvent: {}, + dispatch: 'bubbling', + }); + + expect(onTouchStart).toHaveBeenCalledTimes(1); + }); + + test('fires focus inside View with pointerEvents="none"', async () => { + const onFocus = jest.fn(); + await render( + + + , + ); + + await fireEvent(screen.getByTestId('input'), 'focus', emptyEvent); + + // `pointerEvents` only affects touches. The input can still be focused, e.g. from a keyboard. + expect(onFocus).toHaveBeenCalledTimes(1); + }); + + test('fires touchStart on disabled Pressable (legacy: blocks it)', async () => { + const onPress = jest.fn(); + const onTouchStart = jest.fn(); + await render( + , + ); + + await fireEvent(screen.getByTestId('pressable'), 'touchStart', emptyEvent); + + // Modern matches iOS, where only Pressability checks `disabled`. Legacy blocks events whose + // touch responder declines the touch. + expect(onTouchStart).toHaveBeenCalledTimes(isModern() ? 1 : 0); + expect(onPress).not.toHaveBeenCalled(); + }); +}); + +describe('display: none', () => { + test('does not press inside View with display: none (legacy: presses)', async () => { + const onPress = jest.fn(); + await render( + + + , + ); + + await fireEvent.press(screen.getByTestId('pressable', { includeHiddenElements: true })); + + // Modern matches React Native, where a hidden view has no frame, so nothing in it can be + // touched. Legacy doesn't check `display`. + expect(onPress).toHaveBeenCalledTimes(isModern() ? 0 : 1); + }); + + test('drops touchStart on View with display: none instead of moving it to the parent (legacy: fires on the View)', async () => { + const onParentTouchStart = jest.fn(); + const onTouchStart = jest.fn(); + await render( + + + , + ); + + await fireEvent( + screen.getByTestId('view', { includeHiddenElements: true }), + 'touchStart', + emptyEvent, + ); + + // Unlike `pointerEvents="none"`, the hidden View covers no area of its parent to touch. + expect(onTouchStart).toHaveBeenCalledTimes(isModern() ? 0 : 1); + expect(onParentTouchStart).not.toHaveBeenCalled(); + }); + + test('does not press an element that became hidden after it was queried (legacy: presses)', async () => { + const onPress = jest.fn(); + function Screen({ hidden }: { hidden: boolean }) { + return ( + + + + ); + } + + await render(