From b561465b413ced44e338e8efa7e016a281d75b0d Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Fri, 9 Oct 2026 15:57:28 +0200 Subject: [PATCH 1/7] feat: modern event subsystem (#1944) --- contributing/event-dispatch.md | 34 +- contributing/native-events.md | 4 +- contributing/native-state.md | 6 +- contributing/testing.md | 13 + jest-setup-modern.ts | 6 + jest.config.js | 21 +- src/__tests__/config.test.ts | 14 + src/config.ts | 12 + src/events/__tests__/create-event.test.ts | 84 + src/events/__tests__/dispatch-event.test.tsx | 69 + src/events/__tests__/fire-event.test.tsx | 1846 +---------------- src/events/builders/__tests__/index.test.ts | 17 - src/events/builders/__tests__/merge.test.ts | 35 - src/events/builders/__tests__/scroll.test.ts | 37 - src/events/builders/__tests__/text.test.ts | 52 - src/events/builders/common.ts | 108 - src/events/builders/index.ts | 4 - src/events/builders/merge.ts | 30 - src/events/builders/scroll.ts | 36 - src/events/builders/text.ts | 74 - src/events/create-event.ts | 104 + src/events/dispatch-event.ts | 29 + src/events/dispatch.ts | 34 - src/events/fire-event.ts | 111 +- .../{ => legacy}/__tests__/dispatch.test.tsx | 2 +- .../legacy/__tests__/fire-event.test.tsx | 1818 ++++++++++++++++ .../builders/__tests__/base.test.ts | 11 +- .../builders/__tests__/common.test.ts | 33 +- src/events/{ => legacy}/builders/base.ts | 5 + src/events/legacy/builders/common.ts | 23 + src/events/legacy/dispatch.ts | 19 + src/events/legacy/fire-event.ts | 82 + src/events/{ => legacy}/index.ts | 7 +- src/events/{ => legacy}/is-enabled.ts | 4 +- src/events/{ => legacy}/propagation.ts | 6 +- src/events/{ => legacy}/warnings.ts | 14 +- src/events/modern/__tests__/dispatch.test.tsx | 527 +++++ .../modern/__tests__/event-types.test.ts | 142 ++ src/events/modern/__tests__/event.test.ts | 180 ++ .../modern/__tests__/fire-event.test.tsx | 806 +++++++ src/events/modern/dispatch.ts | 165 ++ src/events/modern/event-types.ts | 136 ++ src/events/modern/event.ts | 257 +++ src/events/modern/fire-event-utils.ts | 219 ++ src/events/modern/fire-event.ts | 147 ++ .../{ => shared}/__tests__/handler.test.tsx | 20 +- .../__tests__/invoke-event-handler.test.tsx | 69 + src/events/shared/__tests__/merge.test.ts | 63 + src/events/shared/__tests__/payloads.test.ts | 105 + src/events/{ => shared}/handler.ts | 39 +- src/events/shared/invoke-event-handler.ts | 35 + src/events/shared/merge.ts | 33 + src/events/{ => shared}/native-state.ts | 4 +- src/events/shared/payloads.ts | 156 ++ src/events/{ => shared}/types.ts | 2 +- .../{ => shared}/update-native-state.ts | 44 +- src/helpers/object.ts | 3 +- src/helpers/text-input.ts | 2 +- src/pure.ts | 2 +- src/test-utils/event-serializer.ts | 29 + src/test-utils/event-system.ts | 11 + src/test-utils/events.ts | 16 +- .../__snapshots__/clear.test.tsx.snap | 207 -- .../__snapshots__/paste.test.tsx.snap | 234 --- src/user-event/__tests__/clear.test.tsx | 47 +- src/user-event/__tests__/paste.test.tsx | 49 +- .../accessibility-action.ts | 2 +- src/user-event/clear.ts | 4 +- src/user-event/paste.ts | 7 +- .../__snapshots__/longPress.test.tsx.snap | 56 - .../__snapshots__/press.test.tsx.snap | 42 - src/user-event/press/press.ts | 16 +- .../scroll-to-flat-list.test.tsx.snap | 54 - .../__snapshots__/scroll-to.test.tsx.snap | 54 - src/user-event/scroll/scroll-to.ts | 31 +- .../__snapshots__/type-managed.test.tsx.snap | 260 --- .../__snapshots__/type.test.tsx.snap | 466 ----- src/user-event/type/__tests__/type.test.tsx | 66 +- src/user-event/type/type.ts | 12 +- src/user-event/utils/interaction.ts | 29 +- src/user-event/utils/warnings.ts | 4 +- 81 files changed, 5802 insertions(+), 3854 deletions(-) create mode 100644 jest-setup-modern.ts create mode 100644 src/events/__tests__/create-event.test.ts create mode 100644 src/events/__tests__/dispatch-event.test.tsx delete mode 100644 src/events/builders/__tests__/index.test.ts delete mode 100644 src/events/builders/__tests__/merge.test.ts delete mode 100644 src/events/builders/__tests__/scroll.test.ts delete mode 100644 src/events/builders/__tests__/text.test.ts delete mode 100644 src/events/builders/common.ts delete mode 100644 src/events/builders/index.ts delete mode 100644 src/events/builders/merge.ts delete mode 100644 src/events/builders/scroll.ts delete mode 100644 src/events/builders/text.ts create mode 100644 src/events/create-event.ts create mode 100644 src/events/dispatch-event.ts delete mode 100644 src/events/dispatch.ts rename src/events/{ => legacy}/__tests__/dispatch.test.tsx (96%) create mode 100644 src/events/legacy/__tests__/fire-event.test.tsx rename src/events/{ => legacy}/builders/__tests__/base.test.ts (76%) rename src/events/{ => legacy}/builders/__tests__/common.test.ts (54%) rename src/events/{ => legacy}/builders/base.ts (77%) create mode 100644 src/events/legacy/builders/common.ts create mode 100644 src/events/legacy/dispatch.ts create mode 100644 src/events/legacy/fire-event.ts rename src/events/{ => legacy}/index.ts (60%) rename src/events/{ => legacy}/is-enabled.ts (96%) rename src/events/{ => legacy}/propagation.ts (96%) rename src/events/{ => legacy}/warnings.ts (91%) create mode 100644 src/events/modern/__tests__/dispatch.test.tsx create mode 100644 src/events/modern/__tests__/event-types.test.ts create mode 100644 src/events/modern/__tests__/event.test.ts create mode 100644 src/events/modern/__tests__/fire-event.test.tsx create mode 100644 src/events/modern/dispatch.ts create mode 100644 src/events/modern/event-types.ts create mode 100644 src/events/modern/event.ts create mode 100644 src/events/modern/fire-event-utils.ts create mode 100644 src/events/modern/fire-event.ts rename src/events/{ => shared}/__tests__/handler.test.tsx (85%) create mode 100644 src/events/shared/__tests__/invoke-event-handler.test.tsx create mode 100644 src/events/shared/__tests__/merge.test.ts create mode 100644 src/events/shared/__tests__/payloads.test.ts rename src/events/{ => shared}/handler.ts (55%) create mode 100644 src/events/shared/invoke-event-handler.ts create mode 100644 src/events/shared/merge.ts rename src/events/{ => shared}/native-state.ts (87%) create mode 100644 src/events/shared/payloads.ts rename src/events/{ => shared}/types.ts (93%) rename src/events/{ => shared}/update-native-state.ts (61%) create mode 100644 src/test-utils/event-serializer.ts create mode 100644 src/test-utils/event-system.ts diff --git a/contributing/event-dispatch.md b/contributing/event-dispatch.md index db90c0c6b..17a06fba7 100644 --- a/contributing/event-dispatch.md +++ b/contributing/event-dispatch.md @@ -2,24 +2,26 @@ RNTL has two ways to trigger events. Neither goes through React Native's native event system. Both find `on*` props in the rendered tree and call them inside `act()`. -Both are built on the shared event subsystem in `src/events/`, which also holds `fireEvent` itself: +Both are built on the shared event subsystem in `src/events/legacy/`, which also holds `fireEvent` itself. This is the `'legacy'` event system, the default for the `unstable_eventSystem` config option. A `'modern'` event system that follows React Native's event dispatch is in progress. -| File | Contents | -| ------------------------------------------- | --------------------------------------------------------------------------------------- | -| `fire-event.ts` | Public `fireEvent` API | -| `handler.ts` | Finding the `on*` handler for an event name in props | -| `propagation.ts` | Bubbling vs direct events, walking up host and composite elements | -| `is-enabled.ts` | Whether a device would deliver the event: `pointerEvents`, `editable`, touch responders | -| `dispatch.ts` | `dispatchEvent()`: calls the target's own handler in `act()`, used by `userEvent` | -| `warnings.ts` | `eventDiagnostics` warnings for `fireEvent`, and helpers shared with `userEvent` | -| `builders/` | Event payloads, matching what React Native sends on a device | -| `native-state.ts`, `update-native-state.ts` | [Native state](native-state.md) and how `fireEvent` updates it | +The files in `src/events/legacy/`: -`src/user-event/` is a separate module on top of `src/events/` and imports it only through `src/events/index.ts`. +| File | Contents | +| ---------------- | --------------------------------------------------------------------------------------------------------- | +| `fire-event.ts` | Public `fireEvent` API | +| `propagation.ts` | Bubbling vs direct events, walking up host and composite elements | +| `is-enabled.ts` | Whether a device would deliver the event: `pointerEvents`, `editable`, touch responders | +| `dispatch.ts` | `dispatchEvent()`: calls the target's own handler in `act()`, used by `userEvent` | +| `warnings.ts` | `eventDiagnostics` warnings for `fireEvent`, and helpers shared with `userEvent` | +| `builders/` | Legacy event objects: `wrapNativeEvent()` (stubs from `baseSyntheticEvent()`), touch and responder events | + +Code used by both event systems lives in `src/events/shared/`: `handler.ts` (finding the `on*` handler for an event name in props), `native-state.ts` and `update-native-state.ts` ([native state](native-state.md) and how `fireEvent` updates it), `payloads.ts` (`nativeEvent` payloads matching what React Native sends on a device), `merge.ts` (deep merging custom props into them), and `types.ts`. + +`src/user-event/` is a separate module on top of the event subsystem. It creates and dispatches native events through the facades `src/events/create-event.ts` and `src/events/dispatch-event.ts`, and imports the rest only through `src/events/legacy/index.ts`, which also re-exports `src/events/shared/handler.ts` and `src/events/shared/native-state.ts`. ## `fireEvent` -`src/events/fire-event.ts` is the public API. It calls a single handler for a single event, found with `findEventHandler()` from `src/events/propagation.ts`. The work is in finding the right handler: +`src/events/legacy/fire-event.ts` is the public API. It calls a single handler for a single event, found with `findEventHandler()` from `src/events/legacy/propagation.ts`. The work is in finding the right handler: - It starts at the target and moves up the tree until it finds a handler. It also checks props of composite components, not only host elements. - Direct events (see [Native event propagation](native-events.md)) still bubble, with a warning when they reach an ancestor that emits them. `fireEvent.layout()` only checks the target. @@ -29,11 +31,11 @@ Both are built on the shared event subsystem in `src/events/`, which also holds `src/user-event/` simulates a whole interaction (press, type, scroll, …) as a realistic sequence of events with delays between them. The sequences are based on how React Native behaves on real devices. -Each step uses `dispatchEvent()`, which only calls the target's own handler. It doesn't bubble or check whether the element is enabled. Each action does those checks itself, so the rules for an interaction live in one place. +Each step dispatches a native event, created for the configured event system by `createEvent()` (`src/events/create-event.ts`: a legacy event object or a `SyntheticEvent`) and dispatched by `dispatchEvent()` (`src/events/dispatch-event.ts`), or calls a JavaScript callback (`changeText`, `pressIn`, ...) with `invokeEventHandler()`, which only calls the target's own handler. Modern `fireEvent.changeText()` instead calls `onChangeText` from the `change` dispatch, through `dispatchEvent()`'s `afterTargetHandler` option, right after the input's own `onChange`, where `TextInput` calls it. Neither checks whether the element is enabled. Each action does those checks itself, so the rules for an interaction live in one place. For the `eventDiagnostics` warning, each action tracks itself with an `Interaction` from `src/user-event/utils/interaction.ts`: -- Dispatch events with `interaction.dispatchEvent()`, so it records whether any handler ran. Events go to `interaction.target`, which is the element the action was called with, unless the action moves it (as `press()` does when an ancestor handles the press). If the action has to call a handler itself, record it with `interaction.recordEvent()` (as `pullToRefresh()` does for `onRefresh` on the `refreshControl` prop). +- Dispatch native events with `interaction.dispatchEvent(eventType, buildFocusEvent())`, using the `build*Event()` helpers from `src/events/create-event.ts`, and call JavaScript callbacks with `interaction.invokeEventHandler(eventType, ...params)` (or `interaction.dispatchTouchEvent()` for `pressIn`, `pressOut` and `longPress`, which passes a touch event), so it records whether any handler ran. Events go to `interaction.target`, which is the element the action was called with, unless the action moves it (as `press()` does when an ancestor handles the press). If the action has to call a handler itself, record it with `interaction.recordEvent()` (as `pullToRefresh()` does for `onRefresh` on the `refreshControl` prop). - Set `hasUpdatedNativeState` when the action writes to `nativeState`. - Add elements that could handle the action but don't accept it to `skippedTargets`: disabled, non-editable `TextInput`, blocked by `pointerEvents`, or with a responder that declines the touch. The warning first reports the ones blocked by `pointerEvents`, with the element that blocks them (`getPointerEventsBlocker()`). Otherwise it reports the disabled ones (`computeAriaDisabled()`, which includes non-editable `TextInput`; when all of them are non-editable `TextInput`, the message calls them non-editable, see `formatDisabledTargets()`), and skips the warning if every skipped element has a responder that declines the touch. Text actions (`type()`, `clear()`, `paste()`) add the `TextInput` when it is non-editable or blocked by `pointerEvents`. - Call `warnAboutUnhandledInteraction()` from `src/user-event/utils/warnings.ts` at the end. It warns only if no handler ran and native state didn't change. @@ -42,5 +44,5 @@ For the `eventDiagnostics` warning, each action tracks itself with an `Interacti - To change which handler gets a single event, change `fireEvent`. To make an interaction more realistic, change the `userEvent` action. - Keep `dispatchEvent()` simple. -- Put event rules that both need, like the `pointerEvents` and `editable` checks, in `src/events/`. They may build on general helpers from `src/helpers/` (for example `isEditableTextInput`). Code used only by `userEvent`, like delays and scroll steps, stays in `src/user-event/`. +- Put event rules that both need, like the `pointerEvents` and `editable` checks, in `src/events/legacy/`. They may build on general helpers from `src/helpers/` (for example `isEditableTextInput`). Code used only by `userEvent`, like delays and scroll steps, stays in `src/user-event/`. - Event sequences should match a real device. Check on a device before changing one, and keep the code comments explaining the observed behavior. diff --git a/contributing/native-events.md b/contributing/native-events.md index 4d5ab48b5..842b1b36f 100644 --- a/contributing/native-events.md +++ b/contributing/native-events.md @@ -2,7 +2,7 @@ In React Native, some events **bubble** up to parent elements and others are **direct**, meaning only the element that emitted them receives them. `fireEvent` should behave the same way. -Today, `fireEvent` still bubbles direct events, with a warning (see [Known gaps](#known-gaps)). Only `fireEvent.layout()` does not bubble. The rules live in `isDirectEvent()` in `src/events/propagation.ts`. +Today, `fireEvent` still bubbles direct events, with a warning (see [Known gaps](#known-gaps)). Only `fireEvent.layout()` does not bubble. The rules live in `isDirectEvent()` in `src/events/legacy/propagation.ts`. ## Which events are which @@ -34,7 +34,7 @@ Until then, `fireEvent` logs a warning when a direct event bubbles from a nested `contentSizeChange` is not a native `ScrollView` event, so the table above doesn't list it. The `ScrollView` component calls `onContentSizeChange` from the `onLayout` of its content view and passes `onContentSizeChange: null` to the host element. The Jest `ScrollView` mock passes the prop to the host element instead, so the rule uses `ScrollView` as the emitting element. `FlatList` and `SectionList` always set this handler, and tests fire the event on list items, so making it direct will break more tests than other events. -Both rules depend on the Jest mock. The `FlatList` cases in `src/events/__tests__/fire-event.test.tsx` cover both, so a mock change that moves these handlers fails them. +Both rules depend on the Jest mock. The `FlatList` cases in `src/events/legacy/__tests__/fire-event.test.tsx` cover both, so a mock change that moves these handlers fails them. ## Sources 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..ac38c4e2d 100644 --- a/contributing/testing.md +++ b/contributing/testing.md @@ -13,3 +13,16 @@ Every change in `src/` should come with tests. Tests use Jest and live next to t - Shared setup lives in `jest-setup.ts`. - Auto-cleanup between tests comes from `src/index.ts`. - Coverage is collected from `src/`, excluding tests and `src/test-utils/`. + +## Event systems + +`fireEvent` and `userEvent` work with both event systems (`configure({ unstable_eventSystem })`), so `jest.config.js` has two projects: + +- `legacy` runs all tests with the default `'legacy'` event system. +- `modern` runs all tests again with `'modern'` (`jest-setup-modern.ts`). + +Both projects share the same snapshots. `createEventLogger()` entries print only the `nativeEvent` of event payloads (`src/test-utils/event-serializer.ts`), so a snapshot is the same for a legacy event object and a modern `SyntheticEvent`, and a difference between the two systems fails the snapshot. Use `--selectProjects legacy` or `--selectProjects modern` to run one of them. + +When a test expects different behavior in the two systems, e.g. events bubbling to a parent, branch on `getConfig().unstable_eventSystem` inside the test instead of skipping it. + +Tests of legacy behavior the modern event system doesn't have (several handler arguments, bubbling to composite props, direct events bubbling with a warning, ...) call `runInLegacyEventSystem()` from `src/test-utils/event-system.ts` at the top of the file (or in a `describe()`). They run in the legacy event system in both projects, e.g. `src/events/legacy/__tests__/fire-event.test.tsx`. diff --git a/jest-setup-modern.ts b/jest-setup-modern.ts new file mode 100644 index 000000000..6274d014d --- /dev/null +++ b/jest-setup-modern.ts @@ -0,0 +1,6 @@ +import { configure } from './src/pure'; + +// Runs after `resetToDefaults()` in `jest-setup.ts`. +beforeEach(() => { + configure({ unstable_eventSystem: 'modern' }); +}); diff --git a/jest.config.js b/jest.config.js index b47157dd6..8fe6f054f 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,16 +1,33 @@ -module.exports = { +const baseProject = { preset: '@react-native/jest-preset', setupFilesAfterEnv: ['./jest-setup.ts'], testPathIgnorePatterns: ['dist/', 'examples/', 'experiments-app/', 'codemods/'], modulePathIgnorePatterns: ['/refs/'], testTimeout: 60000, transformIgnorePatterns: ['/node_modules/(?!(@react-native|react-native)/).*/'], - snapshotSerializers: ['@relmify/jest-serializer-strip-ansi/always'], + snapshotSerializers: [ + '@relmify/jest-serializer-strip-ansi/always', + './src/test-utils/event-serializer.ts', + ], clearMocks: true, +}; + +module.exports = { + testTimeout: 60000, collectCoverageFrom: [ 'src/**/*.{js,jsx,ts,tsx}', '!src/**/__tests__/**', '!src/**/*.test.js', '!src/test-utils/**', // Exclude setup files ], + projects: [ + { ...baseProject, displayName: 'legacy' }, + // All tests again with `configure({ unstable_eventSystem: 'modern' })`. They share snapshots with the + // legacy run, so both event systems must call the same handlers with the same native events. + { + ...baseProject, + displayName: 'modern', + setupFilesAfterEnv: [...baseProject.setupFilesAfterEnv, './jest-setup-modern.ts'], + }, + ], }; diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 1fecd4f33..5b88a76f8 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -13,6 +13,7 @@ afterEach(() => { test('getConfig() returns existing configuration', () => { expect(getConfig().asyncUtilTimeout).toEqual(1000); expect(getConfig().defaultIncludeHiddenElements).toEqual(false); + expect(getConfig().unstable_eventSystem).toEqual('legacy'); }); test('configure() overrides existing config values', () => { @@ -23,9 +24,21 @@ test('configure() overrides existing config values', () => { defaultDebugOptions: { message: 'debug message' }, defaultIncludeHiddenElements: false, eventDiagnostics: false, + unstable_eventSystem: 'legacy', }); }); +test('configure() sets unstable_eventSystem', () => { + configure({ unstable_eventSystem: 'modern' }); + expect(getConfig().unstable_eventSystem).toEqual('modern'); + + configure({ asyncUtilTimeout: 2000 }); + expect(getConfig().unstable_eventSystem).toEqual('modern'); + + resetToDefaults(); + expect(getConfig().unstable_eventSystem).toEqual('legacy'); +}); + test('resetToDefaults() resets config to defaults', () => { configure({ asyncUtilTimeout: 5000, @@ -66,6 +79,7 @@ test('does not warn when only valid options are passed', () => { defaultIncludeHiddenElements: true, defaultDebugOptions: { message: 'test' }, defaultHidden: false, + unstable_eventSystem: 'modern', }); expect(_console.warn).not.toHaveBeenCalled(); diff --git a/src/config.ts b/src/config.ts index b19cb18cf..895b06829 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,6 +5,8 @@ import { validateOptions } from './helpers/validate-options'; * Global configuration options for React Native Testing Library. */ +export type EventSystem = 'legacy' | 'modern'; + export type Config = { /** Default timeout, in ms, for `waitFor` and `findBy*` queries. */ asyncUtilTimeout: number; @@ -18,6 +20,13 @@ export type Config = { */ eventDiagnostics: boolean; + /** + * Experimental, may change or be removed in any release. Event system used by `fireEvent` and + * `userEvent`. `'legacy'` is the current simplified implementation. `'modern'` follows React + * Native's event dispatch. Defaults to `'legacy'`. + */ + unstable_eventSystem: EventSystem; + /** Default options for `debug` helper. */ defaultDebugOptions?: Partial; }; @@ -31,6 +40,7 @@ const defaultConfig: Config = { asyncUtilTimeout: 1000, defaultIncludeHiddenElements: false, eventDiagnostics: false, + unstable_eventSystem: 'legacy', }; let config = { ...defaultConfig }; @@ -45,6 +55,7 @@ export function configure(options: Partial) { defaultHidden, defaultIncludeHiddenElements, eventDiagnostics, + unstable_eventSystem, ...rest } = options; @@ -59,6 +70,7 @@ export function configure(options: Partial) { defaultDebugOptions, defaultIncludeHiddenElements: resolvedDefaultIncludeHiddenElements, eventDiagnostics: eventDiagnostics ?? config.eventDiagnostics, + unstable_eventSystem: unstable_eventSystem ?? config.unstable_eventSystem, }; } diff --git a/src/events/__tests__/create-event.test.ts b/src/events/__tests__/create-event.test.ts new file mode 100644 index 000000000..d837c4458 --- /dev/null +++ b/src/events/__tests__/create-event.test.ts @@ -0,0 +1,84 @@ +import { configure } from '../..'; +import { + buildAccessibilityActionEvent, + buildBlurEvent, + buildContentSizeChangeEvent, + buildEndEditingEvent, + createEvent, + buildFocusEvent, + buildKeyPressEvent, + buildPressEvent, + buildScrollEvent, + buildSubmitEditingEvent, + buildTextChangeEvent, + buildTextSelectionChangeEvent, +} from '../create-event'; +import { SyntheticEvent } from '../modern/event'; + +afterEach(() => { + jest.restoreAllMocks(); +}); + +test('builds a legacy event object in the legacy event system', () => { + configure({ unstable_eventSystem: 'legacy' }); + const nativeEvent = { target: 0 }; + + const event = createEvent('focus', nativeEvent); + + expect(event).not.toBeInstanceOf(SyntheticEvent); + expect(event).toMatchObject({ nativeEvent, timeStamp: 0, preventDefault: expect.any(Function) }); +}); + +test('builds a SyntheticEvent in the modern event system', () => { + configure({ unstable_eventSystem: 'modern' }); + const nativeEvent = { target: 0 }; + + const event = createEvent('focus', nativeEvent); + + expect(event).toBeInstanceOf(SyntheticEvent); + expect(event.nativeEvent).toBe(nativeEvent); + expect(event.type).toBe('focus'); +}); + +test.each(['legacy', 'modern'] as const)( + 'throws for event types that are not native in the %s event system', + (eventSystem) => { + configure({ unstable_eventSystem: eventSystem }); + + expect(() => createEvent('changeText', {})).toThrow( + '"changeText" is not a native event type. Use invokeEventHandler() instead.', + ); + }, +); + +test.each([ + ['press', () => buildPressEvent()], + ['focus', () => buildFocusEvent()], + ['blur', () => buildBlurEvent()], + ['accessibilityaction', () => buildAccessibilityActionEvent('increment')], + ['momentumscrollend', () => buildScrollEvent('momentumScrollEnd', { x: 0, y: 100 })], + ['change', () => buildTextChangeEvent('Hello', { start: 5, end: 5 })], + ['keypress', () => buildKeyPressEvent('a')], + ['submitediting', () => buildSubmitEditingEvent('Hello')], + ['endediting', () => buildEndEditingEvent('Hello')], + ['selectionchange', () => buildTextSelectionChangeEvent({ start: 0, end: 5 })], + ['contentsizechange', () => buildContentSizeChangeEvent({ width: 100, height: 20 })], +])('builds %s event for the configured event system', (type, build) => { + // The touch payload has a `Date.now()` timestamp. + jest.spyOn(Date, 'now').mockReturnValue(1); + configure({ unstable_eventSystem: 'legacy' }); + const legacyEvent = build(); + configure({ unstable_eventSystem: 'modern' }); + const modernEvent = build(); + + expect(legacyEvent).not.toBeInstanceOf(SyntheticEvent); + expect(modernEvent).toBeInstanceOf(SyntheticEvent); + expect(modernEvent.type).toBe(type); + expect(modernEvent.nativeEvent).toEqual(legacyEvent.nativeEvent); +}); + +test('buildScrollEvent passes its arguments to the native event', () => { + expect(buildScrollEvent('scroll', { x: 0, y: 100 }).nativeEvent).toMatchObject({ + contentOffset: { x: 0, y: 100 }, + }); +}); diff --git a/src/events/__tests__/dispatch-event.test.tsx b/src/events/__tests__/dispatch-event.test.tsx new file mode 100644 index 000000000..d25aee720 --- /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 { getConfig } from '../../config'; +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 = getConfig().unstable_eventSystem === 'modern'; + 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 = getConfig().unstable_eventSystem === 'modern'; + 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__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index e1e950893..07c89479b 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -1,1806 +1,100 @@ import * as React from 'react'; -import type { TextInputProps } from 'react-native'; -import { - FlatList, - Image, - ImageBackground, - Modal, - PanResponder, - Pressable, - ScrollView, - Text, - TextInput, - TouchableHighlight, - TouchableNativeFeedback, - TouchableOpacity, - TouchableWithoutFeedback, - View, -} from 'react-native'; +import { Pressable, ScrollView, Text, TextInput, View } from 'react-native'; -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 { configure, fireEvent, render, screen } from '../..'; +import { getConfig } from '../../config'; +import { SyntheticEvent } from '../modern/event'; -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 } }; - -beforeEach(() => { - jest.spyOn(Date, 'now').mockImplementation(() => 100100100100); -}); - -test('fireEvent accepts event name with or without "on" prefix', async () => { - const onPress = jest.fn(); - await render(); - - await fireEvent(screen.getByTestId('btn'), 'press'); - expect(onPress).toHaveBeenCalledTimes(1); - - await fireEvent(screen.getByTestId('btn'), 'onPress'); - expect(onPress).toHaveBeenCalledTimes(2); -}); - -test('fireEvent with "on" prefixed name does not call unprefixed handler props', async () => { - const press = jest.fn(); - const testOnlyPress = jest.fn(); - // @ts-expect-error Intentionally passing such props - await render(); - - await fireEvent(screen.getByTestId('view'), 'onPress'); - expect(press).not.toHaveBeenCalled(); - expect(testOnlyPress).not.toHaveBeenCalled(); - - await fireEvent(screen.getByTestId('view'), 'press'); - expect(press).toHaveBeenCalledTimes(1); -}); - -test('fireEvent passes event data to handler', async () => { - const onPress = jest.fn(); - await render(); - await fireEvent.press(screen.getByTestId('btn'), pressEventData); - expect(onPress.mock.calls[0][0]).toMatchObject(pressEventData); -}); - -test('fireEvent passes multiple parameters to handler', async () => { - const handlePress = jest.fn(); - await render(); - await fireEvent(screen.getByTestId('btn'), 'press', 'param1', 'param2', 'param3'); - expect(handlePress).toHaveBeenCalledWith('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); -}); - -test('fireEvent bubbles event to parent handler', async () => { - const onPress = jest.fn(); +test('fireEvent calls handlers of the selected event system', async () => { + const onPointerDown = jest.fn(); + const onParentPointerDownCapture = jest.fn(); await render( - - Press me - , + + + , ); - await fireEvent.press(screen.getByText('Press me')); - expect(onPress).toHaveBeenCalled(); -}); - -describe('fireEvent.press', () => { - test('passes default press event object to handler', async () => { - const onPress = jest.fn(); - await render(); - await fireEvent.press(screen.getByTestId('btn')); - expect(onPress.mock.calls[0][0]).toMatchInlineSnapshot(` - { - "currentTarget": { - "measure": [Function], - }, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], - "nativeEvent": { - "changedTouches": [], - "identifier": 0, - "locationX": 0, - "locationY": 0, - "pageX": 0, - "pageY": 0, - "target": 0, - "timestamp": 100100100100, - "touches": [], - }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, - } - `); - }); - - test('overrides default event properties with passed event props', async () => { - const onPress = jest.fn(); - await render(); - const customEventData = { nativeEvent: { pageX: 20, pageY: 30 } }; - await fireEvent.press(screen.getByTestId('btn'), customEventData); - expect(onPress.mock.calls[0][0]).toMatchInlineSnapshot(` - { - "currentTarget": { - "measure": [Function], - }, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], - "nativeEvent": { - "changedTouches": [], - "identifier": 0, - "locationX": 0, - "locationY": 0, - "pageX": 20, - "pageY": 30, - "target": 0, - "timestamp": 100100100100, - "touches": [], - }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, - } - `); - }); - - test.each([ - ['Pressable', Pressable], - ['TouchableOpacity', TouchableOpacity], - ['TouchableHighlight', TouchableHighlight], - ['TouchableWithoutFeedback', TouchableWithoutFeedback], - ['TouchableNativeFeedback', TouchableNativeFeedback], - ])('works on %s', async (_, Component) => { - const onPress = jest.fn(); - await render( - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - Component is a valid React component - but some RN versions have incorrect type definitions - - Press me - , - ); - await fireEvent.press(screen.getByTestId('subject')); - expect(onPress).toHaveBeenCalled(); - }); - - test('works with testOnly_onPress handlers', async () => { - const onPress = jest.fn(); - const onPressIn = jest.fn(); - const onPressOut = jest.fn(); - const onLongPress = jest.fn(); - const testOnlyPressProps = { - testOnly_onPress: onPress, - testOnly_onPressIn: onPressIn, - testOnly_onPressOut: onPressOut, - testOnly_onLongPress: onLongPress, - }; - - await render(); - - const subject = screen.getByTestId('subject'); - - await fireEvent.press(subject); - expect(onPress).toHaveBeenCalledTimes(1); - - await fireEvent(subject, 'pressIn'); - expect(onPressIn).toHaveBeenCalledTimes(1); - - await fireEvent(subject, 'pressOut'); - expect(onPressOut).toHaveBeenCalledTimes(1); - - await fireEvent(subject, 'longPress'); - expect(onLongPress).toHaveBeenCalledTimes(1); - }); -}); - -describe('fireEvent.changeText', () => { - test('works on TextInput', async () => { - 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'); - }); - - test('updates native state when fired with `on*` prefixed name', async () => { - const onChangeText = jest.fn(); - await render(); - const input = screen.getByTestId('input'); - await fireEvent(input, 'onChangeText', 'new text'); - expect(onChangeText).toHaveBeenCalledWith('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(); - const input = screen.getByTestId('input'); - await fireEvent.changeText(input, 'new text'); - expect(onChangeText).not.toHaveBeenCalled(); - expect(nativeState.valueForInstance.get(input)).toBeUndefined(); - }); -}); - -describe('fireEvent.scroll', () => { - test('passes default scroll event object to handler', async () => { - const onScroll = jest.fn(); - await render( - - Content - , - ); - const scrollView = screen.getByTestId('scroll'); - await fireEvent.scroll(scrollView); - expect(onScroll.mock.calls[0][0]).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, - }, - }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, - } - `); - }); - - test('overrides default event properties with passed event props', async () => { - const onScroll = jest.fn(); - await render( - - Content - , - ); - const scrollView = screen.getByTestId('scroll'); - const customEventData = { - nativeEvent: { contentOffset: { x: 50, y: 200 } }, - }; - await fireEvent.scroll(scrollView, customEventData); - expect(onScroll.mock.calls[0][0]).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, - }, - }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, - } - `); - }); - - test('works on ScrollView', async () => { - const onScroll = jest.fn(); - await render( - - Content - , - ); - const scrollView = screen.getByTestId('scroll'); - await fireEvent.scroll(scrollView, verticalScrollEvent); - expect(onScroll.mock.calls[0][0]).toMatchObject(verticalScrollEvent); - expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ - x: 0, - y: 200, - }); - }); - - 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'], - ['onScrollEndDrag', 'scrollEndDrag'], - ['onMomentumScrollBegin', 'momentumScrollBegin'], - ['onMomentumScrollEnd', 'momentumScrollEnd'], - ])('fires %s on ScrollView', async (propName, eventType) => { - const handler = jest.fn(); - await render(); - const scrollView = screen.getByTestId('scroll'); - await fireEvent(scrollView, eventType, verticalScrollEvent); - expect(handler).toHaveBeenCalledWith(verticalScrollEvent); - expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ - x: 0, - y: 200, - }); - }); - - test('without contentOffset scrolls to (0, 0)', async () => { - const onScroll = jest.fn(); - await render( - - Content - , - ); - const scrollView = screen.getByTestId('scroll'); - await fireEvent.scroll(scrollView, {}); - expect(onScroll.mock.calls[0][0]).toMatchObject({ - nativeEvent: { contentOffset: { x: 0, y: 0 } }, - }); - expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ - x: 0, - y: 0, - }); - }); - - test('with non-finite contentOffset values uses 0', async () => { - const onScroll = jest.fn(); - await render( - - Content - , - ); - const scrollView = screen.getByTestId('scroll'); - await fireEvent.scroll(scrollView, { - nativeEvent: { contentOffset: { y: Infinity } }, - }); - expect(onScroll).toHaveBeenCalled(); - expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ - x: 0, - y: 0, - }); - }); - - test('with horizontal scroll updates native state', async () => { - const onScroll = jest.fn(); - await render( - - Content - , - ); - const scrollView = screen.getByTestId('scroll'); - await fireEvent.scroll(scrollView, horizontalScrollEvent); - expect(onScroll.mock.calls[0][0]).toMatchObject(horizontalScrollEvent); - expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ - x: 50, - y: 0, - }); - }); - - test('without contentOffset via fireEvent() does not update native state', async () => { - const onScroll = jest.fn(); - await render( - - Content - , - ); - const scrollView = screen.getByTestId('scroll'); - await fireEvent(scrollView, 'scroll', { nativeEvent: {} }); - expect(onScroll).toHaveBeenCalled(); - expect(nativeState.contentOffsetForInstance.get(scrollView)).toBeUndefined(); - }); - - test('with non-finite x contentOffset value uses 0', async () => { - const onScroll = jest.fn(); - await render( - - Content - , - ); - const scrollView = screen.getByTestId('scroll'); - await fireEvent.scroll(scrollView, { - nativeEvent: { contentOffset: { x: Infinity } }, - }); - expect(onScroll).toHaveBeenCalled(); - expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ - x: 0, - y: 0, - }); - }); - - test('uses layout size from previous layout event as layoutMeasurement', async () => { - const onScroll = jest.fn(); - await render( {}} />); - const scrollView = screen.getByTestId('scroll'); - - await fireEvent.layout(scrollView, { width: 390, height: 750 }); - await fireEvent.scroll(scrollView); - - expect(onScroll.mock.calls[0][0].nativeEvent.layoutMeasurement).toEqual({ - width: 390, - height: 750, - }); - }); - - test('prefers passed layoutMeasurement over layout size from layout event', async () => { - const onScroll = jest.fn(); - await render( {}} />); - const scrollView = screen.getByTestId('scroll'); - - await fireEvent.layout(scrollView, { width: 390, height: 750 }); - await fireEvent.scroll(scrollView, { - nativeEvent: { layoutMeasurement: { width: 100, height: 200 } }, - }); - - expect(onScroll.mock.calls[0][0].nativeEvent.layoutMeasurement).toEqual({ - width: 100, - height: 200, - }); - }); - - 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( - - {}} /> - , - ); - const content = screen.getByTestId('content'); - - await fireEvent.layout(content, { width: 390, height: 750 }); - await fireEvent.scroll(content); - - expect(onScroll.mock.calls[0][0].nativeEvent.layoutMeasurement).toEqual({ - width: 0, - height: 0, - }); - warnSpy.mockRestore(); - }); -}); - -describe('fireEvent.layout', () => { - test('passes default layout event object to handler', async () => { - const onLayout = jest.fn(); - await render(); - - await fireEvent.layout(screen.getByTestId('view')); - expect(onLayout.mock.calls[0][0]).toMatchInlineSnapshot(` - { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], - "nativeEvent": { - "layout": { - "height": 0, - "width": 0, - "x": 0, - "y": 0, - }, - "target": 0, - }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, - } - `); - }); - - test('merges the passed layout onto the zeroed rectangle', async () => { - const onLayout = jest.fn(); - await render(); - - await fireEvent.layout(screen.getByTestId('view'), { width: 200, height: 80 }); - - expect(onLayout.mock.calls[0][0].nativeEvent).toEqual({ - layout: { x: 0, y: 0, width: 200, height: 80 }, - target: 0, - }); - }); - - test('uses zero for layout fields passed as undefined', async () => { - configure({ eventDiagnostics: true }); - const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); - const onLayout = jest.fn(); - await render( - - - - , - ); - const withoutHandler = screen.getByTestId('without-handler'); - - await fireEvent.layout(screen.getByTestId('with-handler'), { x: undefined, width: undefined }); - await fireEvent.layout(withoutHandler, { width: undefined, height: undefined }); - - expect(onLayout.mock.calls[0][0].nativeEvent.layout).toEqual({ - x: 0, - y: 0, - width: 0, - height: 0, - }); - expect(nativeState.layoutSizeForInstance.get(withoutHandler)).toEqual({ width: 0, height: 0 }); - expect(warnSpy).not.toHaveBeenCalled(); - warnSpy.mockRestore(); - }); - - test('does not bubble to the handler on an ancestor element', async () => { - const onLayout = jest.fn(); - await render( - - - , - ); - - await fireEvent.layout(screen.getByTestId('child'), { height: 80 }); - - expect(onLayout).not.toHaveBeenCalled(); - }); - - test('bubbles with a warning when fired as generic layout event', async () => { - const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); - const onLayout = jest.fn(); - await render( - - - , - ); - - await fireEvent(screen.getByTestId('child'), 'layout', layoutEvent); - await fireEvent(screen.getByTestId('child'), 'onLayout', layoutEvent); - - expect(onLayout).toHaveBeenCalledTimes(2); - expect(warnSpy).toHaveBeenCalledTimes(2); - warnSpy.mockRestore(); - }); - - test('does not warn when layout size is saved in native state without onLayout handler', async () => { - configure({ eventDiagnostics: true }); - const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); - await render(); - - await fireEvent.layout(screen.getByTestId('view')); - - expect(warnSpy).not.toHaveBeenCalled(); - 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( - false} />, - ); - - await fireEvent.layout(screen.getByTestId('view')); - - expect(onLayout).toHaveBeenCalledTimes(1); - }); - - test('saves layout size in native state', async () => { - await render( {}} />); - const view = screen.getByTestId('view'); - - await fireEvent.layout(view, { x: 10, y: 20, width: 100, height: 80 }); - expect(nativeState.layoutSizeForInstance.get(view)).toEqual({ width: 100, height: 80 }); - - await fireEvent(view, 'layout', { nativeEvent: { layout: { width: 50, height: NaN } } }); - expect(nativeState.layoutSizeForInstance.get(view)).toEqual({ width: 50, height: 0 }); - }); - - test('saves layout size in native state even without onLayout handler', async () => { - await render(); - const view = screen.getByTestId('view'); - - await fireEvent.layout(view, { width: 100, height: 80 }); - - expect(nativeState.layoutSizeForInstance.get(view)).toEqual({ width: 100, height: 80 }); - }); - - test('does not call onLayout of composite component that does not forward it', async () => { - const onLayout = jest.fn(); - const Box = (_props: { onLayout: () => void }) => ; - await render(); - - await fireEvent.layout(screen.getByTestId('view')); - - expect(onLayout).not.toHaveBeenCalled(); - }); + const init = { nativeEvent: { pointerId: 1 } }; + const result = await fireEvent(screen.getByTestId('target'), 'pointerDown', init); + const isModern = getConfig().unstable_eventSystem === 'modern'; + // Modern dispatches a `SyntheticEvent` through capture and bubble phases. Legacy calls only the + // target prop with the passed arguments. + expect(result).toBe(isModern ? true : undefined); + expect(onParentPointerDownCapture).toHaveBeenCalledTimes(isModern ? 1 : 0); + expect(onPointerDown.mock.calls[0][0] instanceof SyntheticEvent).toBe(isModern); + expect(onPointerDown.mock.calls[0][0].nativeEvent).toEqual({ pointerId: 1 }); }); -describe('direct events', () => { - let warnSpy: jest.SpyInstance; +test('fireEvent with an event type React Native does not dispatch natively', async () => { + const onChangeText = jest.fn(); + await render(); - beforeEach(() => { - warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); - }); - - afterEach(() => { - warnSpy.mockRestore(); - }); - - type DirectEventCase = { - name: string; - eventType: string; - ui: (handler: jest.Mock) => React.ReactElement; - }; - - const directEventCases: DirectEventCase[] = [ - { - name: 'layout from View content', - eventType: 'layout', - ui: (handler) => ( - - - - ), - }, - { - name: 'accessibilityAction from Pressable content', - eventType: 'accessibilityAction', - ui: (handler) => ( - - Button - - ), - }, - ...buildDirectEventCases( - 'View content', - ['accessibilityTap', 'magicTap', 'accessibilityEscape'], - (handlerProps) => ( - - - - ), - ), - { - name: 'textLayout from nested Text', - eventType: 'textLayout', - ui: (handler) => ( - - Nested - - ), - }, - ...buildDirectEventCases( - 'TextInput content', - ['scroll', 'selectionChange', 'contentSizeChange'], - (handlerProps) => ( - - Nested - - ), - ), - ...buildDirectEventCases( - 'Image content', - ['loadStart', 'progress', 'partialLoad', 'load', 'error', 'loadEnd'], - // Image does not accept children, clone it to fire the event on a nested element. - (handlerProps) => - React.cloneElement( - , - {}, - Nested, - ), - ), - ...buildDirectEventCases( - 'ScrollView content', - [ - 'scroll', - 'scrollBeginDrag', - 'scrollEndDrag', - 'momentumScrollBegin', - 'momentumScrollEnd', - 'scrollToTop', - 'contentSizeChange', - ], - (handlerProps) => ( - - - - ), - ), - { - name: 'scroll from TextInput to ancestor ScrollView', - eventType: 'scroll', - ui: (handler) => ( - - - - ), - }, - { - name: 'refresh from FlatList item', - eventType: 'refresh', - ui: (handler) => ( - {item}} - refreshing={false} - onRefresh={handler} - /> - ), - }, - { - name: 'contentSizeChange from FlatList item', - eventType: 'contentSizeChange', - ui: (handler) => ( - {item}} - onContentSizeChange={handler} - /> - ), - }, - ...buildDirectEventCases( - 'Modal content', - ['requestClose', 'show', 'dismiss', 'orientationChange'], - (handlerProps) => ( - - Content - - ), - ), - ]; - - // Builds one case per event type, passing the matching `on*` handler prop to `ui`. - function buildDirectEventCases( - source: string, - eventTypes: string[], - ui: (handlerProps: Record) => React.ReactElement, - ): DirectEventCase[] { - return eventTypes.map((eventType) => ({ - name: `${eventType} from ${source}`, - eventType, - ui: (handler) => ui({ [getEventHandlerName(eventType)]: handler }), - })); - } - - test.each(directEventCases)('bubbles $name with a warning', async ({ eventType, ui }) => { - const handler = jest.fn(); - await render(ui(handler)); - - await fireEvent(screen.getByTestId('target'), eventType); - - expect(handler).toHaveBeenCalledTimes(1); - expect(warnSpy).toHaveBeenCalledTimes(1); - }); - - test.each(directEventCases)( - 'does not warn for $name when fired on the emitting element', - async ({ eventType, ui }) => { - const handler = jest.fn(); - await render(ui(handler)); - - await fireEvent(screen.getByTestId('emitter'), eventType); - - expect(handler).toHaveBeenCalledTimes(1); - expect(warnSpy).not.toHaveBeenCalled(); - }, + const error = await fireEvent(screen.getByTestId('input'), 'changeText', 'Hello').then( + () => undefined, + (e: Error) => e.message, ); - - test('warns when fired with "on" prefixed event name', async () => { - const onMomentumScrollEnd = jest.fn(); - await render( - - - , - ); - - await fireEvent(screen.getByTestId('child'), 'onMomentumScrollEnd'); - - expect(onMomentumScrollEnd).toHaveBeenCalledTimes(1); - expect(warnSpy).toHaveBeenCalledTimes(1); - }); - - test('warns about stopping bubbling in the next major version', async () => { - await render( - {}}> - - , - ); - - 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: - - - " - `); - }); - - test.each([true, false])( - 'warns about bubbling regardless of eventDiagnostics (%s)', - async (eventDiagnostics) => { - configure({ eventDiagnostics }); - await render( - {}}> - - , - ); - - await fireEvent.scroll(screen.getByTestId('child')); - - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy.mock.calls[0][0]).toContain('"scroll" does not bubble in React Native'); - }, + const isModern = getConfig().unstable_eventSystem === 'modern'; + 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, ); - - test('warns when handler is on composite component above the emitting element', async () => { - const onScroll = jest.fn(); - const Screen = (_props: { onScroll: () => void }) => ( - - - - ); - await render(); - - await fireEvent.scroll(screen.getByTestId('child')); - - expect(onScroll).toHaveBeenCalledTimes(1); - expect(warnSpy).toHaveBeenCalledTimes(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(); - - await fireEvent.scroll(screen.getByTestId('child')); - - expect(onScroll).toHaveBeenCalledTimes(1); - expect(warnSpy).not.toHaveBeenCalled(); - }); - - 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 - - ); - await render(); - - await fireEvent(screen.getByText('Card'), 'load'); - await fireEvent(screen.getByText('Card'), 'show'); - - expect(onLoad).toHaveBeenCalledTimes(1); - expect(onShow).toHaveBeenCalledTimes(1); - expect(warnSpy).not.toHaveBeenCalled(); - }); - - test('does not warn when bubbling to host element that does not emit the event', async () => { - const onLoad = jest.fn(); - await render( - // @ts-expect-error View does not have onLoad prop - - Content - , - ); - - await fireEvent(screen.getByText('Content'), 'load'); - - expect(onLoad).toHaveBeenCalledTimes(1); - expect(warnSpy).not.toHaveBeenCalled(); - }); - - test('does not warn when bubbling load event to ImageBackground handler', async () => { - const onLoad = jest.fn(); - await render( - - Caption - , - ); - - await fireEvent(screen.getByText('Caption'), 'load'); - - expect(onLoad).toHaveBeenCalledTimes(1); - expect(warnSpy).not.toHaveBeenCalled(); - }); -}); - -test('fireEvent fires custom event (onCustomEvent) on composite component', async () => { - const CustomComponent = ({ onCustomEvent }: { onCustomEvent: (data: string) => void }) => ( - onCustomEvent('event data')}> - Custom - - ); - const handler = jest.fn(); - await render(); - await fireEvent(screen.getByText('Custom'), 'customEvent', 'event data'); - expect(handler).toHaveBeenCalledWith('event data'); + expect(onChangeText.mock.calls).toEqual(isModern ? [] : [['Hello']]); }); -test('fireEvent fires event with custom prop name (handlePress) on composite component', async () => { - const MyButton = ({ handlePress }: { handlePress: () => void }) => ( - - Button - +test('fireEvent.changeText', async () => { + const calls: string[] = []; + await render( + calls.push(`onChange: ${event.nativeEvent.text}`)} + onChangeText={(text) => calls.push(`onChangeText: ${text}`)} + />, ); - 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(); -}); -test('fireEvent calls handler on element when both element and parent have handlers', async () => { - const childHandler = jest.fn(); - const parentHandler = jest.fn(); - await render( - - - Press me - - , + await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); + expect(calls).toEqual( + getConfig().unstable_eventSystem === 'modern' + ? ['onChange: Hello', 'onChangeText: Hello'] + : ['onChangeText: Hello'], ); - await fireEvent.press(screen.getByTestId('child')); - expect(childHandler).toHaveBeenCalledTimes(1); - expect(parentHandler).not.toHaveBeenCalled(); }); -test('fireEvent does nothing when element is unmounted', async () => { +test('fireEvent.press uses the legacy implementation', async () => { const onPress = jest.fn(); await render( - - - , + + Press me + , ); - const element = screen.getByTestId('btn'); - await screen.rerender(); - await fireEvent.press(element); - expect(onPress).not.toHaveBeenCalled(); -}); - -test('fireEvent does not throw when called with non-existent event name', async () => { - await render(); - const element = screen.getByTestId('btn'); - await expect(fireEvent(element, 'nonExistentEvent' as any)).resolves.toBeUndefined(); -}); - -test('fireEvent handles handler that throws gracefully', async () => { - const error = new Error('Handler error'); - const onPress = jest.fn(() => { - throw error; - }); - await render(); - await expect(fireEvent.press(screen.getByTestId('btn'))).rejects.toThrow('Handler error'); + expect(await fireEvent.press(screen.getByText('Press me'))).toBeUndefined(); expect(onPress).toHaveBeenCalledTimes(1); + expect(onPress.mock.calls[0][0]).not.toBeInstanceOf(SyntheticEvent); }); -describe('disabled elements', () => { - test('does not fire on disabled Pressable', async () => { - const onPress = jest.fn(); - await render( - - Trigger - , - ); - await fireEvent.press(screen.getByText('Trigger')); - expect(onPress).not.toHaveBeenCalled(); - }); - - test('does not fire on disabled TouchableOpacity', async () => { - const onPress = jest.fn(); - await render( - - Trigger - , - ); - await fireEvent.press(screen.getByText('Trigger')); - expect(onPress).not.toHaveBeenCalled(); - }); - - test('bubbles event past disabled inner to enabled outer Pressable', async () => { - const handleInnerPress = jest.fn(); - const handleOuterPress = jest.fn(); - await render( - - - Inner Trigger - - , - ); - await fireEvent.press(screen.getByText('Inner Trigger')); - expect(handleInnerPress).not.toHaveBeenCalled(); - expect(handleOuterPress).toHaveBeenCalledTimes(1); - }); - - test('bubbles event past disabled inner to enabled outer TouchableOpacity', async () => { - const handleInnerPress = jest.fn(); - const handleOuterPress = jest.fn(); - await render( - - - Inner Trigger - - , - ); - await fireEvent.press(screen.getByText('Inner Trigger')); - expect(handleInnerPress).not.toHaveBeenCalled(); - expect(handleOuterPress).toHaveBeenCalledTimes(1); - }); - - test('ignores custom disabled prop on composite component (only respects native disabled)', async () => { - const TestComponent = ({ onPress }: { onPress: () => void; disabled?: boolean }) => ( - - Trigger Test - - ); - const handlePress = jest.fn(); - await render(); - await fireEvent.press(screen.getByText('Trigger Test')); - expect(handlePress).toHaveBeenCalledTimes(1); - }); -}); - -describe('unhandled event warning', () => { - let warnSpy: jest.SpyInstance; - - beforeEach(() => { - configure({ eventDiagnostics: true }); - warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => {}); - }); - - afterEach(() => { - warnSpy.mockRestore(); - }); - - test('warns when the handler is on a disabled element', async () => { - await render( - - Trigger - , - ); - - await fireEvent.press(screen.getByText('Trigger')); - - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - "Cannot fire the "press" event on a disabled element. - If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. - - - - Trigger - - " - `); - }); - - test('lists every disabled element the event skipped', async () => { - await render( - - - Trigger - - , - ); - - await fireEvent.press(screen.getByText('Trigger')); - - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - "Cannot fire the "press" event on disabled elements. - If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. - - - - Trigger - - - - - - - Trigger - - - " - `); - }); - - test('warns when no element handles the event', async () => { - await render( - - Trigger - , - ); - - await fireEvent.press(screen.getByText('Trigger')); - - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - "No "onPress" handler found on the element or its ancestors. - If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. - - - Trigger - " - `); - }); - - test('does not warn when the event bubbles to an enabled parent', async () => { - await render( - - - Inner Trigger - - , - ); - - await fireEvent.press(screen.getByText('Inner Trigger')); - - expect(warnSpy).not.toHaveBeenCalled(); - }); - - test('warns when the handler is blocked by pointerEvents="none" on an ancestor', async () => { - await render( - - - , - ); - - await fireEvent.press(screen.getByTestId('btn')); - - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - "Cannot fire the "press" event on an element blocked by pointerEvents. - If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. - - - - Blocked by: - - " - `); - }); - - test('warns when multiple handlers are blocked by pointerEvents="none" on an ancestor', async () => { - await render( - - - - - , - ); - - await fireEvent.press(screen.getByTestId('inner')); - - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - "Cannot fire the "press" event on elements blocked by pointerEvents. - If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. - - - - - - - - Blocked by: - - " - `); - }); - - test('reports pointerEvents rather than disabled when both block the handler', async () => { - await render( - - - , - ); - - await fireEvent.press(screen.getByTestId('btn')); - - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - "Cannot fire the "press" event on an element blocked by pointerEvents. - If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. - - - - Blocked by: - - " - `); - }); - - test('reports the element that blocks with pointerEvents', async () => { - await render( - - - - - - , - ); - - await fireEvent.press(screen.getByTestId('inside-box-only')); - await fireEvent.press(screen.getByTestId('box-none')); - - expect(warnSpy).toHaveBeenCalledTimes(2); - expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - "Cannot fire the "press" event on an element blocked by pointerEvents. - If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. - - - - Blocked by: - - " - `); - expect(warnSpy.mock.calls[1][0]).toMatchInlineSnapshot(` - "Cannot fire the "press" event on an element blocked by pointerEvents. - If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. - - - - Blocked by: - - " - `); - }); - - test('reports non-editable TextInput for events not affected by pointerEvents', async () => { - await render( - - - , - ); - - await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); - - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy.mock.calls[0][0]).toMatch( - /^Cannot fire the "changeText" event on a non-editable TextInput\./, - ); - }); - - test('does not warn when the responder declines the touch', async () => { - const onPress = jest.fn(); - // @ts-expect-error Host View does not declare `onPress`. - await render( false} onPress={onPress} />); - - await fireEvent.press(screen.getByTestId('view')); - - expect(onPress).not.toHaveBeenCalled(); - expect(warnSpy).not.toHaveBeenCalled(); - }); - - test('warns when the handler is blocked by non-editable TextInput', async () => { - await render(); - - await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); - - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - "Cannot fire the "changeText" event on a non-editable TextInput. - If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. - - " - `); - }); - - test.each([ - ['focus', 'onFocus'], - ['blur', 'onBlur'], - ['press', 'onPress'], - ])('warns when "%s" is blocked by non-editable TextInput', async (eventType, handlerName) => { - await render(); - - await fireEvent(screen.getByTestId('input'), eventType); - - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy.mock.calls[0][0]).toMatch( - new RegExp(`^Cannot fire the "${eventType}" event on a non-editable TextInput\\.`), - ); - }); - - test('names the non-editable TextInput when the handler is on its parent', async () => { - const onFocus = jest.fn(); - await render( - // Spread because `View` types include `onFocus` only since RN 0.88. - - - , - ); - - await fireEvent(screen.getByTestId('input'), 'focus'); - - expect(onFocus).not.toHaveBeenCalled(); - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - "Cannot fire the "focus" event on a non-editable TextInput. - If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. - - " - `); - }); - - test('reports disabled elements when they include a non-editable TextInput', async () => { - await render( - - - , - ); - - await fireEvent.press(screen.getByTestId('input')); - - 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(); - - await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); - - expect(warnSpy).not.toHaveBeenCalled(); - }); - - test('does not warn when eventDiagnostics is turned off', async () => { - configure({ eventDiagnostics: false }); - await render( - - - Disabled - - No handler - , - ); - - await fireEvent.press(screen.getByText('Disabled')); - await fireEvent.press(screen.getByText('No handler')); - await fireEvent.layout(screen.getByText('No handler')); - - expect(warnSpy).not.toHaveBeenCalled(); - }); -}); - -describe('pointerEvents prop', () => { - test('does not fire inside View with pointerEvents="none"', 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( - - - Trigger - - , - ); - await fireEvent.press(screen.getByText('Trigger')); - expect(onPress).not.toHaveBeenCalled(); - }); - - test('fires inside View with pointerEvents="box-none"', async () => { - const onPress = jest.fn(); - await render( - - - Trigger - - , - ); - await fireEvent.press(screen.getByText('Trigger')); - expect(onPress).toHaveBeenCalledTimes(1); - }); - - test('fires inside View with pointerEvents="auto"', async () => { - const onPress = jest.fn(); - await render( - - - Trigger - - , - ); - await fireEvent.press(screen.getByText('Trigger')); - expect(onPress).toHaveBeenCalledTimes(1); - }); - - test('does not fire deeply inside View with pointerEvents="box-only"', async () => { - const onPress = jest.fn(); - await render( - - - - Trigger - - - , - ); - await fireEvent.press(screen.getByText('Trigger')); - expect(onPress).not.toHaveBeenCalled(); - }); - - test('fires non-pointer events inside View with pointerEvents="box-none"', async () => { - const onLayout = jest.fn(); - await render(); - await fireEvent(screen.getByTestId('view'), 'layout'); - expect(onLayout).toHaveBeenCalled(); - }); - - test('fires on Pressable with pointerEvents="box-only" on itself', async () => { - const onPress = jest.fn(); - await render(); - await fireEvent.press(screen.getByTestId('pressable')); - expect(onPress).toHaveBeenCalled(); - }); -}); - -describe('non-editable TextInput', () => { - function WrappedTextInput(props: TextInputProps) { - return ; - } - - function DoubleWrappedTextInput(props: TextInputProps) { - return ; - } - - test('blocks touch-related events but allows non-touch events', async () => { - const onFocus = jest.fn(); - const onChangeText = jest.fn(); - const onSubmitEditing = jest.fn(); - const onLayout = jest.fn(); - - await render( - , - ); - - const input = screen.getByTestId('input'); - await fireEvent(input, 'focus'); - await fireEvent.changeText(input, 'Text'); - await fireEvent(input, 'submitEditing', { nativeEvent: { text: 'Text' } }); - await fireEvent(input, 'layout', layoutEvent); - - expect(onFocus).not.toHaveBeenCalled(); - expect(onChangeText).not.toHaveBeenCalled(); - expect(onSubmitEditing).not.toHaveBeenCalled(); - expect(onLayout).toHaveBeenCalledWith(layoutEvent); - }); - - test('blocks touch-related events when firing on nested Text child', async () => { - const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); - const onFocus = jest.fn(); - const onChangeText = jest.fn(); - const onSubmitEditing = jest.fn(); - const onLayout = jest.fn(); - - await render( - - Nested Text - , - ); - - const subject = screen.getByText('Nested Text'); - await fireEvent(subject, 'focus'); - await fireEvent(subject, 'onFocus'); - await fireEvent.changeText(subject, 'Text'); - await fireEvent(subject, 'submitEditing', { - nativeEvent: { text: 'Text' }, - }); - await fireEvent(subject, 'onSubmitEditing', { - nativeEvent: { text: 'Text' }, - }); - await fireEvent(subject, 'layout', layoutEvent); - await fireEvent(subject, 'onLayout', layoutEvent); - - expect(onFocus).not.toHaveBeenCalled(); - expect(onChangeText).not.toHaveBeenCalled(); - expect(onSubmitEditing).not.toHaveBeenCalled(); - // Layout is a direct event, but still bubbles to the parent TextInput with a warning - expect(onLayout).toHaveBeenCalledTimes(2); - expect(onLayout).toHaveBeenCalledWith(layoutEvent); - expect(warnSpy).toHaveBeenCalledTimes(2); - warnSpy.mockRestore(); - }); - - test.each([ - ['WrappedTextInput', WrappedTextInput], - ['DoubleWrappedTextInput', DoubleWrappedTextInput], - ])('blocks touch-related events on %s', async (_, Component) => { - const onFocus = jest.fn(); - const onChangeText = jest.fn(); - const onSubmitEditing = jest.fn(); - const onLayout = jest.fn(); - - await render( - , - ); - - const input = screen.getByTestId('input'); - await fireEvent(input, 'focus'); - await fireEvent.changeText(input, 'Text'); - await fireEvent(input, 'submitEditing', { nativeEvent: { text: 'Text' } }); - await fireEvent(input, 'layout', layoutEvent); - - expect(onFocus).not.toHaveBeenCalled(); - expect(onChangeText).not.toHaveBeenCalled(); - expect(onSubmitEditing).not.toHaveBeenCalled(); - expect(onLayout).toHaveBeenCalledWith(layoutEvent); - }); - - test('fires layout event', async () => { - const onLayout = jest.fn(); - await render(); - await fireEvent(screen.getByTestId('input'), 'layout'); - expect(onLayout).toHaveBeenCalled(); - }); +test('fireEvent.scroll and fireEvent.layout pass events of the selected event system', async () => { + const onScroll = jest.fn(); + const onLayout = jest.fn(); + await render(); - test('fires scroll event', async () => { - const onScroll = jest.fn(); - await render(); - await fireEvent(screen.getByTestId('input'), 'scroll'); - expect(onScroll).toHaveBeenCalled(); - }); + const scrollResult = await fireEvent.scroll(screen.getByTestId('scrollView')); + const layoutResult = await fireEvent.layout(screen.getByTestId('scrollView')); + const isModern = getConfig().unstable_eventSystem === 'modern'; + expect(scrollResult).toBe(isModern ? true : undefined); + expect(layoutResult).toBe(isModern ? true : undefined); + expect(onScroll.mock.calls[0][0] instanceof SyntheticEvent).toBe(isModern); + expect(onLayout.mock.calls[0][0] instanceof SyntheticEvent).toBe(isModern); }); -describe('responder system', () => { - test('respects disabled prop through composite wrappers', async () => { - function TestChildTouchableComponent({ - onPress, - someProp, - }: { - onPress: () => void; - someProp: boolean; - }) { - return ( - - - Trigger - - - ); - } - const handlePress = jest.fn(); - await render( - - - , - ); - await fireEvent.press(screen.getByText('Trigger')); - expect(handlePress).not.toHaveBeenCalled(); - }); +test('reads the event system on each call', async () => { + const onLayout = jest.fn(); + await render(); - test('fires responderMove on PanResponder component', async () => { - const onDrag = jest.fn(); - function TestDraggableComponent({ onDrag }: { onDrag: () => void }) { - const responderHandlers = PanResponder.create({ - onMoveShouldSetPanResponder: () => true, - onPanResponderMove: onDrag, - }).panHandlers; - return ( - - Trigger - - ); - } - await render(); - await fireEvent(screen.getByText('Trigger'), 'responderMove', { - touchHistory: { mostRecentTimeStamp: '2', touchBank: [] }, - }); - expect(onDrag).toHaveBeenCalled(); - }); + configure({ unstable_eventSystem: 'legacy' }); + await fireEvent.layout(screen.getByTestId('view')); + configure({ unstable_eventSystem: 'modern' }); + await fireEvent.layout(screen.getByTestId('view')); + expect(onLayout.mock.calls[0][0]).not.toBeInstanceOf(SyntheticEvent); + expect(onLayout.mock.calls[1][0]).toBeInstanceOf(SyntheticEvent); }); diff --git a/src/events/builders/__tests__/index.test.ts b/src/events/builders/__tests__/index.test.ts deleted file mode 100644 index 548ed471a..000000000 --- a/src/events/builders/__tests__/index.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import * as eventBuilder from '..'; - -test('re-exports all event builders', () => { - expect(eventBuilder.buildTouchEvent).toBeInstanceOf(Function); - expect(eventBuilder.buildResponderGrantEvent).toBeInstanceOf(Function); - expect(eventBuilder.buildResponderReleaseEvent).toBeInstanceOf(Function); - expect(eventBuilder.buildFocusEvent).toBeInstanceOf(Function); - expect(eventBuilder.buildBlurEvent).toBeInstanceOf(Function); - expect(eventBuilder.buildScrollEvent).toBeInstanceOf(Function); - expect(eventBuilder.buildTextChangeEvent).toBeInstanceOf(Function); - expect(eventBuilder.buildKeyPressEvent).toBeInstanceOf(Function); - expect(eventBuilder.buildSubmitEditingEvent).toBeInstanceOf(Function); - expect(eventBuilder.buildEndEditingEvent).toBeInstanceOf(Function); - expect(eventBuilder.buildTextSelectionChangeEvent).toBeInstanceOf(Function); - expect(eventBuilder.buildContentSizeChangeEvent).toBeInstanceOf(Function); - expect(eventBuilder.mergeEventProps).toBeInstanceOf(Function); -}); diff --git a/src/events/builders/__tests__/merge.test.ts b/src/events/builders/__tests__/merge.test.ts deleted file mode 100644 index eec683e2f..000000000 --- a/src/events/builders/__tests__/merge.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { buildTouchEvent } from '../common'; -import { mergeEventProps } from '../merge'; - -test('returns the same event when no props are passed', () => { - const event = buildTouchEvent(); - const nativeEvent = { ...event.nativeEvent }; - - expect(mergeEventProps(event)).toBe(event); - expect(event.nativeEvent).toEqual(nativeEvent); -}); - -test('deep merges nested objects and keeps other default fields', () => { - const event = mergeEventProps(buildTouchEvent(), { nativeEvent: { pageX: 10, pageY: 20 } }); - - expect(event.nativeEvent.pageX).toBe(10); - expect(event.nativeEvent.pageY).toBe(20); - expect(event.nativeEvent.locationX).toBe(0); - expect(typeof event.preventDefault).toBe('function'); -}); - -test('replaces arrays and primitive values instead of merging them', () => { - const event = mergeEventProps(buildTouchEvent(), { - nativeEvent: { touches: [{ identifier: 1 }] }, - timeStamp: 42, - }); - - expect(event.nativeEvent.touches).toEqual([{ identifier: 1 }]); - expect(event.timeStamp).toBe(42); -}); - -test('adds props that the event does not have', () => { - const event = mergeEventProps(buildTouchEvent(), { custom: { value: 1 } }); - - expect(event).toMatchObject({ custom: { value: 1 } }); -}); diff --git a/src/events/builders/__tests__/scroll.test.ts b/src/events/builders/__tests__/scroll.test.ts deleted file mode 100644 index 7ae6d6c70..000000000 --- a/src/events/builders/__tests__/scroll.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { buildScrollEvent } from '../scroll'; - -test('buildScrollEvent returns default scroll event', () => { - const event = buildScrollEvent(); - - expect(event.nativeEvent).toEqual({ - contentInset: { bottom: 0, left: 0, right: 0, top: 0 }, - contentOffset: { y: 0, x: 0 }, - contentSize: { height: 0, width: 0 }, - layoutMeasurement: { height: 0, width: 0 }, - responderIgnoreScroll: true, - target: 0, - velocity: { y: 0, x: 0 }, - }); -}); - -test('buildScrollEvent uses provided offset', () => { - const event = buildScrollEvent({ y: 100, x: 50 }); - - expect(event.nativeEvent.contentOffset).toEqual({ y: 100, x: 50 }); -}); - -test('buildScrollEvent uses provided options', () => { - const event = buildScrollEvent( - { y: 0, x: 0 }, - { - contentSize: { height: 1000, width: 400 }, - layoutMeasurement: { height: 800, width: 400 }, - }, - ); - - expect(event.nativeEvent.contentSize).toEqual({ height: 1000, width: 400 }); - expect(event.nativeEvent.layoutMeasurement).toEqual({ - height: 800, - width: 400, - }); -}); diff --git a/src/events/builders/__tests__/text.test.ts b/src/events/builders/__tests__/text.test.ts deleted file mode 100644 index 8970d4060..000000000 --- a/src/events/builders/__tests__/text.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { - buildContentSizeChangeEvent, - buildEndEditingEvent, - buildKeyPressEvent, - buildSubmitEditingEvent, - buildTextChangeEvent, - buildTextSelectionChangeEvent, -} from '../text'; - -test('buildTextChangeEvent returns event with text and selection', () => { - const event = buildTextChangeEvent('Hello', { start: 5, end: 5 }); - - expect(event.nativeEvent).toEqual({ - text: 'Hello', - target: 0, - eventCount: 0, - selection: { start: 5, end: 5 }, - }); -}); - -test('buildKeyPressEvent returns event with key', () => { - const event = buildKeyPressEvent('a'); - - expect(event.nativeEvent).toEqual({ key: 'a' }); -}); - -test('buildSubmitEditingEvent returns event with text', () => { - const event = buildSubmitEditingEvent('Hello'); - - expect(event.nativeEvent).toEqual({ text: 'Hello', target: 0 }); -}); - -test('buildEndEditingEvent returns event with text', () => { - const event = buildEndEditingEvent('Hello'); - - expect(event.nativeEvent).toEqual({ text: 'Hello', target: 0 }); -}); - -test('buildTextSelectionChangeEvent returns event with selection', () => { - const event = buildTextSelectionChangeEvent({ start: 0, end: 4 }); - - expect(event.nativeEvent).toEqual({ selection: { start: 0, end: 4 } }); -}); - -test('buildContentSizeChangeEvent returns event with contentSize', () => { - const event = buildContentSizeChangeEvent({ width: 100, height: 50 }); - - expect(event.nativeEvent).toEqual({ - contentSize: { width: 100, height: 50 }, - target: 0, - }); -}); diff --git a/src/events/builders/common.ts b/src/events/builders/common.ts deleted file mode 100644 index 64f961129..000000000 --- a/src/events/builders/common.ts +++ /dev/null @@ -1,108 +0,0 @@ -import type { LayoutRectangle } from '../types'; -import { baseSyntheticEvent } from './base'; - -/** - * Experimental values: - * - iOS: `{"changedTouches": [[Circular]], "identifier": 1, "locationX": 253, "locationY": 30.333328247070312, "pageX": 273, "pageY": 141.3333282470703, "target": 75, "timestamp": 875928682.0450834, "touches": [[Circular]]}` - * - Android: `{"changedTouches": [[Circular]], "identifier": 0, "locationX": 160, "locationY": 40.3636360168457, "pageX": 180, "pageY": 140.36363220214844, "target": 53, "targetSurface": -1, "timestamp": 10290805, "touches": [[Circular]]}` - */ -export function buildTouchEvent() { - return { - ...baseSyntheticEvent(), - nativeEvent: { - changedTouches: [] as unknown[], - identifier: 0, - locationX: 0, - locationY: 0, - pageX: 0, - pageY: 0, - target: 0, - timestamp: Date.now(), - touches: [] as unknown[], - }, - currentTarget: { measure: () => {} }, - }; -} - -export type TouchEvent = ReturnType; - -export function buildResponderGrantEvent() { - return { - ...buildTouchEvent(), - dispatchConfig: { registrationName: 'onResponderGrant' }, - }; -} - -export function buildResponderReleaseEvent() { - return { - ...buildTouchEvent(), - dispatchConfig: { registrationName: 'onResponderRelease' }, - }; -} - -/** - * Experimental values: - * - iOS: `{"eventCount": 0, "target": 75, "text": ""}` - * - Android: `{"target": 53}` - */ -export function buildFocusEvent() { - return { - ...baseSyntheticEvent(), - nativeEvent: { - target: 0, - }, - }; -} - -/** - * Experimental values: - * - iOS: `{"eventCount": 0, "target": 75, "text": ""}` - * - Android: `{"target": 53}` - */ -export function buildBlurEvent() { - return { - ...baseSyntheticEvent(), - nativeEvent: { - target: 0, - }, - }; -} - -/** - * Builds an accessibility action event, as delivered to the `onAccessibilityAction` - * handler when an assistive technology triggers an action. - * - * Experimental values: - * - `{"actionName": "increment"}` - */ -export function buildAccessibilityActionEvent(actionName: string) { - return { - ...baseSyntheticEvent(), - nativeEvent: { - actionName, - }, - }; -} - -/** - * Builds a layout event, as delivered to the `onLayout` handler when an element's - * size or position is measured by the layout engine. - * - * The passed `layout` values are merged onto a zeroed rectangle, so only the - * fields relevant to the test need to be provided. - */ -export function buildLayoutEvent(layout?: Partial) { - return { - ...baseSyntheticEvent(), - nativeEvent: { - // `??` rather than spread, so that explicit `undefined` values fall back to `0`. - layout: { - x: layout?.x ?? 0, - y: layout?.y ?? 0, - width: layout?.width ?? 0, - height: layout?.height ?? 0, - }, - target: 0, - }, - }; -} diff --git a/src/events/builders/index.ts b/src/events/builders/index.ts deleted file mode 100644 index 813d38feb..000000000 --- a/src/events/builders/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './common'; -export * from './merge'; -export * from './scroll'; -export * from './text'; diff --git a/src/events/builders/merge.ts b/src/events/builders/merge.ts deleted file mode 100644 index aa068145e..000000000 --- a/src/events/builders/merge.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { EventProps } from '../types'; - -/** - * Deep merges custom props into a built event, so tests can override only the fields they need. - * Nested objects are merged, other values (including arrays) are replaced. Mutates and returns - * the passed event. - */ -export function mergeEventProps(event: T, eventProps?: EventProps): T { - if (eventProps) { - mergeInto(event as EventProps, eventProps); - } - - return event; -} - -function mergeInto(target: EventProps, source: EventProps) { - for (const key of Object.keys(source)) { - const sourceValue = source[key]; - const targetValue = target[key]; - if (isObject(sourceValue) && isObject(targetValue)) { - mergeInto(targetValue, sourceValue); - } else { - target[key] = sourceValue; - } - } -} - -function isObject(value: unknown): value is EventProps { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} diff --git a/src/events/builders/scroll.ts b/src/events/builders/scroll.ts deleted file mode 100644 index a9a799371..000000000 --- a/src/events/builders/scroll.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { Point, Size } from '../../types'; -import { baseSyntheticEvent } from './base'; - -/** - * Other options for constructing a scroll event. - */ -export type ScrollEventOptions = { - contentSize?: Size; - layoutMeasurement?: Size; -}; - -/** - * Experimental values: - * - iOS: `{"contentInset": {"bottom": 0, "left": 0, "right": 0, "top": 0}, "contentOffset": {"x": 0, "y": 5.333333333333333}, "contentSize": {"height": 1676.6666259765625, "width": 390}, "layoutMeasurement": {"height": 753, "width": 390}, "zoomScale": 1}` - * - Android: `{"contentInset": {"bottom": 0, "left": 0, "right": 0, "top": 0}, "contentOffset": {"x": 0, "y": 31.619047164916992}, "contentSize": {"height": 1624.761962890625, "width": 411.4285583496094}, "layoutMeasurement": {"height": 785.5238037109375, "width": 411.4285583496094}, "responderIgnoreScroll": true, "target": 139, "velocity": {"x": -1.3633992671966553, "y": -1.3633992671966553}}` - */ -export function buildScrollEvent(offset: Point = { y: 0, x: 0 }, options?: ScrollEventOptions) { - return { - ...baseSyntheticEvent(), - nativeEvent: { - contentInset: { bottom: 0, left: 0, right: 0, top: 0 }, - contentOffset: { y: offset.y, x: offset.x }, - contentSize: { - height: options?.contentSize?.height ?? 0, - width: options?.contentSize?.width ?? 0, - }, - layoutMeasurement: { - height: options?.layoutMeasurement?.height ?? 0, - width: options?.layoutMeasurement?.width ?? 0, - }, - responderIgnoreScroll: true, - target: 0, - velocity: { y: 0, x: 0 }, - }, - }; -} diff --git a/src/events/builders/text.ts b/src/events/builders/text.ts deleted file mode 100644 index 8b0346484..000000000 --- a/src/events/builders/text.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { Size, TextRange } from '../../types'; -import { baseSyntheticEvent } from './base'; - -/** - * Experimental values: - * - iOS: `{"eventCount": 4, "target": 75, "text": "Test"}` - * - Android: `{"eventCount": 6, "target": 53, "text": "Tes"}` - */ -export function buildTextChangeEvent(text: string, { start, end }: TextRange) { - return { - ...baseSyntheticEvent(), - nativeEvent: { text, target: 0, eventCount: 0, selection: { start, end } }, - }; -} - -/** - * Experimental values: - * - iOS: `{"eventCount": 3, "key": "a", "target": 75}` - * - Android: `{"key": "a"}` - */ -export function buildKeyPressEvent(key: string) { - return { - ...baseSyntheticEvent(), - nativeEvent: { key }, - }; -} - -/** - * Experimental values: - * - iOS: `{"eventCount": 4, "target": 75, "text": "Test"}` - * - Android: `{"target": 53, "text": "Test"}` - */ -export function buildSubmitEditingEvent(text: string) { - return { - ...baseSyntheticEvent(), - nativeEvent: { text, target: 0 }, - }; -} - -/** - * Experimental values: - * - iOS: `{"eventCount": 4, "target": 75, "text": "Test"}` - * - Android: `{"target": 53, "text": "Test"}` - */ -export function buildEndEditingEvent(text: string) { - return { - ...baseSyntheticEvent(), - nativeEvent: { text, target: 0 }, - }; -} - -/** - * Experimental values: - * - iOS: `{"selection": {"end": 4, "start": 4}, "target": 75}` - * - Android: `{"selection": {"end": 4, "start": 4}}` - */ -export function buildTextSelectionChangeEvent({ start, end }: TextRange) { - return { - ...baseSyntheticEvent(), - nativeEvent: { selection: { start, end } }, - }; -} - -/** - * Experimental values: - * - iOS: `{"contentSize": {"height": 21.666666666666668, "width": 11.666666666666666}, "target": 75}` - * - Android: `{"contentSize": {"height": 61.45454406738281, "width": 352.7272644042969}, "target": 53}` - */ -export function buildContentSizeChangeEvent({ width, height }: Size) { - return { - ...baseSyntheticEvent(), - nativeEvent: { contentSize: { width, height }, target: 0 }, - }; -} diff --git a/src/events/create-event.ts b/src/events/create-event.ts new file mode 100644 index 000000000..ffb69a0ff --- /dev/null +++ b/src/events/create-event.ts @@ -0,0 +1,104 @@ +import { getConfig } from '../config'; +import type { Point, Size, TextRange } from '../types'; +import { wrapNativeEvent } from './legacy/builders/base'; +import type { NativeEventPayload, SyntheticEvent } from './modern/event'; +import { createEvent as createModernEvent } from './modern/event'; +import { getEventTypeConfig } from './modern/event-types'; +import type { ScrollNativeEventOptions } from './shared/payloads'; +import { + buildAccessibilityActionNativeEvent, + buildBlurNativeEvent, + buildContentSizeChangeNativeEvent, + buildEndEditingNativeEvent, + buildFocusNativeEvent, + buildKeyPressNativeEvent, + buildScrollNativeEvent, + buildSubmitEditingNativeEvent, + buildTextChangeNativeEvent, + buildTextSelectionChangeNativeEvent, + buildTouchNativeEvent, +} from './shared/payloads'; + +export type LegacyEvent = ReturnType>; + +/** + * Creates the event object for a native event (`focus`, `change`, `scroll`, ...) in the event + * system selected by `configure({ unstable_eventSystem })`: a legacy event object with stubs, or a + * `SyntheticEvent`. Used by `userEvent`, with `dispatchEvent()`. Unlike modern `createEvent()`, + * takes the `nativeEvent` itself, not an init object. + * + * Callbacks that components call from JavaScript (`changeText`, `pressIn`, `responderGrant`, ...) + * aren't native events. Call them with `invokeEventHandler()`. + * + * @param eventType a native event type without the `on*` prefix, e.g. `focus` + */ +export function createEvent( + eventType: string, + nativeEvent: NativeEventPayload, +): LegacyEvent | SyntheticEvent { + if (getEventTypeConfig(eventType) == null) { + throw new Error(`"${eventType}" is not a native event type. Use invokeEventHandler() instead.`); + } + + if (getConfig().unstable_eventSystem === 'legacy') { + return wrapNativeEvent(nativeEvent); + } + + // Not `null`: the event type is known. + return createModernEvent(eventType, { nativeEvent }) as SyntheticEvent; +} + +export function buildPressEvent() { + return createEvent('press', buildTouchNativeEvent()); +} + +export function buildFocusEvent() { + return createEvent('focus', buildFocusNativeEvent()); +} + +export function buildBlurEvent() { + return createEvent('blur', buildBlurNativeEvent()); +} + +export function buildAccessibilityActionEvent(actionName: string) { + return createEvent('accessibilityAction', buildAccessibilityActionNativeEvent(actionName)); +} + +export function buildScrollEvent( + eventType: ScrollEventType, + offset?: Point, + options?: ScrollNativeEventOptions, +) { + return createEvent(eventType, buildScrollNativeEvent(offset, options)); +} + +export function buildTextChangeEvent(text: string, range: TextRange) { + return createEvent('change', buildTextChangeNativeEvent(text, range)); +} + +export function buildKeyPressEvent(key: string) { + return createEvent('keyPress', buildKeyPressNativeEvent(key)); +} + +export function buildSubmitEditingEvent(text: string) { + return createEvent('submitEditing', buildSubmitEditingNativeEvent(text)); +} + +export function buildEndEditingEvent(text: string) { + return createEvent('endEditing', buildEndEditingNativeEvent(text)); +} + +export function buildTextSelectionChangeEvent(range: TextRange) { + return createEvent('selectionChange', buildTextSelectionChangeNativeEvent(range)); +} + +export function buildContentSizeChangeEvent(size: Size) { + return createEvent('contentSizeChange', buildContentSizeChangeNativeEvent(size)); +} + +type ScrollEventType = + | 'scroll' + | 'scrollBeginDrag' + | 'scrollEndDrag' + | 'momentumScrollBegin' + | 'momentumScrollEnd'; diff --git a/src/events/dispatch-event.ts b/src/events/dispatch-event.ts new file mode 100644 index 000000000..b91b5197d --- /dev/null +++ b/src/events/dispatch-event.ts @@ -0,0 +1,29 @@ +import type { TestInstance } from 'test-renderer'; + +import type { LegacyEvent } from './create-event'; +import { dispatchEvent as dispatchLegacyEvent } from './legacy/dispatch'; +import { dispatchEvent as dispatchModernEvent } from './modern/dispatch'; +import { SyntheticEvent } from './modern/event'; + +/** + * Dispatches an event from `createEvent()` to a host element. Used by `userEvent`. + * + * - Legacy event object: calls the element's own `on*` prop with it. + * - `SyntheticEvent`: dispatched as React Native does (capture and bubble phases for bubbling + * events). `press` is a native event type (`topPress`), so it reaches host `onPress` props only, + * not `Pressable`'s `onPress`. + * + * @param eventType a native event type without the `on*` prefix, e.g. `focus` + * @returns `true` if a handler was called. + */ +export async function dispatchEvent( + instance: TestInstance, + eventType: string, + event: LegacyEvent | SyntheticEvent, +): Promise { + if (event instanceof SyntheticEvent) { + return await dispatchModernEvent(instance, event); + } + + return await dispatchLegacyEvent(instance, eventType, event); +} diff --git a/src/events/dispatch.ts b/src/events/dispatch.ts deleted file mode 100644 index 400b2c080..000000000 --- a/src/events/dispatch.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { TestInstance } from 'test-renderer'; - -import { act } from '../act'; -import { isInstanceMounted } from '../helpers/component-tree'; -import { getEventHandlerFromProps } from './handler'; - -/** - * Basic dispatch event function used by User Event module. - * - * @param instance instance to trigger event on - * @param eventType type of the event - * @param event event payload(s) - * @returns `true` if a handler was called. - */ -export async function dispatchEvent( - instance: TestInstance, - eventType: string, - ...event: unknown[] -): Promise { - if (!isInstanceMounted(instance)) { - return false; - } - - const handler = getEventHandlerFromProps(instance.props, eventType); - if (!handler) { - return false; - } - - await act(() => { - handler(...event); - }); - - return true; -} diff --git a/src/events/fire-event.ts b/src/events/fire-event.ts index ee998c9e2..4b2fc5fe5 100644 --- a/src/events/fire-event.ts +++ b/src/events/fire-event.ts @@ -1,80 +1,69 @@ import type { TestInstance } from 'test-renderer'; -import { act } from '../act'; -import { isInstanceMounted } from '../helpers/component-tree'; -import { isHostScrollView } from '../helpers/host-component-names'; -import { buildLayoutEvent, buildTouchEvent } from './builders/common'; -import { mergeEventProps } from './builders/merge'; -import { buildScrollEvent } from './builders/scroll'; -import { normalizeEventType } from './handler'; -import { nativeState } from './native-state'; -import { findEventHandler } from './propagation'; -import type { EventProps, EventType, LayoutRectangle } from './types'; -import { updateNativeStateFromEvent } from './update-native-state'; -import { warnAboutUnhandledEvent } from './warnings'; +import { getConfig } from '../config'; +import { fireEvent as legacyFireEvent } from './legacy/fire-event'; +import type { FireEventInit } from './modern/fire-event'; +import { fireEvent as modernFireEvent } from './modern/fire-event'; +import type { EventProps, EventType, LayoutRectangle } from './shared/types'; -async function fireEvent(instance: TestInstance, eventType: EventType, ...data: unknown[]) { - return await fireEventInternal(instance, { type: eventType, data, bubbles: true }); -} - -fireEvent.changeText = async (instance: TestInstance, text: string) => - await fireEvent(instance, 'changeText', text); +/** + * Public `fireEvent`. Calls the implementation of the event system selected by + * `configure({ unstable_eventSystem })`, read on each call, so `configure()` inside a test applies. + * + * Types follow the legacy signatures. The modern implementation checks its arguments at runtime. + */ +async function fireEvent( + instance: TestInstance, + eventType: EventType, + ...data: unknown[] +): Promise { + if (isModern()) { + return await modernFireEvent(instance, eventType, ...(data as [FireEventInit])); + } -fireEvent.press = async (instance: TestInstance, eventProps?: EventProps) => { - await fireEvent(instance, 'press', mergeEventProps(buildTouchEvent(), eventProps)); -}; + return await legacyFireEvent(instance, eventType, ...data); +} -fireEvent.scroll = async (instance: TestInstance, eventProps?: EventProps) => { - const layoutMeasurement = isHostScrollView(instance) - ? nativeState.layoutSizeForInstance.get(instance) - : undefined; - const event = buildScrollEvent(undefined, { layoutMeasurement }); - await fireEvent(instance, 'scroll', mergeEventProps(event, eventProps)); -}; +fireEvent.changeText = async (instance: TestInstance, text: string): Promise => { + if (isModern()) { + return await modernFireEvent.changeText(instance, text); + } -// Does not bubble and checks only the element's own props, as React Native delivers layout events -// only to the measured element. This is the intended behavior: `fireEvent(instance, 'layout')` -// still bubbles (with a deprecation warning) for compatibility, and will match this in the next -// major version. -fireEvent.layout = async (instance: TestInstance, layout?: Partial) => { - await fireEventInternal(instance, { - type: 'layout', - data: [buildLayoutEvent(layout)], - bubbles: false, - }); + return await legacyFireEvent.changeText(instance, text); }; -type FireEventOptions = { - type: EventType; - data: unknown[]; - bubbles: boolean; +// Legacy in both event systems for now: `Pressable` and `Touchable*` get touches through the +// responder system, which the modern event system doesn't implement yet. +fireEvent.press = async (instance: TestInstance, eventProps?: EventProps): Promise => { + await legacyFireEvent.press(instance, eventProps); }; -async function fireEventInternal(instance: TestInstance, options: FireEventOptions) { - const { type, data, bubbles } = options; - if (!isInstanceMounted(instance)) { - return; +fireEvent.scroll = async ( + instance: TestInstance, + eventProps?: EventProps, +): Promise => { + if (isModern()) { + return await modernFireEvent.scroll(instance, eventProps as FireEventInit); } - // `fireEvent` accepts event types with and without the `on*` prefix. - const hasUpdatedNativeState = updateNativeStateFromEvent( - instance, - normalizeEventType(type), - data[0], - ); + await legacyFireEvent.scroll(instance, eventProps); + return undefined; +}; - const { handler, skippedTargets } = findEventHandler(instance, type, { bubbles }); - if (!handler) { - warnAboutUnhandledEvent(instance, type, { skippedTargets, hasUpdatedNativeState }); - return; +fireEvent.layout = async ( + instance: TestInstance, + layout?: Partial, +): Promise => { + if (isModern()) { + return await modernFireEvent.layout(instance, layout); } - let returnValue; - await act(() => { - returnValue = handler(...data); - }); + await legacyFireEvent.layout(instance, layout); + return undefined; +}; - return returnValue; +function isModern() { + return getConfig().unstable_eventSystem === 'modern'; } export { fireEvent }; diff --git a/src/events/__tests__/dispatch.test.tsx b/src/events/legacy/__tests__/dispatch.test.tsx similarity index 96% rename from src/events/__tests__/dispatch.test.tsx rename to src/events/legacy/__tests__/dispatch.test.tsx index 6e00b0613..9befbcee2 100644 --- a/src/events/__tests__/dispatch.test.tsx +++ b/src/events/legacy/__tests__/dispatch.test.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { Text } from 'react-native'; -import { render, screen } from '../..'; +import { render, screen } from '../../..'; import { buildTouchEvent } from '../builders/common'; import { dispatchEvent } from '../dispatch'; diff --git a/src/events/legacy/__tests__/fire-event.test.tsx b/src/events/legacy/__tests__/fire-event.test.tsx new file mode 100644 index 000000000..ec5c1c74a --- /dev/null +++ b/src/events/legacy/__tests__/fire-event.test.tsx @@ -0,0 +1,1818 @@ +import * as React from 'react'; +import type { TextInputProps } from 'react-native'; +import { + FlatList, + Image, + ImageBackground, + Modal, + PanResponder, + Pressable, + ScrollView, + Text, + TextInput, + TouchableHighlight, + TouchableNativeFeedback, + TouchableOpacity, + TouchableWithoutFeedback, + View, +} from 'react-native'; + +import { fireEvent, render, screen } from '../../..'; +import { configure } from '../../../config'; +import { _console, logger } from '../../../helpers/logger'; +import { runInLegacyEventSystem } from '../../../test-utils/event-system'; +import { getEventHandlerName } from '../../shared/handler'; +import { nativeState } from '../../shared/native-state'; + +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 } }; + +runInLegacyEventSystem(); + +beforeEach(() => { + jest.spyOn(Date, 'now').mockImplementation(() => 100100100100); +}); + +test('fireEvent accepts event name with or without "on" prefix', async () => { + const onPress = jest.fn(); + await render(); + + await fireEvent(screen.getByTestId('btn'), 'press'); + expect(onPress).toHaveBeenCalledTimes(1); + + await fireEvent(screen.getByTestId('btn'), 'onPress'); + expect(onPress).toHaveBeenCalledTimes(2); +}); + +test('fireEvent with "on" prefixed name does not call unprefixed handler props', async () => { + const press = jest.fn(); + const testOnlyPress = jest.fn(); + // @ts-expect-error Intentionally passing such props + await render(); + + await fireEvent(screen.getByTestId('view'), 'onPress'); + expect(press).not.toHaveBeenCalled(); + expect(testOnlyPress).not.toHaveBeenCalled(); + + await fireEvent(screen.getByTestId('view'), 'press'); + expect(press).toHaveBeenCalledTimes(1); +}); + +test('fireEvent passes event data to handler', async () => { + const onPress = jest.fn(); + await render(); + await fireEvent.press(screen.getByTestId('btn'), pressEventData); + expect(onPress.mock.calls[0][0]).toMatchObject(pressEventData); +}); + +test('fireEvent passes multiple parameters to handler', async () => { + const handlePress = jest.fn(); + await render(); + await fireEvent(screen.getByTestId('btn'), 'press', 'param1', 'param2', 'param3'); + expect(handlePress).toHaveBeenCalledWith('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); +}); + +test('fireEvent bubbles event to parent handler', async () => { + const onPress = jest.fn(); + await render( + + Press me + , + ); + await fireEvent.press(screen.getByText('Press me')); + expect(onPress).toHaveBeenCalled(); +}); + +describe('fireEvent.press', () => { + test('passes default press event object to handler', async () => { + const onPress = jest.fn(); + await render(); + await fireEvent.press(screen.getByTestId('btn')); + expect(onPress.mock.calls[0][0]).toMatchInlineSnapshot(` + { + "currentTarget": { + "measure": [Function], + }, + "isDefaultPrevented": [Function], + "isPersistent": [Function], + "isPropagationStopped": [Function], + "nativeEvent": { + "changedTouches": [], + "identifier": 0, + "locationX": 0, + "locationY": 0, + "pageX": 0, + "pageY": 0, + "target": 0, + "timestamp": 100100100100, + "touches": [], + }, + "persist": [Function], + "preventDefault": [Function], + "stopPropagation": [Function], + "target": {}, + "timeStamp": 0, + } + `); + }); + + test('overrides default event properties with passed event props', async () => { + const onPress = jest.fn(); + await render(); + const customEventData = { nativeEvent: { pageX: 20, pageY: 30 } }; + await fireEvent.press(screen.getByTestId('btn'), customEventData); + expect(onPress.mock.calls[0][0]).toMatchInlineSnapshot(` + { + "currentTarget": { + "measure": [Function], + }, + "isDefaultPrevented": [Function], + "isPersistent": [Function], + "isPropagationStopped": [Function], + "nativeEvent": { + "changedTouches": [], + "identifier": 0, + "locationX": 0, + "locationY": 0, + "pageX": 20, + "pageY": 30, + "target": 0, + "timestamp": 100100100100, + "touches": [], + }, + "persist": [Function], + "preventDefault": [Function], + "stopPropagation": [Function], + "target": {}, + "timeStamp": 0, + } + `); + }); + + test.each([ + ['Pressable', Pressable], + ['TouchableOpacity', TouchableOpacity], + ['TouchableHighlight', TouchableHighlight], + ['TouchableWithoutFeedback', TouchableWithoutFeedback], + ['TouchableNativeFeedback', TouchableNativeFeedback], + ])('works on %s', async (_, Component) => { + const onPress = jest.fn(); + await render( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - Component is a valid React component - but some RN versions have incorrect type definitions + + Press me + , + ); + await fireEvent.press(screen.getByTestId('subject')); + expect(onPress).toHaveBeenCalled(); + }); + + test('works with testOnly_onPress handlers', async () => { + const onPress = jest.fn(); + const onPressIn = jest.fn(); + const onPressOut = jest.fn(); + const onLongPress = jest.fn(); + const testOnlyPressProps = { + testOnly_onPress: onPress, + testOnly_onPressIn: onPressIn, + testOnly_onPressOut: onPressOut, + testOnly_onLongPress: onLongPress, + }; + + await render(); + + const subject = screen.getByTestId('subject'); + + await fireEvent.press(subject); + expect(onPress).toHaveBeenCalledTimes(1); + + await fireEvent(subject, 'pressIn'); + expect(onPressIn).toHaveBeenCalledTimes(1); + + await fireEvent(subject, 'pressOut'); + expect(onPressOut).toHaveBeenCalledTimes(1); + + await fireEvent(subject, 'longPress'); + expect(onLongPress).toHaveBeenCalledTimes(1); + }); +}); + +describe('fireEvent.changeText', () => { + test('works on TextInput', async () => { + 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'); + }); + + test('updates native state when fired with `on*` prefixed name', async () => { + const onChangeText = jest.fn(); + await render(); + const input = screen.getByTestId('input'); + await fireEvent(input, 'onChangeText', 'new text'); + expect(onChangeText).toHaveBeenCalledWith('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(); + const input = screen.getByTestId('input'); + await fireEvent.changeText(input, 'new text'); + expect(onChangeText).not.toHaveBeenCalled(); + expect(nativeState.valueForInstance.get(input)).toBeUndefined(); + }); +}); + +test('change event saves value of TextInput in native state', async () => { + await render(); + const input = screen.getByTestId('input'); + + await fireEvent(input, 'change', { nativeEvent: { text: 'new text' } }); + + expect(nativeState.valueForInstance.get(input)).toBe('new text'); +}); + +describe('fireEvent.scroll', () => { + test('passes default scroll event object to handler', async () => { + const onScroll = jest.fn(); + await render( + + Content + , + ); + const scrollView = screen.getByTestId('scroll'); + await fireEvent.scroll(scrollView); + expect(onScroll.mock.calls[0][0]).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, + }, + }, + "persist": [Function], + "preventDefault": [Function], + "stopPropagation": [Function], + "target": {}, + "timeStamp": 0, + } + `); + }); + + test('overrides default event properties with passed event props', async () => { + const onScroll = jest.fn(); + await render( + + Content + , + ); + const scrollView = screen.getByTestId('scroll'); + const customEventData = { + nativeEvent: { contentOffset: { x: 50, y: 200 } }, + }; + await fireEvent.scroll(scrollView, customEventData); + expect(onScroll.mock.calls[0][0]).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, + }, + }, + "persist": [Function], + "preventDefault": [Function], + "stopPropagation": [Function], + "target": {}, + "timeStamp": 0, + } + `); + }); + + test('works on ScrollView', async () => { + const onScroll = jest.fn(); + await render( + + Content + , + ); + const scrollView = screen.getByTestId('scroll'); + await fireEvent.scroll(scrollView, verticalScrollEvent); + expect(onScroll.mock.calls[0][0]).toMatchObject(verticalScrollEvent); + expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ + x: 0, + y: 200, + }); + }); + + 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'], + ['onScrollEndDrag', 'scrollEndDrag'], + ['onMomentumScrollBegin', 'momentumScrollBegin'], + ['onMomentumScrollEnd', 'momentumScrollEnd'], + ])('fires %s on ScrollView', async (propName, eventType) => { + const handler = jest.fn(); + await render(); + const scrollView = screen.getByTestId('scroll'); + await fireEvent(scrollView, eventType, verticalScrollEvent); + expect(handler).toHaveBeenCalledWith(verticalScrollEvent); + expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ + x: 0, + y: 200, + }); + }); + + test('without contentOffset scrolls to (0, 0)', async () => { + const onScroll = jest.fn(); + await render( + + Content + , + ); + const scrollView = screen.getByTestId('scroll'); + await fireEvent.scroll(scrollView, {}); + expect(onScroll.mock.calls[0][0]).toMatchObject({ + nativeEvent: { contentOffset: { x: 0, y: 0 } }, + }); + expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ + x: 0, + y: 0, + }); + }); + + test('with non-finite contentOffset values uses 0', async () => { + const onScroll = jest.fn(); + await render( + + Content + , + ); + const scrollView = screen.getByTestId('scroll'); + await fireEvent.scroll(scrollView, { + nativeEvent: { contentOffset: { y: Infinity } }, + }); + expect(onScroll).toHaveBeenCalled(); + expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ + x: 0, + y: 0, + }); + }); + + test('with horizontal scroll updates native state', async () => { + const onScroll = jest.fn(); + await render( + + Content + , + ); + const scrollView = screen.getByTestId('scroll'); + await fireEvent.scroll(scrollView, horizontalScrollEvent); + expect(onScroll.mock.calls[0][0]).toMatchObject(horizontalScrollEvent); + expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ + x: 50, + y: 0, + }); + }); + + test('without contentOffset via fireEvent() does not update native state', async () => { + const onScroll = jest.fn(); + await render( + + Content + , + ); + const scrollView = screen.getByTestId('scroll'); + await fireEvent(scrollView, 'scroll', { nativeEvent: {} }); + expect(onScroll).toHaveBeenCalled(); + expect(nativeState.contentOffsetForInstance.get(scrollView)).toBeUndefined(); + }); + + test('with non-finite x contentOffset value uses 0', async () => { + const onScroll = jest.fn(); + await render( + + Content + , + ); + const scrollView = screen.getByTestId('scroll'); + await fireEvent.scroll(scrollView, { + nativeEvent: { contentOffset: { x: Infinity } }, + }); + expect(onScroll).toHaveBeenCalled(); + expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ + x: 0, + y: 0, + }); + }); + + test('uses layout size from previous layout event as layoutMeasurement', async () => { + const onScroll = jest.fn(); + await render( {}} />); + const scrollView = screen.getByTestId('scroll'); + + await fireEvent.layout(scrollView, { width: 390, height: 750 }); + await fireEvent.scroll(scrollView); + + expect(onScroll.mock.calls[0][0].nativeEvent.layoutMeasurement).toEqual({ + width: 390, + height: 750, + }); + }); + + test('prefers passed layoutMeasurement over layout size from layout event', async () => { + const onScroll = jest.fn(); + await render( {}} />); + const scrollView = screen.getByTestId('scroll'); + + await fireEvent.layout(scrollView, { width: 390, height: 750 }); + await fireEvent.scroll(scrollView, { + nativeEvent: { layoutMeasurement: { width: 100, height: 200 } }, + }); + + expect(onScroll.mock.calls[0][0].nativeEvent.layoutMeasurement).toEqual({ + width: 100, + height: 200, + }); + }); + + 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( + + {}} /> + , + ); + const content = screen.getByTestId('content'); + + await fireEvent.layout(content, { width: 390, height: 750 }); + await fireEvent.scroll(content); + + expect(onScroll.mock.calls[0][0].nativeEvent.layoutMeasurement).toEqual({ + width: 0, + height: 0, + }); + warnSpy.mockRestore(); + }); +}); + +describe('fireEvent.layout', () => { + test('passes default layout event object to handler', async () => { + const onLayout = jest.fn(); + await render(); + + await fireEvent.layout(screen.getByTestId('view')); + + expect(onLayout.mock.calls[0][0]).toMatchInlineSnapshot(` + { + "currentTarget": {}, + "isDefaultPrevented": [Function], + "isPersistent": [Function], + "isPropagationStopped": [Function], + "nativeEvent": { + "layout": { + "height": 0, + "width": 0, + "x": 0, + "y": 0, + }, + "target": 0, + }, + "persist": [Function], + "preventDefault": [Function], + "stopPropagation": [Function], + "target": {}, + "timeStamp": 0, + } + `); + }); + + test('merges the passed layout onto the zeroed rectangle', async () => { + const onLayout = jest.fn(); + await render(); + + await fireEvent.layout(screen.getByTestId('view'), { width: 200, height: 80 }); + + expect(onLayout.mock.calls[0][0].nativeEvent).toEqual({ + layout: { x: 0, y: 0, width: 200, height: 80 }, + target: 0, + }); + }); + + test('uses zero for layout fields passed as undefined', async () => { + configure({ eventDiagnostics: true }); + const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); + const onLayout = jest.fn(); + await render( + + + + , + ); + const withoutHandler = screen.getByTestId('without-handler'); + + await fireEvent.layout(screen.getByTestId('with-handler'), { x: undefined, width: undefined }); + await fireEvent.layout(withoutHandler, { width: undefined, height: undefined }); + + expect(onLayout.mock.calls[0][0].nativeEvent.layout).toEqual({ + x: 0, + y: 0, + width: 0, + height: 0, + }); + expect(nativeState.layoutSizeForInstance.get(withoutHandler)).toEqual({ width: 0, height: 0 }); + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + test('does not bubble to the handler on an ancestor element', async () => { + const onLayout = jest.fn(); + await render( + + + , + ); + + await fireEvent.layout(screen.getByTestId('child'), { height: 80 }); + + expect(onLayout).not.toHaveBeenCalled(); + }); + + test('bubbles with a warning when fired as generic layout event', async () => { + const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); + const onLayout = jest.fn(); + await render( + + + , + ); + + await fireEvent(screen.getByTestId('child'), 'layout', layoutEvent); + await fireEvent(screen.getByTestId('child'), 'onLayout', layoutEvent); + + expect(onLayout).toHaveBeenCalledTimes(2); + expect(warnSpy).toHaveBeenCalledTimes(2); + warnSpy.mockRestore(); + }); + + test('does not warn when layout size is saved in native state without onLayout handler', async () => { + configure({ eventDiagnostics: true }); + const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); + await render(); + + await fireEvent.layout(screen.getByTestId('view')); + + expect(warnSpy).not.toHaveBeenCalled(); + 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( + false} />, + ); + + await fireEvent.layout(screen.getByTestId('view')); + + expect(onLayout).toHaveBeenCalledTimes(1); + }); + + test('saves layout size in native state', async () => { + await render( {}} />); + const view = screen.getByTestId('view'); + + await fireEvent.layout(view, { x: 10, y: 20, width: 100, height: 80 }); + expect(nativeState.layoutSizeForInstance.get(view)).toEqual({ width: 100, height: 80 }); + + await fireEvent(view, 'layout', { nativeEvent: { layout: { width: 50, height: NaN } } }); + expect(nativeState.layoutSizeForInstance.get(view)).toEqual({ width: 50, height: 0 }); + }); + + test('saves layout size in native state even without onLayout handler', async () => { + await render(); + const view = screen.getByTestId('view'); + + await fireEvent.layout(view, { width: 100, height: 80 }); + + expect(nativeState.layoutSizeForInstance.get(view)).toEqual({ width: 100, height: 80 }); + }); + + test('does not call onLayout of composite component that does not forward it', async () => { + const onLayout = jest.fn(); + const Box = (_props: { onLayout: () => void }) => ; + await render(); + + await fireEvent.layout(screen.getByTestId('view')); + + expect(onLayout).not.toHaveBeenCalled(); + }); +}); + +describe('direct events', () => { + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + type DirectEventCase = { + name: string; + eventType: string; + ui: (handler: jest.Mock) => React.ReactElement; + }; + + const directEventCases: DirectEventCase[] = [ + { + name: 'layout from View content', + eventType: 'layout', + ui: (handler) => ( + + + + ), + }, + { + name: 'accessibilityAction from Pressable content', + eventType: 'accessibilityAction', + ui: (handler) => ( + + Button + + ), + }, + ...buildDirectEventCases( + 'View content', + ['accessibilityTap', 'magicTap', 'accessibilityEscape'], + (handlerProps) => ( + + + + ), + ), + { + name: 'textLayout from nested Text', + eventType: 'textLayout', + ui: (handler) => ( + + Nested + + ), + }, + ...buildDirectEventCases( + 'TextInput content', + ['scroll', 'selectionChange', 'contentSizeChange'], + (handlerProps) => ( + + Nested + + ), + ), + ...buildDirectEventCases( + 'Image content', + ['loadStart', 'progress', 'partialLoad', 'load', 'error', 'loadEnd'], + // Image does not accept children, clone it to fire the event on a nested element. + (handlerProps) => + React.cloneElement( + , + {}, + Nested, + ), + ), + ...buildDirectEventCases( + 'ScrollView content', + [ + 'scroll', + 'scrollBeginDrag', + 'scrollEndDrag', + 'momentumScrollBegin', + 'momentumScrollEnd', + 'scrollToTop', + 'contentSizeChange', + ], + (handlerProps) => ( + + + + ), + ), + { + name: 'scroll from TextInput to ancestor ScrollView', + eventType: 'scroll', + ui: (handler) => ( + + + + ), + }, + { + name: 'refresh from FlatList item', + eventType: 'refresh', + ui: (handler) => ( + {item}} + refreshing={false} + onRefresh={handler} + /> + ), + }, + { + name: 'contentSizeChange from FlatList item', + eventType: 'contentSizeChange', + ui: (handler) => ( + {item}} + onContentSizeChange={handler} + /> + ), + }, + ...buildDirectEventCases( + 'Modal content', + ['requestClose', 'show', 'dismiss', 'orientationChange'], + (handlerProps) => ( + + Content + + ), + ), + ]; + + // Builds one case per event type, passing the matching `on*` handler prop to `ui`. + function buildDirectEventCases( + source: string, + eventTypes: string[], + ui: (handlerProps: Record) => React.ReactElement, + ): DirectEventCase[] { + return eventTypes.map((eventType) => ({ + name: `${eventType} from ${source}`, + eventType, + ui: (handler) => ui({ [getEventHandlerName(eventType)]: handler }), + })); + } + + test.each(directEventCases)('bubbles $name with a warning', async ({ eventType, ui }) => { + const handler = jest.fn(); + await render(ui(handler)); + + await fireEvent(screen.getByTestId('target'), eventType); + + expect(handler).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + test.each(directEventCases)( + 'does not warn for $name when fired on the emitting element', + async ({ eventType, ui }) => { + const handler = jest.fn(); + await render(ui(handler)); + + await fireEvent(screen.getByTestId('emitter'), eventType); + + expect(handler).toHaveBeenCalledTimes(1); + expect(warnSpy).not.toHaveBeenCalled(); + }, + ); + + test('warns when fired with "on" prefixed event name', async () => { + const onMomentumScrollEnd = jest.fn(); + await render( + + + , + ); + + await fireEvent(screen.getByTestId('child'), 'onMomentumScrollEnd'); + + expect(onMomentumScrollEnd).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + test('warns about stopping bubbling in the next major version', async () => { + await render( + {}}> + + , + ); + + 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: + + + " + `); + }); + + test.each([true, false])( + 'warns about bubbling regardless of eventDiagnostics (%s)', + async (eventDiagnostics) => { + configure({ eventDiagnostics }); + await render( + {}}> + + , + ); + + await fireEvent.scroll(screen.getByTestId('child')); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toContain('"scroll" does not bubble in React Native'); + }, + ); + + test('warns when handler is on composite component above the emitting element', async () => { + const onScroll = jest.fn(); + const Screen = (_props: { onScroll: () => void }) => ( + + + + ); + await render(); + + await fireEvent.scroll(screen.getByTestId('child')); + + expect(onScroll).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledTimes(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(); + + await fireEvent.scroll(screen.getByTestId('child')); + + expect(onScroll).toHaveBeenCalledTimes(1); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + 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 + + ); + await render(); + + await fireEvent(screen.getByText('Card'), 'load'); + await fireEvent(screen.getByText('Card'), 'show'); + + expect(onLoad).toHaveBeenCalledTimes(1); + expect(onShow).toHaveBeenCalledTimes(1); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test('does not warn when bubbling to host element that does not emit the event', async () => { + const onLoad = jest.fn(); + await render( + // @ts-expect-error View does not have onLoad prop + + Content + , + ); + + await fireEvent(screen.getByText('Content'), 'load'); + + expect(onLoad).toHaveBeenCalledTimes(1); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test('does not warn when bubbling load event to ImageBackground handler', async () => { + const onLoad = jest.fn(); + await render( + + Caption + , + ); + + await fireEvent(screen.getByText('Caption'), 'load'); + + expect(onLoad).toHaveBeenCalledTimes(1); + expect(warnSpy).not.toHaveBeenCalled(); + }); +}); + +test('fireEvent fires custom event (onCustomEvent) on composite component', async () => { + const CustomComponent = ({ onCustomEvent }: { onCustomEvent: (data: string) => void }) => ( + onCustomEvent('event data')}> + Custom + + ); + const handler = jest.fn(); + await render(); + await fireEvent(screen.getByText('Custom'), 'customEvent', 'event data'); + expect(handler).toHaveBeenCalledWith('event data'); +}); + +test('fireEvent fires event with custom prop name (handlePress) on composite component', async () => { + const MyButton = ({ handlePress }: { handlePress: () => void }) => ( + + Button + + ); + 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(); +}); + +test('fireEvent calls handler on element when both element and parent have handlers', async () => { + const childHandler = jest.fn(); + const parentHandler = jest.fn(); + await render( + + + Press me + + , + ); + await fireEvent.press(screen.getByTestId('child')); + expect(childHandler).toHaveBeenCalledTimes(1); + expect(parentHandler).not.toHaveBeenCalled(); +}); + +test('fireEvent does nothing when element is unmounted', async () => { + const onPress = jest.fn(); + await render( + + + , + ); + const element = screen.getByTestId('btn'); + + await screen.rerender(); + await fireEvent.press(element); + expect(onPress).not.toHaveBeenCalled(); +}); + +test('fireEvent does not throw when called with non-existent event name', async () => { + await render(); + const element = screen.getByTestId('btn'); + await expect(fireEvent(element, 'nonExistentEvent' as any)).resolves.toBeUndefined(); +}); + +test('fireEvent handles handler that throws gracefully', async () => { + const error = new Error('Handler error'); + const onPress = jest.fn(() => { + throw error; + }); + await render(); + await expect(fireEvent.press(screen.getByTestId('btn'))).rejects.toThrow('Handler error'); + expect(onPress).toHaveBeenCalledTimes(1); +}); + +describe('disabled elements', () => { + test('does not fire on disabled Pressable', async () => { + const onPress = jest.fn(); + await render( + + Trigger + , + ); + await fireEvent.press(screen.getByText('Trigger')); + expect(onPress).not.toHaveBeenCalled(); + }); + + test('does not fire on disabled TouchableOpacity', async () => { + const onPress = jest.fn(); + await render( + + Trigger + , + ); + await fireEvent.press(screen.getByText('Trigger')); + expect(onPress).not.toHaveBeenCalled(); + }); + + test('bubbles event past disabled inner to enabled outer Pressable', async () => { + const handleInnerPress = jest.fn(); + const handleOuterPress = jest.fn(); + await render( + + + Inner Trigger + + , + ); + await fireEvent.press(screen.getByText('Inner Trigger')); + expect(handleInnerPress).not.toHaveBeenCalled(); + expect(handleOuterPress).toHaveBeenCalledTimes(1); + }); + + test('bubbles event past disabled inner to enabled outer TouchableOpacity', async () => { + const handleInnerPress = jest.fn(); + const handleOuterPress = jest.fn(); + await render( + + + Inner Trigger + + , + ); + await fireEvent.press(screen.getByText('Inner Trigger')); + expect(handleInnerPress).not.toHaveBeenCalled(); + expect(handleOuterPress).toHaveBeenCalledTimes(1); + }); + + test('ignores custom disabled prop on composite component (only respects native disabled)', async () => { + const TestComponent = ({ onPress }: { onPress: () => void; disabled?: boolean }) => ( + + Trigger Test + + ); + const handlePress = jest.fn(); + await render(); + await fireEvent.press(screen.getByText('Trigger Test')); + expect(handlePress).toHaveBeenCalledTimes(1); + }); +}); + +describe('unhandled event warning', () => { + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + configure({ eventDiagnostics: true }); + warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + test('warns when the handler is on a disabled element', async () => { + await render( + + Trigger + , + ); + + await fireEvent.press(screen.getByText('Trigger')); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "Cannot fire the "press" event on a disabled element. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + + + Trigger + + " + `); + }); + + test('lists every disabled element the event skipped', async () => { + await render( + + + Trigger + + , + ); + + await fireEvent.press(screen.getByText('Trigger')); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "Cannot fire the "press" event on disabled elements. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + + + Trigger + + + + + + + Trigger + + + " + `); + }); + + test('warns when no element handles the event', async () => { + await render( + + Trigger + , + ); + + await fireEvent.press(screen.getByText('Trigger')); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "No "onPress" handler found on the element or its ancestors. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + + Trigger + " + `); + }); + + test('does not warn when the event bubbles to an enabled parent', async () => { + await render( + + + Inner Trigger + + , + ); + + await fireEvent.press(screen.getByText('Inner Trigger')); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test('warns when the handler is blocked by pointerEvents="none" on an ancestor', async () => { + await render( + + + , + ); + + await fireEvent.press(screen.getByTestId('btn')); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "Cannot fire the "press" event on an element blocked by pointerEvents. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + + + Blocked by: + + " + `); + }); + + test('warns when multiple handlers are blocked by pointerEvents="none" on an ancestor', async () => { + await render( + + + + + , + ); + + await fireEvent.press(screen.getByTestId('inner')); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "Cannot fire the "press" event on elements blocked by pointerEvents. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + + + + + + + Blocked by: + + " + `); + }); + + test('reports pointerEvents rather than disabled when both block the handler', async () => { + await render( + + + , + ); + + await fireEvent.press(screen.getByTestId('btn')); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "Cannot fire the "press" event on an element blocked by pointerEvents. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + + + Blocked by: + + " + `); + }); + + test('reports the element that blocks with pointerEvents', async () => { + await render( + + + + + + , + ); + + await fireEvent.press(screen.getByTestId('inside-box-only')); + await fireEvent.press(screen.getByTestId('box-none')); + + expect(warnSpy).toHaveBeenCalledTimes(2); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "Cannot fire the "press" event on an element blocked by pointerEvents. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + + + Blocked by: + + " + `); + expect(warnSpy.mock.calls[1][0]).toMatchInlineSnapshot(` + "Cannot fire the "press" event on an element blocked by pointerEvents. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + + + Blocked by: + + " + `); + }); + + test('reports non-editable TextInput for events not affected by pointerEvents', async () => { + await render( + + + , + ); + + await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatch( + /^Cannot fire the "changeText" event on a non-editable TextInput\./, + ); + }); + + test('does not warn when the responder declines the touch', async () => { + const onPress = jest.fn(); + // @ts-expect-error Host View does not declare `onPress`. + await render( false} onPress={onPress} />); + + await fireEvent.press(screen.getByTestId('view')); + + expect(onPress).not.toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test('warns when the handler is blocked by non-editable TextInput', async () => { + await render(); + + await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "Cannot fire the "changeText" event on a non-editable TextInput. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + " + `); + }); + + test.each([ + ['focus', 'onFocus'], + ['blur', 'onBlur'], + ['press', 'onPress'], + ])('warns when "%s" is blocked by non-editable TextInput', async (eventType, handlerName) => { + await render(); + + await fireEvent(screen.getByTestId('input'), eventType); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatch( + new RegExp(`^Cannot fire the "${eventType}" event on a non-editable TextInput\\.`), + ); + }); + + test('names the non-editable TextInput when the handler is on its parent', async () => { + const onFocus = jest.fn(); + await render( + // Spread because `View` types include `onFocus` only since RN 0.88. + + + , + ); + + await fireEvent(screen.getByTestId('input'), 'focus'); + + expect(onFocus).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "Cannot fire the "focus" event on a non-editable TextInput. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + " + `); + }); + + test('reports disabled elements when they include a non-editable TextInput', async () => { + await render( + + + , + ); + + await fireEvent.press(screen.getByTestId('input')); + + 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(); + + await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test('does not warn when eventDiagnostics is turned off', async () => { + configure({ eventDiagnostics: false }); + await render( + + + Disabled + + No handler + , + ); + + await fireEvent.press(screen.getByText('Disabled')); + await fireEvent.press(screen.getByText('No handler')); + await fireEvent.layout(screen.getByText('No handler')); + + expect(warnSpy).not.toHaveBeenCalled(); + }); +}); + +describe('pointerEvents prop', () => { + test('does not fire inside View with pointerEvents="none"', 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( + + + Trigger + + , + ); + await fireEvent.press(screen.getByText('Trigger')); + expect(onPress).not.toHaveBeenCalled(); + }); + + test('fires inside View with pointerEvents="box-none"', async () => { + const onPress = jest.fn(); + await render( + + + Trigger + + , + ); + await fireEvent.press(screen.getByText('Trigger')); + expect(onPress).toHaveBeenCalledTimes(1); + }); + + test('fires inside View with pointerEvents="auto"', async () => { + const onPress = jest.fn(); + await render( + + + Trigger + + , + ); + await fireEvent.press(screen.getByText('Trigger')); + expect(onPress).toHaveBeenCalledTimes(1); + }); + + test('does not fire deeply inside View with pointerEvents="box-only"', async () => { + const onPress = jest.fn(); + await render( + + + + Trigger + + + , + ); + await fireEvent.press(screen.getByText('Trigger')); + expect(onPress).not.toHaveBeenCalled(); + }); + + test('fires non-pointer events inside View with pointerEvents="box-none"', async () => { + const onLayout = jest.fn(); + await render(); + await fireEvent(screen.getByTestId('view'), 'layout'); + expect(onLayout).toHaveBeenCalled(); + }); + + test('fires on Pressable with pointerEvents="box-only" on itself', async () => { + const onPress = jest.fn(); + await render(); + await fireEvent.press(screen.getByTestId('pressable')); + expect(onPress).toHaveBeenCalled(); + }); +}); + +describe('non-editable TextInput', () => { + function WrappedTextInput(props: TextInputProps) { + return ; + } + + function DoubleWrappedTextInput(props: TextInputProps) { + return ; + } + + test('blocks touch-related events but allows non-touch events', async () => { + const onFocus = jest.fn(); + const onChangeText = jest.fn(); + const onSubmitEditing = jest.fn(); + const onLayout = jest.fn(); + + await render( + , + ); + + const input = screen.getByTestId('input'); + await fireEvent(input, 'focus'); + await fireEvent.changeText(input, 'Text'); + await fireEvent(input, 'submitEditing', { nativeEvent: { text: 'Text' } }); + await fireEvent(input, 'layout', layoutEvent); + + expect(onFocus).not.toHaveBeenCalled(); + expect(onChangeText).not.toHaveBeenCalled(); + expect(onSubmitEditing).not.toHaveBeenCalled(); + expect(onLayout).toHaveBeenCalledWith(layoutEvent); + }); + + test('blocks touch-related events when firing on nested Text child', async () => { + const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); + const onFocus = jest.fn(); + const onChangeText = jest.fn(); + const onSubmitEditing = jest.fn(); + const onLayout = jest.fn(); + + await render( + + Nested Text + , + ); + + const subject = screen.getByText('Nested Text'); + await fireEvent(subject, 'focus'); + await fireEvent(subject, 'onFocus'); + await fireEvent.changeText(subject, 'Text'); + await fireEvent(subject, 'submitEditing', { + nativeEvent: { text: 'Text' }, + }); + await fireEvent(subject, 'onSubmitEditing', { + nativeEvent: { text: 'Text' }, + }); + await fireEvent(subject, 'layout', layoutEvent); + await fireEvent(subject, 'onLayout', layoutEvent); + + expect(onFocus).not.toHaveBeenCalled(); + expect(onChangeText).not.toHaveBeenCalled(); + expect(onSubmitEditing).not.toHaveBeenCalled(); + // Layout is a direct event, but still bubbles to the parent TextInput with a warning + expect(onLayout).toHaveBeenCalledTimes(2); + expect(onLayout).toHaveBeenCalledWith(layoutEvent); + expect(warnSpy).toHaveBeenCalledTimes(2); + warnSpy.mockRestore(); + }); + + test.each([ + ['WrappedTextInput', WrappedTextInput], + ['DoubleWrappedTextInput', DoubleWrappedTextInput], + ])('blocks touch-related events on %s', async (_, Component) => { + const onFocus = jest.fn(); + const onChangeText = jest.fn(); + const onSubmitEditing = jest.fn(); + const onLayout = jest.fn(); + + await render( + , + ); + + const input = screen.getByTestId('input'); + await fireEvent(input, 'focus'); + await fireEvent.changeText(input, 'Text'); + await fireEvent(input, 'submitEditing', { nativeEvent: { text: 'Text' } }); + await fireEvent(input, 'layout', layoutEvent); + + expect(onFocus).not.toHaveBeenCalled(); + expect(onChangeText).not.toHaveBeenCalled(); + expect(onSubmitEditing).not.toHaveBeenCalled(); + expect(onLayout).toHaveBeenCalledWith(layoutEvent); + }); + + test('fires layout event', async () => { + const onLayout = jest.fn(); + await render(); + await fireEvent(screen.getByTestId('input'), 'layout'); + expect(onLayout).toHaveBeenCalled(); + }); + + test('fires scroll event', async () => { + const onScroll = jest.fn(); + await render(); + await fireEvent(screen.getByTestId('input'), 'scroll'); + expect(onScroll).toHaveBeenCalled(); + }); +}); + +describe('responder system', () => { + test('respects disabled prop through composite wrappers', async () => { + function TestChildTouchableComponent({ + onPress, + someProp, + }: { + onPress: () => void; + someProp: boolean; + }) { + return ( + + + Trigger + + + ); + } + const handlePress = jest.fn(); + await render( + + + , + ); + await fireEvent.press(screen.getByText('Trigger')); + expect(handlePress).not.toHaveBeenCalled(); + }); + + test('fires responderMove on PanResponder component', async () => { + const onDrag = jest.fn(); + function TestDraggableComponent({ onDrag }: { onDrag: () => void }) { + const responderHandlers = PanResponder.create({ + onMoveShouldSetPanResponder: () => true, + onPanResponderMove: onDrag, + }).panHandlers; + return ( + + Trigger + + ); + } + await render(); + await fireEvent(screen.getByText('Trigger'), 'responderMove', { + touchHistory: { mostRecentTimeStamp: '2', touchBank: [] }, + }); + expect(onDrag).toHaveBeenCalled(); + }); +}); diff --git a/src/events/builders/__tests__/base.test.ts b/src/events/legacy/builders/__tests__/base.test.ts similarity index 76% rename from src/events/builders/__tests__/base.test.ts rename to src/events/legacy/builders/__tests__/base.test.ts index 47ecedc24..761efd3b3 100644 --- a/src/events/builders/__tests__/base.test.ts +++ b/src/events/legacy/builders/__tests__/base.test.ts @@ -1,4 +1,4 @@ -import { baseSyntheticEvent } from '../base'; +import { baseSyntheticEvent, wrapNativeEvent } from '../base'; test('returns object with all required properties and default values', () => { const event = baseSyntheticEvent(); @@ -33,3 +33,12 @@ test('can be spread into other objects', () => { expect(extendedEvent).toHaveProperty('preventDefault'); expect(extendedEvent.nativeEvent).toEqual({ test: 'value' }); }); + +test('wrapNativeEvent adds base stubs around the native event', () => { + const nativeEvent = { target: 0 }; + const event = wrapNativeEvent(nativeEvent); + + expect(event.nativeEvent).toBe(nativeEvent); + expect(event.timeStamp).toBe(0); + expect(typeof event.preventDefault).toBe('function'); +}); diff --git a/src/events/builders/__tests__/common.test.ts b/src/events/legacy/builders/__tests__/common.test.ts similarity index 54% rename from src/events/builders/__tests__/common.test.ts rename to src/events/legacy/builders/__tests__/common.test.ts index 42066a66d..64bf6a405 100644 --- a/src/events/builders/__tests__/common.test.ts +++ b/src/events/legacy/builders/__tests__/common.test.ts @@ -1,11 +1,4 @@ -import { - buildBlurEvent, - buildFocusEvent, - buildLayoutEvent, - buildResponderGrantEvent, - buildResponderReleaseEvent, - buildTouchEvent, -} from '../common'; +import { buildResponderGrantEvent, buildResponderReleaseEvent, buildTouchEvent } from '../common'; test('buildTouchEvent returns event with touch nativeEvent', () => { const event = buildTouchEvent(); @@ -42,27 +35,3 @@ test('buildResponderReleaseEvent returns touch event with dispatchConfig', () => }); expect(event.nativeEvent).toHaveProperty('touches'); }); - -test('buildFocusEvent returns event with target', () => { - const event = buildFocusEvent(); - - expect(event.nativeEvent).toEqual({ target: 0 }); - expect(event).toHaveProperty('preventDefault'); -}); - -test('buildBlurEvent returns event with target', () => { - const event = buildBlurEvent(); - - expect(event.nativeEvent).toEqual({ target: 0 }); - expect(event).toHaveProperty('preventDefault'); -}); - -test('buildLayoutEvent returns event with zeroed layout rectangle', () => { - const event = buildLayoutEvent(); - - expect(event.nativeEvent).toEqual({ - layout: { x: 0, y: 0, width: 0, height: 0 }, - target: 0, - }); - expect(event).toHaveProperty('preventDefault'); -}); diff --git a/src/events/builders/base.ts b/src/events/legacy/builders/base.ts similarity index 77% rename from src/events/builders/base.ts rename to src/events/legacy/builders/base.ts index ff45e9828..d8dfd8192 100644 --- a/src/events/builders/base.ts +++ b/src/events/legacy/builders/base.ts @@ -19,3 +19,8 @@ export function baseSyntheticEvent(): BaseEvent { timeStamp: 0, }; } + +/** Wraps a `nativeEvent` in a legacy event object with `baseSyntheticEvent()` stubs. */ +export function wrapNativeEvent(nativeEvent: T) { + return { ...baseSyntheticEvent(), nativeEvent }; +} diff --git a/src/events/legacy/builders/common.ts b/src/events/legacy/builders/common.ts new file mode 100644 index 000000000..6cd4c3d6c --- /dev/null +++ b/src/events/legacy/builders/common.ts @@ -0,0 +1,23 @@ +import { buildTouchNativeEvent } from '../../shared/payloads'; +import { wrapNativeEvent } from './base'; + +export function buildTouchEvent() { + return { + ...wrapNativeEvent(buildTouchNativeEvent()), + currentTarget: { measure: () => {} }, + }; +} + +export function buildResponderGrantEvent() { + return { + ...buildTouchEvent(), + dispatchConfig: { registrationName: 'onResponderGrant' }, + }; +} + +export function buildResponderReleaseEvent() { + return { + ...buildTouchEvent(), + dispatchConfig: { registrationName: 'onResponderRelease' }, + }; +} diff --git a/src/events/legacy/dispatch.ts b/src/events/legacy/dispatch.ts new file mode 100644 index 000000000..3f56f3860 --- /dev/null +++ b/src/events/legacy/dispatch.ts @@ -0,0 +1,19 @@ +import type { TestInstance } from 'test-renderer'; + +import { invokeEventHandler } from '../shared/invoke-event-handler'; + +/** + * Basic dispatch event function used by User Event module. + * + * @param instance instance to trigger event on + * @param eventType type of the event + * @param event event payload + * @returns `true` if a handler was called. + */ +export async function dispatchEvent( + instance: TestInstance, + eventType: string, + event: unknown, +): Promise { + return await invokeEventHandler(instance, eventType, event); +} diff --git a/src/events/legacy/fire-event.ts b/src/events/legacy/fire-event.ts new file mode 100644 index 000000000..306cc3c0d --- /dev/null +++ b/src/events/legacy/fire-event.ts @@ -0,0 +1,82 @@ +import type { TestInstance } from 'test-renderer'; + +import { act } from '../../act'; +import { isInstanceMounted } from '../../helpers/component-tree'; +import { normalizeEventType } from '../shared/handler'; +import { mergeEventProps } from '../shared/merge'; +import { buildLayoutNativeEvent, buildScrollNativeEvent } from '../shared/payloads'; +import type { EventProps, EventType, LayoutRectangle } from '../shared/types'; +import { + getNativeStateEventProps, + updateNativeStateFromEvent, +} from '../shared/update-native-state'; +import { wrapNativeEvent } from './builders/base'; +import { buildTouchEvent } from './builders/common'; +import { findEventHandler } from './propagation'; +import { warnAboutUnhandledEvent } from './warnings'; + +async function fireEvent(instance: TestInstance, eventType: EventType, ...data: unknown[]) { + return await fireEventInternal(instance, { type: eventType, data, bubbles: true }); +} + +fireEvent.changeText = async (instance: TestInstance, text: string) => + await fireEvent(instance, 'changeText', text); + +fireEvent.press = async (instance: TestInstance, eventProps?: EventProps) => { + await fireEvent(instance, 'press', mergeEventProps(buildTouchEvent(), eventProps)); +}; + +fireEvent.scroll = async (instance: TestInstance, eventProps?: EventProps) => { + const nativeEvent = mergeEventProps( + buildScrollNativeEvent(), + getNativeStateEventProps(instance, 'scroll'), + ); + await fireEvent(instance, 'scroll', mergeEventProps(wrapNativeEvent(nativeEvent), eventProps)); +}; + +// Does not bubble and checks only the element's own props, as React Native delivers layout events +// only to the measured element. This is the intended behavior: `fireEvent(instance, 'layout')` +// still bubbles (with a deprecation warning) for compatibility, and will match this in the next +// major version. +fireEvent.layout = async (instance: TestInstance, layout?: Partial) => { + await fireEventInternal(instance, { + type: 'layout', + data: [wrapNativeEvent(buildLayoutNativeEvent(layout))], + bubbles: false, + }); +}; + +type FireEventOptions = { + type: EventType; + data: unknown[]; + bubbles: boolean; +}; + +async function fireEventInternal(instance: TestInstance, options: FireEventOptions) { + const { type, data, bubbles } = options; + if (!isInstanceMounted(instance)) { + return; + } + + // `fireEvent` accepts event types with and without the `on*` prefix. + const hasUpdatedNativeState = updateNativeStateFromEvent( + instance, + normalizeEventType(type), + data[0], + ); + + const { handler, skippedTargets } = findEventHandler(instance, type, { bubbles }); + if (!handler) { + warnAboutUnhandledEvent(instance, type, { skippedTargets, hasUpdatedNativeState }); + return; + } + + let returnValue; + await act(() => { + returnValue = handler(...data); + }); + + return returnValue; +} + +export { fireEvent }; diff --git a/src/events/index.ts b/src/events/legacy/index.ts similarity index 60% rename from src/events/index.ts rename to src/events/legacy/index.ts index 117425c85..94a970247 100644 --- a/src/events/index.ts +++ b/src/events/legacy/index.ts @@ -1,9 +1,6 @@ export * from './builders/common'; -export * from './builders/scroll'; -export * from './builders/text'; -export * from './dispatch'; -export * from './handler'; +export * from '../shared/handler'; export { isPointerEventEnabled } from './is-enabled'; -export * from './native-state'; +export * from '../shared/native-state'; export type { EventWarning } from './warnings'; export { formatDisabledTargets, getPointerEventsBlockedTargets, logEventWarning } from './warnings'; diff --git a/src/events/is-enabled.ts b/src/events/legacy/is-enabled.ts similarity index 96% rename from src/events/is-enabled.ts rename to src/events/legacy/is-enabled.ts index 0aef969a9..67429aa50 100644 --- a/src/events/is-enabled.ts +++ b/src/events/legacy/is-enabled.ts @@ -1,8 +1,8 @@ import { StyleSheet } from 'react-native'; import type { TestInstance } from 'test-renderer'; -import { isHostTextInput } from '../helpers/host-component-names'; -import { isEditableTextInput } from '../helpers/text-input'; +import { isHostTextInput } from '../../helpers/host-component-names'; +import { isEditableTextInput } from '../../helpers/text-input'; /** * pointerEvents controls whether the View can be the target of touch events. diff --git a/src/events/propagation.ts b/src/events/legacy/propagation.ts similarity index 96% rename from src/events/propagation.ts rename to src/events/legacy/propagation.ts index 34396a31e..d947c5e4b 100644 --- a/src/events/propagation.ts +++ b/src/events/legacy/propagation.ts @@ -6,10 +6,10 @@ import { isHostScrollView, isHostText, isHostTextInput, -} from '../helpers/host-component-names'; -import { getEventHandlerFromProps, normalizeEventType } from './handler'; +} from '../../helpers/host-component-names'; +import { getEventHandlerFromProps, normalizeEventType } from '../shared/handler'; +import type { EventHandler } from '../shared/types'; import { isEventEnabled, isTouchResponder } from './is-enabled'; -import type { EventHandler } from './types'; import { warnAboutBubblingDirectEvent } from './warnings'; export type FindEventHandlerOptions = { diff --git a/src/events/warnings.ts b/src/events/legacy/warnings.ts similarity index 91% rename from src/events/warnings.ts rename to src/events/legacy/warnings.ts index 14e758618..5a81beade 100644 --- a/src/events/warnings.ts +++ b/src/events/legacy/warnings.ts @@ -1,13 +1,13 @@ import redent from 'redent'; import type { TestInstance } from 'test-renderer'; -import { getConfig } from '../config'; -import { computeAriaDisabled } from '../helpers/accessibility'; -import { formatElement, formatJson } from '../helpers/format-element'; -import { isHostTextInput } from '../helpers/host-component-names'; -import { logger } from '../helpers/logger'; -import { isEditableTextInput } from '../helpers/text-input'; -import { getEventHandlerName, normalizeEventType } from './handler'; +import { getConfig } from '../../config'; +import { computeAriaDisabled } from '../../helpers/accessibility'; +import { formatElement, formatJson } from '../../helpers/format-element'; +import { isHostTextInput } from '../../helpers/host-component-names'; +import { logger } from '../../helpers/logger'; +import { isEditableTextInput } from '../../helpers/text-input'; +import { getEventHandlerName, normalizeEventType } from '../shared/handler'; import { getPointerEventsBlocker, isEventBlockableByPointerEvents } from './is-enabled'; type UnhandledEventInfo = { diff --git a/src/events/modern/__tests__/dispatch.test.tsx b/src/events/modern/__tests__/dispatch.test.tsx new file mode 100644 index 000000000..a5bd17c9c --- /dev/null +++ b/src/events/modern/__tests__/dispatch.test.tsx @@ -0,0 +1,527 @@ +import * as React from 'react'; +import { Text, TextInput, View } from 'react-native'; +import type { TestInstance } from 'test-renderer'; + +import { render, screen } from '../../..'; +import { dispatchEvent } from '../dispatch'; +import type { NativeEventPayload, SyntheticEvent } from '../event'; +import { createEvent } from '../event'; + +function createKnownEvent(eventType: string, nativeEvent?: NativeEventPayload): SyntheticEvent { + const event = createEvent(eventType, { nativeEvent }); + if (event == null) { + throw new Error(`Unknown event type: ${eventType}`); + } + + return event; +} + +type HandlerProps = Record void>; + +type Call = { + prop: string; + currentTarget: string | undefined; + target: string | undefined; + eventPhase: number; +}; + +function logHandlers(calls: Call[], id: string, propNames: string[]): HandlerProps { + return Object.fromEntries( + propNames.map((propName) => [ + propName, + (event: SyntheticEvent) => { + calls.push({ + prop: `${id}.${propName}`, + currentTarget: event.currentTarget?.props.testID, + target: event.target?.props.testID, + eventPhase: event.eventPhase, + }); + }, + ]), + ); +} + +/** Lets handler props be spread on `View`, whose types don't include all of them. */ +function handlerProps(props: HandlerProps): HandlerProps { + return props; +} + +function getProps(calls: Call[]) { + return calls.map((call) => call.prop); +} + +function renderNestedViews(propNames: string[], calls: Call[]) { + return render( + + + + + , + ); +} + +describe('bubbling events', () => { + test('run capture phase from the root, then bubble phase to the root', async () => { + const calls: Call[] = []; + await renderNestedViews(['onFocus', 'onFocusCapture'], calls); + + await dispatchEvent(screen.getByTestId('target'), createKnownEvent('focus')); + + expect(calls).toEqual([ + { prop: 'root.onFocusCapture', currentTarget: 'root', target: 'target', eventPhase: 1 }, + { prop: 'parent.onFocusCapture', currentTarget: 'parent', target: 'target', eventPhase: 1 }, + { prop: 'target.onFocusCapture', currentTarget: 'target', target: 'target', eventPhase: 2 }, + { prop: 'target.onFocus', currentTarget: 'target', target: 'target', eventPhase: 2 }, + { prop: 'parent.onFocus', currentTarget: 'parent', target: 'target', eventPhase: 3 }, + { prop: 'root.onFocus', currentTarget: 'root', target: 'target', eventPhase: 3 }, + ]); + }); + + test('call handlers on all host ancestors, skipping elements without handler', async () => { + const onSubmitEditing = jest.fn(); + await render( + + + + + , + ); + + await dispatchEvent( + screen.getByTestId('input'), + createKnownEvent('submitEditing', { text: 'Hello' }), + ); + + expect(onSubmitEditing).toHaveBeenCalledTimes(1); + expect(onSubmitEditing.mock.calls[0][0].nativeEvent).toEqual({ text: 'Hello' }); + }); + + test('stop when a capture handler stops propagation', async () => { + const calls: Call[] = []; + await render( + + { + calls.push({ prop: 'parent.onFocusCapture' } as Call); + event.stopPropagation(); + }, + })} + > + + + , + ); + + await dispatchEvent(screen.getByTestId('target'), createKnownEvent('focus')); + + expect(getProps(calls)).toEqual(['parent.onFocusCapture']); + }); + + test.each(['stopPropagation', 'stopImmediatePropagation'] as const)( + 'stop when a bubble handler calls %s()', + async (method) => { + const calls: Call[] = []; + await render( + + { + calls.push({ prop: 'parent.onFocus' } as Call); + event[method](); + }, + })} + > + + + , + ); + + await dispatchEvent(screen.getByTestId('target'), createKnownEvent('focus')); + + expect(getProps(calls)).toEqual(['target.onFocus', 'parent.onFocus']); + }, + ); + + test('with skipBubbling run capture phase, but call only the target when bubbling', async () => { + const calls: Call[] = []; + await renderNestedViews(['onPointerEnter', 'onPointerEnterCapture'], calls); + + await dispatchEvent(screen.getByTestId('target'), createKnownEvent('pointerEnter')); + + expect(getProps(calls)).toEqual([ + 'root.onPointerEnterCapture', + 'parent.onPointerEnterCapture', + 'target.onPointerEnterCapture', + 'target.onPointerEnter', + ]); + }); +}); + +describe('direct events', () => { + test('call only the target, without capture phase', async () => { + const calls: Call[] = []; + await renderNestedViews(['onLayout', 'onLayoutCapture'], calls); + + await dispatchEvent(screen.getByTestId('target'), createKnownEvent('layout')); + + expect(calls).toEqual([ + { prop: 'target.onLayout', currentTarget: 'target', target: 'target', eventPhase: 2 }, + ]); + }); + + test('do not reach ancestors when the target has no handler', async () => { + const onScroll = jest.fn(); + await render( + + + , + ); + + await dispatchEvent(screen.getByTestId('target'), createKnownEvent('scroll')); + + expect(onScroll).not.toHaveBeenCalled(); + }); +}); + +describe('afterTargetHandler', () => { + test('runs at the target right after its bubble handler, before ancestors', async () => { + const calls: Call[] = []; + await renderNestedViews(['onFocus', 'onFocusCapture'], calls); + const target = screen.getByTestId('target'); + const afterTargetHandler = jest.fn((_target: TestInstance, event: SyntheticEvent) => { + calls.push({ + prop: 'afterTargetHandler', + currentTarget: event.currentTarget?.props.testID, + target: event.target?.props.testID, + eventPhase: event.eventPhase, + }); + }); + + await dispatchEvent(target, createKnownEvent('focus'), { afterTargetHandler }); + + expect(getProps(calls)).toEqual([ + 'root.onFocusCapture', + 'parent.onFocusCapture', + 'target.onFocusCapture', + 'target.onFocus', + 'afterTargetHandler', + 'parent.onFocus', + 'root.onFocus', + ]); + expect(calls[4]).toEqual({ + prop: 'afterTargetHandler', + currentTarget: 'target', + target: 'target', + eventPhase: 2, + }); + expect(afterTargetHandler.mock.calls[0][0]).toBe(target); + }); + + test('runs for direct events and when the target has no handler', async () => { + await render(); + const afterTargetHandler = jest.fn(); + + await dispatchEvent(screen.getByTestId('target'), createKnownEvent('layout'), { + afterTargetHandler, + }); + + expect(afterTargetHandler).toHaveBeenCalledTimes(1); + }); + + test('is skipped when a capture handler stops propagation', async () => { + await render( + event.stopPropagation() })}> + + , + ); + const afterTargetHandler = jest.fn(); + + await dispatchEvent(screen.getByTestId('target'), createKnownEvent('focus'), { + afterTargetHandler, + }); + + expect(afterTargetHandler).not.toHaveBeenCalled(); + }); + + test('runs when the target bubble handler stops propagation', async () => { + const onParentFocus = jest.fn(); + await render( + + event.stopImmediatePropagation() })} + /> + , + ); + const afterTargetHandler = jest.fn(); + + await dispatchEvent(screen.getByTestId('target'), createKnownEvent('focus'), { + afterTargetHandler, + }); + + expect(afterTargetHandler).toHaveBeenCalledTimes(1); + expect(onParentFocus).not.toHaveBeenCalled(); + }); + + test('is skipped when the target handler throws, and its own error is rethrown', async () => { + const onParentFocus = jest.fn(); + await render( + + { + throw new Error('Target error'); + }, + })} + /> + , + ); + const afterTargetHandler = jest.fn(); + + await expect( + dispatchEvent(screen.getByTestId('target'), createKnownEvent('focus'), { + afterTargetHandler, + }), + ).rejects.toThrow('Target error'); + expect(afterTargetHandler).not.toHaveBeenCalled(); + expect(onParentFocus).toHaveBeenCalledTimes(1); + + await expect( + dispatchEvent(screen.getByTestId('target'), createKnownEvent('blur'), { + afterTargetHandler: () => { + throw new Error('After target error'); + }, + }), + ).rejects.toThrow('After target error'); + }); + + test('does not count as a called handler', async () => { + await render(); + + expect( + await dispatchEvent(screen.getByTestId('target'), createKnownEvent('focus'), { + afterTargetHandler: () => {}, + }), + ).toBe(false); + }); +}); + +test('does not call props of composite components', async () => { + const onFocus = jest.fn(); + const Box = (_props: { onFocus: () => void }) => ; + await render(); + + await dispatchEvent(screen.getByTestId('target'), createKnownEvent('focus')); + + expect(onFocus).not.toHaveBeenCalled(); +}); + +test('calls testOnly_ props in both phases when there is no regular prop', async () => { + const calls: Call[] = []; + await render( + + + , + ); + + await dispatchEvent(screen.getByTestId('target'), createKnownEvent('focus')); + + expect(getProps(calls)).toEqual(['parent.testOnly_onFocusCapture', 'target.onFocus']); +}); + +test('does nothing when the target is unmounted', async () => { + const onFocus = jest.fn(); + await render(); + const target = screen.getByTestId('target'); + await screen.rerender(); + + const result = await dispatchEvent(target, createKnownEvent('focus')); + + expect(result).toBe(false); + expect(onFocus).not.toHaveBeenCalled(); +}); + +test('passes React Native event object to handlers', async () => { + const onPointerUp = jest.fn(); + await render(); + const target = screen.getByTestId('target'); + const payload = { pageX: 10, pageY: 20, timestamp: 1234 }; + + await dispatchEvent(target, createKnownEvent('pointerUp', payload)); + + const event: SyntheticEvent = onPointerUp.mock.calls[0][0]; + expect(event.type).toBe('pointerup'); + expect(event.bubbles).toBe(true); + expect(event.cancelable).toBe(true); + expect(event.isTrusted).toBe(true); + expect(event.nativeEvent).toBe(payload); + expect(event.timeStamp).toBe(1234); + expect(event.target).toBe(target); + expect(event.dispatchConfig).toEqual({ + phasedRegistrationNames: { bubbled: 'onPointerUp', captured: 'onPointerUpCapture' }, + }); +}); + +test('passes direct event object to handlers', async () => { + const onLayout = jest.fn(); + await render(); + + await dispatchEvent( + screen.getByTestId('target'), + createKnownEvent('layout', { layout: { width: 100 } }), + ); + + const event: SyntheticEvent = onLayout.mock.calls[0][0]; + expect(event.type).toBe('layout'); + expect(event.bubbles).toBe(false); + expect(event.rnIsDirect).toBe(true); + expect(event.dispatchConfig).toEqual({ registrationName: 'onLayout' }); +}); + +test('exposes composed path during dispatch and resets the event after it', async () => { + let composedPath: TestInstance[] = []; + const onFocus = jest.fn((event: SyntheticEvent) => { + composedPath = event.composedPath(); + }); + await render( + + + , + ); + + await dispatchEvent(screen.getByTestId('target'), createKnownEvent('focus')); + + expect(composedPath.map((node) => node.props.testID)).toEqual(['target', 'root']); + const event: SyntheticEvent = onFocus.mock.calls[0][0]; + expect(event.currentTarget).toBeNull(); + expect(event.eventPhase).toBe(0); + expect(event.composedPath()).toEqual([]); + expect(event.isPropagationStopped()).toBe(false); + expect(event.target).toBe(screen.getByTestId('target')); +}); + +test('calls handlers with the current element as `this`', async () => { + const onFocus = jest.fn(); + await render(); + + await dispatchEvent(screen.getByTestId('target'), createKnownEvent('focus')); + + expect(onFocus.mock.contexts[0]).toBe(screen.getByTestId('target')); +}); + +test('returns whether a handler on the path was called', async () => { + await render( + {} })}> + + + + + , + ); + + expect(await dispatchEvent(screen.getByTestId('nested'), createKnownEvent('focus'))).toBe(true); + expect(await dispatchEvent(screen.getByTestId('target'), createKnownEvent('layout'))).toBe(false); +}); + +test('keeps defaultPrevented after a handler prevents default', async () => { + await render( + event.preventDefault() })} + />, + ); + const event = createKnownEvent('focus'); + + await dispatchEvent(screen.getByTestId('target'), event); + + expect(event.defaultPrevented).toBe(true); +}); + +test('rethrows the first handler error after calling all handlers', async () => { + const onRootFocus = jest.fn(); + await render( + + { + throw new Error('Parent error'); + }, + })} + > + { + throw new Error('Target error'); + }, + })} + /> + + , + ); + + await expect( + dispatchEvent(screen.getByTestId('target'), createKnownEvent('focus')), + ).rejects.toThrow('Target error'); + expect(onRootFocus).toHaveBeenCalledTimes(1); +}); + +test('renders state updates from other handlers when a handler throws', async () => { + function Subject() { + const [count, setCount] = React.useState(0); + return ( + setCount((value) => value + 1) })}> + { + throw new Error('Target error'); + }, + })} + /> + Count: {count} + + ); + } + await render(); + + await expect( + dispatchEvent(screen.getByTestId('target'), createKnownEvent('focus')), + ).rejects.toThrow('Target error'); + expect(screen.getByText('Count: 1')).toBeOnTheScreen(); +}); + +test('renders state updates from all handlers once', async () => { + let renderCount = 0; + function Counter() { + const [parentCount, setParentCount] = React.useState(0); + const [targetCount, setTargetCount] = React.useState(0); + renderCount += 1; + + return ( + setParentCount((count) => count + 1) })}> + setTargetCount((count) => count + 1) })} + > + {`${parentCount}:${targetCount}`} + + + ); + } + await render(); + const initialRenderCount = renderCount; + + await dispatchEvent(screen.getByTestId('target'), createKnownEvent('focus')); + + expect(screen.getByText('1:1')).toBeTruthy(); + expect(renderCount - initialRenderCount).toBe(1); +}); diff --git a/src/events/modern/__tests__/event-types.test.ts b/src/events/modern/__tests__/event-types.test.ts new file mode 100644 index 000000000..571786cab --- /dev/null +++ b/src/events/modern/__tests__/event-types.test.ts @@ -0,0 +1,142 @@ +import { createEvent } from '../event'; +import { + BUBBLING_EVENT_TYPES, + DIRECT_EVENT_TYPES, + getEventTypeConfig, + SKIP_BUBBLING_EVENT_TYPES, +} from '../event-types'; + +type ViewConfig = { + bubblingEventTypes?: Record< + string, + { phasedRegistrationNames: { bubbled: string; captured: string; skipBubbling?: boolean } } + >; + directEventTypes?: Record; +}; + +const viewConfigModules = [ + 'react-native/Libraries/NativeComponent/BaseViewConfig.ios', + 'react-native/Libraries/NativeComponent/BaseViewConfig.android', + 'react-native/Libraries/Components/DrawerAndroid/AndroidDrawerLayoutNativeComponent', + 'react-native/Libraries/Components/RefreshControl/AndroidSwipeRefreshLayoutNativeComponent', + 'react-native/Libraries/Components/RefreshControl/PullToRefreshViewNativeComponent', + 'react-native/Libraries/Components/ScrollView/ScrollViewNativeComponent', + 'react-native/Libraries/Components/Switch/AndroidSwitchNativeComponent', + 'react-native/Libraries/Components/Switch/SwitchNativeComponent', + 'react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent', + 'react-native/Libraries/Components/TextInput/RCTMultilineTextInputNativeComponent', + 'react-native/Libraries/Components/TextInput/RCTSingelineTextInputNativeComponent', + 'react-native/Libraries/Image/ImageViewNativeComponent', + 'react-native/Libraries/Modal/RCTModalHostViewNativeComponent', +]; + +// Missing in older React Native versions. +const optionalViewConfigModules = [ + // Added in React Native 0.81. + 'react-native/src/private/components/virtualview/VirtualViewNativeComponent', +]; + +// Not exported by React Native, so listed by hand in `event-types.ts`. +const manualDirectEventTypes = ['textLayout']; + +function loadReactNativeEventTypes() { + const bubbling = new Set(); + const skipBubbling = new Set(); + const direct = new Set(manualDirectEventTypes); + + for (const viewConfig of loadViewConfigs()) { + for (const [topLevelType, config] of Object.entries(viewConfig.bubblingEventTypes ?? {})) { + const eventType = toEventType(topLevelType); + // Prop names derived from the event type match React Native's. + expect(getDispatchConfig(eventType)).toEqual({ + phasedRegistrationNames: config.phasedRegistrationNames, + }); + bubbling.add(eventType); + if (config.phasedRegistrationNames.skipBubbling) { + skipBubbling.add(eventType); + } + } + + for (const [topLevelType, config] of Object.entries(viewConfig.directEventTypes ?? {})) { + // Gesture handler events: registered without `top`, not dispatched by React Native. + if (!topLevelType.startsWith('top')) { + continue; + } + + const eventType = toEventType(topLevelType); + expect(getDispatchConfig(eventType)).toEqual({ + registrationName: config.registrationName, + }); + direct.add(eventType); + } + } + + return { bubbling, skipBubbling, direct }; +} + +function loadViewConfigs(): ViewConfig[] { + const modules = viewConfigModules.map((modulePath) => jest.requireActual(modulePath)); + for (const modulePath of optionalViewConfigModules) { + try { + modules.push(jest.requireActual(modulePath)); + } catch { + // Not available in the installed React Native version. + } + } + + return modules.map((module) => module.__INTERNAL_VIEW_CONFIG ?? module.default); +} + +function toEventType(topLevelType: string) { + return topLevelType.charAt(3).toLowerCase() + topLevelType.slice(4); +} + +function getDispatchConfig(eventType: string) { + return createEvent(eventType)?.dispatchConfig; +} + +// The lists are the union across supported React Native versions, so older versions declare only a +// subset of them (e.g. `keyDown` and `keyUp` were added in React Native 0.84). +test('event types cover React Native view configs', () => { + const reactNative = loadReactNativeEventTypes(); + + expect(BUBBLING_EVENT_TYPES).toEqual(expect.arrayContaining([...reactNative.bubbling])); + expect(SKIP_BUBBLING_EVENT_TYPES).toEqual(expect.arrayContaining([...reactNative.skipBubbling])); + expect(DIRECT_EVENT_TYPES).toEqual(expect.arrayContaining([...reactNative.direct])); +}); + +test('getEventTypeConfig() returns config of bubbling event', () => { + expect(getEventTypeConfig('pointerUp')).toEqual({ + kind: 'bubbling', + handlerName: 'onPointerUp', + captureHandlerName: 'onPointerUpCapture', + skipBubbling: false, + }); +}); + +test('getEventTypeConfig() returns config of bubbling event with skipBubbling', () => { + expect(getEventTypeConfig('pointerEnter')).toEqual({ + kind: 'bubbling', + handlerName: 'onPointerEnter', + captureHandlerName: 'onPointerEnterCapture', + skipBubbling: true, + }); +}); + +test('getEventTypeConfig() returns config of direct event', () => { + expect(getEventTypeConfig('layout')).toEqual({ kind: 'direct', handlerName: 'onLayout' }); +}); + +test('getEventTypeConfig() returns config of direct event with non-standard handler name', () => { + expect(getEventTypeConfig('safeAreaInsetsChange')).toEqual({ + kind: 'direct', + handlerName: 'experimental_onSafeAreaInsetsChange', + }); +}); + +test('getEventTypeConfig() returns null for events unknown to React Native', () => { + expect(getEventTypeConfig('changeText')).toBeNull(); + expect(getEventTypeConfig('pressIn')).toBeNull(); + expect(getEventTypeConfig('onFocus')).toBeNull(); + expect(getEventTypeConfig('toString')).toBeNull(); +}); diff --git a/src/events/modern/__tests__/event.test.ts b/src/events/modern/__tests__/event.test.ts new file mode 100644 index 000000000..897455c6d --- /dev/null +++ b/src/events/modern/__tests__/event.test.ts @@ -0,0 +1,180 @@ +import { format } from 'pretty-format'; +import type { TestInstance } from 'test-renderer'; + +import { createEvent, getEventInternals, SyntheticEvent } from '../event'; +import type { EventTypeConfig } from '../event-types'; + +const typeConfig: EventTypeConfig = { kind: 'direct', handlerName: 'onFocus' }; + +afterEach(() => { + jest.restoreAllMocks(); +}); + +test('exposes init values', () => { + const nativeEvent = { target: 1 }; + const event = new SyntheticEvent( + 'layout', + { cancelable: true, timeStamp: 123 }, + nativeEvent, + typeConfig, + ); + + expect(event.type).toBe('layout'); + expect(event.bubbles).toBe(false); + expect(event.cancelable).toBe(true); + expect(event.rnIsDirect).toBe(true); + expect(event.timeStamp).toBe(123); + expect(event.nativeEvent).toBe(nativeEvent); + expect(event.dispatchConfig).toEqual({ registrationName: 'onFocus' }); + expect(event.isTrusted).toBe(true); +}); + +test('has no target and phase outside of dispatch', () => { + const event = new SyntheticEvent('focus', {}, {}, typeConfig); + + expect(event.target).toBeNull(); + expect(event.currentTarget).toBeNull(); + expect(event.eventPhase).toBe(SyntheticEvent.NONE); + expect(event.composedPath()).toEqual([]); +}); + +test('keeps dispatch fields out of printed and compared events', () => { + const event = new SyntheticEvent('focus', { timeStamp: 1 }, {}, typeConfig); + const target = { type: 'View', props: { testID: 'target' } } as unknown as TestInstance; + getEventInternals(event).target = target; + + expect(event.target).toBe(target); + expect(event).toEqual(new SyntheticEvent('focus', { timeStamp: 1 }, {}, typeConfig)); + expect(format(event)).not.toContain('target'); +}); + +test('defaults timeStamp to performance.now()', () => { + jest.spyOn(performance, 'now').mockReturnValue(456); + + const event = new SyntheticEvent('focus', {}, {}, typeConfig); + + expect(event.timeStamp).toBe(456); +}); + +test('preventDefault() marks cancelable event as default prevented', () => { + const event = new SyntheticEvent('focus', { cancelable: true }, {}, typeConfig); + + event.preventDefault(); + + expect(event.defaultPrevented).toBe(true); + expect(event.isDefaultPrevented()).toBe(true); +}); + +test('preventDefault() does nothing on non-cancelable event', () => { + const event = new SyntheticEvent('focus', {}, {}, typeConfig); + + event.preventDefault(); + + expect(event.defaultPrevented).toBe(false); + expect(event.isDefaultPrevented()).toBe(false); +}); + +test.each(['stopPropagation', 'stopImmediatePropagation'] as const)( + '%s() stops propagation', + (method) => { + const event = new SyntheticEvent('focus', {}, {}, typeConfig); + expect(event.isPropagationStopped()).toBe(false); + + event[method](); + + expect(event.isPropagationStopped()).toBe(true); + expect(event.cancelBubble).toBe(true); + }, +); + +test('cancelBubble can only be set to true', () => { + const event = new SyntheticEvent('focus', {}, {}, typeConfig); + + event.cancelBubble = true; + event.cancelBubble = false; + + expect(event.isPropagationStopped()).toBe(true); +}); + +test('persist() is a no-op', () => { + const event = new SyntheticEvent('focus', {}, {}, typeConfig); + + expect(() => event.persist()).not.toThrow(); +}); + +test('defines event phase constants on class and prototype', () => { + const event = new SyntheticEvent('focus', {}, {}, typeConfig); + + expect([ + SyntheticEvent.NONE, + SyntheticEvent.CAPTURING_PHASE, + SyntheticEvent.AT_TARGET, + SyntheticEvent.BUBBLING_PHASE, + ]).toEqual([0, 1, 2, 3]); + expect([event.NONE, event.CAPTURING_PHASE, event.AT_TARGET, event.BUBBLING_PHASE]).toEqual([ + 0, 1, 2, 3, + ]); + expect(Object.keys(event)).not.toContain('NONE'); +}); + +describe('createEvent()', () => { + test('creates bubbling event', () => { + const nativeEvent = { pageX: 10, timestamp: 1234 }; + + const event = createEvent('pointerUp', { nativeEvent }); + + expect(event).toBeInstanceOf(SyntheticEvent); + expect(event?.type).toBe('pointerup'); + expect(event?.bubbles).toBe(true); + expect(event?.cancelable).toBe(true); + expect(event?.rnIsDirect).toBe(false); + expect(event?.nativeEvent).toBe(nativeEvent); + expect(event?.timeStamp).toBe(1234); + expect(event?.dispatchConfig).toEqual({ + phasedRegistrationNames: { bubbled: 'onPointerUp', captured: 'onPointerUpCapture' }, + }); + }); + + test('creates non-bubbling event for skipBubbling event types', () => { + const event = createEvent('pointerEnter'); + + expect(event?.bubbles).toBe(false); + expect(event?.rnIsDirect).toBe(false); + expect(event?.dispatchConfig).toEqual({ + phasedRegistrationNames: { + bubbled: 'onPointerEnter', + captured: 'onPointerEnterCapture', + skipBubbling: true, + }, + }); + }); + + test('creates direct event', () => { + const event = createEvent('layout'); + + expect(event?.type).toBe('layout'); + expect(event?.bubbles).toBe(false); + expect(event?.rnIsDirect).toBe(true); + expect(event?.dispatchConfig).toEqual({ registrationName: 'onLayout' }); + }); + + test('defaults nativeEvent to an empty object', () => { + expect(createEvent('focus')?.nativeEvent).toEqual({}); + }); + + test.each([ + [{ nativeEvent: { timeStamp: 1 } }, 1], + [{ nativeEvent: { timestamp: 2 } }, 2], + [{ nativeEvent: { timeStamp: 1, timestamp: 2 } }, 1], + [{ nativeEvent: { timeStamp: 1, timestamp: 2 }, timeStamp: 3 }, 3], + [{ nativeEvent: { timestamp: 'later' } }, 456], + ])('takes timeStamp from %p', (init, timeStamp) => { + jest.spyOn(performance, 'now').mockReturnValue(456); + + expect(createEvent('focus', init)?.timeStamp).toBe(timeStamp); + }); + + test('returns null for events unknown to React Native', () => { + expect(createEvent('changeText', { nativeEvent: { text: 'Hello' } })).toBeNull(); + }); +}); diff --git a/src/events/modern/__tests__/fire-event.test.tsx b/src/events/modern/__tests__/fire-event.test.tsx new file mode 100644 index 000000000..0970d6093 --- /dev/null +++ b/src/events/modern/__tests__/fire-event.test.tsx @@ -0,0 +1,806 @@ +import * as React from 'react'; +import { ScrollView, Text, TextInput, View } from 'react-native'; +import type { TestInstance } from 'test-renderer'; + +import { render, screen } from '../../..'; +import { nativeState } from '../../shared/native-state'; +import type { SyntheticEvent } from '../event'; +import type { FireEventInit } from '../fire-event'; +import { fireEvent } from '../fire-event'; + +const defaultScrollPayload = { + 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 }, +}; + +/** Calls `fireEvent` with arguments its types don't allow, as JavaScript callers can. */ +function fireEventUntyped(instance: TestInstance, eventType: string, ...args: unknown[]) { + return fireEvent(instance, eventType, ...(args as [FireEventInit])); +} + +describe('event object', () => { + test('throws without an event object', async () => { + const onFocus = jest.fn(); + await render(); + + await expect(fireEventUntyped(screen.getByTestId('input'), 'focus')).rejects.toThrow( + `Unable to fire a "focus" event. Please provide an event object, e.g. fireEvent(element, 'focus', { nativeEvent: {} }).`, + ); + expect(onFocus).not.toHaveBeenCalled(); + }); + + test('throws with more than one argument after the event type', async () => { + const onFocus = jest.fn(); + await render(); + + await expect( + fireEventUntyped(screen.getByTestId('input'), 'focus', { nativeEvent: {} }, {}), + ).rejects.toThrow( + 'Unable to fire a "focus" event. Expected a single event object, received 2 arguments.', + ); + expect(onFocus).not.toHaveBeenCalled(); + }); + + test.each([ + ['text', 'string "text"'], + [42, 'number 42'], + [null, 'null'], + [undefined, 'undefined'], + [[{ nativeEvent: {} }], 'an array'], + [new Date(0), 'an instance of Date'], + [() => {}, 'a function'], + ])('throws when the event is %p', async (event, description) => { + const onFocus = jest.fn(); + await render(); + + await expect(fireEventUntyped(screen.getByTestId('input'), 'focus', event)).rejects.toThrow( + `Unable to fire a "focus" event. Expected an event object, received ${description}.`, + ); + expect(onFocus).not.toHaveBeenCalled(); + }); + + test.each([ + [{ target: 1 }, '"target". "target" is set by the dispatch.'], + [ + { target: {}, currentTarget: { measure: () => {} } }, + '"target", "currentTarget". "target", "currentTarget" are set by the dispatch.', + ], + [{ 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" is set by the dispatch. "persist" is provided by the event. Pass "text" in "nativeEvent" instead.', + ], + ])('throws on unsupported keys %p', async (event, message) => { + const onFocus = jest.fn(); + await render(); + + await expect( + fireEventUntyped(screen.getByTestId('input'), 'focus', { nativeEvent: {}, ...event }), + ).rejects.toThrow(`Unable to fire a "focus" event. Unsupported event object keys: ${message}`); + expect(onFocus).not.toHaveBeenCalled(); + }); + + test('throws when nativeEvent is not an object', async () => { + await render(); + + await expect( + fireEventUntyped(screen.getByTestId('input'), 'focus', { nativeEvent: 'text' }), + ).rejects.toThrow( + 'Unable to fire a "focus" event. Expected "nativeEvent" to be an object, received string "text".', + ); + }); + + test.each([ + ['123', 'string "123"'], + [Number.NaN, 'number NaN'], + [Infinity, 'number Infinity'], + [null, 'null'], + ])('throws when timeStamp is %p', async (timeStamp, description) => { + const onFocus = jest.fn(); + await render(); + + await expect( + fireEventUntyped(screen.getByTestId('input'), 'focus', { timeStamp }), + ).rejects.toThrow( + `Unable to fire a "focus" event. Expected "timeStamp" to be a finite number, received ${description}.`, + ); + expect(onFocus).not.toHaveBeenCalled(); + }); + + 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: SyntheticEvent = 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 as event.nativeEvent', 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: SyntheticEvent = onSubmitEditing.mock.calls[0][0]; + expect(event.type).toBe('submitediting'); + expect(event.nativeEvent).toBe(nativeEvent); + expect(event.target).toBe(screen.getByTestId('input')); + }); + + test('uses an empty nativeEvent when the event object has none', async () => { + const onFocus = jest.fn(); + await render(); + + await fireEvent(screen.getByTestId('input'), 'focus', {}); + + expect(onFocus.mock.calls[0][0].nativeEvent).toEqual({}); + }); + + 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); + }); +}); + +test('throws without an element', async () => { + await expect( + fireEventUntyped(null as unknown as TestInstance, 'focus', { nativeEvent: {} }), + ).rejects.toThrow('Unable to fire a "focus" event. Please provide a host element.'); +}); + +test('accepts event type with or without the "on" prefix', async () => { + const onFocus = jest.fn(); + await render(); + + await fireEvent(screen.getByTestId('input'), 'focus', {}); + await fireEvent(screen.getByTestId('input'), 'onFocus', {}); + + expect(onFocus).toHaveBeenCalledTimes(2); + expect(onFocus.mock.calls[1][0].type).toBe('focus'); +}); + +test('dispatches bubbling events through capture and bubble phases', async () => { + const calls: string[] = []; + await render( + calls.push('parent.onPointerDownCapture')} + onPointerDown={() => calls.push('parent.onPointerDown')} + > + calls.push('target.onPointerDown')} /> + , + ); + + await fireEvent(screen.getByTestId('target'), 'pointerDown', {}); + + expect(calls).toEqual([ + 'parent.onPointerDownCapture', + 'target.onPointerDown', + 'parent.onPointerDown', + ]); +}); + +test('dispatches direct events to the target only', 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 call props of composite components', async () => { + const onCompositeFocus = jest.fn(); + function Wrapper({ children }: { children: React.ReactNode; onFocus: () => void }) { + return {children}; + } + + await render( + + + , + ); + + await fireEvent(screen.getByTestId('input'), 'focus', {}); + + expect(onCompositeFocus).not.toHaveBeenCalled(); +}); + +describe('event types unknown to React Native', () => { + test.each([ + ['changeText', 'changeText', '. Use fireEvent.changeText() or userEvent.type() instead.'], + ['onChangeText', 'changeText', '. Use fireEvent.changeText() or userEvent.type() instead.'], + ['pressIn', 'pressIn', '. Use userEvent.press() instead.'], + ['pressOut', 'pressOut', '. Use userEvent.press() instead.'], + ['longPress', 'longPress', '. Use userEvent.longPress() instead.'], + ['customEvent', 'customEvent', ', so no handler would be called.'], + ])('"%s" throws', async (eventType, normalizedType, hint) => { + const handler = jest.fn(); + const handlerProps = { + onChangeText: handler, + onPressIn: handler, + onPressOut: handler, + onLongPress: handler, + onCustomEvent: handler, + }; + await render(); + + await expect( + fireEventUntyped(screen.getByTestId('input'), eventType, { nativeEvent: {} }), + ).rejects.toThrow( + `Unable to fire a "${normalizedType}" event. React Native doesn't dispatch ` + + `"${normalizedType}" natively${hint}`, + ); + expect(handler).not.toHaveBeenCalled(); + }); + + test('throws before checking the event object', async () => { + await render(); + + await expect( + fireEventUntyped(screen.getByTestId('input'), 'changeText', 'Hello'), + ).rejects.toThrow(`React Native doesn't dispatch "changeText" natively`); + }); +}); + +test('returns false when a handler calls preventDefault()', async () => { + await render( + event.preventDefault()}> + Press me + , + ); + + expect(await fireEvent(screen.getByTestId('text'), 'press', {})).toBe(false); +}); + +test('returns true when no handler calls preventDefault()', async () => { + const onPress = jest.fn(); + await render( + + Press me + , + ); + + expect(await fireEvent(screen.getByTestId('text'), 'press', {})).toBe(true); + expect(onPress).toHaveBeenCalledTimes(1); +}); + +test('is a no-op on an unmounted element', async () => { + const onFocus = jest.fn(); + await render(); + const input = screen.getByTestId('input'); + await screen.rerender(); + + expect(await fireEvent(input, 'focus', {})).toBe(true); + expect(onFocus).not.toHaveBeenCalled(); +}); + +test('rejects with the handler error', async () => { + await render( + { + throw new Error('Handler error'); + }} + />, + ); + + await expect(fireEvent(screen.getByTestId('input'), 'focus', {})).rejects.toThrow( + 'Handler error', + ); +}); + +test('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', {}); + + expect(screen.getByText('Count: 1')).toBeOnTheScreen(); +}); + +describe('native state', () => { + test.each([ + 'scroll', + 'onScroll', + 'scrollBeginDrag', + 'scrollEndDrag', + 'momentumScrollBegin', + 'momentumScrollEnd', + ])('saves content offset of ScrollView from %s event', async (eventType) => { + await render(); + const scrollView = screen.getByTestId('scroll'); + + await fireEvent(scrollView, eventType, { nativeEvent: { contentOffset: { y: 200 } } }); + + expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ x: 0, y: 200 }); + }); + + test('does not save content offset from scroll event without one', async () => { + await render(); + const scrollView = screen.getByTestId('scroll'); + + await fireEvent(scrollView, 'scroll', { nativeEvent: {} }); + + expect(nativeState.contentOffsetForInstance.get(scrollView)).toBeUndefined(); + }); + + test('saves layout size from layout event, with non-finite values as 0', async () => { + await render(); + const view = screen.getByTestId('view'); + + await fireEvent(view, 'layout', { nativeEvent: { layout: { width: 50, height: NaN } } }); + + expect(nativeState.layoutSizeForInstance.get(view)).toEqual({ width: 50, height: 0 }); + }); + + test.each(['change', 'onChange'])('saves value of TextInput from %s event', async (eventType) => { + await render(); + const input = screen.getByTestId('input'); + + await fireEvent(input, eventType, { nativeEvent: { text: 'Hello' } }); + + expect(nativeState.valueForInstance.get(input)).toBe('Hello'); + }); + + test('does not save value from change event without text or on non-editable TextInput', async () => { + await render( + <> + + + , + ); + const input = screen.getByTestId('input'); + const nonEditable = screen.getByTestId('non-editable'); + + await fireEvent(input, 'change', { nativeEvent: {} }); + await fireEvent(nonEditable, 'change', { nativeEvent: { text: 'Hello' } }); + + expect(nativeState.valueForInstance.get(input)).toBeUndefined(); + expect(nativeState.valueForInstance.get(nonEditable)).toBeUndefined(); + }); + + test('is saved before handlers run', async () => { + let contentOffset; + const onScroll = jest.fn(() => { + contentOffset = nativeState.contentOffsetForInstance.get(screen.getByTestId('scroll')); + }); + await render(); + + await fireEvent(screen.getByTestId('scroll'), 'scroll', { + nativeEvent: { contentOffset: { y: 200 } }, + }); + + expect(contentOffset).toEqual({ x: 0, y: 200 }); + }); + + test('is not saved for unmounted element', async () => { + await render(); + const scrollView = screen.getByTestId('scroll'); + await screen.rerender(); + + await fireEvent(scrollView, 'scroll', { nativeEvent: { contentOffset: { y: 200 } } }); + + expect(nativeState.contentOffsetForInstance.get(scrollView)).toBeUndefined(); + }); +}); + +describe('fireEvent.changeText', () => { + 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', async () => { + const calls: string[] = []; + const onChange = jest.fn((_event: unknown) => calls.push('onChange')); + const onChangeText = jest.fn((_text: string) => calls.push('onChangeText')); + await render(); + + await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); + + expect(calls).toEqual(['onChange', 'onChangeText']); + expect(onChangeText).toHaveBeenCalledWith('Hello'); + const event = onChange.mock.calls[0][0] as SyntheticEvent; + expect(event.type).toBe('change'); + expect(event.target).toBe(screen.getByTestId('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', async () => { + const calls: string[] = []; + await render( + + calls.push('input.onChangeText')} + /> + , + ); + + await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); + + expect(calls).toEqual([ + 'parent.onChangeCapture', + 'input.onChangeCapture', + 'input.onChange', + 'input.onChangeText', + 'parent.onChange', + ]); + }); + + test('does not call onChangeText of an ancestor', async () => { + const onChangeText = jest.fn(); + await render( + + + , + ); + + await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); + + expect(onChangeText).not.toHaveBeenCalled(); + }); + + test('does not call onChange or onChangeText when a capture handler stops propagation', async () => { + const onChange = jest.fn(); + const onChangeText = jest.fn(); + await render( + event.stopPropagation() }}> + + , + ); + + await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); + + expect(onChange).not.toHaveBeenCalled(); + expect(onChangeText).not.toHaveBeenCalled(); + }); + + 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', async () => { + const onChangeText = jest.fn(); + await render( + { + throw new Error('Change error'); + }} + onChangeText={onChangeText} + />, + ); + + await expect(fireEvent.changeText(screen.getByTestId('input'), 'Hello')).rejects.toThrow( + 'Change error', + ); + expect(onChangeText).not.toHaveBeenCalled(); + }); + + test('calls onChangeText without onChange', async () => { + const onChangeText = jest.fn(); + await render(); + + await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); + + expect(onChangeText).toHaveBeenCalledWith('Hello'); + }); + + 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('returns false when a handler calls preventDefault()', async () => { + await render( event.preventDefault()} />); + + expect(await fireEvent.changeText(screen.getByTestId('input'), 'Hello')).toBe(false); + }); + + test('does nothing on non-editable TextInput', async () => { + const onChange = jest.fn(); + const onChangeText = jest.fn(); + await render( + , + ); + const input = screen.getByTestId('input'); + + expect(await fireEvent.changeText(input, 'Hello')).toBe(true); + + expect(onChange).not.toHaveBeenCalled(); + expect(onChangeText).not.toHaveBeenCalled(); + expect(nativeState.valueForInstance.get(input)).toBeUndefined(); + }); + + test('throws on elements other than TextInput', async () => { + const onChangeText = jest.fn(); + await render(); + + await expect(fireEvent.changeText(screen.getByTestId('view'), 'Hello')).rejects.toThrow( + 'Unable to fire a "changeText" event. Expected a host "TextInput" element, received "View".', + ); + expect(onChangeText).not.toHaveBeenCalled(); + }); + + test('throws when text is not a string', async () => { + await render(); + + await expect( + fireEvent.changeText(screen.getByTestId('input'), 5 as unknown as string), + ).rejects.toThrow( + 'Unable to fire a "changeText" event. Expected text to be a string, received number 5.', + ); + }); + + test('throws without an element', async () => { + await expect(fireEvent.changeText(null as unknown as TestInstance, 'Hello')).rejects.toThrow( + 'Unable to fire a "changeText" event. Please provide a host element.', + ); + }); +}); + +describe('fireEvent.scroll', () => { + test('passes default scroll payload as nativeEvent of a direct event', async () => { + const onScroll = jest.fn(); + await render(); + + await fireEvent.scroll(screen.getByTestId('scroll')); + + const event: SyntheticEvent = onScroll.mock.calls[0][0]; + expect(event.type).toBe('scroll'); + expect(event.rnIsDirect).toBe(true); + expect(event.nativeEvent).toEqual(defaultScrollPayload); + }); + + test('deep merges passed nativeEvent onto default payload', async () => { + const onScroll = jest.fn(); + await render(); + + await fireEvent.scroll(screen.getByTestId('scroll'), { + nativeEvent: { contentOffset: { y: 200 }, zoomScale: 2 }, + }); + + expect(onScroll.mock.calls[0][0].nativeEvent).toEqual({ + ...defaultScrollPayload, + contentOffset: { x: 0, y: 200 }, + zoomScale: 2, + }); + }); + + test('passes timeStamp to handlers', async () => { + const onScroll = jest.fn(); + await render(); + + await fireEvent.scroll(screen.getByTestId('scroll'), { timeStamp: 123 }); + + expect(onScroll.mock.calls[0][0].timeStamp).toBe(123); + }); + + test('saves content offset of ScrollView in native state', async () => { + await render(); + const scrollView = screen.getByTestId('scroll'); + + await fireEvent.scroll(scrollView, { nativeEvent: { contentOffset: { x: 50 } } }); + expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ x: 50, y: 0 }); + + await fireEvent.scroll(scrollView, { nativeEvent: { contentOffset: { y: Infinity } } }); + expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ x: 0, y: 0 }); + }); + + test('uses layout size of ScrollView as default layoutMeasurement', async () => { + const onScroll = jest.fn(); + await render(); + const scrollView = screen.getByTestId('scroll'); + + await fireEvent.layout(scrollView, { width: 390, height: 750 }); + await fireEvent.scroll(scrollView); + await fireEvent.scroll(scrollView, { + nativeEvent: { layoutMeasurement: { width: 100, height: 200 } }, + }); + + expect(onScroll.mock.calls[0][0].nativeEvent.layoutMeasurement).toEqual({ + width: 390, + height: 750, + }); + expect(onScroll.mock.calls[1][0].nativeEvent.layoutMeasurement).toEqual({ + width: 100, + height: 200, + }); + }); + + test('does not use layout size of other elements as layoutMeasurement', async () => { + const onScroll = jest.fn(); + await render(); + const view = screen.getByTestId('view'); + + await fireEvent.layout(view, { width: 390, height: 750 }); + await fireEvent.scroll(view); + + expect(onScroll.mock.calls[0][0].nativeEvent.layoutMeasurement).toEqual({ + width: 0, + height: 0, + }); + }); + + test('does not call onScroll of an ancestor ScrollView', async () => { + const onScroll = jest.fn(); + await render( + + + , + ); + + await fireEvent.scroll(screen.getByTestId('content')); + + expect(onScroll).not.toHaveBeenCalled(); + }); + + test('uses default payload when the event object is undefined', async () => { + const onScroll = jest.fn(); + await render(); + + await fireEvent.scroll(screen.getByTestId('scroll'), undefined); + + expect(onScroll.mock.calls[0][0].nativeEvent).toEqual(defaultScrollPayload); + }); + + test('validates the event object', async () => { + await render(); + + await expect( + fireEvent.scroll(screen.getByTestId('scroll'), { persist: () => {} } as FireEventInit), + ).rejects.toThrow('Unable to fire a "scroll" event. Unsupported event object keys: "persist".'); + }); + + test('throws without an element', async () => { + await expect(fireEvent.scroll(null as unknown as TestInstance)).rejects.toThrow( + 'Unable to fire a "scroll" event. Please provide a host element.', + ); + }); +}); + +describe('fireEvent.layout', () => { + test('passes zeroed layout as nativeEvent of a direct event', async () => { + const onLayout = jest.fn(); + await render(); + + await fireEvent.layout(screen.getByTestId('view')); + + const event: SyntheticEvent = onLayout.mock.calls[0][0]; + expect(event.type).toBe('layout'); + expect(event.rnIsDirect).toBe(true); + expect(event.nativeEvent).toEqual({ layout: { x: 0, y: 0, width: 0, height: 0 }, target: 0 }); + }); + + test('merges passed layout onto zeroed rectangle, with undefined fields as 0', async () => { + const onLayout = jest.fn(); + await render(); + + await fireEvent.layout(screen.getByTestId('view'), { width: 200, height: 80, x: undefined }); + + expect(onLayout.mock.calls[0][0].nativeEvent.layout).toEqual({ + x: 0, + y: 0, + width: 200, + height: 80, + }); + }); + + test('saves layout size in native state, also without onLayout handler', async () => { + await render(); + const view = screen.getByTestId('view'); + + await fireEvent.layout(view, { x: 10, y: 20, width: 100, height: 80 }); + + expect(nativeState.layoutSizeForInstance.get(view)).toEqual({ width: 100, height: 80 }); + }); + + test('does not call onLayout of an ancestor', async () => { + const onLayout = jest.fn(); + await render( + + + , + ); + + await fireEvent.layout(screen.getByTestId('child'), { height: 80 }); + + expect(onLayout).not.toHaveBeenCalled(); + }); + + test('does not call onLayout of composite component that does not forward it', async () => { + const onLayout = jest.fn(); + const Box = (_props: { onLayout: () => void }) => ; + await render(); + + await fireEvent.layout(screen.getByTestId('view')); + + expect(onLayout).not.toHaveBeenCalled(); + }); + + test('throws without an element', async () => { + await expect(fireEvent.layout(null as unknown as TestInstance)).rejects.toThrow( + 'Unable to fire a "layout" event. Please provide a host element.', + ); + }); +}); diff --git a/src/events/modern/dispatch.ts b/src/events/modern/dispatch.ts new file mode 100644 index 000000000..290445c99 --- /dev/null +++ b/src/events/modern/dispatch.ts @@ -0,0 +1,165 @@ +import type { TestInstance } from 'test-renderer'; + +import { act } from '../../act'; +import { isInstanceMounted } from '../../helpers/component-tree'; +import { getHandlerByName } from '../shared/handler'; +import type { EventPhase } from './event'; +import { getEventInternals, SyntheticEvent } from './event'; + +/** + * Dispatches an event from `createEvent()` as React Native does for native events + * (`src/private/renderer/events/dispatchNativeEvent.js`, then `dispatch()` in + * `src/private/webapis/dom/events/EventTarget.js`): + * + * - Bubbling events: capture phase from the root to the target (`on*Capture`), then bubble phase + * back to the root (`on*`). `skipBubbling` events call only the target when bubbling. + * - Direct events: only the target's `on*` prop. + * + * Only host elements are on the path, so composite props are never called. A `testOnly_` prop + * (`testOnly_onFocus`) is used when the element has no regular one. Every handler on the + * path runs until one stops propagation. The first handler error is rethrown after all handlers + * ran. One `act()` wraps the dispatch, as React Native batches updates for the whole event. + * + * Not implemented yet: responder negotiation, which React Native runs first for touch, scroll and + * selection change events. + * + * @returns `true` if a handler was called. Check `event.defaultPrevented` for `preventDefault()`. + */ +export async function dispatchEvent( + target: TestInstance, + event: SyntheticEvent, + options?: DispatchOptions, +): Promise { + if (!isInstanceMounted(target)) { + return false; + } + + const path = getEventPath(target, event); + const state: DispatchState = { hasCalledHandler: false, firstError: null }; + + await act(() => { + getEventInternals(event).target = target; + getEventInternals(event).composedPath = path; + + runCapturePhase(event, path, state); + runBubblePhase(event, path, state, options?.afterTargetHandler); + resetEvent(event); + }); + + // After `act()`, which would skip rendering other handlers' updates if its callback threw. + if (state.firstError != null) { + throw state.firstError.error; + } + + return state.hasCalledHandler; +} + +export type DispatchOptions = { + /** + * Runs at the target in the bubble phase, right after the target's own handler, like a composite + * component wrapping the host prop: `TextInput` passes `onChange={_onChange}`, which calls + * `props.onChange(event)`, then `props.onChangeText(text)`. Skipped when propagation stopped + * before the target, or when the target's handler threw. Runs when the target has no handler. + */ + afterTargetHandler?: (target: TestInstance, event: SyntheticEvent) => void; +}; + +type DispatchState = { + hasCalledHandler: boolean; + /** Wrapped, so a thrown `undefined` is rethrown too. */ + firstError: { error: unknown } | null; +}; + +/** Target first, then host ancestors. The root container is not an element, so it's excluded. */ +function getEventPath(target: TestInstance, event: SyntheticEvent): TestInstance[] { + if (event.rnIsDirect) { + return [target]; + } + + const path: TestInstance[] = []; + let current: TestInstance | null = target; + while (current?.parent != null) { + path.push(current); + current = current.parent; + } + + return path; +} + +/** From the root down to the target, calling `on*Capture` props. Direct events skip it. */ +function runCapturePhase(event: SyntheticEvent, path: TestInstance[], state: DispatchState) { + const config = getEventInternals(event).typeConfig; + if (config.kind === 'direct') { + return; + } + + for (let i = path.length - 1; i >= 0 && !event.isPropagationStopped(); i -= 1) { + const phase = i === 0 ? SyntheticEvent.AT_TARGET : SyntheticEvent.CAPTURING_PHASE; + callHandler(event, path[i], config.captureHandlerName, phase, state); + } +} + +/** From the target up to the root, calling `on*` props. Non-bubbling events stop at the target. */ +function runBubblePhase( + event: SyntheticEvent, + path: TestInstance[], + state: DispatchState, + afterTargetHandler: DispatchOptions['afterTargetHandler'], +) { + const handlerName = getEventInternals(event).typeConfig.handlerName; + const nodes = event.bubbles ? path : path.slice(0, 1); + for (let i = 0; i < nodes.length && !event.isPropagationStopped(); i += 1) { + const isTarget = i === 0; + const phase = isTarget ? SyntheticEvent.AT_TARGET : SyntheticEvent.BUBBLING_PHASE; + callHandler( + event, + nodes[i], + handlerName, + phase, + state, + isTarget ? afterTargetHandler : undefined, + ); + } +} + +function callHandler( + event: SyntheticEvent, + node: TestInstance, + handlerName: string, + phase: EventPhase, + state: DispatchState, + afterHandler?: DispatchOptions['afterTargetHandler'], +) { + getEventInternals(event).eventPhase = phase; + getEventInternals(event).currentTarget = node; + + const handler = getHandlerByName(node.props, handlerName); + if (handler == null && afterHandler == null) { + return; + } + + try { + if (handler != null) { + state.hasCalledHandler = true; + handler.call(node, event); + } + + // In the same `try`, as a component's wrapper doesn't reach its own code after the prop throws. + afterHandler?.(node, event); + } catch (error) { + // Keep dispatching: one failing handler doesn't stop handlers on other elements, as in React + // Native (`handleListenerError()` in `EventTarget.js`) and the DOM. `dispatchEvent()` rethrows + // the first error once the whole dispatch is done. + if (state.firstError == null) { + state.firstError = { error }; + } + } +} + +/** Clears the event's internals, as the event can still be read after the dispatch. */ +function resetEvent(event: SyntheticEvent) { + getEventInternals(event).eventPhase = SyntheticEvent.NONE; + getEventInternals(event).currentTarget = null; + getEventInternals(event).composedPath = []; + getEventInternals(event).stopPropagation = false; +} diff --git a/src/events/modern/event-types.ts b/src/events/modern/event-types.ts new file mode 100644 index 000000000..60dd241bb --- /dev/null +++ b/src/events/modern/event-types.ts @@ -0,0 +1,136 @@ +import { getEventHandlerName } from '../shared/handler'; + +/** + * Event types known to React Native, without the `top` prefix (`focus`, not `topFocus`). + * + * `@react-native/jest-preset` mocks native components, so React Native's view config registry stays + * empty in Jest. These are static copies of the union of `BaseViewConfig.{ios,android}.js` and the + * built-in components' view configs. `__tests__/event-types.test.ts` checks them against the + * installed `react-native`. + */ + +export const BUBBLING_EVENT_TYPES: readonly string[] = [ + 'blur', + 'change', + 'click', + 'endEditing', + 'focus', + 'gotPointerCapture', + 'keyDown', + 'keyPress', + 'keyUp', + 'lostPointerCapture', + 'pointerCancel', + 'pointerDown', + 'pointerEnter', + 'pointerLeave', + 'pointerMove', + 'pointerOut', + 'pointerOver', + 'pointerUp', + 'press', + 'select', + 'submitEditing', + 'touchCancel', + 'touchEnd', + 'touchMove', + 'touchStart', +]; + +/** Bubbling events that run the capture phase as usual, but call only the target when bubbling. */ +export const SKIP_BUBBLING_EVENT_TYPES: readonly string[] = ['pointerEnter', 'pointerLeave']; + +export const DIRECT_EVENT_TYPES: readonly string[] = [ + 'accessibilityAction', + 'accessibilityEscape', + 'accessibilityTap', + 'changeSync', + 'contentSizeChange', + 'dismiss', + 'drawerClose', + 'drawerOpen', + 'drawerSlide', + 'drawerStateChanged', + 'error', + 'keyPressSync', + 'layout', + 'load', + 'loadEnd', + 'loadStart', + 'loadingError', + 'loadingFinish', + 'loadingStart', + 'magicTap', + 'message', + 'modeChange', + 'momentumScrollBegin', + 'momentumScrollEnd', + 'orientationChange', + 'partialLoad', + 'progress', + 'refresh', + 'requestClose', + 'safeAreaInsetsChange', + 'scroll', + 'scrollBeginDrag', + 'scrollEndDrag', + 'scrollToTop', + 'selectionChange', + 'show', + // From the inline view config in `TextNativeComponent.js`, which is not exported. + 'textLayout', +]; + +/** + * Direct events whose handler prop doesn't follow the `on` + event type pattern. + */ +const DIRECT_EVENT_HANDLER_NAMES: Readonly> = { + // Added in React Native 0.89. + safeAreaInsetsChange: 'experimental_onSafeAreaInsetsChange', +}; + +/** Capture phase from the root to the target, then bubble phase back to the root. */ +type BubblingEventTypeConfig = { + kind: 'bubbling'; + /** Bubble phase prop, e.g. `onFocus`. */ + handlerName: string; + /** Capture phase prop, e.g. `onFocusCapture`. */ + captureHandlerName: string; + /** Runs the capture phase as usual, but calls only the target when bubbling. */ + skipBubbling: boolean; +}; + +/** Calls only the target's prop, without the capture phase. */ +type DirectEventTypeConfig = { + kind: 'direct'; + /** e.g. `onLayout` */ + handlerName: string; +}; + +/** How React Native dispatches an event type, and which handler props it calls. */ +export type EventTypeConfig = BubblingEventTypeConfig | DirectEventTypeConfig; + +/** + * Returns how React Native dispatches the event type (e.g. `focus`), or `null` if React Native + * doesn't know it. + */ +export function getEventTypeConfig(eventType: string): EventTypeConfig | null { + if (BUBBLING_EVENT_TYPES.includes(eventType)) { + const handlerName = getEventHandlerName(eventType); + return { + kind: 'bubbling', + handlerName, + captureHandlerName: `${handlerName}Capture`, + skipBubbling: SKIP_BUBBLING_EVENT_TYPES.includes(eventType), + }; + } + + if (DIRECT_EVENT_TYPES.includes(eventType)) { + return { + kind: 'direct', + handlerName: DIRECT_EVENT_HANDLER_NAMES[eventType] ?? getEventHandlerName(eventType), + }; + } + + return null; +} diff --git a/src/events/modern/event.ts b/src/events/modern/event.ts new file mode 100644 index 000000000..30ee22fd4 --- /dev/null +++ b/src/events/modern/event.ts @@ -0,0 +1,257 @@ +import type { TestInstance } from 'test-renderer'; + +import type { EventTypeConfig } from './event-types'; +import { getEventTypeConfig } from './event-types'; + +export type NativeEventPayload = Record; + +/** + * React Native's `DispatchConfig`, exposed as `event.dispatchConfig`. React's legacy event system + * calls handler props "registration names". + */ +export type DispatchConfig = + | { phasedRegistrationNames: { bubbled: string; captured: string; skipBubbling?: boolean } } + | { registrationName: string }; + +export type CreateEventInit = { + /** Passed to handlers as `event.nativeEvent`. Defaults to `{}`. */ + nativeEvent?: NativeEventPayload; + /** + * The event's `timeStamp`. Defaults to the native timestamp, as in React Native + * (`nativeEvent.timeStamp ?? nativeEvent.timestamp`), then to `performance.now()`. + */ + timeStamp?: number; +}; + +type SyntheticEventInit = { + cancelable?: boolean; + timeStamp?: number; +}; + +const NONE = 0; +const CAPTURING_PHASE = 1; +const AT_TARGET = 2; +const BUBBLING_PHASE = 3; + +export type EventPhase = + | typeof NONE + | typeof CAPTURING_PHASE + | typeof AT_TARGET + | typeof BUBBLING_PHASE; + +/** + * Event fields that the dispatch reads or updates, as in React Native's `EventInternals`. Stored + * under a non-enumerable symbol key, so printed and compared events don't include the element tree. + */ +type EventInternals = { + readonly typeConfig: EventTypeConfig; + target: TestInstance | null; + currentTarget: TestInstance | null; + eventPhase: EventPhase; + composedPath: TestInstance[]; + stopPropagation: boolean; +}; + +const EVENT_INTERNALS = Symbol('eventInternals'); + +/** + * Event object passed to handlers by the modern event system. + * + * Mirrors React Native's `LegacySyntheticEvent`, which extends the W3C `Event` + * (`src/private/renderer/events/LegacySyntheticEvent.js`, + * `src/private/webapis/dom/events/Event.js`). + * `target` and `currentTarget` are host elements, the same objects that queries return. + */ +export class SyntheticEvent { + static readonly NONE = NONE; + static readonly CAPTURING_PHASE = CAPTURING_PHASE; + static readonly AT_TARGET = AT_TARGET; + static readonly BUBBLING_PHASE = BUBBLING_PHASE; + + private readonly _type: string; + private readonly _bubbles: boolean; + private readonly _cancelable: boolean; + private readonly _rnIsDirect: boolean; + private readonly _timeStamp: number; + private readonly _nativeEvent: NativeEventPayload; + private readonly _dispatchConfig: DispatchConfig; + private _defaultPrevented = false; + + constructor( + type: string, + init: SyntheticEventInit, + nativeEvent: NativeEventPayload, + typeConfig: EventTypeConfig, + ) { + this._type = type; + this._bubbles = typeConfig.kind === 'bubbling' && !typeConfig.skipBubbling; + this._cancelable = Boolean(init.cancelable); + this._rnIsDirect = typeConfig.kind === 'direct'; + this._timeStamp = init.timeStamp ?? performance.now(); + this._nativeEvent = nativeEvent; + this._dispatchConfig = toDispatchConfig(typeConfig); + Object.defineProperty(this, EVENT_INTERNALS, { + value: { + typeConfig, + target: null, + currentTarget: null, + eventPhase: NONE, + composedPath: [], + stopPropagation: false, + } satisfies EventInternals, + }); + } + + // Getters, so the constants live on the prototype as in React Native, not on each event. + get NONE(): typeof NONE { + return NONE; + } + + get CAPTURING_PHASE(): typeof CAPTURING_PHASE { + return CAPTURING_PHASE; + } + + get AT_TARGET(): typeof AT_TARGET { + return AT_TARGET; + } + + get BUBBLING_PHASE(): typeof BUBBLING_PHASE { + return BUBBLING_PHASE; + } + + get type(): string { + return this._type; + } + + get bubbles(): boolean { + return this._bubbles; + } + + get cancelable(): boolean { + return this._cancelable; + } + + /** Direct events are dispatched only to the target, without the capture phase. */ + get rnIsDirect(): boolean { + return this._rnIsDirect; + } + + get timeStamp(): number { + return this._timeStamp; + } + + get nativeEvent(): NativeEventPayload { + return this._nativeEvent; + } + + get dispatchConfig(): DispatchConfig { + return this._dispatchConfig; + } + + get defaultPrevented(): boolean { + return this._defaultPrevented; + } + + get target(): TestInstance | null { + return getEventInternals(this).target; + } + + get currentTarget(): TestInstance | null { + return getEventInternals(this).currentTarget; + } + + get eventPhase(): EventPhase { + return getEventInternals(this).eventPhase; + } + + /** Only the native event dispatch creates events, and React Native marks those as trusted. */ + get isTrusted(): boolean { + return true; + } + + get cancelBubble(): boolean { + return getEventInternals(this).stopPropagation; + } + + set cancelBubble(value: boolean) { + if (value) { + getEventInternals(this).stopPropagation = true; + } + } + + composedPath(): TestInstance[] { + return getEventInternals(this).composedPath.slice(); + } + + preventDefault(): void { + if (this._cancelable) { + this._defaultPrevented = true; + } + } + + stopPropagation(): void { + getEventInternals(this).stopPropagation = true; + } + + /** Same as `stopPropagation()`, as each element has a single prop handler per phase. */ + stopImmediatePropagation(): void { + getEventInternals(this).stopPropagation = true; + } + + /** No-op: React Native no longer pools events. */ + persist(): void {} + + isDefaultPrevented(): boolean { + return this.defaultPrevented; + } + + isPropagationStopped(): boolean { + return this.cancelBubble; + } +} + +/** + * Creates the event React Native would create for a native event of this type, ready for + * `dispatchEvent()`. Like Testing Library's DOM `createEvent()`. + * + * @param eventType e.g. `focus` or `pointerUp` + * @returns `null` for events unknown to React Native, as React Native drops them. + */ +export function createEvent(eventType: string, init: CreateEventInit = {}): SyntheticEvent | null { + const config = getEventTypeConfig(eventType); + if (config == null) { + return null; + } + + const nativeEvent = init.nativeEvent ?? {}; + // React Native keeps the native timestamp as the event's `timeStamp`. + const nativeTimeStamp = nativeEvent.timeStamp ?? nativeEvent.timestamp; + const timeStamp = + init.timeStamp ?? (typeof nativeTimeStamp === 'number' ? nativeTimeStamp : undefined); + return new SyntheticEvent( + // React Native's event type is the lowercased name, e.g. `pointerup`. + eventType.toLowerCase(), + { cancelable: true, timeStamp }, + nativeEvent, + config, + ); +} + +/** @internal */ +export function getEventInternals(event: SyntheticEvent): EventInternals { + return (event as unknown as { [EVENT_INTERNALS]: EventInternals })[EVENT_INTERNALS]; +} + +function toDispatchConfig(config: EventTypeConfig): DispatchConfig { + if (config.kind === 'direct') { + return { registrationName: config.handlerName }; + } + + return { + phasedRegistrationNames: { + bubbled: config.handlerName, + captured: config.captureHandlerName, + ...(config.skipBubbling ? { skipBubbling: true } : {}), + }, + }; +} diff --git a/src/events/modern/fire-event-utils.ts b/src/events/modern/fire-event-utils.ts new file mode 100644 index 000000000..6da71db9c --- /dev/null +++ b/src/events/modern/fire-event-utils.ts @@ -0,0 +1,219 @@ +import type { TestInstance } from 'test-renderer'; + +import { ErrorWithStack } from '../../helpers/errors'; +import { isHostTextInput } from '../../helpers/host-component-names'; +import type { CreateEventInit } from './event'; +import { SyntheticEvent } from './event'; +import { getEventTypeConfig } from './event-types'; + +const ALLOWED_INIT_KEYS = ['nativeEvent', 'timeStamp']; + +/** Legacy events carried these as stubs. The dispatch sets them to host elements. */ +const DISPATCH_KEYS = ['target', 'currentTarget']; + +const EVENT_KEYS = new Set( + Object.getOwnPropertyNames(SyntheticEvent.prototype).filter((key) => key !== 'constructor'), +); + +/** What to use instead of event types that only the legacy system or `userEvent` can fire. */ +const UNKNOWN_EVENT_TYPE_ALTERNATIVES: Record = { + changeText: 'fireEvent.changeText() or userEvent.type()', + pressIn: 'userEvent.press()', + pressOut: 'userEvent.press()', + longPress: 'userEvent.longPress()', +}; + +/** + * Throws for event types React Native doesn't dispatch natively, such as `changeText` or custom + * prop names. React Native drops them, so dropping them here would call no handler, and a test + * asserting that a handler wasn't called would pass for the wrong reason. + * + * @param callsite the function to remove from the error stack, e.g. `fireEvent` + */ +export function ensureEventType( + eventType: string, + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + callsite: Function, +): void { + if (getEventTypeConfig(eventType) != null) { + return; + } + + const alternative = UNKNOWN_EVENT_TYPE_ALTERNATIVES[eventType]; + throw new ErrorWithStack( + `Unable to fire a "${eventType}" event. React Native doesn't dispatch "${eventType}" natively` + + (alternative != null ? `. Use ${alternative} instead.` : ', so no handler would be called.'), + callsite, + ); +} + +/** + * Throws unless exactly one argument follows the event type in modern `fireEvent`, as React Native + * handlers receive a single event. Catches legacy calls passing several handler arguments. + * + * @param callsite the function to remove from the error stack, e.g. `fireEvent` + */ +export function ensureSingleEventArg( + eventType: string, + args: unknown[], + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + callsite: Function, +): void { + if (args.length === 0) { + throw new ErrorWithStack( + `Unable to fire a "${eventType}" event. Please provide an event object, ` + + `e.g. fireEvent(element, '${eventType}', { nativeEvent: {} }).`, + callsite, + ); + } + + if (args.length > 1) { + throw new ErrorWithStack( + `Unable to fire a "${eventType}" event. Expected a single event object, ` + + `received ${args.length} arguments.`, + callsite, + ); + } +} + +/** + * Validates an event object of modern `fireEvent`: a plain object, with only `nativeEvent` + * (a plain object) and `timeStamp` (a finite number) keys. + * + * @param callsite the function to remove from the error stack, e.g. `fireEvent` + */ +export function validateEventInit( + eventType: string, + init: unknown, + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + callsite: Function, +): CreateEventInit { + if (!isPlainObject(init)) { + throw new ErrorWithStack( + `Unable to fire a "${eventType}" event. Expected an event object, ` + + `received ${describeValue(init)}.`, + callsite, + ); + } + + const unknownKeys = Object.keys(init).filter((key) => !ALLOWED_INIT_KEYS.includes(key)); + if (unknownKeys.length > 0) { + throw new ErrorWithStack( + `Unable to fire a "${eventType}" event. Unsupported event object keys: ` + + `${formatKeys(unknownKeys)}. ${describeUnknownKeys(unknownKeys)}`, + callsite, + ); + } + + if (init.nativeEvent !== undefined && !isPlainObject(init.nativeEvent)) { + throw new ErrorWithStack( + `Unable to fire a "${eventType}" event. Expected "nativeEvent" to be an object, ` + + `received ${describeValue(init.nativeEvent)}.`, + callsite, + ); + } + + if ( + init.timeStamp !== undefined && + (typeof init.timeStamp !== 'number' || !Number.isFinite(init.timeStamp)) + ) { + throw new ErrorWithStack( + `Unable to fire a "${eventType}" event. Expected "timeStamp" to be a finite number, ` + + `received ${describeValue(init.timeStamp)}.`, + callsite, + ); + } + + return init as CreateEventInit; +} + +/** + * Validates the arguments of `fireEvent.changeText`: a host `TextInput`, as only `TextInput` calls + * `onChangeText`, and a string. + * + * @param callsite the function to remove from the error stack, e.g. `fireEvent.changeText` + */ +export function validateChangeTextArgs( + instance: TestInstance, + text: unknown, + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + callsite: Function, +): asserts text is string { + if (!isHostTextInput(instance)) { + throw new ErrorWithStack( + `Unable to fire a "changeText" event. Expected a host "TextInput" element, ` + + `received "${instance.type}".`, + callsite, + ); + } + + if (typeof text !== 'string') { + throw new ErrorWithStack( + `Unable to fire a "changeText" event. Expected text to be a string, ` + + `received ${describeValue(text)}.`, + callsite, + ); + } +} + +/** + * Explains why each key can't be passed. Dropping keys silently, as DOM event constructors do, + * would leave handlers reading `undefined` with no explanation. + */ +function describeUnknownKeys(keys: string[]): string { + const dispatchKeys = keys.filter((key) => DISPATCH_KEYS.includes(key)); + const eventKeys = keys.filter((key) => !dispatchKeys.includes(key) && EVENT_KEYS.has(key)); + const otherKeys = keys.filter((key) => !dispatchKeys.includes(key) && !eventKeys.includes(key)); + + const reasons: string[] = []; + if (dispatchKeys.length > 0) { + reasons.push(`${formatKeys(dispatchKeys)} ${isOrAre(dispatchKeys)} set by the dispatch.`); + } + + if (eventKeys.length > 0) { + reasons.push(`${formatKeys(eventKeys)} ${isOrAre(eventKeys)} provided by the event.`); + } + + if (otherKeys.length > 0) { + reasons.push(`Pass ${formatKeys(otherKeys)} in "nativeEvent" instead.`); + } + + return reasons.join(' '); +} + +function formatKeys(keys: string[]): string { + return keys.map((key) => `"${key}"`).join(', '); +} + +function isOrAre(keys: string[]): string { + return keys.length === 1 ? 'is' : 'are'; +} + +function isPlainObject(value: unknown): value is Record { + if (value === null || typeof value !== 'object') { + return false; + } + + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function describeValue(value: unknown): string { + if (value == null) { + return String(value); + } + + if (Array.isArray(value)) { + return 'an array'; + } + + if (typeof value === 'object') { + return `an instance of ${value.constructor?.name ?? 'an unknown class'}`; + } + + if (typeof value === 'function') { + return 'a function'; + } + + return typeof value === 'string' ? `string "${value}"` : `${typeof value} ${String(value)}`; +} diff --git a/src/events/modern/fire-event.ts b/src/events/modern/fire-event.ts new file mode 100644 index 000000000..1a711c5df --- /dev/null +++ b/src/events/modern/fire-event.ts @@ -0,0 +1,147 @@ +import type { TestInstance } from 'test-renderer'; + +import { isInstanceMounted } from '../../helpers/component-tree'; +import { ErrorWithStack } from '../../helpers/errors'; +import { isEditableTextInput } from '../../helpers/text-input'; +import { getEventHandlerFromProps, normalizeEventType } from '../shared/handler'; +import { mergeEventProps } from '../shared/merge'; +import { + buildLayoutNativeEvent, + buildScrollNativeEvent, + buildTextChangeNativeEvent, +} from '../shared/payloads'; +import type { LayoutRectangle } from '../shared/types'; +import { + getNativeStateEventProps, + updateNativeStateFromEvent, +} from '../shared/update-native-state'; +import type { DispatchOptions } from './dispatch'; +import { dispatchEvent } from './dispatch'; +import type { CreateEventInit, NativeEventPayload } from './event'; +import { createEvent } from './event'; +import { + ensureEventType, + ensureSingleEventArg, + validateChangeTextArgs, + validateEventInit, +} from './fire-event-utils'; + +/** + * Event object passed to modern `fireEvent`. Handlers receive a `SyntheticEvent` from + * `createEvent()`, with `nativeEvent` as its `nativeEvent`. + */ +export type FireEventInit = CreateEventInit; + +/** + * Fires an event on a host element: `createEvent()`, then `dispatchEvent()`, like Testing Library's + * DOM `fireEvent(element, event)`. The event type is needed to find the event's dispatch config. + * + * Exactly one event object is required, as React Native handlers receive a single event. Event + * types React Native doesn't dispatch natively (`changeText`, custom prop names) throw. + * + * @param eventType with or without the `on*` prefix, e.g. `focus` or `onFocus` + * @returns `false` if a handler called `preventDefault()`, otherwise `true`. + */ +export async function fireEvent( + instance: TestInstance, + eventType: string, + ...args: [event: FireEventInit] +): Promise { + const normalizedType = normalizeEventType(eventType); + ensureInstance(instance, normalizedType, fireEvent); + // Before the event object, as `fireEvent(input, 'changeText', 'Hello')` needs the hint more. + ensureEventType(normalizedType, fireEvent); + ensureSingleEventArg(normalizedType, args, fireEvent); + const init = validateEventInit(normalizedType, args[0], fireEvent); + return await fireEventInternal(instance, normalizedType, init); +} + +/** + * Changes the text of a `TextInput` as a device does: fires a `change` event with the new text, + * and calls `onChangeText(text)` right after the input's own `onChange`, as `TextInput` does from + * its `onChange` wrapper. Ancestors receive the bubbling `onChange`, but never `onChangeText`. + * A non-editable `TextInput` receives no events, as on a device. + * + * @returns `false` if a handler called `preventDefault()`, otherwise `true`. + */ +fireEvent.changeText = async (instance: TestInstance, text: string): Promise => { + ensureInstance(instance, 'changeText', fireEvent.changeText); + validateChangeTextArgs(instance, text, fireEvent.changeText); + if (!isEditableTextInput(instance)) { + return true; + } + + const selection = { start: text.length, end: text.length }; + const payload = buildTextChangeNativeEvent(text, selection); + const dispatchOptions: DispatchOptions = { + afterTargetHandler: (target) => { + getEventHandlerFromProps(target.props, 'changeText')?.(text); + }, + }; + + return await fireEventInternal(instance, 'change', {}, payload, dispatchOptions); +}; + +/** + * Fires a `scroll` event. The passed `nativeEvent` is deep merged onto a default scroll payload, + * whose `layoutMeasurement` is the `ScrollView`'s size from its last `layout` event. + */ +fireEvent.scroll = async (instance: TestInstance, event: FireEventInit = {}): Promise => { + ensureInstance(instance, 'scroll', fireEvent.scroll); + const init = validateEventInit('scroll', event, fireEvent.scroll); + return await fireEventInternal(instance, 'scroll', init, buildScrollNativeEvent()); +}; + +/** Fires a `layout` event. The passed `layout` is merged onto a zeroed rectangle. */ +fireEvent.layout = async ( + instance: TestInstance, + layout?: Partial, +): Promise => { + ensureInstance(instance, 'layout', fireEvent.layout); + return await fireEventInternal(instance, 'layout', {}, buildLayoutNativeEvent(layout)); +}; + +/** + * Expects an event type React Native dispatches natively, without the `on*` prefix. + * + * @param basePayload the payload a device would send. Native state, like a `ScrollView`'s + * size, is merged onto it, then the passed `nativeEvent` is deep merged on top. Without it, the + * passed `nativeEvent` is used as is. + */ +async function fireEventInternal( + instance: TestInstance, + eventType: string, + init: CreateEventInit, + basePayload?: NativeEventPayload, + dispatchOptions?: DispatchOptions, +): Promise { + const nativeEvent = basePayload + ? mergeEventProps(basePayload, getNativeStateEventProps(instance, eventType), init.nativeEvent) + : init.nativeEvent; + const event = createEvent(eventType, { ...init, nativeEvent }); + if (event == null) { + return true; + } + + // Before the dispatch, as a device updates its native views before emitting the event. + if (isInstanceMounted(instance)) { + updateNativeStateFromEvent(instance, eventType, event); + } + + await dispatchEvent(instance, event, dispatchOptions); + return !event.defaultPrevented; +} + +function ensureInstance( + instance: TestInstance, + eventType: string, + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + callsite: Function, +) { + if (instance == null) { + throw new ErrorWithStack( + `Unable to fire a "${eventType}" event. Please provide a host element.`, + callsite, + ); + } +} diff --git a/src/events/__tests__/handler.test.tsx b/src/events/shared/__tests__/handler.test.tsx similarity index 85% rename from src/events/__tests__/handler.test.tsx rename to src/events/shared/__tests__/handler.test.tsx index b08ccf496..53bd2e03b 100644 --- a/src/events/__tests__/handler.test.tsx +++ b/src/events/shared/__tests__/handler.test.tsx @@ -1,8 +1,8 @@ import * as React from 'react'; import { Text, View } from 'react-native'; -import { render, screen } from '../..'; -import { getEventHandlerFromProps, normalizeEventType } from '../handler'; +import { render, screen } from '../../..'; +import { getEventHandlerFromProps, getHandlerByName, normalizeEventType } from '../handler'; test('getEventHandlerFromProps strict mode', async () => { const onPress = jest.fn(); @@ -99,3 +99,19 @@ test('normalizeEventType strips the `on*` prefix', () => { expect(normalizeEventType('once')).toBe('once'); expect(normalizeEventType('on')).toBe('on'); }); + +test('getHandlerByName returns function props with the exact name or its testOnly_ variant', () => { + const onFocus = jest.fn(); + const testOnlyOnBlur = jest.fn(); + const props = { + onFocus, + testOnly_onFocus: jest.fn(), + testOnly_onBlur: testOnlyOnBlur, + onLayout: 'not a function', + }; + + expect(getHandlerByName(props, 'onFocus')).toBe(onFocus); + expect(getHandlerByName(props, 'onBlur')).toBe(testOnlyOnBlur); + expect(getHandlerByName(props, 'focus')).toBeUndefined(); + expect(getHandlerByName(props, 'onLayout')).toBeUndefined(); +}); diff --git a/src/events/shared/__tests__/invoke-event-handler.test.tsx b/src/events/shared/__tests__/invoke-event-handler.test.tsx new file mode 100644 index 000000000..7793bb29c --- /dev/null +++ b/src/events/shared/__tests__/invoke-event-handler.test.tsx @@ -0,0 +1,69 @@ +import * as React from 'react'; +import { ScrollView, Text, TextInput, View } from 'react-native'; + +import { render, screen } from '../../..'; +import { invokeEventHandler } from '../invoke-event-handler'; + +test('calls the element handler with all params', async () => { + const onContentSizeChange = jest.fn(); + await render(); + + const result = await invokeEventHandler( + screen.getByTestId('scroll'), + 'contentSizeChange', + 100, + 200, + ); + + expect(result).toBe(true); + expect(onContentSizeChange).toHaveBeenCalledWith(100, 200); +}); + +test('calls testOnly_ handlers', async () => { + const onChangeText = jest.fn(); + const testOnlyProps = { testOnly_onChangeText: onChangeText }; + await render(); + + expect(await invokeEventHandler(screen.getByTestId('view'), 'changeText', 'Hello')).toBe(true); + expect(onChangeText).toHaveBeenCalledWith('Hello'); +}); + +test('does not call ancestor handlers', async () => { + const onParentChangeText = jest.fn(); + const parentProps = { onChangeText: onParentChangeText }; + await render( + + + , + ); + + expect(await invokeEventHandler(screen.getByTestId('input'), 'changeText', 'Hello')).toBe(false); + expect(onParentChangeText).not.toHaveBeenCalled(); +}); + +test('returns false when the element is unmounted', async () => { + const onChangeText = jest.fn(); + await render(); + const input = screen.getByTestId('input'); + await screen.rerender(); + + expect(await invokeEventHandler(input, 'changeText', 'Hello')).toBe(false); + expect(onChangeText).not.toHaveBeenCalled(); +}); + +test('renders state updates from the handler', async () => { + function Subject() { + const [text, setText] = React.useState(''); + return ( + <> + + Text: {text} + + ); + } + await render(); + + await invokeEventHandler(screen.getByTestId('input'), 'changeText', 'Hello'); + + expect(screen.getByText('Text: Hello')).toBeOnTheScreen(); +}); diff --git a/src/events/shared/__tests__/merge.test.ts b/src/events/shared/__tests__/merge.test.ts new file mode 100644 index 000000000..848e30b07 --- /dev/null +++ b/src/events/shared/__tests__/merge.test.ts @@ -0,0 +1,63 @@ +import { mergeEventProps } from '../merge'; + +function buildEvent() { + return { + nativeEvent: { pageX: 0, pageY: 0, locationX: 0, touches: [] as unknown[] }, + preventDefault: () => {}, + timeStamp: 0, + }; +} + +test('returns the same event when no props are passed', () => { + const event = buildEvent(); + const nativeEvent = { ...event.nativeEvent }; + + expect(mergeEventProps(event)).toBe(event); + expect(event.nativeEvent).toEqual(nativeEvent); +}); + +test('deep merges nested objects and keeps other default fields', () => { + const event = mergeEventProps(buildEvent(), { nativeEvent: { pageX: 10, pageY: 20 } }); + + expect(event.nativeEvent.pageX).toBe(10); + expect(event.nativeEvent.pageY).toBe(20); + expect(event.nativeEvent.locationX).toBe(0); + expect(typeof event.preventDefault).toBe('function'); +}); + +test('replaces arrays and primitive values instead of merging them', () => { + const event = mergeEventProps(buildEvent(), { + nativeEvent: { touches: [{ identifier: 1 }] }, + timeStamp: 42, + }); + + expect(event.nativeEvent.touches).toEqual([{ identifier: 1 }]); + expect(event.timeStamp).toBe(42); +}); + +test('adds props that the event does not have', () => { + const event = mergeEventProps(buildEvent(), { custom: { value: 1 } }); + + expect(event).toMatchObject({ custom: { value: 1 } }); +}); + +test('merges multiple props in order, skipping undefined ones', () => { + const event = mergeEventProps( + buildEvent(), + { nativeEvent: { pageX: 10, pageY: 20 } }, + undefined, + { nativeEvent: { pageX: 30 } }, + ); + + expect(event.nativeEvent).toMatchObject({ pageX: 30, pageY: 20, locationX: 0 }); +}); + +test('does not pollute Object.prototype through a __proto__ key', () => { + const props = JSON.parse( + '{"__proto__": {"polluted": true}, "nativeEvent": {"__proto__": {"polluted": true}}}', + ); + + mergeEventProps(buildEvent(), props); + + expect(({} as Record).polluted).toBeUndefined(); +}); diff --git a/src/events/shared/__tests__/payloads.test.ts b/src/events/shared/__tests__/payloads.test.ts new file mode 100644 index 000000000..b18d59bc8 --- /dev/null +++ b/src/events/shared/__tests__/payloads.test.ts @@ -0,0 +1,105 @@ +import { + buildAccessibilityActionNativeEvent, + buildBlurNativeEvent, + buildContentSizeChangeNativeEvent, + buildEndEditingNativeEvent, + buildFocusNativeEvent, + buildKeyPressNativeEvent, + buildLayoutNativeEvent, + buildScrollNativeEvent, + buildSubmitEditingNativeEvent, + buildTextChangeNativeEvent, + buildTextSelectionChangeNativeEvent, + buildTouchNativeEvent, +} from '../payloads'; + +test('buildTouchNativeEvent returns touch payload', () => { + expect(buildTouchNativeEvent()).toEqual({ + changedTouches: [], + identifier: 0, + locationX: 0, + locationY: 0, + pageX: 0, + pageY: 0, + target: 0, + timestamp: expect.any(Number), + touches: [], + }); +}); + +test('buildFocusNativeEvent and buildBlurNativeEvent return target', () => { + expect(buildFocusNativeEvent()).toEqual({ target: 0 }); + expect(buildBlurNativeEvent()).toEqual({ target: 0 }); +}); + +test('buildAccessibilityActionNativeEvent returns action name', () => { + expect(buildAccessibilityActionNativeEvent('increment')).toEqual({ actionName: 'increment' }); +}); + +test('buildLayoutNativeEvent merges layout onto zeroed rectangle', () => { + expect(buildLayoutNativeEvent()).toEqual({ + layout: { x: 0, y: 0, width: 0, height: 0 }, + target: 0, + }); + expect(buildLayoutNativeEvent({ width: 100, x: undefined })).toEqual({ + layout: { x: 0, y: 0, width: 100, height: 0 }, + target: 0, + }); +}); + +test('buildScrollNativeEvent returns default scroll payload', () => { + expect(buildScrollNativeEvent()).toEqual({ + contentInset: { bottom: 0, left: 0, right: 0, top: 0 }, + contentOffset: { y: 0, x: 0 }, + contentSize: { height: 0, width: 0 }, + layoutMeasurement: { height: 0, width: 0 }, + responderIgnoreScroll: true, + target: 0, + velocity: { y: 0, x: 0 }, + }); +}); + +test('buildScrollNativeEvent uses provided offset and options', () => { + const nativeEvent = buildScrollNativeEvent( + { y: 100, x: 50 }, + { + contentSize: { height: 1000, width: 400 }, + layoutMeasurement: { height: 800, width: 400 }, + }, + ); + + expect(nativeEvent.contentOffset).toEqual({ y: 100, x: 50 }); + expect(nativeEvent.contentSize).toEqual({ height: 1000, width: 400 }); + expect(nativeEvent.layoutMeasurement).toEqual({ height: 800, width: 400 }); +}); + +test('buildTextChangeNativeEvent returns text and selection', () => { + expect(buildTextChangeNativeEvent('Hello', { start: 5, end: 5 })).toEqual({ + text: 'Hello', + target: 0, + eventCount: 0, + selection: { start: 5, end: 5 }, + }); +}); + +test('buildKeyPressNativeEvent returns key', () => { + expect(buildKeyPressNativeEvent('a')).toEqual({ key: 'a' }); +}); + +test('buildSubmitEditingNativeEvent and buildEndEditingNativeEvent return text', () => { + expect(buildSubmitEditingNativeEvent('Hello')).toEqual({ text: 'Hello', target: 0 }); + expect(buildEndEditingNativeEvent('Hello')).toEqual({ text: 'Hello', target: 0 }); +}); + +test('buildTextSelectionChangeNativeEvent returns selection', () => { + expect(buildTextSelectionChangeNativeEvent({ start: 0, end: 4 })).toEqual({ + selection: { start: 0, end: 4 }, + }); +}); + +test('buildContentSizeChangeNativeEvent returns content size', () => { + expect(buildContentSizeChangeNativeEvent({ width: 100, height: 50 })).toEqual({ + contentSize: { width: 100, height: 50 }, + target: 0, + }); +}); diff --git a/src/events/handler.ts b/src/events/shared/handler.ts similarity index 55% rename from src/events/handler.ts rename to src/events/shared/handler.ts index 8e0088942..ca6ce5a4e 100644 --- a/src/events/handler.ts +++ b/src/events/shared/handler.ts @@ -1,6 +1,6 @@ import type { EventHandler } from './types'; -export type EventHandlerOptions = { +type EventHandlerOptions = { /** Include check for event handler named without adding `on*` prefix. */ loose?: boolean; }; @@ -10,24 +10,29 @@ export function getEventHandlerFromProps( eventType: string, options?: EventHandlerOptions, ): EventHandler | undefined { - const handlerName = getEventHandlerName(eventType); - if (typeof props[handlerName] === 'function') { - return props[handlerName] as EventHandler; - } - - if (options?.loose && typeof props[eventType] === 'function') { - return props[eventType] as EventHandler; - } - - if (typeof props[`testOnly_${handlerName}`] === 'function') { - return props[`testOnly_${handlerName}`] as EventHandler; - } + return ( + getHandlerByName(props, getEventHandlerName(eventType)) ?? + (options?.loose ? getHandlerByName(props, eventType) : undefined) + ); +} - if (options?.loose && typeof props[`testOnly_${eventType}`] === 'function') { - return props[`testOnly_${eventType}`] as EventHandler; - } +/** + * Returns the handler prop with exactly the given name (e.g. `onFocusCapture`), or else its + * `testOnly_` variant (`testOnly_onFocusCapture`), which lets tests handle events on host elements. + */ +export function getHandlerByName( + props: Record, + handlerName: string, +): EventHandler | undefined { + return getHandlerProp(props, handlerName) ?? getHandlerProp(props, `testOnly_${handlerName}`); +} - return undefined; +function getHandlerProp( + props: Record, + propName: string, +): EventHandler | undefined { + const handler = props[propName]; + return typeof handler === 'function' ? (handler as EventHandler) : undefined; } /** diff --git a/src/events/shared/invoke-event-handler.ts b/src/events/shared/invoke-event-handler.ts new file mode 100644 index 000000000..d152acff6 --- /dev/null +++ b/src/events/shared/invoke-event-handler.ts @@ -0,0 +1,35 @@ +import type { TestInstance } from 'test-renderer'; + +import { act } from '../../act'; +import { isInstanceMounted } from '../../helpers/component-tree'; +import { getEventHandlerFromProps } from './handler'; + +/** + * Calls the element's own handler prop for the event type (`onChangeText`, or + * `testOnly_onChangeText`) with the given params, in `act()`. For callbacks that components call + * from JavaScript instead of dispatching native events, e.g. `ScrollView`'s + * `onContentSizeChange(width, height)`. + * + * @param eventType without the `on*` prefix, e.g. `changeText` + * @returns `true` if a handler was called. + */ +export async function invokeEventHandler( + instance: TestInstance, + eventType: string, + ...params: unknown[] +): Promise { + if (!isInstanceMounted(instance)) { + return false; + } + + const handler = getEventHandlerFromProps(instance.props, eventType); + if (!handler) { + return false; + } + + await act(() => { + handler(...params); + }); + + return true; +} diff --git a/src/events/shared/merge.ts b/src/events/shared/merge.ts new file mode 100644 index 000000000..de9cfce52 --- /dev/null +++ b/src/events/shared/merge.ts @@ -0,0 +1,33 @@ +import { isObject } from '../../helpers/object'; +import type { EventProps } from './types'; + +/** + * Deep merges custom props into a built event, so tests can override only the fields they need. + * Props are merged in order, so later ones win. Nested objects are merged, other values (including + * arrays) are replaced. Mutates and returns the passed event. + */ +export function mergeEventProps( + event: T, + ...eventProps: Array +): T { + for (const props of eventProps) { + if (props) { + mergeInto(event as EventProps, props); + } + } + + return event; +} + +function mergeInto(target: EventProps, source: EventProps) { + for (const key of Object.keys(source)) { + const sourceValue = source[key]; + const targetValue = target[key]; + // Only recurse into the target's own objects, so a `__proto__` key can't reach `Object.prototype`. + if (Object.hasOwn(target, key) && isObject(sourceValue) && isObject(targetValue)) { + mergeInto(targetValue, sourceValue); + } else { + target[key] = sourceValue; + } + } +} diff --git a/src/events/native-state.ts b/src/events/shared/native-state.ts similarity index 87% rename from src/events/native-state.ts rename to src/events/shared/native-state.ts index c4b405c7c..501840bb3 100644 --- a/src/events/native-state.ts +++ b/src/events/shared/native-state.ts @@ -1,13 +1,13 @@ import type { TestInstance } from 'test-renderer'; -import type { Point, Size } from '../types'; +import type { Point, Size } from '../../types'; /** * Simulated native state for unmanaged controls. * * Values from `value` props (managed controls) should take precedence over these values. */ -export type NativeState = { +type NativeState = { valueForInstance: WeakMap; contentOffsetForInstance: WeakMap; layoutSizeForInstance: WeakMap; diff --git a/src/events/shared/payloads.ts b/src/events/shared/payloads.ts new file mode 100644 index 000000000..8e668971e --- /dev/null +++ b/src/events/shared/payloads.ts @@ -0,0 +1,156 @@ +import type { Point, Size, TextRange } from '../../types'; +import type { LayoutRectangle } from './types'; + +/** + * `nativeEvent` payloads, matching what React Native sends on a device. Both event systems use them: + * legacy wraps them in a stub event (`legacy/builders/`), modern in a `SyntheticEvent`. + */ + +/** + * Experimental values: + * - iOS: `{"changedTouches": [[Circular]], "identifier": 1, "locationX": 253, "locationY": 30.333328247070312, "pageX": 273, "pageY": 141.3333282470703, "target": 75, "timestamp": 875928682.0450834, "touches": [[Circular]]}` + * - Android: `{"changedTouches": [[Circular]], "identifier": 0, "locationX": 160, "locationY": 40.3636360168457, "pageX": 180, "pageY": 140.36363220214844, "target": 53, "targetSurface": -1, "timestamp": 10290805, "touches": [[Circular]]}` + */ +export function buildTouchNativeEvent() { + return { + changedTouches: [] as unknown[], + identifier: 0, + locationX: 0, + locationY: 0, + pageX: 0, + pageY: 0, + target: 0, + timestamp: Date.now(), + touches: [] as unknown[], + }; +} + +/** + * Experimental values: + * - iOS: `{"eventCount": 0, "target": 75, "text": ""}` + * - Android: `{"target": 53}` + */ +export function buildFocusNativeEvent() { + return { target: 0 }; +} + +/** + * Experimental values: + * - iOS: `{"eventCount": 0, "target": 75, "text": ""}` + * - Android: `{"target": 53}` + */ +export function buildBlurNativeEvent() { + return { target: 0 }; +} + +/** + * Delivered to `onAccessibilityAction` when an assistive technology triggers an action. + * + * Experimental values: + * - `{"actionName": "increment"}` + */ +export function buildAccessibilityActionNativeEvent(actionName: string) { + return { actionName }; +} + +/** + * Delivered to `onLayout` when the layout engine measures an element. The passed `layout` values + * are merged onto a zeroed rectangle, so only the fields relevant to the test need to be provided. + */ +export function buildLayoutNativeEvent(layout?: Partial) { + return { + // `??` rather than spread, so that explicit `undefined` values fall back to `0`. + layout: { + x: layout?.x ?? 0, + y: layout?.y ?? 0, + width: layout?.width ?? 0, + height: layout?.height ?? 0, + }, + target: 0, + }; +} + +export type ScrollNativeEventOptions = { + contentSize?: Size; + layoutMeasurement?: Size; +}; + +/** + * Experimental values: + * - iOS: `{"contentInset": {"bottom": 0, "left": 0, "right": 0, "top": 0}, "contentOffset": {"x": 0, "y": 5.333333333333333}, "contentSize": {"height": 1676.6666259765625, "width": 390}, "layoutMeasurement": {"height": 753, "width": 390}, "zoomScale": 1}` + * - Android: `{"contentInset": {"bottom": 0, "left": 0, "right": 0, "top": 0}, "contentOffset": {"x": 0, "y": 31.619047164916992}, "contentSize": {"height": 1624.761962890625, "width": 411.4285583496094}, "layoutMeasurement": {"height": 785.5238037109375, "width": 411.4285583496094}, "responderIgnoreScroll": true, "target": 139, "velocity": {"x": -1.3633992671966553, "y": -1.3633992671966553}}` + */ +export function buildScrollNativeEvent( + offset: Point = { y: 0, x: 0 }, + options?: ScrollNativeEventOptions, +) { + return { + contentInset: { bottom: 0, left: 0, right: 0, top: 0 }, + contentOffset: { y: offset.y, x: offset.x }, + contentSize: { + height: options?.contentSize?.height ?? 0, + width: options?.contentSize?.width ?? 0, + }, + layoutMeasurement: { + height: options?.layoutMeasurement?.height ?? 0, + width: options?.layoutMeasurement?.width ?? 0, + }, + responderIgnoreScroll: true, + target: 0, + velocity: { y: 0, x: 0 }, + }; +} + +/** + * Experimental values: + * - iOS: `{"eventCount": 4, "target": 75, "text": "Test"}` + * - Android: `{"eventCount": 6, "target": 53, "text": "Tes"}` + */ +export function buildTextChangeNativeEvent(text: string, { start, end }: TextRange) { + return { text, target: 0, eventCount: 0, selection: { start, end } }; +} + +/** + * Experimental values: + * - iOS: `{"eventCount": 3, "key": "a", "target": 75}` + * - Android: `{"key": "a"}` + */ +export function buildKeyPressNativeEvent(key: string) { + return { key }; +} + +/** + * Experimental values: + * - iOS: `{"eventCount": 4, "target": 75, "text": "Test"}` + * - Android: `{"target": 53, "text": "Test"}` + */ +export function buildSubmitEditingNativeEvent(text: string) { + return { text, target: 0 }; +} + +/** + * Experimental values: + * - iOS: `{"eventCount": 4, "target": 75, "text": "Test"}` + * - Android: `{"target": 53, "text": "Test"}` + */ +export function buildEndEditingNativeEvent(text: string) { + return { text, target: 0 }; +} + +/** + * Experimental values: + * - iOS: `{"selection": {"end": 4, "start": 4}, "target": 75}` + * - Android: `{"selection": {"end": 4, "start": 4}}` + */ +export function buildTextSelectionChangeNativeEvent({ start, end }: TextRange) { + return { selection: { start, end } }; +} + +/** + * Experimental values: + * - iOS: `{"contentSize": {"height": 21.666666666666668, "width": 11.666666666666666}, "target": 75}` + * - Android: `{"contentSize": {"height": 61.45454406738281, "width": 352.7272644042969}, "target": 53}` + */ +export function buildContentSizeChangeNativeEvent({ width, height }: Size) { + return { contentSize: { width, height }, target: 0 }; +} diff --git a/src/events/types.ts b/src/events/shared/types.ts similarity index 93% rename from src/events/types.ts rename to src/events/shared/types.ts index d01d50f8d..aee809036 100644 --- a/src/events/types.ts +++ b/src/events/shared/types.ts @@ -6,7 +6,7 @@ import type { ViewProps, } from 'react-native'; -import type { StringWithAutocomplete } from '../types'; +import type { StringWithAutocomplete } from '../../types'; export type EventHandler = (...args: unknown[]) => unknown; diff --git a/src/events/update-native-state.ts b/src/events/shared/update-native-state.ts similarity index 61% rename from src/events/update-native-state.ts rename to src/events/shared/update-native-state.ts index 739905380..486725f73 100644 --- a/src/events/update-native-state.ts +++ b/src/events/shared/update-native-state.ts @@ -1,9 +1,10 @@ import type { TestInstance } from 'test-renderer'; -import { isHostScrollView } from '../helpers/host-component-names'; -import { isEditableTextInput } from '../helpers/text-input'; -import type { Point, Size } from '../types'; +import { isHostScrollView } from '../../helpers/host-component-names'; +import { isEditableTextInput } from '../../helpers/text-input'; +import type { Point, Size } from '../../types'; import { nativeState } from './native-state'; +import type { EventProps } from './types'; const SCROLL_EVENTS = [ 'scroll', @@ -29,6 +30,14 @@ export function updateNativeStateFromEvent( return true; } + if (eventType === 'change' && isEditableTextInput(instance)) { + const text = tryGetText(value); + if (text != null) { + nativeState.valueForInstance.set(instance, text); + return true; + } + } + if (SCROLL_EVENTS.includes(eventType) && isHostScrollView(instance)) { const contentOffset = tryGetContentOffset(value); if (contentOffset) { @@ -48,6 +57,35 @@ export function updateNativeStateFromEvent( return false; } +/** + * Returns `nativeEvent` fields a device fills from native state, to merge onto a default payload. + * Expects event type without the `on*` prefix (see `normalizeEventType`). + */ +export function getNativeStateEventProps( + instance: TestInstance, + eventType: string, +): EventProps | undefined { + if (SCROLL_EVENTS.includes(eventType) && isHostScrollView(instance)) { + const layoutSize = nativeState.layoutSizeForInstance.get(instance); + // Copied, so that merging passed props onto the payload doesn't change native state. + return layoutSize ? { layoutMeasurement: { ...layoutSize } } : undefined; + } + + return undefined; +} + +function tryGetText(event: unknown): string | null { + try { + // @ts-expect-error: try to extract text from the event value + const text = event?.nativeEvent?.text; + return typeof text === 'string' ? text : null; + } catch { + // Do nothing + } + + return null; +} + function tryGetContentOffset(event: unknown): Point | null { try { // @ts-expect-error: try to extract contentOffset from the event value diff --git a/src/helpers/object.ts b/src/helpers/object.ts index cb517873c..728feefe9 100644 --- a/src/helpers/object.ts +++ b/src/helpers/object.ts @@ -9,7 +9,8 @@ export function pick(object: T, keys: (keyof T)[]): Partial return result; } -function isObject(value: unknown): value is Record { +/** Returns `true` for non-null objects that are not arrays. */ +export function isObject(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } diff --git a/src/helpers/text-input.ts b/src/helpers/text-input.ts index d053e0570..d97254ab4 100644 --- a/src/helpers/text-input.ts +++ b/src/helpers/text-input.ts @@ -1,6 +1,6 @@ import type { TestInstance } from 'test-renderer'; -import { nativeState } from '../events/native-state'; +import { nativeState } from '../events/shared/native-state'; import { isHostTextInput } from './host-component-names'; export function isEditableTextInput(instance: TestInstance) { diff --git a/src/pure.ts b/src/pure.ts index 377c1f678..db64cd82f 100644 --- a/src/pure.ts +++ b/src/pure.ts @@ -15,5 +15,5 @@ export { userEvent } from './user-event'; export type { RenderOptions, RenderResult, DebugFunction } from './render'; export type { RenderHookOptions, RenderHookResult } from './render-hook'; -export type { Config } from './config'; +export type { Config, EventSystem } from './config'; export type { UserEventConfig } from './user-event'; diff --git a/src/test-utils/event-serializer.ts b/src/test-utils/event-serializer.ts new file mode 100644 index 000000000..69cc0420e --- /dev/null +++ b/src/test-utils/event-serializer.ts @@ -0,0 +1,29 @@ +import type { Config, Printer, Refs } from 'pretty-format'; + +import { isObject } from '../helpers/object'; +import { EventEntry } from './events'; + +/** + * Prints `createEventLogger()` entries with only the `nativeEvent` of event payloads, so + * `userEvent` snapshots are the same with legacy event objects and modern `SyntheticEvent`s. + * Other payloads, e.g. the text for `changeText`, are printed as they are. + */ +export function test(value: unknown): boolean { + return value instanceof EventEntry; +} + +export function serialize( + entry: EventEntry, + config: Config, + indentation: string, + depth: number, + refs: Refs, + printer: Printer, +): string { + const { name, payload } = entry; + const printedPayload = + isObject(payload) && isObject(payload.nativeEvent) + ? { nativeEvent: payload.nativeEvent } + : payload; + return printer({ name, payload: printedPayload }, config, indentation, depth, refs); +} diff --git a/src/test-utils/event-system.ts b/src/test-utils/event-system.ts new file mode 100644 index 000000000..f3043be1c --- /dev/null +++ b/src/test-utils/event-system.ts @@ -0,0 +1,11 @@ +import { configure } from '../config'; + +/** + * Runs tests in the legacy event system in both Jest projects, for legacy behavior the modern + * event system doesn't have. Call it at the top of a test file, or in a `describe()`. + */ +export function runInLegacyEventSystem() { + beforeEach(() => { + configure({ unstable_eventSystem: 'legacy' }); + }); +} diff --git a/src/test-utils/events.ts b/src/test-utils/events.ts index 3f7c6d5d2..4b27da782 100644 --- a/src/test-utils/events.ts +++ b/src/test-utils/events.ts @@ -1,18 +1,16 @@ -export interface EventEntry { - name: string; - payload: any; +/** A logged handler call. `event-serializer.ts` prints it the same way in both event systems. */ +export class EventEntry { + constructor( + readonly name: string, + readonly payload: any, + ) {} } export function createEventLogger() { const events: EventEntry[] = []; const logEvent = (name: string) => { return (event: unknown) => { - const eventEntry: EventEntry = { - name, - payload: event, - }; - - events.push(eventEntry); + events.push(new EventEntry(name, event)); }; }; diff --git a/src/user-event/__tests__/__snapshots__/clear.test.tsx.snap b/src/user-event/__tests__/__snapshots__/clear.test.tsx.snap index 3146bd80a..ff29f7d5b 100644 --- a/src/user-event/__tests__/__snapshots__/clear.test.tsx.snap +++ b/src/user-event/__tests__/__snapshots__/clear.test.tsx.snap @@ -5,64 +5,33 @@ exports[`clear() supports basic case: value: "Hello! 1`] = ` { "name": "focus", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "target": 0, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "selectionChange", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "selection": { "end": 6, "start": 0, }, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "keyPress", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "key": "Backspace", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "change", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "eventCount": 0, "selection": { @@ -72,11 +41,6 @@ exports[`clear() supports basic case: value: "Hello! 1`] = ` "target": 0, "text": "", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { @@ -86,56 +50,29 @@ exports[`clear() supports basic case: value: "Hello! 1`] = ` { "name": "selectionChange", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "selection": { "end": 0, "start": 0, }, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "endEditing", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "target": 0, "text": "", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "blur", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "target": 0, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, ] @@ -146,64 +83,33 @@ exports[`clear() supports defaultValue prop: defaultValue: "Hello Default!" 1`] { "name": "focus", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "target": 0, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "selectionChange", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "selection": { "end": 14, "start": 0, }, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "keyPress", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "key": "Backspace", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "change", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "eventCount": 0, "selection": { @@ -213,11 +119,6 @@ exports[`clear() supports defaultValue prop: defaultValue: "Hello Default!" 1`] "target": 0, "text": "", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { @@ -227,56 +128,29 @@ exports[`clear() supports defaultValue prop: defaultValue: "Hello Default!" 1`] { "name": "selectionChange", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "selection": { "end": 0, "start": 0, }, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "endEditing", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "target": 0, "text": "", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "blur", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "target": 0, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, ] @@ -287,64 +161,33 @@ exports[`clear() supports multiline: value: "Hello World!\\nHow are you?" multil { "name": "focus", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "target": 0, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "selectionChange", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "selection": { "end": 25, "start": 0, }, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "keyPress", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "key": "Backspace", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "change", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "eventCount": 0, "selection": { @@ -354,11 +197,6 @@ exports[`clear() supports multiline: value: "Hello World!\\nHow are you?" multil "target": 0, "text": "", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { @@ -368,30 +206,17 @@ exports[`clear() supports multiline: value: "Hello World!\\nHow are you?" multil { "name": "selectionChange", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "selection": { "end": 0, "start": 0, }, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "contentSizeChange", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "contentSize": { "height": 16, @@ -399,46 +224,23 @@ exports[`clear() supports multiline: value: "Hello World!\\nHow are you?" multil }, "target": 0, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "endEditing", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "target": 0, "text": "", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "blur", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "target": 0, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, ] @@ -453,19 +255,10 @@ exports[`clear() works when not all events have handlers 1`] = ` { "name": "endEditing", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "target": 0, "text": "", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, ] diff --git a/src/user-event/__tests__/__snapshots__/paste.test.tsx.snap b/src/user-event/__tests__/__snapshots__/paste.test.tsx.snap index 619cfd17e..acc470a3a 100644 --- a/src/user-event/__tests__/__snapshots__/paste.test.tsx.snap +++ b/src/user-event/__tests__/__snapshots__/paste.test.tsx.snap @@ -5,47 +5,25 @@ exports[`paste() paste on empty text input 1`] = ` { "name": "focus", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "target": 0, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "selectionChange", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "selection": { "end": 0, "start": 0, }, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "change", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "eventCount": 0, "selection": { @@ -55,11 +33,6 @@ exports[`paste() paste on empty text input 1`] = ` "target": 0, "text": "Hi!", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { @@ -69,56 +42,29 @@ exports[`paste() paste on empty text input 1`] = ` { "name": "selectionChange", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "selection": { "end": 3, "start": 3, }, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "endEditing", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "target": 0, "text": "Hi!", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "blur", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "target": 0, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, ] @@ -129,47 +75,25 @@ exports[`paste() paste on filled text input 1`] = ` { "name": "focus", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "target": 0, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "selectionChange", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "selection": { "end": 6, "start": 0, }, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "change", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "eventCount": 0, "selection": { @@ -179,11 +103,6 @@ exports[`paste() paste on filled text input 1`] = ` "target": 0, "text": "Hi!", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { @@ -193,56 +112,29 @@ exports[`paste() paste on filled text input 1`] = ` { "name": "selectionChange", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "selection": { "end": 3, "start": 3, }, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "endEditing", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "target": 0, "text": "Hi!", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "blur", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "target": 0, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, ] @@ -253,47 +145,25 @@ exports[`paste() supports defaultValue prop: defaultValue: "Hello Default!" 1`] { "name": "focus", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "target": 0, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "selectionChange", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "selection": { "end": 14, "start": 0, }, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "change", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "eventCount": 0, "selection": { @@ -303,11 +173,6 @@ exports[`paste() supports defaultValue prop: defaultValue: "Hello Default!" 1`] "target": 0, "text": "Hi!", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { @@ -317,56 +182,29 @@ exports[`paste() supports defaultValue prop: defaultValue: "Hello Default!" 1`] { "name": "selectionChange", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "selection": { "end": 3, "start": 3, }, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "endEditing", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "target": 0, "text": "Hi!", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "blur", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "target": 0, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, ] @@ -377,47 +215,25 @@ exports[`paste() supports multiline: value: "Hello World!\\nHow are you?" multil { "name": "focus", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "target": 0, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "selectionChange", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "selection": { "end": 25, "start": 0, }, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "change", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "eventCount": 0, "selection": { @@ -427,11 +243,6 @@ exports[`paste() supports multiline: value: "Hello World!\\nHow are you?" multil "target": 0, "text": "Hi!", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { @@ -441,30 +252,17 @@ exports[`paste() supports multiline: value: "Hello World!\\nHow are you?" multil { "name": "selectionChange", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "selection": { "end": 3, "start": 3, }, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "contentSizeChange", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "contentSize": { "height": 16, @@ -472,46 +270,23 @@ exports[`paste() supports multiline: value: "Hello World!\\nHow are you?" multil }, "target": 0, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "endEditing", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "target": 0, "text": "Hi!", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "blur", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "target": 0, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, ] @@ -526,19 +301,10 @@ exports[`paste() works when not all events have handlers 1`] = ` { "name": "endEditing", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "target": 0, "text": "Hi!", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, ] diff --git a/src/user-event/__tests__/clear.test.tsx b/src/user-event/__tests__/clear.test.tsx index 5f34d442b..16e581295 100644 --- a/src/user-event/__tests__/clear.test.tsx +++ b/src/user-event/__tests__/clear.test.tsx @@ -3,6 +3,7 @@ import type { TextInputProps } from 'react-native'; import { TextInput, View } from 'react-native'; import { render, screen, userEvent } from '../..'; +import { getConfig } from '../../config'; import { createEventLogger, getEventsNames } from '../../test-utils/events'; beforeEach(() => { @@ -39,7 +40,7 @@ async function renderTextInputWithToolkit(props: TextInputProps = {}) { } describe('clear()', () => { - it('supports basic case', async () => { + test('supports basic case', async () => { jest.spyOn(Date, 'now').mockImplementation(() => 100100100100); const { textInput, events } = await renderTextInputWithToolkit({ value: 'Hello!', @@ -62,7 +63,7 @@ describe('clear()', () => { expect(events).toMatchSnapshot('value: "Hello!'); }); - it.each(['modern', 'legacy'])('works with %s fake timers', async (type) => { + test.each(['modern', 'legacy'])('works with %s fake timers', async (type) => { jest.useFakeTimers({ legacyFakeTimers: type === 'legacy' }); const { textInput, events } = await renderTextInputWithToolkit({ value: 'Hello!', @@ -83,7 +84,7 @@ describe('clear()', () => { ]); }); - it('supports defaultValue prop', async () => { + test('supports defaultValue prop', async () => { const { textInput, events } = await renderTextInputWithToolkit({ defaultValue: 'Hello Default!', }); @@ -105,7 +106,7 @@ describe('clear()', () => { expect(events).toMatchSnapshot('defaultValue: "Hello Default!"'); }); - it('does respect editable prop', async () => { + test('does respect editable prop', async () => { const { textInput } = await renderTextInputWithToolkit({ value: 'Hello!', editable: false, @@ -117,7 +118,7 @@ describe('clear()', () => { expect(textInput).toHaveDisplayValue('Hello!'); }); - it('does respect pointer-events prop', async () => { + test('does respect pointer-events prop', async () => { const { textInput } = await renderTextInputWithToolkit({ value: 'Hello!', pointerEvents: 'none', @@ -129,7 +130,7 @@ describe('clear()', () => { expect(textInput).toHaveDisplayValue('Hello!'); }); - it('supports multiline', async () => { + test('supports multiline', async () => { const { textInput, events } = await renderTextInputWithToolkit({ value: 'Hello World!\nHow are you?', multiline: true, @@ -153,7 +154,7 @@ describe('clear()', () => { expect(events).toMatchSnapshot('value: "Hello World!\nHow are you?" multiline: true,'); }); - it('works when not all events have handlers', async () => { + test('works when not all events have handlers', async () => { const { events, logEvent } = createEventLogger(); await render( { expect(events).toMatchSnapshot(); }); - it('does NOT work on View', async () => { + test('does NOT work on View', async () => { await render(); const user = userEvent.setup(); @@ -185,19 +186,19 @@ describe('clear()', () => { // View that ignores props type checking const AnyView = View as React.ComponentType; - it('does NOT bubble up', async () => { - const parentHandler = jest.fn(); + test('bubbles up only native bubbling events, in the modern event system', async () => { + const { events, logEvent } = createEventLogger(); await render( , @@ -205,10 +206,14 @@ describe('clear()', () => { const user = userEvent.setup(); await user.clear(screen.getByTestId('input')); - expect(parentHandler).not.toHaveBeenCalled(); + // `focus`, `keyPress`, `change`, `endEditing` and `blur` are bubbling events in React Native. + const modernEvents = ['focus', 'keyPress', 'change', 'endEditing', 'blur']; + expect(getEventsNames(events)).toEqual( + getConfig().unstable_eventSystem === 'modern' ? modernEvents : [], + ); }); - it('sets native state value for unmanaged text inputs', async () => { + test('sets native state value for unmanaged text inputs', async () => { await render(); const user = userEvent.setup(); diff --git a/src/user-event/__tests__/paste.test.tsx b/src/user-event/__tests__/paste.test.tsx index d49395b58..289919dea 100644 --- a/src/user-event/__tests__/paste.test.tsx +++ b/src/user-event/__tests__/paste.test.tsx @@ -3,6 +3,7 @@ import type { TextInputProps } from 'react-native'; import { TextInput, View } from 'react-native'; import { render, screen, userEvent } from '../..'; +import { getConfig } from '../../config'; import { createEventLogger, getEventsNames } from '../../test-utils/events'; beforeEach(() => { @@ -39,7 +40,7 @@ async function renderTextInputWithToolkit(props: TextInputProps = {}) { } describe('paste()', () => { - it('paste on empty text input', async () => { + test('paste on empty text input', async () => { jest.spyOn(Date, 'now').mockImplementation(() => 100100100100); const { textInput, events } = await renderTextInputWithToolkit(); @@ -59,7 +60,7 @@ describe('paste()', () => { expect(events).toMatchSnapshot(); }); - it('paste on filled text input', async () => { + test('paste on filled text input', async () => { jest.spyOn(Date, 'now').mockImplementation(() => 100100100100); const { textInput, events } = await renderTextInputWithToolkit({ value: 'Hello!', @@ -81,7 +82,7 @@ describe('paste()', () => { expect(events).toMatchSnapshot(); }); - it.each(['modern', 'legacy'])('works with %s fake timers', async (type) => { + test.each(['modern', 'legacy'])('works with %s fake timers', async (type) => { jest.useFakeTimers({ legacyFakeTimers: type === 'legacy' }); const { textInput, events } = await renderTextInputWithToolkit({ value: 'Hello!', @@ -101,7 +102,7 @@ describe('paste()', () => { ]); }); - it('supports defaultValue prop', async () => { + test('supports defaultValue prop', async () => { const { textInput, events } = await renderTextInputWithToolkit({ defaultValue: 'Hello Default!', }); @@ -122,7 +123,7 @@ describe('paste()', () => { expect(events).toMatchSnapshot('defaultValue: "Hello Default!"'); }); - it('does respect editable prop', async () => { + test('does respect editable prop', async () => { const { textInput } = await renderTextInputWithToolkit({ value: 'Hello!', editable: false, @@ -134,7 +135,7 @@ describe('paste()', () => { expect(textInput).toHaveDisplayValue('Hello!'); }); - it('does respect pointer-events prop', async () => { + test('does respect pointer-events prop', async () => { const { textInput } = await renderTextInputWithToolkit({ value: 'Hello!', pointerEvents: 'none', @@ -146,7 +147,7 @@ describe('paste()', () => { expect(textInput).toHaveDisplayValue('Hello!'); }); - it('supports multiline', async () => { + test('supports multiline', async () => { const { textInput, events } = await renderTextInputWithToolkit({ value: 'Hello World!\nHow are you?', multiline: true, @@ -169,7 +170,7 @@ describe('paste()', () => { expect(events).toMatchSnapshot('value: "Hello World!\nHow are you?" multiline: true,'); }); - it('works when not all events have handlers', async () => { + test('works when not all events have handlers', async () => { const { events, logEvent } = createEventLogger(); await render( { expect(events).toMatchSnapshot(); }); - it('does NOT work on View', async () => { + test('does NOT work on View', async () => { await render(); const user = userEvent.setup(); @@ -201,19 +202,19 @@ describe('paste()', () => { // View that ignores props type checking const AnyView = View as React.ComponentType; - it('does NOT bubble up', async () => { - const parentHandler = jest.fn(); + test('bubbles up only native bubbling events, in the modern event system', async () => { + const { events, logEvent } = createEventLogger(); await render( , @@ -221,10 +222,14 @@ describe('paste()', () => { const user = userEvent.setup(); await user.paste(screen.getByTestId('input'), 'Hi!'); - expect(parentHandler).not.toHaveBeenCalled(); + // `focus`, `keyPress`, `change`, `endEditing` and `blur` are bubbling events in React Native. + const modernEvents = ['focus', 'change', 'endEditing', 'blur']; + expect(getEventsNames(events)).toEqual( + getConfig().unstable_eventSystem === 'modern' ? modernEvents : [], + ); }); - it('sets native state value for unmanaged text inputs', async () => { + test('sets native state value for unmanaged text inputs', async () => { await render(); const user = userEvent.setup(); diff --git a/src/user-event/accessibility-action/accessibility-action.ts b/src/user-event/accessibility-action/accessibility-action.ts index 85f94329e..8f0b49697 100644 --- a/src/user-event/accessibility-action/accessibility-action.ts +++ b/src/user-event/accessibility-action/accessibility-action.ts @@ -1,7 +1,7 @@ import type { AccessibilityActionInfo } from 'react-native'; import type { TestInstance } from 'test-renderer'; -import { buildAccessibilityActionEvent } from '../../events'; +import { buildAccessibilityActionEvent } from '../../events/create-event'; import { computeAriaDisabled } from '../../helpers/accessibility'; import { isTestInstance } from '../../helpers/component-tree'; import { ErrorWithStack } from '../../helpers/errors'; diff --git a/src/user-event/clear.ts b/src/user-event/clear.ts index 407c58359..bca060417 100644 --- a/src/user-event/clear.ts +++ b/src/user-event/clear.ts @@ -5,8 +5,8 @@ import { buildEndEditingEvent, buildFocusEvent, buildTextSelectionChangeEvent, - isPointerEventEnabled, -} from '../events'; +} from '../events/create-event'; +import { isPointerEventEnabled } from '../events/legacy'; import { ErrorWithStack } from '../helpers/errors'; import { isHostTextInput } from '../helpers/host-component-names'; import { getTextInputValue, isEditableTextInput } from '../helpers/text-input'; diff --git a/src/user-event/paste.ts b/src/user-event/paste.ts index 49f63c022..61212b2d5 100644 --- a/src/user-event/paste.ts +++ b/src/user-event/paste.ts @@ -7,9 +7,8 @@ import { buildFocusEvent, buildTextChangeEvent, buildTextSelectionChangeEvent, - isPointerEventEnabled, - nativeState, -} from '../events'; +} from '../events/create-event'; +import { isPointerEventEnabled, nativeState } from '../events/legacy'; import { ErrorWithStack } from '../helpers/errors'; import { isHostTextInput } from '../helpers/host-component-names'; import { getTextInputValue, isEditableTextInput } from '../helpers/text-input'; @@ -49,7 +48,7 @@ export async function paste( const rangeAfter = { start: text.length, end: text.length }; await interaction.dispatchEvent('change', buildTextChangeEvent(text, rangeAfter)); - await interaction.dispatchEvent('changeText', text); + await interaction.invokeEventHandler('changeText', text); await interaction.dispatchEvent('selectionChange', buildTextSelectionChangeEvent(rangeAfter)); // According to the docs only multiline TextInput emits contentSizeChange event diff --git a/src/user-event/press/__tests__/__snapshots__/longPress.test.tsx.snap b/src/user-event/press/__tests__/__snapshots__/longPress.test.tsx.snap index a6b2a3190..6f0db84e2 100644 --- a/src/user-event/press/__tests__/__snapshots__/longPress.test.tsx.snap +++ b/src/user-event/press/__tests__/__snapshots__/longPress.test.tsx.snap @@ -5,15 +5,6 @@ exports[`userEvent.longPress with fake timers calls onLongPress if the delayLong { "name": "longPress", "payload": { - "currentTarget": { - "measure": [Function], - }, - "dispatchConfig": { - "registrationName": "onResponderGrant", - }, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "changedTouches": [], "identifier": 0, @@ -25,11 +16,6 @@ exports[`userEvent.longPress with fake timers calls onLongPress if the delayLong "timestamp": 0, "touches": [], }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, ] @@ -40,15 +26,6 @@ exports[`userEvent.longPress with fake timers works on Pressable 1`] = ` { "name": "pressIn", "payload": { - "currentTarget": { - "measure": [Function], - }, - "dispatchConfig": { - "registrationName": "onResponderGrant", - }, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "changedTouches": [], "identifier": 0, @@ -60,25 +37,11 @@ exports[`userEvent.longPress with fake timers works on Pressable 1`] = ` "timestamp": 0, "touches": [], }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "longPress", "payload": { - "currentTarget": { - "measure": [Function], - }, - "dispatchConfig": { - "registrationName": "onResponderGrant", - }, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "changedTouches": [], "identifier": 0, @@ -90,25 +53,11 @@ exports[`userEvent.longPress with fake timers works on Pressable 1`] = ` "timestamp": 0, "touches": [], }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "pressOut", "payload": { - "currentTarget": { - "measure": [Function], - }, - "dispatchConfig": { - "registrationName": "onResponderRelease", - }, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "changedTouches": [], "identifier": 0, @@ -120,11 +69,6 @@ exports[`userEvent.longPress with fake timers works on Pressable 1`] = ` "timestamp": 500, "touches": [], }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, ] diff --git a/src/user-event/press/__tests__/__snapshots__/press.test.tsx.snap b/src/user-event/press/__tests__/__snapshots__/press.test.tsx.snap index 7f3d902ef..06f4dbefc 100644 --- a/src/user-event/press/__tests__/__snapshots__/press.test.tsx.snap +++ b/src/user-event/press/__tests__/__snapshots__/press.test.tsx.snap @@ -5,15 +5,6 @@ exports[`userEvent.press with fake timers works on Pressable 1`] = ` { "name": "pressIn", "payload": { - "currentTarget": { - "measure": [Function], - }, - "dispatchConfig": { - "registrationName": "onResponderGrant", - }, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "changedTouches": [], "identifier": 0, @@ -25,25 +16,11 @@ exports[`userEvent.press with fake timers works on Pressable 1`] = ` "timestamp": 0, "touches": [], }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "pressOut", "payload": { - "currentTarget": { - "measure": [Function], - }, - "dispatchConfig": { - "registrationName": "onResponderRelease", - }, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "changedTouches": [], "identifier": 0, @@ -55,25 +32,11 @@ exports[`userEvent.press with fake timers works on Pressable 1`] = ` "timestamp": 130, "touches": [], }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "press", "payload": { - "currentTarget": { - "measure": [Function], - }, - "dispatchConfig": { - "registrationName": "onResponderRelease", - }, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "changedTouches": [], "identifier": 0, @@ -85,11 +48,6 @@ exports[`userEvent.press with fake timers works on Pressable 1`] = ` "timestamp": 130, "touches": [], }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, ] diff --git a/src/user-event/press/press.ts b/src/user-event/press/press.ts index 8d9745935..5d9aa602d 100644 --- a/src/user-event/press/press.ts +++ b/src/user-event/press/press.ts @@ -1,13 +1,13 @@ import type { TestInstance } from 'test-renderer'; import { act } from '../../act'; +import { buildPressEvent } from '../../events/create-event'; import { buildResponderGrantEvent, buildResponderReleaseEvent, - buildTouchEvent, getEventHandlerFromProps, isPointerEventEnabled, -} from '../../events'; +} from '../../events/legacy'; import { isTestInstance } from '../../helpers/component-tree'; import { ErrorWithStack } from '../../helpers/errors'; import { isHostText, isHostTextInput } from '../../helpers/host-component-names'; @@ -125,23 +125,23 @@ async function emitDirectPressEvents( options: BasePressOptions, ) { await wait(config); - await interaction.dispatchEvent('pressIn', buildTouchEvent()); + await interaction.dispatchTouchEvent('pressIn'); await wait(config, options.duration); // Long press events are emitted before `pressOut`. if (options.type === 'longPress') { - await interaction.dispatchEvent('longPress', buildTouchEvent()); + await interaction.dispatchTouchEvent('longPress'); } - await interaction.dispatchEvent('pressOut', buildTouchEvent()); + await interaction.dispatchTouchEvent('pressOut'); // Regular press events are emitted after `pressOut` according to the React Native docs. // See: https://reactnative.dev/docs/pressable#onpress // Experimentally for very short presses (< 130ms) `press` events are actually emitted before `onPressOut`, but // we will ignore that as in reality most pressed would be above the 130ms threshold. if (options.type === 'press') { - await interaction.dispatchEvent('press', buildTouchEvent()); + await interaction.dispatchEvent('press', buildPressEvent()); } } @@ -152,12 +152,12 @@ async function emitPressabilityPressEvents( ) { await wait(config); - await interaction.dispatchEvent('responderGrant', buildResponderGrantEvent()); + await interaction.invokeEventHandler('responderGrant', buildResponderGrantEvent()); const duration = options.duration ?? DEFAULT_MIN_PRESS_DURATION; await wait(config, duration); - await interaction.dispatchEvent('responderRelease', buildResponderReleaseEvent()); + await interaction.invokeEventHandler('responderRelease', buildResponderReleaseEvent()); // React Native will wait for minimal delay of DEFAULT_MIN_PRESS_DURATION // before emitting the `pressOut` event. We need to wait here, so that diff --git a/src/user-event/scroll/__tests__/__snapshots__/scroll-to-flat-list.test.tsx.snap b/src/user-event/scroll/__tests__/__snapshots__/scroll-to-flat-list.test.tsx.snap index ba7a21e4d..a0506014a 100644 --- a/src/user-event/scroll/__tests__/__snapshots__/scroll-to-flat-list.test.tsx.snap +++ b/src/user-event/scroll/__tests__/__snapshots__/scroll-to-flat-list.test.tsx.snap @@ -5,10 +5,6 @@ exports[`scrollTo() with FlatList supports vertical drag scroll: scrollTo({ y: 1 { "name": "scrollBeginDrag", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "contentInset": { "bottom": 0, @@ -35,20 +31,11 @@ exports[`scrollTo() with FlatList supports vertical drag scroll: scrollTo({ y: 1 "y": 0, }, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "scroll", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "contentInset": { "bottom": 0, @@ -75,20 +62,11 @@ exports[`scrollTo() with FlatList supports vertical drag scroll: scrollTo({ y: 1 "y": 0, }, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "scroll", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "contentInset": { "bottom": 0, @@ -115,20 +93,11 @@ exports[`scrollTo() with FlatList supports vertical drag scroll: scrollTo({ y: 1 "y": 0, }, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "scroll", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "contentInset": { "bottom": 0, @@ -155,20 +124,11 @@ exports[`scrollTo() with FlatList supports vertical drag scroll: scrollTo({ y: 1 "y": 0, }, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "scroll", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "contentInset": { "bottom": 0, @@ -195,20 +155,11 @@ exports[`scrollTo() with FlatList supports vertical drag scroll: scrollTo({ y: 1 "y": 0, }, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "scrollEndDrag", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "contentInset": { "bottom": 0, @@ -235,11 +186,6 @@ exports[`scrollTo() with FlatList supports vertical drag scroll: scrollTo({ y: 1 "y": 0, }, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, ] diff --git a/src/user-event/scroll/__tests__/__snapshots__/scroll-to.test.tsx.snap b/src/user-event/scroll/__tests__/__snapshots__/scroll-to.test.tsx.snap index b12ed6bae..cbf7df20f 100644 --- a/src/user-event/scroll/__tests__/__snapshots__/scroll-to.test.tsx.snap +++ b/src/user-event/scroll/__tests__/__snapshots__/scroll-to.test.tsx.snap @@ -5,10 +5,6 @@ exports[`scrollTo() supports vertical drag scroll: scrollTo({ y: 100 }) 1`] = ` { "name": "scrollBeginDrag", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "contentInset": { "bottom": 0, @@ -35,20 +31,11 @@ exports[`scrollTo() supports vertical drag scroll: scrollTo({ y: 100 }) 1`] = ` "y": 0, }, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "scroll", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "contentInset": { "bottom": 0, @@ -75,20 +62,11 @@ exports[`scrollTo() supports vertical drag scroll: scrollTo({ y: 100 }) 1`] = ` "y": 0, }, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "scroll", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "contentInset": { "bottom": 0, @@ -115,20 +93,11 @@ exports[`scrollTo() supports vertical drag scroll: scrollTo({ y: 100 }) 1`] = ` "y": 0, }, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "scroll", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "contentInset": { "bottom": 0, @@ -155,20 +124,11 @@ exports[`scrollTo() supports vertical drag scroll: scrollTo({ y: 100 }) 1`] = ` "y": 0, }, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "scroll", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "contentInset": { "bottom": 0, @@ -195,20 +155,11 @@ exports[`scrollTo() supports vertical drag scroll: scrollTo({ y: 100 }) 1`] = ` "y": 0, }, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "scrollEndDrag", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "contentInset": { "bottom": 0, @@ -235,11 +186,6 @@ exports[`scrollTo() supports vertical drag scroll: scrollTo({ y: 100 }) 1`] = ` "y": 0, }, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, ] diff --git a/src/user-event/scroll/scroll-to.ts b/src/user-event/scroll/scroll-to.ts index 6d581d6ef..1e03d058d 100644 --- a/src/user-event/scroll/scroll-to.ts +++ b/src/user-event/scroll/scroll-to.ts @@ -1,7 +1,8 @@ import { stringify } from 'jest-matcher-utils'; import type { TestInstance } from 'test-renderer'; -import { buildScrollEvent, nativeState } from '../../events'; +import { buildScrollEvent } from '../../events/create-event'; +import { nativeState } from '../../events/legacy'; import { ErrorWithStack } from '../../helpers/errors'; import { isHostScrollView } from '../../helpers/host-component-names'; import { pick } from '../../helpers/object'; @@ -55,7 +56,9 @@ export async function scrollTo( layoutMeasurement: options.layoutMeasurement ?? nativeState.layoutSizeForInstance.get(instance), }; - await interaction.dispatchEvent( + // `ScrollView` calls `onContentSizeChange(width, height)` from JavaScript, with two arguments + // instead of an event object, so `dispatchEvent` can't call it. + await interaction.invokeEventHandler( 'contentSizeChange', options.contentSize?.width ?? 0, options.contentSize?.height ?? 0, @@ -100,18 +103,24 @@ async function emitDragScrollEvents( await wait(config); await interaction.dispatchEvent( 'scrollBeginDrag', - buildScrollEvent(scrollSteps[0], scrollOptions), + buildScrollEvent('scrollBeginDrag', scrollSteps[0], scrollOptions), ); // Native emits `scroll` for every drag step, including the last; `scrollEndDrag` repeats its offset. for (let i = 1; i < scrollSteps.length; i += 1) { await wait(config); - await interaction.dispatchEvent('scroll', buildScrollEvent(scrollSteps[i], scrollOptions)); + await interaction.dispatchEvent( + 'scroll', + buildScrollEvent('scroll', scrollSteps[i], scrollOptions), + ); } await wait(config); const lastStep = scrollSteps.at(-1); - await interaction.dispatchEvent('scrollEndDrag', buildScrollEvent(lastStep, scrollOptions)); + await interaction.dispatchEvent( + 'scrollEndDrag', + buildScrollEvent('scrollEndDrag', lastStep, scrollOptions), + ); } async function emitMomentumScrollEvents( @@ -127,18 +136,24 @@ async function emitMomentumScrollEvents( await wait(config); await interaction.dispatchEvent( 'momentumScrollBegin', - buildScrollEvent(scrollSteps[0], scrollOptions), + buildScrollEvent('momentumScrollBegin', scrollSteps[0], scrollOptions), ); // Native emits `scroll` for every momentum step, including the last; `momentumScrollEnd` repeats its offset. for (let i = 1; i < scrollSteps.length; i += 1) { await wait(config); - await interaction.dispatchEvent('scroll', buildScrollEvent(scrollSteps[i], scrollOptions)); + await interaction.dispatchEvent( + 'scroll', + buildScrollEvent('scroll', scrollSteps[i], scrollOptions), + ); } await wait(config); const lastStep = scrollSteps.at(-1); - await interaction.dispatchEvent('momentumScrollEnd', buildScrollEvent(lastStep, scrollOptions)); + await interaction.dispatchEvent( + 'momentumScrollEnd', + buildScrollEvent('momentumScrollEnd', lastStep, scrollOptions), + ); } function ensureScrollViewDirection(instance: TestInstance, options: ScrollToOptions) { diff --git a/src/user-event/type/__tests__/__snapshots__/type-managed.test.tsx.snap b/src/user-event/type/__tests__/__snapshots__/type-managed.test.tsx.snap index 0e39d81ef..f42de1c40 100644 --- a/src/user-event/type/__tests__/__snapshots__/type-managed.test.tsx.snap +++ b/src/user-event/type/__tests__/__snapshots__/type-managed.test.tsx.snap @@ -5,12 +5,6 @@ exports[`type() for managed TextInput supports basic case: input: "Wow" 1`] = ` { "name": "pressIn", "payload": { - "currentTarget": { - "measure": [Function], - }, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "changedTouches": [], "identifier": 0, @@ -22,39 +16,19 @@ exports[`type() for managed TextInput supports basic case: input: "Wow" 1`] = ` "timestamp": 100100100100, "touches": [], }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "focus", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "target": 0, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "pressOut", "payload": { - "currentTarget": { - "measure": [Function], - }, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "changedTouches": [], "identifier": 0, @@ -66,37 +40,19 @@ exports[`type() for managed TextInput supports basic case: input: "Wow" 1`] = ` "timestamp": 100100100100, "touches": [], }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "keyPress", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "key": "W", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "change", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "eventCount": 0, "selection": { @@ -106,11 +62,6 @@ exports[`type() for managed TextInput supports basic case: input: "Wow" 1`] = ` "target": 0, "text": "W", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { @@ -120,47 +71,25 @@ exports[`type() for managed TextInput supports basic case: input: "Wow" 1`] = ` { "name": "selectionChange", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "selection": { "end": 1, "start": 1, }, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "keyPress", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "key": "o", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "change", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "eventCount": 0, "selection": { @@ -170,11 +99,6 @@ exports[`type() for managed TextInput supports basic case: input: "Wow" 1`] = ` "target": 0, "text": "Wo", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { @@ -184,47 +108,25 @@ exports[`type() for managed TextInput supports basic case: input: "Wow" 1`] = ` { "name": "selectionChange", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "selection": { "end": 2, "start": 2, }, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "keyPress", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "key": "w", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "change", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "eventCount": 0, "selection": { @@ -234,11 +136,6 @@ exports[`type() for managed TextInput supports basic case: input: "Wow" 1`] = ` "target": 0, "text": "Wow", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { @@ -248,56 +145,29 @@ exports[`type() for managed TextInput supports basic case: input: "Wow" 1`] = ` { "name": "selectionChange", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "selection": { "end": 3, "start": 3, }, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "endEditing", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "target": 0, "text": "Wow", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "blur", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "target": 0, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, ] @@ -308,12 +178,6 @@ exports[`type() for managed TextInput supports rejecting TextInput: input: "ABC" { "name": "pressIn", "payload": { - "currentTarget": { - "measure": [Function], - }, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "changedTouches": [], "identifier": 0, @@ -325,39 +189,19 @@ exports[`type() for managed TextInput supports rejecting TextInput: input: "ABC" "timestamp": 100100100100, "touches": [], }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "focus", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "target": 0, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "pressOut", "payload": { - "currentTarget": { - "measure": [Function], - }, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "changedTouches": [], "identifier": 0, @@ -369,37 +213,19 @@ exports[`type() for managed TextInput supports rejecting TextInput: input: "ABC" "timestamp": 100100100100, "touches": [], }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "keyPress", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "key": "A", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "change", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "eventCount": 0, "selection": { @@ -409,11 +235,6 @@ exports[`type() for managed TextInput supports rejecting TextInput: input: "ABC" "target": 0, "text": "XXXA", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { @@ -423,47 +244,25 @@ exports[`type() for managed TextInput supports rejecting TextInput: input: "ABC" { "name": "selectionChange", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "selection": { "end": 4, "start": 4, }, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "keyPress", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "key": "B", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "change", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "eventCount": 0, "selection": { @@ -473,11 +272,6 @@ exports[`type() for managed TextInput supports rejecting TextInput: input: "ABC" "target": 0, "text": "XXXB", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { @@ -487,47 +281,25 @@ exports[`type() for managed TextInput supports rejecting TextInput: input: "ABC" { "name": "selectionChange", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "selection": { "end": 4, "start": 4, }, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "keyPress", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "key": "C", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "change", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "eventCount": 0, "selection": { @@ -537,11 +309,6 @@ exports[`type() for managed TextInput supports rejecting TextInput: input: "ABC" "target": 0, "text": "XXXC", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { @@ -551,56 +318,29 @@ exports[`type() for managed TextInput supports rejecting TextInput: input: "ABC" { "name": "selectionChange", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "selection": { "end": 4, "start": 4, }, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "endEditing", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "target": 0, "text": "XXX", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "blur", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "target": 0, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, ] diff --git a/src/user-event/type/__tests__/__snapshots__/type.test.tsx.snap b/src/user-event/type/__tests__/__snapshots__/type.test.tsx.snap index aee07c7ee..3d7c370d6 100644 --- a/src/user-event/type/__tests__/__snapshots__/type.test.tsx.snap +++ b/src/user-event/type/__tests__/__snapshots__/type.test.tsx.snap @@ -5,12 +5,6 @@ exports[`type() supports backspace: input: "{Backspace}a", defaultValue: "xxx" 1 { "name": "pressIn", "payload": { - "currentTarget": { - "measure": [Function], - }, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "changedTouches": [], "identifier": 0, @@ -22,39 +16,19 @@ exports[`type() supports backspace: input: "{Backspace}a", defaultValue: "xxx" 1 "timestamp": 100100100100, "touches": [], }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "focus", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "target": 0, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "pressOut", "payload": { - "currentTarget": { - "measure": [Function], - }, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "changedTouches": [], "identifier": 0, @@ -66,37 +40,19 @@ exports[`type() supports backspace: input: "{Backspace}a", defaultValue: "xxx" 1 "timestamp": 100100100100, "touches": [], }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "keyPress", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "key": "Backspace", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "change", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "eventCount": 0, "selection": { @@ -106,11 +62,6 @@ exports[`type() supports backspace: input: "{Backspace}a", defaultValue: "xxx" 1 "target": 0, "text": "xx", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { @@ -120,47 +71,25 @@ exports[`type() supports backspace: input: "{Backspace}a", defaultValue: "xxx" 1 { "name": "selectionChange", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "selection": { "end": 2, "start": 2, }, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "keyPress", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "key": "a", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "change", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "eventCount": 0, "selection": { @@ -170,11 +99,6 @@ exports[`type() supports backspace: input: "{Backspace}a", defaultValue: "xxx" 1 "target": 0, "text": "xxa", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { @@ -184,56 +108,29 @@ exports[`type() supports backspace: input: "{Backspace}a", defaultValue: "xxx" 1 { "name": "selectionChange", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "selection": { "end": 3, "start": 3, }, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "endEditing", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "target": 0, "text": "xxa", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "blur", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "target": 0, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, ] @@ -244,12 +141,6 @@ exports[`type() supports basic case: input: "abc" 1`] = ` { "name": "pressIn", "payload": { - "currentTarget": { - "measure": [Function], - }, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "changedTouches": [], "identifier": 0, @@ -261,39 +152,19 @@ exports[`type() supports basic case: input: "abc" 1`] = ` "timestamp": 100100100100, "touches": [], }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "focus", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "target": 0, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "pressOut", "payload": { - "currentTarget": { - "measure": [Function], - }, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "changedTouches": [], "identifier": 0, @@ -305,37 +176,19 @@ exports[`type() supports basic case: input: "abc" 1`] = ` "timestamp": 100100100100, "touches": [], }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "keyPress", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "key": "a", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "change", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "eventCount": 0, "selection": { @@ -345,11 +198,6 @@ exports[`type() supports basic case: input: "abc" 1`] = ` "target": 0, "text": "a", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { @@ -359,47 +207,25 @@ exports[`type() supports basic case: input: "abc" 1`] = ` { "name": "selectionChange", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "selection": { "end": 1, "start": 1, }, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "keyPress", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "key": "b", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "change", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "eventCount": 0, "selection": { @@ -409,11 +235,6 @@ exports[`type() supports basic case: input: "abc" 1`] = ` "target": 0, "text": "ab", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { @@ -423,47 +244,25 @@ exports[`type() supports basic case: input: "abc" 1`] = ` { "name": "selectionChange", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "selection": { "end": 2, "start": 2, }, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "keyPress", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "key": "c", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "change", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "eventCount": 0, "selection": { @@ -473,11 +272,6 @@ exports[`type() supports basic case: input: "abc" 1`] = ` "target": 0, "text": "abc", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { @@ -487,56 +281,29 @@ exports[`type() supports basic case: input: "abc" 1`] = ` { "name": "selectionChange", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "selection": { "end": 3, "start": 3, }, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "endEditing", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "target": 0, "text": "abc", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "blur", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "target": 0, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, ] @@ -547,12 +314,6 @@ exports[`type() supports defaultValue prop: input: "ab", defaultValue: "xxx" 1`] { "name": "pressIn", "payload": { - "currentTarget": { - "measure": [Function], - }, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "changedTouches": [], "identifier": 0, @@ -564,39 +325,19 @@ exports[`type() supports defaultValue prop: input: "ab", defaultValue: "xxx" 1`] "timestamp": 100100100100, "touches": [], }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "focus", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "target": 0, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "pressOut", "payload": { - "currentTarget": { - "measure": [Function], - }, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "changedTouches": [], "identifier": 0, @@ -608,37 +349,19 @@ exports[`type() supports defaultValue prop: input: "ab", defaultValue: "xxx" 1`] "timestamp": 100100100100, "touches": [], }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "keyPress", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "key": "a", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "change", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "eventCount": 0, "selection": { @@ -648,11 +371,6 @@ exports[`type() supports defaultValue prop: input: "ab", defaultValue: "xxx" 1`] "target": 0, "text": "xxxa", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { @@ -662,47 +380,25 @@ exports[`type() supports defaultValue prop: input: "ab", defaultValue: "xxx" 1`] { "name": "selectionChange", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "selection": { "end": 4, "start": 4, }, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "keyPress", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "key": "b", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "change", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "eventCount": 0, "selection": { @@ -712,11 +408,6 @@ exports[`type() supports defaultValue prop: input: "ab", defaultValue: "xxx" 1`] "target": 0, "text": "xxxab", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { @@ -726,56 +417,29 @@ exports[`type() supports defaultValue prop: input: "ab", defaultValue: "xxx" 1`] { "name": "selectionChange", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "selection": { "end": 5, "start": 5, }, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "endEditing", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "target": 0, "text": "xxxab", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "blur", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "target": 0, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, ] @@ -786,12 +450,6 @@ exports[`type() supports multiline: input: "{Enter}\\n", multiline: true 1`] = ` { "name": "pressIn", "payload": { - "currentTarget": { - "measure": [Function], - }, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "changedTouches": [], "identifier": 0, @@ -803,39 +461,19 @@ exports[`type() supports multiline: input: "{Enter}\\n", multiline: true 1`] = ` "timestamp": 100100100100, "touches": [], }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "focus", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "target": 0, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "pressOut", "payload": { - "currentTarget": { - "measure": [Function], - }, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "changedTouches": [], "identifier": 0, @@ -847,37 +485,19 @@ exports[`type() supports multiline: input: "{Enter}\\n", multiline: true 1`] = ` "timestamp": 100100100100, "touches": [], }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "keyPress", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "key": "Enter", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "change", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "eventCount": 0, "selection": { @@ -888,11 +508,6 @@ exports[`type() supports multiline: input: "{Enter}\\n", multiline: true 1`] = ` "text": " ", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { @@ -903,30 +518,17 @@ exports[`type() supports multiline: input: "{Enter}\\n", multiline: true 1`] = ` { "name": "selectionChange", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "selection": { "end": 1, "start": 1, }, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "contentSizeChange", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "contentSize": { "height": 32, @@ -934,37 +536,19 @@ exports[`type() supports multiline: input: "{Enter}\\n", multiline: true 1`] = ` }, "target": 0, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "keyPress", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "key": "Enter", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "change", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "eventCount": 0, "selection": { @@ -976,11 +560,6 @@ exports[`type() supports multiline: input: "{Enter}\\n", multiline: true 1`] = ` ", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { @@ -992,30 +571,17 @@ exports[`type() supports multiline: input: "{Enter}\\n", multiline: true 1`] = ` { "name": "selectionChange", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "selection": { "end": 2, "start": 2, }, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "contentSizeChange", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "contentSize": { "height": 48, @@ -1023,48 +589,25 @@ exports[`type() supports multiline: input: "{Enter}\\n", multiline: true 1`] = ` }, "target": 0, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "endEditing", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "target": 0, "text": " ", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, { "name": "blur", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "target": 0, }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, ] @@ -1087,19 +630,10 @@ exports[`type() works when not all events have handlers: input: "abc" 1`] = ` { "name": "endEditing", "payload": { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], "nativeEvent": { "target": 0, "text": "abc", }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, }, }, ] diff --git a/src/user-event/type/__tests__/type.test.tsx b/src/user-event/type/__tests__/type.test.tsx index 23f0405df..88f09a4ba 100644 --- a/src/user-event/type/__tests__/type.test.tsx +++ b/src/user-event/type/__tests__/type.test.tsx @@ -4,6 +4,7 @@ import { TextInput, View } from 'react-native'; import { userEvent } from '../..'; import { render, screen } from '../../..'; +import { getConfig } from '../../../config'; import { createEventLogger, getEventsNames, lastEventPayload } from '../../../test-utils/events'; beforeEach(() => { @@ -37,7 +38,7 @@ async function renderTextInputWithToolkit(props: TextInputProps = {}) { } describe('type()', () => { - it('supports basic case', async () => { + test('supports basic case', async () => { jest.spyOn(Date, 'now').mockImplementation(() => 100100100100); const { events } = await renderTextInputWithToolkit(); @@ -67,7 +68,7 @@ describe('type()', () => { expect(events).toMatchSnapshot('input: "abc"'); }); - it('includes caret selection in the change event nativeEvent', async () => { + test('includes caret selection in the change event nativeEvent', async () => { const { events } = await renderTextInputWithToolkit(); const user = userEvent.setup(); @@ -84,7 +85,7 @@ describe('type()', () => { }); }); - it('drives components that read the caret from change event (regression)', async () => { + test('drives components that read the caret from change event (regression)', async () => { const onChange = jest.fn(); function MaskedInput() { @@ -117,7 +118,7 @@ describe('type()', () => { expect(screen.getByTestId('masked-input').props.value).toBe('abc'); }); - it.each(['modern', 'legacy'])('works with %s fake timers', async (type) => { + test.each(['modern', 'legacy'])('works with %s fake timers', async (type) => { jest.useFakeTimers({ legacyFakeTimers: type === 'legacy' }); const { events } = await renderTextInputWithToolkit(); @@ -145,7 +146,7 @@ describe('type()', () => { ]); }); - it('supports defaultValue prop', async () => { + test('supports defaultValue prop', async () => { const { events } = await renderTextInputWithToolkit({ defaultValue: 'xxx', }); @@ -172,7 +173,7 @@ describe('type()', () => { expect(events).toMatchSnapshot('input: "ab", defaultValue: "xxx"'); }); - it('does respect editable prop', async () => { + test('does respect editable prop', async () => { const { events } = await renderTextInputWithToolkit({ editable: false, }); @@ -183,7 +184,7 @@ describe('type()', () => { expect(getEventsNames(events)).toEqual([]); }); - it('supports backspace', async () => { + test('supports backspace', async () => { const { events } = await renderTextInputWithToolkit({ defaultValue: 'xxx', }); @@ -210,7 +211,7 @@ describe('type()', () => { expect(events).toMatchSnapshot('input: "{Backspace}a", defaultValue: "xxx"'); }); - it('supports multiline', async () => { + test('supports multiline', async () => { const { events } = await renderTextInputWithToolkit({ multiline: true, }); @@ -265,7 +266,7 @@ describe('type()', () => { }); }); - it('triggers submit event with `submitEditing: true`', async () => { + test('triggers submit event with `submitEditing: true`', async () => { const { events } = await renderTextInputWithToolkit(); const user = userEvent.setup(); @@ -293,7 +294,7 @@ describe('type()', () => { }); }); - it('works when not all events have handlers', async () => { + test('works when not all events have handlers', async () => { const { events, logEvent } = createEventLogger(); await render( { expect(events).toMatchSnapshot('input: "abc"'); }); - it('does NOT work on View', async () => { + test('does NOT work on View', async () => { await render(); const user = userEvent.setup(); @@ -330,19 +331,19 @@ describe('type()', () => { // View that ignores props type checking const AnyView = View as React.ComponentType; - it('does NOT bubble up', async () => { - const parentHandler = jest.fn(); + test('bubbles up only native bubbling events, in the modern event system', async () => { + const { events, logEvent } = createEventLogger(); await render( , @@ -350,10 +351,19 @@ describe('type()', () => { const user = userEvent.setup(); await user.type(screen.getByTestId('input'), 'abc'); - expect(parentHandler).not.toHaveBeenCalled(); + // `focus`, `keyPress`, `change`, `endEditing` and `blur` are bubbling events in React Native. + const modernEvents = [ + 'focus', + ...['keyPress', 'change', 'keyPress', 'change', 'keyPress', 'change'], + 'endEditing', + 'blur', + ]; + expect(getEventsNames(events)).toEqual( + getConfig().unstable_eventSystem === 'modern' ? modernEvents : [], + ); }); - it('supports direct access', async () => { + test('supports direct access', async () => { const { events, logEvent } = createEventLogger(); await render( { }); // See: https://github.com/callstack/react-native-testing-library/issues/1588 - it('can call "persist()" on "onKeyPress" event handler', async () => { + test('can call "persist()" on "onKeyPress" event handler', async () => { const handleKeyPress = jest.fn(); await render( { expect(handleKeyPress).toHaveBeenCalledTimes(3); }); - it('respects the "maxLength" prop', async () => { + test('respects the "maxLength" prop', async () => { const { events } = await renderTextInputWithToolkit({ maxLength: 2 }); const user = userEvent.setup(); @@ -425,7 +435,7 @@ describe('type()', () => { }); }); - it('unmanaged text inputs preserve their native state', async () => { + test('unmanaged text inputs preserve their native state', async () => { await render(); const user = userEvent.setup(); @@ -439,7 +449,7 @@ describe('type()', () => { expect(input).toHaveDisplayValue('Hello World'); }); - it('skips blur and endEditing events when `skipBlur: true`', async () => { + test('skips blur and endEditing events when `skipBlur: true`', async () => { const { events } = await renderTextInputWithToolkit(); const user = userEvent.setup(); diff --git a/src/user-event/type/type.ts b/src/user-event/type/type.ts index cd78d2be3..62657f12e 100644 --- a/src/user-event/type/type.ts +++ b/src/user-event/type/type.ts @@ -9,10 +9,8 @@ import { buildSubmitEditingEvent, buildTextChangeEvent, buildTextSelectionChangeEvent, - buildTouchEvent, - isPointerEventEnabled, - nativeState, -} from '../../events'; +} from '../../events/create-event'; +import { isPointerEventEnabled, nativeState } from '../../events/legacy'; import { ErrorWithStack } from '../../helpers/errors'; import { isHostTextInput } from '../../helpers/host-component-names'; import { getTextInputValue, isEditableTextInput } from '../../helpers/text-input'; @@ -49,14 +47,14 @@ export async function type( const keys = parseKeys(text); if (!options?.skipPress) { - await interaction.dispatchEvent('pressIn', buildTouchEvent()); + await interaction.dispatchTouchEvent('pressIn'); } await interaction.dispatchEvent('focus', buildFocusEvent()); if (!options?.skipPress) { await wait(this.config); - await interaction.dispatchEvent('pressOut', buildTouchEvent()); + await interaction.dispatchTouchEvent('pressOut'); } for (const key of keys) { @@ -122,7 +120,7 @@ export async function emitTypingEvents( }; await interaction.dispatchEvent('change', buildTextChangeEvent(text, selectionRange)); - await interaction.dispatchEvent('changeText', text); + await interaction.invokeEventHandler('changeText', text); await interaction.dispatchEvent('selectionChange', buildTextSelectionChangeEvent(selectionRange)); // According to the docs only multiline TextInput emits contentSizeChange event diff --git a/src/user-event/utils/interaction.ts b/src/user-event/utils/interaction.ts index 7ea2f7b3f..6a5c539a0 100644 --- a/src/user-event/utils/interaction.ts +++ b/src/user-event/utils/interaction.ts @@ -1,6 +1,10 @@ import type { TestInstance } from 'test-renderer'; -import { dispatchEvent } from '../../events'; +import type { LegacyEvent } from '../../events/create-event'; +import { dispatchEvent } from '../../events/dispatch-event'; +import { buildTouchEvent } from '../../events/legacy'; +import type { SyntheticEvent } from '../../events/modern/event'; +import { invokeEventHandler } from '../../events/shared/invoke-event-handler'; /** * Tracks what a single `userEvent` interaction did, so `warnAboutUnhandledInteraction` @@ -23,8 +27,27 @@ export class Interaction { public target: TestInstance, ) {} - async dispatchEvent(eventName: string, ...event: unknown[]) { - const hasCalledHandler = await dispatchEvent(this.target, eventName, ...event); + /** Dispatches a native event from `createEvent()`, e.g. `buildFocusEvent()`. */ + async dispatchEvent(eventName: string, event: LegacyEvent | SyntheticEvent) { + const hasCalledHandler = await dispatchEvent(this.target, eventName, event); + this.recordEvent(eventName, hasCalledHandler); + } + + /** + * Calls a touch callback that `Pressable` calls from JavaScript (`pressIn`, `pressOut`, + * `longPress`) with a touch event, the way Pressability does. + */ + async dispatchTouchEvent(eventName: string) { + await this.invokeEventHandler(eventName, buildTouchEvent()); + } + + /** + * Calls the target's own handler with the given params, for callbacks that components call + * from JavaScript instead of dispatching events, e.g. `onChangeText(text)` or `ScrollView`'s + * `onContentSizeChange(width, height)`. + */ + async invokeEventHandler(eventName: string, ...params: unknown[]) { + const hasCalledHandler = await invokeEventHandler(this.target, eventName, ...params); this.recordEvent(eventName, hasCalledHandler); } diff --git a/src/user-event/utils/warnings.ts b/src/user-event/utils/warnings.ts index 1625c92cf..b5eecada8 100644 --- a/src/user-event/utils/warnings.ts +++ b/src/user-event/utils/warnings.ts @@ -1,10 +1,10 @@ import { getConfig } from '../../config'; -import type { EventWarning } from '../../events'; +import type { EventWarning } from '../../events/legacy'; import { formatDisabledTargets, getPointerEventsBlockedTargets, logEventWarning, -} from '../../events'; +} from '../../events/legacy'; import { computeAriaDisabled } from '../../helpers/accessibility'; import type { Interaction } from './interaction'; From 899bc4b0241b7cbdfaddac64447962b91958786e Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Mon, 12 Oct 2026 22:14:05 +0200 Subject: [PATCH 2/7] feat: modern fire event press (#1946) --- contributing/event-dispatch.md | 16 +- contributing/native-events.md | 2 +- contributing/testing.md | 4 +- src/events/__tests__/create-event.test.ts | 2 - src/events/__tests__/dispatch-event.test.tsx | 6 +- src/events/__tests__/fire-event.test.tsx | 3217 ++++++++++++++++- src/events/create-event.ts | 5 - src/events/fire-event.ts | 22 +- .../legacy/__tests__/fire-event.test.tsx | 1818 ---------- src/events/legacy/fire-event.ts | 1 - src/events/legacy/index.ts | 4 +- src/events/legacy/is-enabled.ts | 39 +- src/events/legacy/propagation.ts | 8 +- src/events/legacy/warnings.ts | 73 +- .../modern/__tests__/fire-event.test.tsx | 806 ----- .../modern/__tests__/pressability.test.tsx | 134 + src/events/modern/dispatch.ts | 13 +- src/events/modern/event-types.ts | 15 +- src/events/modern/event.ts | 31 +- src/events/modern/fire-event-utils.ts | 15 +- src/events/modern/fire-event.ts | 84 +- src/events/modern/pressability.ts | 180 + src/events/shared/__tests__/handler.test.tsx | 56 +- src/events/shared/handler.ts | 28 +- src/events/shared/invoke-event-handler.ts | 4 +- src/events/shared/payloads.ts | 2 - src/events/shared/pointer-events.ts | 39 + src/events/shared/warnings.ts | 65 + src/helpers/text.ts | 17 + src/test-utils/event-system.ts | 7 +- src/user-event/__tests__/clear.test.tsx | 8 +- .../__tests__/event-diagnostics.test.tsx | 61 +- src/user-event/__tests__/paste.test.tsx | 8 +- src/user-event/legacy/press.ts | 63 + src/user-event/modern/press.ts | 40 + .../__tests__/press.real-timers.test.tsx | 6 +- src/user-event/press/__tests__/press.test.tsx | 151 +- src/user-event/press/press.ts | 69 +- src/user-event/type/__tests__/type.test.tsx | 8 +- src/user-event/utils/interaction.ts | 2 +- src/user-event/utils/warnings.ts | 4 +- 41 files changed, 4144 insertions(+), 2989 deletions(-) delete mode 100644 src/events/legacy/__tests__/fire-event.test.tsx delete mode 100644 src/events/modern/__tests__/fire-event.test.tsx create mode 100644 src/events/modern/__tests__/pressability.test.tsx create mode 100644 src/events/modern/pressability.ts create mode 100644 src/events/shared/pointer-events.ts create mode 100644 src/events/shared/warnings.ts create mode 100644 src/helpers/text.ts create mode 100644 src/user-event/legacy/press.ts create mode 100644 src/user-event/modern/press.ts diff --git a/contributing/event-dispatch.md b/contributing/event-dispatch.md index 17a06fba7..24dcab5f2 100644 --- a/contributing/event-dispatch.md +++ b/contributing/event-dispatch.md @@ -12,12 +12,16 @@ The files in `src/events/legacy/`: | `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` | +| `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), and `types.ts`. +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`. -`src/user-event/` is a separate module on top of the event subsystem. It creates and dispatches native events through the facades `src/events/create-event.ts` and `src/events/dispatch-event.ts`, and imports the rest only through `src/events/legacy/index.ts`, which also re-exports `src/events/shared/handler.ts` and `src/events/shared/native-state.ts`. +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. + +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`. Legacy helpers (touch and responder event builders) come from `src/events/legacy/index.ts`, which also re-exports `src/events/shared/handler.ts`, `src/events/shared/native-state.ts` and `isPointerEventEnabled()`. Other code imports `src/events/modern/` and `src/events/shared/` modules directly: `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` @@ -33,11 +37,13 @@ Code used by both event systems lives in `src/events/shared/`: `handler.ts` (fin Each step dispatches a native event, created for the configured event system by `createEvent()` (`src/events/create-event.ts`: a legacy event object or a `SyntheticEvent`) and dispatched by `dispatchEvent()` (`src/events/dispatch-event.ts`), or calls a JavaScript callback (`changeText`, `pressIn`, ...) with `invokeEventHandler()`, which only calls the target's own handler. Modern `fireEvent.changeText()` instead calls `onChangeText` from the `change` dispatch, through `dispatchEvent()`'s `afterTargetHandler` option, right after the input's own `onChange`, where `TextInput` calls it. Neither checks whether the element is enabled. Each action does those checks itself, so the rules for an interaction live in one place. +Where the two event systems need different rules, the action picks them by `getConfig().unstable_eventSystem` from `src/user-event/legacy/` or `src/user-event/modern/`, e.g. `findPressTarget()` for `press()` and `longPress()`. The modern one 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. + For the `eventDiagnostics` warning, each action tracks itself with an `Interaction` from `src/user-event/utils/interaction.ts`: -- Dispatch native events with `interaction.dispatchEvent(eventType, buildFocusEvent())`, using the `build*Event()` helpers from `src/events/create-event.ts`, and call JavaScript callbacks with `interaction.invokeEventHandler(eventType, ...params)` (or `interaction.dispatchTouchEvent()` for `pressIn`, `pressOut` and `longPress`, which passes a touch event), so it records whether any handler ran. Events go to `interaction.target`, which is the element the action was called with, unless the action moves it (as `press()` does when an ancestor handles the press). If the action has to call a handler itself, record it with `interaction.recordEvent()` (as `pullToRefresh()` does for `onRefresh` on the `refreshControl` prop). +- 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. ## Guidelines diff --git a/contributing/native-events.md b/contributing/native-events.md index 842b1b36f..f1ccee9f6 100644 --- a/contributing/native-events.md +++ b/contributing/native-events.md @@ -34,7 +34,7 @@ Until then, `fireEvent` logs a warning when a direct event bubbles from a nested `contentSizeChange` is not a native `ScrollView` event, so the table above doesn't list it. The `ScrollView` component calls `onContentSizeChange` from the `onLayout` of its content view and passes `onContentSizeChange: null` to the host element. The Jest `ScrollView` mock passes the prop to the host element instead, so the rule uses `ScrollView` as the emitting element. `FlatList` and `SectionList` always set this handler, and tests fire the event on list items, so making it direct will break more tests than other events. -Both rules depend on the Jest mock. The `FlatList` cases in `src/events/legacy/__tests__/fire-event.test.tsx` cover both, so a mock change that moves these handlers fails them. +Both rules depend on the Jest mock. The `FlatList` cases in `src/events/__tests__/fire-event.test.tsx` cover both, so a mock change that moves these handlers fails them. ## Sources diff --git a/contributing/testing.md b/contributing/testing.md index ac38c4e2d..cbbdf0697 100644 --- a/contributing/testing.md +++ b/contributing/testing.md @@ -23,6 +23,6 @@ Every change in `src/` should come with tests. Tests use Jest and live next to t Both projects share the same snapshots. `createEventLogger()` entries print only the `nativeEvent` of event payloads (`src/test-utils/event-serializer.ts`), so a snapshot is the same for a legacy event object and a modern `SyntheticEvent`, and a difference between the two systems fails the snapshot. Use `--selectProjects legacy` or `--selectProjects modern` to run one of them. -When a test expects different behavior in the two systems, e.g. events bubbling to a parent, branch on `getConfig().unstable_eventSystem` inside the test instead of skipping it. +When a test expects different behavior in the two systems, e.g. events bubbling to a parent, branch on `getConfig().unstable_eventSystem` inside the test instead of skipping it, with a short comment saying which system matches React Native. Name the test after the modern behavior, which aims to match React Native, with the legacy difference in a `(legacy: ...)` suffix, e.g. `does not bubble $name (legacy: bubbles with a warning)`. Prefer ternaries in the expectations (`toHaveBeenCalledTimes(isModern ? 0 : 1)`). For assertions only one system can make, like a legacy warning message, end the test with `if (isModern) return;` before them (`jest/no-conditional-expect` forbids `expect()` inside `if` blocks). Generic `fireEvent(element, eventType, event)` calls that run in both systems pass an event object, e.g. `{ nativeEvent: {} }`, as modern `fireEvent` requires one. `src/events/__tests__/fire-event.test.tsx` runs the public `fireEvent` this way. -Tests of legacy behavior the modern event system doesn't have (several handler arguments, bubbling to composite props, direct events bubbling with a warning, ...) call `runInLegacyEventSystem()` from `src/test-utils/event-system.ts` at the top of the file (or in a `describe()`). They run in the legacy event system in both projects, e.g. `src/events/legacy/__tests__/fire-event.test.tsx`. +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. diff --git a/src/events/__tests__/create-event.test.ts b/src/events/__tests__/create-event.test.ts index d837c4458..d37ba1a14 100644 --- a/src/events/__tests__/create-event.test.ts +++ b/src/events/__tests__/create-event.test.ts @@ -7,7 +7,6 @@ import { createEvent, buildFocusEvent, buildKeyPressEvent, - buildPressEvent, buildScrollEvent, buildSubmitEditingEvent, buildTextChangeEvent, @@ -52,7 +51,6 @@ test.each(['legacy', 'modern'] as const)( ); test.each([ - ['press', () => buildPressEvent()], ['focus', () => buildFocusEvent()], ['blur', () => buildBlurEvent()], ['accessibilityaction', () => buildAccessibilityActionEvent('increment')], diff --git a/src/events/__tests__/dispatch-event.test.tsx b/src/events/__tests__/dispatch-event.test.tsx index d25aee720..5ea271c28 100644 --- a/src/events/__tests__/dispatch-event.test.tsx +++ b/src/events/__tests__/dispatch-event.test.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { TextInput, View } from 'react-native'; import { render, screen } from '../..'; -import { getConfig } from '../../config'; +import { isModernEventSystem } from '../../test-utils/event-system'; import { createEvent } from '../create-event'; import { dispatchEvent } from '../dispatch-event'; import { SyntheticEvent } from '../modern/event'; @@ -23,7 +23,7 @@ test('dispatches the event of the selected event system', async () => { const event = createEvent('pointerDown', { pointerId: 1 }); expect(await dispatchEvent(screen.getByTestId('target'), 'pointerDown', event)).toBe(true); expect(onTargetPointerDown).toHaveBeenCalledWith(event); - const isModern = getConfig().unstable_eventSystem === 'modern'; + 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( @@ -43,7 +43,7 @@ test('returns whether an ancestor handled the event', async () => { const event = createEvent('pointerDown', { pointerId: 1 }); const result = await dispatchEvent(screen.getByTestId('target'), 'pointerDown', event); - const isModern = getConfig().unstable_eventSystem === 'modern'; + const isModern = isModernEventSystem(); expect(result).toBe(isModern); expect(onParentPointerDown).toHaveBeenCalledTimes(isModern ? 1 : 0); }); diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index 07c89479b..d4bfefc2d 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -1,93 +1,3196 @@ import * as React from 'react'; -import { Pressable, ScrollView, Text, TextInput, View } from 'react-native'; +import type { TextInputProps } from 'react-native'; +import { + FlatList, + Image, + ImageBackground, + Modal, + PanResponder, + Pressable, + ScrollView, + Text, + TextInput, + TouchableHighlight, + TouchableNativeFeedback, + TouchableOpacity, + TouchableWithoutFeedback, + View, +} from 'react-native'; +import type { TestInstance } from 'test-renderer'; -import { configure, fireEvent, render, screen } from '../..'; -import { getConfig } from '../../config'; +import { fireEvent, render, screen } from '../..'; +import { configure } from '../../config'; +import { _console, logger } from '../../helpers/logger'; +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'; -test('fireEvent calls handlers of the selected event system', async () => { - const onPointerDown = jest.fn(); - const onParentPointerDownCapture = jest.fn(); +// 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 defaultTouchPayload = { + changedTouches: [], + identifier: 0, + locationX: 0, + locationY: 0, + pageX: 0, + pageY: 0, + target: 0, + timestamp: expect.any(Number), + touches: [], +}; + +beforeEach(() => { + jest.spyOn(Date, 'now').mockImplementation(() => 100100100100); +}); + +/** 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; + } +} + +test('fireEvent accepts event name with or without "on" prefix', async () => { + const onPress = jest.fn(); + await render(); + + await fireEvent(screen.getByTestId('btn'), 'press', emptyEvent); + expect(onPress).toHaveBeenCalledTimes(1); + + await fireEvent(screen.getByTestId('btn'), 'onPress', emptyEvent); + expect(onPress).toHaveBeenCalledTimes(2); +}); + +test('fireEvent sets event type without the "on" prefix (legacy: passes the event object as given)', async () => { + const onFocus = jest.fn(); + await render(); + + await fireEvent(screen.getByTestId('input'), 'onFocus', emptyEvent); + + // Modern matches React Native, where the event type has no "on" prefix. Legacy passes the event + // object as given, without a type. + expect(onFocus.mock.calls[0][0].type).toBe(isModern() ? 'focus' : undefined); +}); + +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 fireEvent(screen.getByTestId('view'), 'onPress', emptyEvent); + expect(press).not.toHaveBeenCalled(); + expect(testOnlyPress).not.toHaveBeenCalled(); + + // 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); +}); + +test('fireEvent passes event data to handler', async () => { + const onPress = jest.fn(); + await render(); + await fireEvent.press(screen.getByTestId('btn'), pressEventData); + expect(onPress.mock.calls[0][0]).toMatchObject(pressEventData); +}); + +test('fireEvent throws with more than one event argument (legacy: passes them all to the handler)', async () => { + const handlePress = jest.fn(); + await render(); + 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.' + : undefined, + ); + expect(handlePress.mock.calls).toEqual(isModern() ? [] : [['param1', 'param2', 'param3']]); +}); + +// 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 () => { + const onPress = jest.fn(); await render( - + + Press me + , + ); + await fireEvent.press(screen.getByText('Press me')); + 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')} + > , ); - const init = { nativeEvent: { pointerId: 1 } }; - const result = await fireEvent(screen.getByTestId('target'), 'pointerDown', init); - const isModern = getConfig().unstable_eventSystem === 'modern'; - // Modern dispatches a `SyntheticEvent` through capture and bubble phases. Legacy calls only the - // target prop with the passed arguments. - expect(result).toBe(isModern ? true : undefined); - expect(onParentPointerDownCapture).toHaveBeenCalledTimes(isModern ? 1 : 0); - expect(onPointerDown.mock.calls[0][0] instanceof SyntheticEvent).toBe(isModern); - expect(onPointerDown.mock.calls[0][0].nativeEvent).toEqual({ pointerId: 1 }); + 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 with an event type React Native does not dispatch natively', async () => { - const onChangeText = jest.fn(); - await render(); +test('fireEvent does not call props of composite components (legacy: calls them)', async () => { + const onCompositeFocus = jest.fn(); + function Wrapper({ children }: { children: React.ReactNode; onFocus: () => void }) { + return {children}; + } + + await render( + + + , + ); + + await fireEvent(screen.getByTestId('input'), 'focus', emptyEvent); + + // Modern matches React Native, which dispatches events only to host elements. + expect(onCompositeFocus).toHaveBeenCalledTimes(isModern() ? 0 : 1); +}); + +describe('event object', () => { + test('throws without an event object (legacy: calls the handler without arguments)', async () => { + const onFocus = jest.fn(); + await render(); + + const error = await getErrorMessage(fireEvent(screen.getByTestId('input'), 'focus')); + + // Modern matches React Native, where handlers always receive an event. + expect(error).toBe( + isModern() + ? `Unable to fire a "focus" event. Please provide an event object, e.g. fireEvent(element, 'focus', { nativeEvent: {} }).` + : undefined, + ); + expect(onFocus.mock.calls).toEqual(isModern() ? [] : [[]]); + }); + + test('throws without an event object for Pressability events (legacy: calls the callback without arguments)', async () => { + const onPressIn = jest.fn(); + await render(); + + const error = await getErrorMessage(fireEvent(screen.getByTestId('pressable'), 'pressIn')); + + expect(error).toBe( + isModern() + ? `Unable to fire a "pressIn" event. Please provide an event object, e.g. fireEvent(element, 'pressIn', { nativeEvent: {} }).` + : undefined, + ); + expect(onPressIn.mock.calls).toEqual(isModern() ? [] : [[]]); + }); + + test.each([ + ['text', 'string "text"'], + [42, 'number 42'], + [null, 'null'], + [undefined, 'undefined'], + [[{ nativeEvent: {} }], 'an array'], + [new Date(0), 'an instance of Date'], + [() => {}, 'a function'], + ])( + 'throws when the event is %p (legacy: passes it to the handler)', + async (event, description) => { + const onFocus = jest.fn(); + await render(); + + const error = await getErrorMessage(fireEvent(screen.getByTestId('input'), 'focus', event)); + + expect(error).toBe( + isModern() + ? `Unable to fire a "focus" event. Expected an event object, received ${description}.` + : undefined, + ); + expect(onFocus.mock.calls).toEqual(isModern() ? [] : [[event]]); + }, + ); + + test.each([ + [{ target: 1 }, '"target". "target" is set by the dispatch.'], + [ + { target: {}, currentTarget: { measure: () => {} } }, + '"target", "currentTarget". "target", "currentTarget" are set by the dispatch.', + ], + [{ 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" is set by the dispatch. "persist" is 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 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].nativeEvent).toMatchInlineSnapshot(` + { + "changedTouches": [], + "identifier": 0, + "locationX": 0, + "locationY": 0, + "pageX": 0, + "pageY": 0, + "target": 0, + "timestamp": 100100100100, + "touches": [], + } + `); + // 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], + }, + "isDefaultPrevented": [Function], + "isPersistent": [Function], + "isPropagationStopped": [Function], + "nativeEvent": undefined, + "persist": [Function], + "preventDefault": [Function], + "stopPropagation": [Function], + "target": {}, + "timeStamp": 0, + } + `); + }); + + 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(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('press'); + expect(event?.target).toBe(screen.getByText('Press me')); + expect(currentTarget).toBe(screen.getByTestId('pressable')); + expect(eventPhase).toBe(event?.BUBBLING_PHASE); + // 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 }, timeStamp: 123 }; + await fireEvent.press(screen.getByTestId('btn'), customEventData); + expect(onPress.mock.calls[0][0].nativeEvent).toMatchInlineSnapshot(` + { + "changedTouches": [], + "identifier": 0, + "locationX": 0, + "locationY": 0, + "pageX": 20, + "pageY": 30, + "target": 0, + "timestamp": 100100100100, + "touches": [], + } + `); + 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": { + "measure": [Function], + }, + "isDefaultPrevented": [Function], + "isPersistent": [Function], + "isPropagationStopped": [Function], + "nativeEvent": { + "changedTouches": [], + "identifier": 0, + "locationX": 0, + "locationY": 0, + "pageX": 20, + "pageY": 30, + "target": 0, + "timestamp": 100100100100, + "touches": [], + }, + "persist": [Function], + "preventDefault": [Function], + "stopPropagation": [Function], + "target": {}, + "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], + ['TouchableHighlight', TouchableHighlight], + ['TouchableWithoutFeedback', TouchableWithoutFeedback], + ['TouchableNativeFeedback', TouchableNativeFeedback], + ])('works on %s', async (_, Component) => { + const onPress = jest.fn(); + await render( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - Component is a valid React component - but some RN versions have incorrect type definitions + + Press me + , + ); + await fireEvent.press(screen.getByTestId('subject')); + 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(); + const onPressOut = jest.fn(); + const onLongPress = jest.fn(); + const testOnlyPressProps = { + testOnly_onPress: onPress, + testOnly_onPressIn: onPressIn, + testOnly_onPressOut: onPressOut, + testOnly_onLongPress: onLongPress, + }; + + await render(); + + const subject = screen.getByTestId('subject'); + + await fireEvent.press(subject); + expect(onPress).toHaveBeenCalledTimes(1); + + await fireEvent(subject, 'pressIn', emptyEvent); + expect(onPressIn).toHaveBeenCalledTimes(1); + + await fireEvent(subject, 'pressOut', emptyEvent); + expect(onPressOut).toHaveBeenCalledTimes(1); + + 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', () => { + 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, '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.each(['change', 'onChange'])( + '%s event saves value of TextInput in native state', + async (eventType) => { + await render(); + const input = screen.getByTestId('input'); + + await fireEvent(input, eventType, { nativeEvent: { text: 'new text' } }); + + expect(nativeState.valueForInstance.get(input)).toBe('new text'); + }, + ); + + test('change event without text or on non-editable TextInput does not save value', async () => { + await render( + <> + + + , + ); + const input = screen.getByTestId('input'); + const nonEditable = screen.getByTestId('non-editable'); + + await fireEvent(input, 'change', emptyEvent); + await fireEvent(nonEditable, 'change', { nativeEvent: { text: 'Hello' } }); + + expect(nativeState.valueForInstance.get(input)).toBeUndefined(); + expect(nativeState.valueForInstance.get(nonEditable)).toBeUndefined(); + }); + + test('is saved before handlers run', async () => { + let contentOffset; + const onScroll = jest.fn(() => { + contentOffset = nativeState.contentOffsetForInstance.get(screen.getByTestId('scroll')); + }); + await render(); + + await fireEvent(screen.getByTestId('scroll'), 'scroll', verticalScrollEvent); + + expect(contentOffset).toEqual({ x: 0, y: 200 }); + }); +}); + +describe('fireEvent.scroll', () => { + test('passes a SyntheticEvent with default scroll payload to handler (legacy: a plain event object)', async () => { + const onScroll = jest.fn(); + await render( + + Content + , + ); + const scrollView = screen.getByTestId('scroll'); + await fireEvent.scroll(scrollView); + 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": undefined, + "persist": [Function], + "preventDefault": [Function], + "stopPropagation": [Function], + "target": {}, + "timeStamp": 0, + } + `); + }); + + test('overrides default event properties with passed event props', async () => { + const onScroll = jest.fn(); + await render( + + Content + , + ); + const scrollView = screen.getByTestId('scroll'); + const customEventData = { + nativeEvent: { contentOffset: { x: 50, y: 200 }, zoomScale: 2 }, + timeStamp: 123, + }; + await fireEvent.scroll(scrollView, customEventData); + expect(onScroll.mock.calls[0][0].nativeEvent).toMatchInlineSnapshot(` + { + "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, + }, + "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 () => { + const onScroll = jest.fn(); + await render( + + Content + , + ); + const scrollView = screen.getByTestId('scroll'); + await fireEvent.scroll(scrollView, verticalScrollEvent); + expect(onScroll.mock.calls[0][0]).toMatchObject(verticalScrollEvent); + expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ + x: 0, + y: 200, + }); + }); + + 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); + // Modern matches React Native, which passes a `SyntheticEvent`. Legacy passes the event object + // as given. + expect(onScroll.mock.calls).toEqual([[isModern() ? anySyntheticEvent : verticalScrollEvent]]); + expect(onScroll.mock.calls[0][0]).toMatchObject(verticalScrollEvent); + expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ + x: 0, + y: 200, + }); + }); + + test.each([ + ['onScroll', 'scroll'], + ['onScrollBeginDrag', 'scrollBeginDrag'], + ['onScrollEndDrag', 'scrollEndDrag'], + ['onMomentumScrollBegin', 'momentumScrollBegin'], + ['onMomentumScrollEnd', 'momentumScrollEnd'], + ])('fires %s on ScrollView', async (propName, eventType) => { + const handler = jest.fn(); + await render(); + const scrollView = screen.getByTestId('scroll'); + await fireEvent(scrollView, eventType, 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, + }); + }); + + test('without contentOffset scrolls to (0, 0)', async () => { + const onScroll = jest.fn(); + await render( + + Content + , + ); + const scrollView = screen.getByTestId('scroll'); + await fireEvent.scroll(scrollView, {}); + expect(onScroll.mock.calls[0][0]).toMatchObject({ + nativeEvent: { contentOffset: { x: 0, y: 0 } }, + }); + expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ + x: 0, + y: 0, + }); + }); + + test('uses default payload when the event object is undefined', async () => { + const onScroll = jest.fn(); + await render(); + const scrollView = screen.getByTestId('scroll'); - const error = await fireEvent(screen.getByTestId('input'), 'changeText', 'Hello').then( - () => undefined, - (e: Error) => e.message, + 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( + + Content + , + ); + const scrollView = screen.getByTestId('scroll'); + await fireEvent.scroll(scrollView, { + nativeEvent: { contentOffset: { y: Infinity } }, + }); + expect(onScroll).toHaveBeenCalled(); + expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ + x: 0, + y: 0, + }); + }); + + test('with horizontal scroll updates native state', async () => { + const onScroll = jest.fn(); + await render( + + Content + , + ); + const scrollView = screen.getByTestId('scroll'); + await fireEvent.scroll(scrollView, horizontalScrollEvent); + expect(onScroll.mock.calls[0][0]).toMatchObject(horizontalScrollEvent); + expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ + x: 50, + y: 0, + }); + }); + + test('without contentOffset via fireEvent() does not update native state', async () => { + const onScroll = jest.fn(); + await render( + + Content + , + ); + const scrollView = screen.getByTestId('scroll'); + await fireEvent(scrollView, 'scroll', { nativeEvent: {} }); + expect(onScroll).toHaveBeenCalled(); + expect(nativeState.contentOffsetForInstance.get(scrollView)).toBeUndefined(); + }); + + test('with non-finite x contentOffset value uses 0', async () => { + const onScroll = jest.fn(); + await render( + + Content + , + ); + const scrollView = screen.getByTestId('scroll'); + await fireEvent.scroll(scrollView, { + nativeEvent: { contentOffset: { x: Infinity } }, + }); + expect(onScroll).toHaveBeenCalled(); + expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ + x: 0, + y: 0, + }); + }); + + test('uses layout size from previous layout event as layoutMeasurement', async () => { + const onScroll = jest.fn(); + await render( {}} />); + const scrollView = screen.getByTestId('scroll'); + + await fireEvent.layout(scrollView, { width: 390, height: 750 }); + await fireEvent.scroll(scrollView); + + expect(onScroll.mock.calls[0][0].nativeEvent.layoutMeasurement).toEqual({ + width: 390, + height: 750, + }); + }); + + test('prefers passed layoutMeasurement over layout size from layout event', async () => { + const onScroll = jest.fn(); + await render( {}} />); + const scrollView = screen.getByTestId('scroll'); + + await fireEvent.layout(scrollView, { width: 390, height: 750 }); + await fireEvent.scroll(scrollView, { + nativeEvent: { layoutMeasurement: { width: 100, height: 200 } }, + }); + + expect(onScroll.mock.calls[0][0].nativeEvent.layoutMeasurement).toEqual({ + width: 100, + height: 200, + }); + }); + + test('does not use layout size of non-ScrollView element as layoutMeasurement', async () => { + const onScroll = jest.fn(); + await render( + + {/* Spread because `View` types don't include `onScroll`. */} + {}} {...{ onScroll }} /> + , + ); + const content = screen.getByTestId('content'); + + await fireEvent.layout(content, { width: 390, height: 750 }); + await fireEvent.scroll(content); + + expect(onScroll.mock.calls[0][0].nativeEvent.layoutMeasurement).toEqual({ + width: 0, + height: 0, + }); + }); +}); + +describe('fireEvent.layout', () => { + 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].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": undefined, + "persist": [Function], + "preventDefault": [Function], + "stopPropagation": [Function], + "target": {}, + "timeStamp": 0, + } + `); + }); + + test('merges the passed layout onto the zeroed rectangle', async () => { + const onLayout = jest.fn(); + await render(); + + await fireEvent.layout(screen.getByTestId('view'), { width: 200, height: 80 }); + + expect(onLayout.mock.calls[0][0].nativeEvent).toEqual({ + layout: { x: 0, y: 0, width: 200, height: 80 }, + target: 0, + }); + }); + + test('uses zero for layout fields passed as undefined', async () => { + configure({ eventDiagnostics: true }); + const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); + const onLayout = jest.fn(); + await render( + + + + , + ); + const withoutHandler = screen.getByTestId('without-handler'); + + await fireEvent.layout(screen.getByTestId('with-handler'), { x: undefined, width: undefined }); + await fireEvent.layout(withoutHandler, { width: undefined, height: undefined }); + + expect(onLayout.mock.calls[0][0].nativeEvent.layout).toEqual({ + x: 0, + y: 0, + width: 0, + height: 0, + }); + expect(nativeState.layoutSizeForInstance.get(withoutHandler)).toEqual({ width: 0, height: 0 }); + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + test('does not bubble to the handler on an ancestor element', async () => { + const onLayout = jest.fn(); + await render( + + + , + ); + + await fireEvent.layout(screen.getByTestId('child'), { height: 80 }); + + expect(onLayout).not.toHaveBeenCalled(); + }); + + 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( + + + , + ); + + await fireEvent(screen.getByTestId('child'), 'layout', layoutEvent); + await fireEvent(screen.getByTestId('child'), 'onLayout', layoutEvent); + + // 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 : 2); + expect(warnSpy).toHaveBeenCalledTimes(isModern() ? 0 : 2); + warnSpy.mockRestore(); + }); + + test('does not warn when layout size is saved in native state without onLayout handler', async () => { + configure({ eventDiagnostics: true }); + const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); + await render(); + + await fireEvent.layout(screen.getByTestId('view')); + + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + test('is not blocked by element responder rejecting touches', async () => { + const onLayout = jest.fn(); + await render( + false} />, + ); + + await fireEvent.layout(screen.getByTestId('view')); + + expect(onLayout).toHaveBeenCalledTimes(1); + }); + + test('saves layout size in native state', async () => { + await render( {}} />); + const view = screen.getByTestId('view'); + + await fireEvent.layout(view, { x: 10, y: 20, width: 100, height: 80 }); + expect(nativeState.layoutSizeForInstance.get(view)).toEqual({ width: 100, height: 80 }); + + await fireEvent(view, 'layout', { nativeEvent: { layout: { width: 50, height: NaN } } }); + expect(nativeState.layoutSizeForInstance.get(view)).toEqual({ width: 50, height: 0 }); + }); + + test('saves layout size in native state even without onLayout handler', async () => { + await render(); + const view = screen.getByTestId('view'); + + await fireEvent.layout(view, { width: 100, height: 80 }); + + expect(nativeState.layoutSizeForInstance.get(view)).toEqual({ width: 100, height: 80 }); + }); + + test('does not call onLayout of composite component that does not forward it', async () => { + const onLayout = jest.fn(); + const Box = (_props: { onLayout: () => void }) => ; + await render(); + + await fireEvent.layout(screen.getByTestId('view')); + + expect(onLayout).not.toHaveBeenCalled(); + }); +}); + +describe('direct events', () => { + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + type DirectEventCase = { + name: string; + eventType: string; + ui: (handler: jest.Mock) => React.ReactElement; + }; + + const directEventCases: DirectEventCase[] = [ + { + name: 'layout from View content', + eventType: 'layout', + ui: (handler) => ( + + + + ), + }, + { + name: 'accessibilityAction from Pressable content', + eventType: 'accessibilityAction', + ui: (handler) => ( + + Button + + ), + }, + ...buildDirectEventCases( + 'View content', + ['accessibilityTap', 'magicTap', 'accessibilityEscape'], + (handlerProps) => ( + + + + ), + ), + { + name: 'textLayout from nested Text', + eventType: 'textLayout', + ui: (handler) => ( + + Nested + + ), + }, + ...buildDirectEventCases( + 'TextInput content', + ['scroll', 'selectionChange', 'contentSizeChange'], + (handlerProps) => ( + + Nested + + ), + ), + ...buildDirectEventCases( + 'Image content', + ['loadStart', 'progress', 'partialLoad', 'load', 'error', 'loadEnd'], + // Image does not accept children, clone it to fire the event on a nested element. + (handlerProps) => + React.cloneElement( + , + {}, + Nested, + ), + ), + ...buildDirectEventCases( + 'ScrollView content', + [ + 'scroll', + 'scrollBeginDrag', + 'scrollEndDrag', + 'momentumScrollBegin', + 'momentumScrollEnd', + 'scrollToTop', + 'contentSizeChange', + ], + (handlerProps) => ( + + + + ), + ), + { + name: 'scroll from TextInput to ancestor ScrollView', + eventType: 'scroll', + ui: (handler) => ( + + + + ), + }, + { + name: 'refresh from FlatList item', + eventType: 'refresh', + ui: (handler) => ( + {item}} + refreshing={false} + onRefresh={handler} + /> + ), + }, + { + name: 'contentSizeChange from FlatList item', + eventType: 'contentSizeChange', + ui: (handler) => ( + {item}} + onContentSizeChange={handler} + /> + ), + }, + ...buildDirectEventCases( + 'Modal content', + ['requestClose', 'show', 'dismiss', 'orientationChange'], + (handlerProps) => ( + + Content + + ), + ), + ]; + + // Builds one case per event type, passing the matching `on*` handler prop to `ui`. + function buildDirectEventCases( + source: string, + eventTypes: string[], + ui: (handlerProps: Record) => React.ReactElement, + ): DirectEventCase[] { + return eventTypes.map((eventType) => ({ + name: `${eventType} from ${source}`, + eventType, + ui: (handler) => ui({ [getEventHandlerName(eventType)]: 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, emptyEvent); + + // Modern matches React Native: direct events don't bubble. Legacy still bubbles them, with a + // warning. + expect(handler).toHaveBeenCalledTimes(isModern() ? 0 : 1); + expect(warnSpy).toHaveBeenCalledTimes(isModern() ? 0 : 1); + }, ); - const isModern = getConfig().unstable_eventSystem === 'modern'; + + test.each(directEventCases)( + 'does not warn for $name when fired on the emitting element', + async ({ eventType, ui }) => { + const handler = jest.fn(); + await render(ui(handler)); + + await fireEvent(screen.getByTestId('emitter'), eventType, emptyEvent); + + expect(handler).toHaveBeenCalledTimes(1); + expect(warnSpy).not.toHaveBeenCalled(); + }, + ); + + 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 when fired with "on" prefixed event name (legacy: bubbles with a warning)', async () => { + const onMomentumScrollEnd = jest.fn(); + await render( + + + , + ); + + await fireEvent(screen.getByTestId('child'), 'onMomentumScrollEnd', emptyEvent); + + // Modern matches React Native: direct events don't bubble. Legacy still bubbles them, with a + // warning. + expect(onMomentumScrollEnd).toHaveBeenCalledTimes(isModern() ? 0 : 1); + expect(warnSpy).toHaveBeenCalledTimes(isModern() ? 0 : 1); + }); + + 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. Legacy still bubbles them, with a + // warning. + 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. + // Legacy calls the composite prop, with a warning. + 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('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'], + ['onPressIn', 'onPressIn'], + ])('"%s" calls only %s of the responder', async (eventType, name) => { + const calls: string[] = []; + await render( + <> + + Pressable + + + Touchable + + , + ); + + await fireEvent(screen.getByText('Pressable'), eventType, emptyEvent); + await fireEvent(screen.getByText('Touchable'), eventType, emptyEvent); + + expect(calls).toEqual([`pressable.${name}`, `touchable.${name}`]); + }); + + test('passes a direct event with default touch payload (legacy: passes the event object as given)', async () => { + const onPressIn = jest.fn(); + await render( + + Press me + , + ); + + await fireEvent(screen.getByText('Press me'), 'pressIn', { + nativeEvent: { pageX: 20 }, + timeStamp: 123, + }); + + const event = onPressIn.mock.calls[0][0]; + // Modern matches React Native, which derives Pressability events from a touch. + expect(event.nativeEvent).toEqual( + isModern() ? { ...defaultTouchPayload, pageX: 20 } : { pageX: 20 }, + ); + expect(event.timeStamp).toBe(123); + if (!isModern()) return; + expect(event.type).toBe('pressin'); + expect(event.bubbles).toBe(false); + expect(event.rnIsDirect).toBe(true); + expect(event.target).toBe(screen.getByText('Press me')); + }); +}); + +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 + + ); + const handler = jest.fn(); + await render(); + 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 "changeText" event. React Native doesn't dispatch "changeText" natively. Use fireEvent.changeText() or userEvent.type() instead.` + isModern() + ? `Unable to fire a "customEvent" event. React Native doesn't dispatch "customEvent" natively, so no handler would be called.` : undefined, ); - expect(onChangeText.mock.calls).toEqual(isModern ? [] : [['Hello']]); + expect(handler.mock.calls).toEqual(isModern() ? [] : [['event data']]); }); -test('fireEvent.changeText', async () => { - const calls: string[] = []; - await render( - calls.push(`onChange: ${event.nativeEvent.text}`)} - onChangeText={(text) => calls.push(`onChangeText: ${text}`)} - />, +test('fireEvent throws for custom prop name (handlePress) on composite component (legacy: calls it)', async () => { + const MyButton = ({ handlePress }: { handlePress: () => void }) => ( + + Button + ); + const handler = jest.fn(); + await render(); + 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 dispatch "handlePress" natively, so no handler would be called.` + : undefined, + ); + expect(handler).toHaveBeenCalledTimes(isModern() ? 0 : 1); +}); - await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); - expect(calls).toEqual( - getConfig().unstable_eventSystem === 'modern' - ? ['onChange: Hello', 'onChangeText: Hello'] - : ['onChangeText: Hello'], +test('fireEvent calls handler on element when both element and parent have handlers', async () => { + const childHandler = jest.fn(); + const parentHandler = jest.fn(); + await render( + + + Press me + + , ); + await fireEvent.press(screen.getByTestId('child')); + expect(childHandler).toHaveBeenCalledTimes(1); + expect(parentHandler).not.toHaveBeenCalled(); }); -test('fireEvent.press uses the legacy implementation', async () => { +test('fireEvent does nothing when element is unmounted', async () => { const onPress = jest.fn(); + const onFocus = jest.fn(); + const onChangeText = jest.fn(); await render( - - Press me - , + + + + + , ); + const button = screen.getByTestId('btn'); + const input = screen.getByTestId('input'); + const scrollView = screen.getByTestId('scroll'); + + await screen.rerender(); + 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 throws with non-existent event name (legacy: does nothing)', async () => { + await render(); + const element = screen.getByTestId('btn'); + 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 dispatch "nonExistentEvent" natively, so no handler would be called.`, + } + : { result: undefined }, + ); +}); - expect(await fireEvent.press(screen.getByText('Press me'))).toBeUndefined(); +test('fireEvent rejects with the handler error', async () => { + const error = new Error('Handler error'); + const onPress = jest.fn(() => { + throw error; + }); + 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(onPress.mock.calls[0][0]).not.toBeInstanceOf(SyntheticEvent); + expect(onFocus).toHaveBeenCalledTimes(1); }); -test('fireEvent.scroll and fireEvent.layout pass events of the selected event system', async () => { - const onScroll = jest.fn(); - const onLayout = jest.fn(); - await render(); +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', () => { + test('does not fire on disabled Pressable', async () => { + const onPress = jest.fn(); + await render( + + Trigger + , + ); + await fireEvent.press(screen.getByText('Trigger')); + expect(onPress).not.toHaveBeenCalled(); + }); + + test('does not fire on disabled TouchableOpacity', async () => { + const onPress = jest.fn(); + await render( + + Trigger + , + ); + await fireEvent.press(screen.getByText('Trigger')); + expect(onPress).not.toHaveBeenCalled(); + }); + + test('bubbles event past disabled inner to enabled outer Pressable', async () => { + const handleInnerPress = jest.fn(); + const handleOuterPress = jest.fn(); + await render( + + + Inner Trigger + + , + ); + await fireEvent.press(screen.getByText('Inner Trigger')); + expect(handleInnerPress).not.toHaveBeenCalled(); + expect(handleOuterPress).toHaveBeenCalledTimes(1); + }); + + test('bubbles event past disabled inner to enabled outer TouchableOpacity', async () => { + const handleInnerPress = jest.fn(); + const handleOuterPress = jest.fn(); + await render( + + + Inner Trigger + + , + ); + await fireEvent.press(screen.getByText('Inner Trigger')); + expect(handleInnerPress).not.toHaveBeenCalled(); + 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 }) => ( + + Trigger Test + + ); + const handlePress = jest.fn(); + await render(); + await fireEvent.press(screen.getByText('Trigger Test')); + expect(handlePress).toHaveBeenCalledTimes(1); + }); +}); + +describe('unhandled event warning', () => { + // Modern `fireEvent` doesn't warn about unhandled events. + runInLegacyEventSystem(); + + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + configure({ eventDiagnostics: true }); + warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + 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( + + Trigger + , + ); + + await fireEvent.press(screen.getByText('Trigger')); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "Cannot fire the "press" event on a disabled element. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + + + Trigger + + " + `); + }); + + 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( + + + Trigger + + , + ); + + await fireEvent.press(screen.getByText('Trigger')); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "Cannot fire the "press" event on disabled elements. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + + + Trigger + + + + + + + Trigger + + + " + `); + }); + + test('warns when no element handles the event', async () => { + await render( + + Trigger + , + ); + + await fireEvent.press(screen.getByText('Trigger')); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "No "onPress" handler found on the element or its ancestors. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + + Trigger + " + `); + }); + + 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( + + + Inner Trigger + + , + ); + + await fireEvent.press(screen.getByText('Inner Trigger')); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test('warns when the handler is blocked by pointerEvents="none" on an ancestor', async () => { + await render( + + + , + ); + + await fireEvent.press(screen.getByTestId('btn')); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "Cannot fire the "press" event on an element blocked by pointerEvents. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + + + Blocked by: + + " + `); + }); + + test('warns when multiple handlers are blocked by pointerEvents="none" on an ancestor', async () => { + await render( + + + + + , + ); + + await fireEvent.press(screen.getByTestId('inner')); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "Cannot fire the "press" event on elements blocked by pointerEvents. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + + + + + + + Blocked by: + + " + `); + }); + + test('reports pointerEvents rather than disabled when both block the handler', async () => { + await render( + + + , + ); + + await fireEvent.press(screen.getByTestId('btn')); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "Cannot fire the "press" event on an element blocked by pointerEvents. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + + + Blocked by: + + " + `); + }); + + test('reports the element that blocks with pointerEvents', async () => { + await render( + + + + + + , + ); + + await fireEvent.press(screen.getByTestId('inside-box-only')); + await fireEvent.press(screen.getByTestId('box-none')); + + expect(warnSpy).toHaveBeenCalledTimes(2); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "Cannot fire the "press" event on an element blocked by pointerEvents. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + + + Blocked by: + + " + `); + expect(warnSpy.mock.calls[1][0]).toMatchInlineSnapshot(` + "Cannot fire the "press" event on an element blocked by pointerEvents. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + + + Blocked by: + + " + `); + }); + + test('reports non-editable TextInput for events not affected by pointerEvents', async () => { + await render( + + + , + ); + + await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatch( + /^Cannot fire the "changeText" event on a non-editable TextInput\./, + ); + }); + + test('does not warn when the responder declines the touch', async () => { + const onPress = jest.fn(); + // @ts-expect-error Host View does not declare `onPress`. + await render( false} onPress={onPress} />); + + await fireEvent.press(screen.getByTestId('view')); + + expect(onPress).not.toHaveBeenCalled(); + 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(); + + await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "Cannot fire the "changeText" event on a non-editable TextInput. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + " + `); + }); + + 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'], + ])('warns when "%s" is fired on non-editable TextInput', async (eventType, handlerName) => { + const handler = jest.fn(); + await render(); + + 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\\.`), + ); + }); + + test('names the non-editable TextInput when the handler is on its parent', async () => { + const onFocus = jest.fn(); + await render( + // Spread because `View` types include `onFocus` only since RN 0.88. + + + , + ); + + await fireEvent(screen.getByTestId('input'), 'focus', emptyEvent); + + expect(onFocus).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "Cannot fire the "focus" event on a non-editable TextInput. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + " + `); + }); + + 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('warns when element has no onLayout handler and layout event has no layout', async () => { + await render(); + + await fireEvent(screen.getByTestId('view'), 'layout', { nativeEvent: {} }); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "No "onLayout" handler found on the element or its ancestors. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + " + `); + }); + + test('does not warn when the event updates native state', async () => { + await render( + <> + + + , + ); + + await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); + await fireEvent.scroll(screen.getByTestId('scroll'), verticalScrollEvent); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test('does not warn on an unmounted element', async () => { + await render( + + + + + , + ); + const pressable = screen.getByTestId('pressable'); + const input = screen.getByTestId('input'); + const view = screen.getByTestId('view'); + await screen.rerender(); + + await fireEvent.press(pressable); + await fireEvent.changeText(input, 'Hello'); + await fireEvent(view, 'focus', emptyEvent); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test('does not warn when eventDiagnostics is turned off', async () => { + 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(); + }); +}); + +describe('pointerEvents prop', () => { + test('does not fire inside View with pointerEvents="none"', 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="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( + + + Trigger + + , + ); + await fireEvent.press(screen.getByText('Trigger')); + expect(onPress).not.toHaveBeenCalled(); + }); + + test('fires inside View with pointerEvents="box-none"', async () => { + const onPress = jest.fn(); + await render( + + + Trigger + + , + ); + await fireEvent.press(screen.getByText('Trigger')); + expect(onPress).toHaveBeenCalledTimes(1); + }); + + test('fires inside View with pointerEvents="auto"', async () => { + const onPress = jest.fn(); + await render( + + + Trigger + + , + ); + await fireEvent.press(screen.getByText('Trigger')); + expect(onPress).toHaveBeenCalledTimes(1); + }); + + test('does not fire deeply inside View with pointerEvents="box-only"', async () => { + const onPress = jest.fn(); + await render( + + + + Trigger + + + , + ); + await fireEvent.press(screen.getByText('Trigger')); + expect(onPress).not.toHaveBeenCalled(); + }); + + test('fires non-pointer events inside View with pointerEvents="box-none"', async () => { + const onLayout = jest.fn(); + await render(); + await fireEvent(screen.getByTestId('view'), 'layout', layoutEvent); + expect(onLayout).toHaveBeenCalled(); + }); + + test('fires on Pressable with pointerEvents="box-only" on itself', async () => { + const onPress = jest.fn(); + await render(); + await fireEvent.press(screen.getByTestId('pressable')); + expect(onPress).toHaveBeenCalled(); + }); + + test('moves the touch from a child to the Pressable with pointerEvents="box-only" (legacy: passes a stub target)', async () => { + const onTextPress = jest.fn(); + const onPress = jest.fn(); + await render( + + Press me + , + ); + + await fireEvent.press(screen.getByText('Press me')); + + expect(onTextPress).not.toHaveBeenCalled(); + expect(onPress).toHaveBeenCalledTimes(1); + // Modern matches React Native, where the touch targets the Pressable. + expect(onPress.mock.calls[0][0].target).toEqual( + isModern() ? screen.getByTestId('pressable') : {}, + ); + }); +}); + +describe('non-editable TextInput', () => { + function WrappedTextInput(props: TextInputProps) { + return ; + } + + function DoubleWrappedTextInput(props: TextInputProps) { + return ; + } + + test('dispatches focus and submitEditing, but not changeText (legacy: blocks touch-related events)', async () => { + const onFocus = jest.fn(); + const onChangeText = jest.fn(); + const onSubmitEditing = jest.fn(); + const onLayout = jest.fn(); + + await render( + , + ); + + const input = screen.getByTestId('input'); + await fireEvent(input, 'focus', emptyEvent); + await fireEvent.changeText(input, 'Text'); + await fireEvent(input, 'submitEditing', { nativeEvent: { text: 'Text' } }); + await fireEvent(input, 'layout', layoutEvent); + + // Legacy matches a device, where a non-editable TextInput can't be focused or submitted. + // Modern `fireEvent()` dispatches the event as given, without checking `editable`. + expect(onFocus).toHaveBeenCalledTimes(isModern() ? 1 : 0); + expect(onChangeText).not.toHaveBeenCalled(); + expect(onSubmitEditing).toHaveBeenCalledTimes(isModern() ? 1 : 0); + // Modern matches React Native, which passes a `SyntheticEvent`. Legacy passes the event object + // as given. + expect(onLayout.mock.calls).toEqual([[isModern() ? anySyntheticEvent : layoutEvent]]); + expect(onLayout.mock.calls[0][0]).toMatchObject(layoutEvent); + }); + + test('bubbles focus and submitEditing from nested Text child, but not layout (legacy: blocks touch-related events)', async () => { + const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); + const onFocus = jest.fn(); + const onChangeText = jest.fn(); + const onSubmitEditing = jest.fn(); + const onLayout = jest.fn(); + + await render( + + Nested Text + , + ); + + const subject = screen.getByText('Nested Text'); + await fireEvent(subject, 'focus', emptyEvent); + await fireEvent(subject, 'onFocus', emptyEvent); + const changeTextError = await getErrorMessage(fireEvent.changeText(subject, 'Text')); + await fireEvent(subject, 'submitEditing', { + nativeEvent: { text: 'Text' }, + }); + await fireEvent(subject, 'onSubmitEditing', { + nativeEvent: { text: 'Text' }, + }); + await fireEvent(subject, 'layout', layoutEvent); + await fireEvent(subject, 'onLayout', layoutEvent); + + // Legacy matches a device, where a non-editable TextInput can't be focused or submitted. + // Modern `fireEvent()` dispatches the events as given, and they bubble from the Text to the + // TextInput. + expect(onFocus).toHaveBeenCalledTimes(isModern() ? 2 : 0); + expect(onSubmitEditing).toHaveBeenCalledTimes(isModern() ? 2 : 0); + // Modern `changeText` accepts only a host TextInput. Legacy walks up to it, and skips it. + expect(changeTextError).toBe( + isModern() + ? 'Unable to fire a "changeText" event. Expected a host "TextInput" element, received "Text".' + : undefined, + ); + expect(onChangeText).not.toHaveBeenCalled(); + // Modern matches React Native: layout is a direct event, so it doesn't reach the parent + // TextInput. Legacy still bubbles it, with a warning. + expect(onLayout.mock.calls).toEqual(isModern() ? [] : [[layoutEvent], [layoutEvent]]); + expect(warnSpy).toHaveBeenCalledTimes(isModern() ? 0 : 2); + warnSpy.mockRestore(); + }); + + test.each([ + ['WrappedTextInput', WrappedTextInput], + ['DoubleWrappedTextInput', DoubleWrappedTextInput], + ])( + 'dispatches focus and submitEditing on %s, but not changeText (legacy: blocks touch-related events)', + async (_, Component) => { + const onFocus = jest.fn(); + const onChangeText = jest.fn(); + const onSubmitEditing = jest.fn(); + const onLayout = jest.fn(); + + await render( + , + ); + + const input = screen.getByTestId('input'); + await fireEvent(input, 'focus', emptyEvent); + await fireEvent.changeText(input, 'Text'); + await fireEvent(input, 'submitEditing', { nativeEvent: { text: 'Text' } }); + await fireEvent(input, 'layout', layoutEvent); + + // Legacy matches a device, where a non-editable TextInput can't be focused or submitted. + // Modern `fireEvent()` dispatches the event as given, without checking `editable`. + expect(onFocus).toHaveBeenCalledTimes(isModern() ? 1 : 0); + expect(onChangeText).not.toHaveBeenCalled(); + expect(onSubmitEditing).toHaveBeenCalledTimes(isModern() ? 1 : 0); + // Modern matches React Native, which passes a `SyntheticEvent`. Legacy passes the event + // object as given. + expect(onLayout.mock.calls).toEqual([[isModern() ? anySyntheticEvent : layoutEvent]]); + expect(onLayout.mock.calls[0][0]).toMatchObject(layoutEvent); + }, + ); + + test('fires layout event', async () => { + const onLayout = jest.fn(); + await render(); + await fireEvent(screen.getByTestId('input'), 'layout', layoutEvent); + expect(onLayout).toHaveBeenCalled(); + }); + + test('fires scroll event', async () => { + const onScroll = jest.fn(); + await render(); + await fireEvent(screen.getByTestId('input'), 'scroll', verticalScrollEvent); + expect(onScroll).toHaveBeenCalled(); + }); +}); + +describe('responder system', () => { + test('respects disabled prop through composite wrappers', async () => { + function TestChildTouchableComponent({ + onPress, + someProp, + }: { + onPress: () => void; + someProp: boolean; + }) { + return ( + + + Trigger + + + ); + } + const handlePress = jest.fn(); + await render( + + + , + ); + await fireEvent.press(screen.getByText('Trigger')); + expect(handlePress).not.toHaveBeenCalled(); + }); + + test('passes the touch to an ancestor when the responder declines it', async () => { + const onViewPress = jest.fn(); + const onPress = jest.fn(); + await render( + + false} + {...{ testOnly_onPress: onViewPress }} + /> + , + ); + + await fireEvent.press(screen.getByTestId('view')); + + expect(onViewPress).not.toHaveBeenCalled(); + expect(onPress).toHaveBeenCalledTimes(1); + }); + + test('does not call onPress when a responder without Pressability claims the touch (legacy: calls onPress of the ancestor)', async () => { + const onPress = jest.fn(); + const onPanResponderGrant = jest.fn(); + const Draggable = () => { + const panResponder = React.useRef( + PanResponder.create({ onStartShouldSetPanResponder: () => true, onPanResponderGrant }), + ).current; + return ; + }; + await render( + + + , + ); + + await fireEvent.press(screen.getByTestId('draggable')); + + // Modern matches React Native, where the `PanResponder` keeps the touch from its ancestors. + expect(onPress).toHaveBeenCalledTimes(isModern() ? 0 : 1); + expect(onPanResponderGrant).not.toHaveBeenCalled(); + }); + + test.each(['press', 'pressIn', 'pressOut', 'longPress'] as const)( + 'does not call the Pressable handler when TextInput without one claims "%s" (legacy: calls it)', + async (eventType) => { + const handler = jest.fn(); + await render( + + + , + ); + + await fireEvent(screen.getByTestId('input'), eventType, emptyEvent); + + // Modern matches React Native, where the TextInput claims the touch. + expect(handler).toHaveBeenCalledTimes(isModern() ? 0 : 1); + }, + ); + + test.each(['press', 'pressIn', 'pressOut', 'longPress'] as const)( + 'does not call the Pressable handler when Text without one claims "%s" (legacy: calls it)', + async (eventType) => { + const handler = jest.fn(); + const textHandlerName = eventType === 'press' ? 'onLongPress' : 'onPress'; + await render( + + Trigger + , + ); + + await fireEvent(screen.getByText('Trigger'), eventType, emptyEvent); + + // Modern matches React Native, where a Text with a press handler claims the touch. + expect(handler).toHaveBeenCalledTimes(isModern() ? 0 : 1); + }, + ); + + test.each([ + ['press', 'onLongPress'], + ['longPress', 'onPress'], + ] as const)( + 'does not call the parent handler when Pressable without one claims "%s" (legacy: calls it)', + async (eventType, innerHandlerName) => { + const handler = jest.fn(); + await render( + + + Trigger + + , + ); + + await fireEvent(screen.getByText('Trigger'), eventType, emptyEvent); + + // Modern matches React Native, where the inner Pressable claims the touch. + expect(handler).toHaveBeenCalledTimes(isModern() ? 0 : 1); + }, + ); + + test('asks responders from the target to the root, without arguments (legacy: asks only responders with a handler)', async () => { + const calls: string[] = []; + const onStartShouldSetResponder = + (name: string) => + (...args: unknown[]) => { + calls.push(`${name} (${args.length} args)`); + return false; + }; + await render( + + + , + ); + + await fireEvent.press(screen.getByTestId('child')); + + // Modern matches React Native's responder negotiation. Legacy asks only the responder of the + // element with the handler, and neither has one. + expect(calls).toEqual(isModern() ? ['child (0 args)', 'parent (0 args)'] : []); + }); - const scrollResult = await fireEvent.scroll(screen.getByTestId('scrollView')); - const layoutResult = await fireEvent.layout(screen.getByTestId('scrollView')); - const isModern = getConfig().unstable_eventSystem === 'modern'; - expect(scrollResult).toBe(isModern ? true : undefined); - expect(layoutResult).toBe(isModern ? true : undefined); - expect(onScroll.mock.calls[0][0] instanceof SyntheticEvent).toBe(isModern); - expect(onLayout.mock.calls[0][0] instanceof SyntheticEvent).toBe(isModern); + test('throws for responderMove on PanResponder component (legacy: calls the handler)', async () => { + const onDrag = jest.fn(); + function TestDraggableComponent({ onDrag }: { onDrag: () => void }) { + const responderHandlers = PanResponder.create({ + onMoveShouldSetPanResponder: () => true, + onPanResponderMove: onDrag, + }).panHandlers; + return ( + + Trigger + + ); + } + await render(); + const error = await getErrorMessage( + fireEvent(screen.getByText('Trigger'), 'responderMove', { + touchHistory: { mostRecentTimeStamp: '2', touchBank: [] }, + }), + ); + // Legacy calls `onResponderMove` directly. On a device, it follows a granted touch with the + // responder system's touch history, which modern doesn't simulate, so modern throws. + expect(error).toBe( + isModern() + ? `Unable to fire a "responderMove" event. React Native doesn't dispatch "responderMove" natively, so no handler would be called.` + : undefined, + ); + expect(onDrag).toHaveBeenCalledTimes(isModern() ? 0 : 1); + }); }); -test('reads the event system on each call', async () => { +test('fireEvent reads the event system on each call', async () => { const onLayout = jest.fn(); await render(); diff --git a/src/events/create-event.ts b/src/events/create-event.ts index ffb69a0ff..58aff016b 100644 --- a/src/events/create-event.ts +++ b/src/events/create-event.ts @@ -16,7 +16,6 @@ import { buildSubmitEditingNativeEvent, buildTextChangeNativeEvent, buildTextSelectionChangeNativeEvent, - buildTouchNativeEvent, } from './shared/payloads'; export type LegacyEvent = ReturnType>; @@ -48,10 +47,6 @@ export function createEvent( return createModernEvent(eventType, { nativeEvent }) as SyntheticEvent; } -export function buildPressEvent() { - return createEvent('press', buildTouchNativeEvent()); -} - export function buildFocusEvent() { return createEvent('focus', buildFocusNativeEvent()); } diff --git a/src/events/fire-event.ts b/src/events/fire-event.ts index 4b2fc5fe5..5a51de7f2 100644 --- a/src/events/fire-event.ts +++ b/src/events/fire-event.ts @@ -32,34 +32,34 @@ fireEvent.changeText = async (instance: TestInstance, text: string): Promise => { + if (isModern()) { + await modernFireEvent.press(instance, eventProps as FireEventInit); + return; + } + await legacyFireEvent.press(instance, eventProps); }; -fireEvent.scroll = async ( - instance: TestInstance, - eventProps?: EventProps, -): Promise => { +fireEvent.scroll = async (instance: TestInstance, eventProps?: EventProps): Promise => { if (isModern()) { - return await modernFireEvent.scroll(instance, eventProps as FireEventInit); + await modernFireEvent.scroll(instance, eventProps as FireEventInit); + return; } await legacyFireEvent.scroll(instance, eventProps); - return undefined; }; fireEvent.layout = async ( instance: TestInstance, layout?: Partial, -): Promise => { +): Promise => { if (isModern()) { - return await modernFireEvent.layout(instance, layout); + await modernFireEvent.layout(instance, layout); + return; } await legacyFireEvent.layout(instance, layout); - return undefined; }; function isModern() { diff --git a/src/events/legacy/__tests__/fire-event.test.tsx b/src/events/legacy/__tests__/fire-event.test.tsx deleted file mode 100644 index ec5c1c74a..000000000 --- a/src/events/legacy/__tests__/fire-event.test.tsx +++ /dev/null @@ -1,1818 +0,0 @@ -import * as React from 'react'; -import type { TextInputProps } from 'react-native'; -import { - FlatList, - Image, - ImageBackground, - Modal, - PanResponder, - Pressable, - ScrollView, - Text, - TextInput, - TouchableHighlight, - TouchableNativeFeedback, - TouchableOpacity, - TouchableWithoutFeedback, - View, -} from 'react-native'; - -import { fireEvent, render, screen } from '../../..'; -import { configure } from '../../../config'; -import { _console, logger } from '../../../helpers/logger'; -import { runInLegacyEventSystem } from '../../../test-utils/event-system'; -import { getEventHandlerName } from '../../shared/handler'; -import { nativeState } from '../../shared/native-state'; - -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 } }; - -runInLegacyEventSystem(); - -beforeEach(() => { - jest.spyOn(Date, 'now').mockImplementation(() => 100100100100); -}); - -test('fireEvent accepts event name with or without "on" prefix', async () => { - const onPress = jest.fn(); - await render(); - - await fireEvent(screen.getByTestId('btn'), 'press'); - expect(onPress).toHaveBeenCalledTimes(1); - - await fireEvent(screen.getByTestId('btn'), 'onPress'); - expect(onPress).toHaveBeenCalledTimes(2); -}); - -test('fireEvent with "on" prefixed name does not call unprefixed handler props', async () => { - const press = jest.fn(); - const testOnlyPress = jest.fn(); - // @ts-expect-error Intentionally passing such props - await render(); - - await fireEvent(screen.getByTestId('view'), 'onPress'); - expect(press).not.toHaveBeenCalled(); - expect(testOnlyPress).not.toHaveBeenCalled(); - - await fireEvent(screen.getByTestId('view'), 'press'); - expect(press).toHaveBeenCalledTimes(1); -}); - -test('fireEvent passes event data to handler', async () => { - const onPress = jest.fn(); - await render(); - await fireEvent.press(screen.getByTestId('btn'), pressEventData); - expect(onPress.mock.calls[0][0]).toMatchObject(pressEventData); -}); - -test('fireEvent passes multiple parameters to handler', async () => { - const handlePress = jest.fn(); - await render(); - await fireEvent(screen.getByTestId('btn'), 'press', 'param1', 'param2', 'param3'); - expect(handlePress).toHaveBeenCalledWith('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); -}); - -test('fireEvent bubbles event to parent handler', async () => { - const onPress = jest.fn(); - await render( - - Press me - , - ); - await fireEvent.press(screen.getByText('Press me')); - expect(onPress).toHaveBeenCalled(); -}); - -describe('fireEvent.press', () => { - test('passes default press event object to handler', async () => { - const onPress = jest.fn(); - await render(); - await fireEvent.press(screen.getByTestId('btn')); - expect(onPress.mock.calls[0][0]).toMatchInlineSnapshot(` - { - "currentTarget": { - "measure": [Function], - }, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], - "nativeEvent": { - "changedTouches": [], - "identifier": 0, - "locationX": 0, - "locationY": 0, - "pageX": 0, - "pageY": 0, - "target": 0, - "timestamp": 100100100100, - "touches": [], - }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, - } - `); - }); - - test('overrides default event properties with passed event props', async () => { - const onPress = jest.fn(); - await render(); - const customEventData = { nativeEvent: { pageX: 20, pageY: 30 } }; - await fireEvent.press(screen.getByTestId('btn'), customEventData); - expect(onPress.mock.calls[0][0]).toMatchInlineSnapshot(` - { - "currentTarget": { - "measure": [Function], - }, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], - "nativeEvent": { - "changedTouches": [], - "identifier": 0, - "locationX": 0, - "locationY": 0, - "pageX": 20, - "pageY": 30, - "target": 0, - "timestamp": 100100100100, - "touches": [], - }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, - } - `); - }); - - test.each([ - ['Pressable', Pressable], - ['TouchableOpacity', TouchableOpacity], - ['TouchableHighlight', TouchableHighlight], - ['TouchableWithoutFeedback', TouchableWithoutFeedback], - ['TouchableNativeFeedback', TouchableNativeFeedback], - ])('works on %s', async (_, Component) => { - const onPress = jest.fn(); - await render( - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - Component is a valid React component - but some RN versions have incorrect type definitions - - Press me - , - ); - await fireEvent.press(screen.getByTestId('subject')); - expect(onPress).toHaveBeenCalled(); - }); - - test('works with testOnly_onPress handlers', async () => { - const onPress = jest.fn(); - const onPressIn = jest.fn(); - const onPressOut = jest.fn(); - const onLongPress = jest.fn(); - const testOnlyPressProps = { - testOnly_onPress: onPress, - testOnly_onPressIn: onPressIn, - testOnly_onPressOut: onPressOut, - testOnly_onLongPress: onLongPress, - }; - - await render(); - - const subject = screen.getByTestId('subject'); - - await fireEvent.press(subject); - expect(onPress).toHaveBeenCalledTimes(1); - - await fireEvent(subject, 'pressIn'); - expect(onPressIn).toHaveBeenCalledTimes(1); - - await fireEvent(subject, 'pressOut'); - expect(onPressOut).toHaveBeenCalledTimes(1); - - await fireEvent(subject, 'longPress'); - expect(onLongPress).toHaveBeenCalledTimes(1); - }); -}); - -describe('fireEvent.changeText', () => { - test('works on TextInput', async () => { - 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'); - }); - - test('updates native state when fired with `on*` prefixed name', async () => { - const onChangeText = jest.fn(); - await render(); - const input = screen.getByTestId('input'); - await fireEvent(input, 'onChangeText', 'new text'); - expect(onChangeText).toHaveBeenCalledWith('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(); - const input = screen.getByTestId('input'); - await fireEvent.changeText(input, 'new text'); - expect(onChangeText).not.toHaveBeenCalled(); - expect(nativeState.valueForInstance.get(input)).toBeUndefined(); - }); -}); - -test('change event saves value of TextInput in native state', async () => { - await render(); - const input = screen.getByTestId('input'); - - await fireEvent(input, 'change', { nativeEvent: { text: 'new text' } }); - - expect(nativeState.valueForInstance.get(input)).toBe('new text'); -}); - -describe('fireEvent.scroll', () => { - test('passes default scroll event object to handler', async () => { - const onScroll = jest.fn(); - await render( - - Content - , - ); - const scrollView = screen.getByTestId('scroll'); - await fireEvent.scroll(scrollView); - expect(onScroll.mock.calls[0][0]).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, - }, - }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, - } - `); - }); - - test('overrides default event properties with passed event props', async () => { - const onScroll = jest.fn(); - await render( - - Content - , - ); - const scrollView = screen.getByTestId('scroll'); - const customEventData = { - nativeEvent: { contentOffset: { x: 50, y: 200 } }, - }; - await fireEvent.scroll(scrollView, customEventData); - expect(onScroll.mock.calls[0][0]).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, - }, - }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, - } - `); - }); - - test('works on ScrollView', async () => { - const onScroll = jest.fn(); - await render( - - Content - , - ); - const scrollView = screen.getByTestId('scroll'); - await fireEvent.scroll(scrollView, verticalScrollEvent); - expect(onScroll.mock.calls[0][0]).toMatchObject(verticalScrollEvent); - expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ - x: 0, - y: 200, - }); - }); - - 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'], - ['onScrollEndDrag', 'scrollEndDrag'], - ['onMomentumScrollBegin', 'momentumScrollBegin'], - ['onMomentumScrollEnd', 'momentumScrollEnd'], - ])('fires %s on ScrollView', async (propName, eventType) => { - const handler = jest.fn(); - await render(); - const scrollView = screen.getByTestId('scroll'); - await fireEvent(scrollView, eventType, verticalScrollEvent); - expect(handler).toHaveBeenCalledWith(verticalScrollEvent); - expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ - x: 0, - y: 200, - }); - }); - - test('without contentOffset scrolls to (0, 0)', async () => { - const onScroll = jest.fn(); - await render( - - Content - , - ); - const scrollView = screen.getByTestId('scroll'); - await fireEvent.scroll(scrollView, {}); - expect(onScroll.mock.calls[0][0]).toMatchObject({ - nativeEvent: { contentOffset: { x: 0, y: 0 } }, - }); - expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ - x: 0, - y: 0, - }); - }); - - test('with non-finite contentOffset values uses 0', async () => { - const onScroll = jest.fn(); - await render( - - Content - , - ); - const scrollView = screen.getByTestId('scroll'); - await fireEvent.scroll(scrollView, { - nativeEvent: { contentOffset: { y: Infinity } }, - }); - expect(onScroll).toHaveBeenCalled(); - expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ - x: 0, - y: 0, - }); - }); - - test('with horizontal scroll updates native state', async () => { - const onScroll = jest.fn(); - await render( - - Content - , - ); - const scrollView = screen.getByTestId('scroll'); - await fireEvent.scroll(scrollView, horizontalScrollEvent); - expect(onScroll.mock.calls[0][0]).toMatchObject(horizontalScrollEvent); - expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ - x: 50, - y: 0, - }); - }); - - test('without contentOffset via fireEvent() does not update native state', async () => { - const onScroll = jest.fn(); - await render( - - Content - , - ); - const scrollView = screen.getByTestId('scroll'); - await fireEvent(scrollView, 'scroll', { nativeEvent: {} }); - expect(onScroll).toHaveBeenCalled(); - expect(nativeState.contentOffsetForInstance.get(scrollView)).toBeUndefined(); - }); - - test('with non-finite x contentOffset value uses 0', async () => { - const onScroll = jest.fn(); - await render( - - Content - , - ); - const scrollView = screen.getByTestId('scroll'); - await fireEvent.scroll(scrollView, { - nativeEvent: { contentOffset: { x: Infinity } }, - }); - expect(onScroll).toHaveBeenCalled(); - expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ - x: 0, - y: 0, - }); - }); - - test('uses layout size from previous layout event as layoutMeasurement', async () => { - const onScroll = jest.fn(); - await render( {}} />); - const scrollView = screen.getByTestId('scroll'); - - await fireEvent.layout(scrollView, { width: 390, height: 750 }); - await fireEvent.scroll(scrollView); - - expect(onScroll.mock.calls[0][0].nativeEvent.layoutMeasurement).toEqual({ - width: 390, - height: 750, - }); - }); - - test('prefers passed layoutMeasurement over layout size from layout event', async () => { - const onScroll = jest.fn(); - await render( {}} />); - const scrollView = screen.getByTestId('scroll'); - - await fireEvent.layout(scrollView, { width: 390, height: 750 }); - await fireEvent.scroll(scrollView, { - nativeEvent: { layoutMeasurement: { width: 100, height: 200 } }, - }); - - expect(onScroll.mock.calls[0][0].nativeEvent.layoutMeasurement).toEqual({ - width: 100, - height: 200, - }); - }); - - 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( - - {}} /> - , - ); - const content = screen.getByTestId('content'); - - await fireEvent.layout(content, { width: 390, height: 750 }); - await fireEvent.scroll(content); - - expect(onScroll.mock.calls[0][0].nativeEvent.layoutMeasurement).toEqual({ - width: 0, - height: 0, - }); - warnSpy.mockRestore(); - }); -}); - -describe('fireEvent.layout', () => { - test('passes default layout event object to handler', async () => { - const onLayout = jest.fn(); - await render(); - - await fireEvent.layout(screen.getByTestId('view')); - - expect(onLayout.mock.calls[0][0]).toMatchInlineSnapshot(` - { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], - "nativeEvent": { - "layout": { - "height": 0, - "width": 0, - "x": 0, - "y": 0, - }, - "target": 0, - }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, - } - `); - }); - - test('merges the passed layout onto the zeroed rectangle', async () => { - const onLayout = jest.fn(); - await render(); - - await fireEvent.layout(screen.getByTestId('view'), { width: 200, height: 80 }); - - expect(onLayout.mock.calls[0][0].nativeEvent).toEqual({ - layout: { x: 0, y: 0, width: 200, height: 80 }, - target: 0, - }); - }); - - test('uses zero for layout fields passed as undefined', async () => { - configure({ eventDiagnostics: true }); - const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); - const onLayout = jest.fn(); - await render( - - - - , - ); - const withoutHandler = screen.getByTestId('without-handler'); - - await fireEvent.layout(screen.getByTestId('with-handler'), { x: undefined, width: undefined }); - await fireEvent.layout(withoutHandler, { width: undefined, height: undefined }); - - expect(onLayout.mock.calls[0][0].nativeEvent.layout).toEqual({ - x: 0, - y: 0, - width: 0, - height: 0, - }); - expect(nativeState.layoutSizeForInstance.get(withoutHandler)).toEqual({ width: 0, height: 0 }); - expect(warnSpy).not.toHaveBeenCalled(); - warnSpy.mockRestore(); - }); - - test('does not bubble to the handler on an ancestor element', async () => { - const onLayout = jest.fn(); - await render( - - - , - ); - - await fireEvent.layout(screen.getByTestId('child'), { height: 80 }); - - expect(onLayout).not.toHaveBeenCalled(); - }); - - test('bubbles with a warning when fired as generic layout event', async () => { - const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); - const onLayout = jest.fn(); - await render( - - - , - ); - - await fireEvent(screen.getByTestId('child'), 'layout', layoutEvent); - await fireEvent(screen.getByTestId('child'), 'onLayout', layoutEvent); - - expect(onLayout).toHaveBeenCalledTimes(2); - expect(warnSpy).toHaveBeenCalledTimes(2); - warnSpy.mockRestore(); - }); - - test('does not warn when layout size is saved in native state without onLayout handler', async () => { - configure({ eventDiagnostics: true }); - const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); - await render(); - - await fireEvent.layout(screen.getByTestId('view')); - - expect(warnSpy).not.toHaveBeenCalled(); - 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( - false} />, - ); - - await fireEvent.layout(screen.getByTestId('view')); - - expect(onLayout).toHaveBeenCalledTimes(1); - }); - - test('saves layout size in native state', async () => { - await render( {}} />); - const view = screen.getByTestId('view'); - - await fireEvent.layout(view, { x: 10, y: 20, width: 100, height: 80 }); - expect(nativeState.layoutSizeForInstance.get(view)).toEqual({ width: 100, height: 80 }); - - await fireEvent(view, 'layout', { nativeEvent: { layout: { width: 50, height: NaN } } }); - expect(nativeState.layoutSizeForInstance.get(view)).toEqual({ width: 50, height: 0 }); - }); - - test('saves layout size in native state even without onLayout handler', async () => { - await render(); - const view = screen.getByTestId('view'); - - await fireEvent.layout(view, { width: 100, height: 80 }); - - expect(nativeState.layoutSizeForInstance.get(view)).toEqual({ width: 100, height: 80 }); - }); - - test('does not call onLayout of composite component that does not forward it', async () => { - const onLayout = jest.fn(); - const Box = (_props: { onLayout: () => void }) => ; - await render(); - - await fireEvent.layout(screen.getByTestId('view')); - - expect(onLayout).not.toHaveBeenCalled(); - }); -}); - -describe('direct events', () => { - let warnSpy: jest.SpyInstance; - - beforeEach(() => { - warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); - }); - - afterEach(() => { - warnSpy.mockRestore(); - }); - - type DirectEventCase = { - name: string; - eventType: string; - ui: (handler: jest.Mock) => React.ReactElement; - }; - - const directEventCases: DirectEventCase[] = [ - { - name: 'layout from View content', - eventType: 'layout', - ui: (handler) => ( - - - - ), - }, - { - name: 'accessibilityAction from Pressable content', - eventType: 'accessibilityAction', - ui: (handler) => ( - - Button - - ), - }, - ...buildDirectEventCases( - 'View content', - ['accessibilityTap', 'magicTap', 'accessibilityEscape'], - (handlerProps) => ( - - - - ), - ), - { - name: 'textLayout from nested Text', - eventType: 'textLayout', - ui: (handler) => ( - - Nested - - ), - }, - ...buildDirectEventCases( - 'TextInput content', - ['scroll', 'selectionChange', 'contentSizeChange'], - (handlerProps) => ( - - Nested - - ), - ), - ...buildDirectEventCases( - 'Image content', - ['loadStart', 'progress', 'partialLoad', 'load', 'error', 'loadEnd'], - // Image does not accept children, clone it to fire the event on a nested element. - (handlerProps) => - React.cloneElement( - , - {}, - Nested, - ), - ), - ...buildDirectEventCases( - 'ScrollView content', - [ - 'scroll', - 'scrollBeginDrag', - 'scrollEndDrag', - 'momentumScrollBegin', - 'momentumScrollEnd', - 'scrollToTop', - 'contentSizeChange', - ], - (handlerProps) => ( - - - - ), - ), - { - name: 'scroll from TextInput to ancestor ScrollView', - eventType: 'scroll', - ui: (handler) => ( - - - - ), - }, - { - name: 'refresh from FlatList item', - eventType: 'refresh', - ui: (handler) => ( - {item}} - refreshing={false} - onRefresh={handler} - /> - ), - }, - { - name: 'contentSizeChange from FlatList item', - eventType: 'contentSizeChange', - ui: (handler) => ( - {item}} - onContentSizeChange={handler} - /> - ), - }, - ...buildDirectEventCases( - 'Modal content', - ['requestClose', 'show', 'dismiss', 'orientationChange'], - (handlerProps) => ( - - Content - - ), - ), - ]; - - // Builds one case per event type, passing the matching `on*` handler prop to `ui`. - function buildDirectEventCases( - source: string, - eventTypes: string[], - ui: (handlerProps: Record) => React.ReactElement, - ): DirectEventCase[] { - return eventTypes.map((eventType) => ({ - name: `${eventType} from ${source}`, - eventType, - ui: (handler) => ui({ [getEventHandlerName(eventType)]: handler }), - })); - } - - test.each(directEventCases)('bubbles $name with a warning', async ({ eventType, ui }) => { - const handler = jest.fn(); - await render(ui(handler)); - - await fireEvent(screen.getByTestId('target'), eventType); - - expect(handler).toHaveBeenCalledTimes(1); - expect(warnSpy).toHaveBeenCalledTimes(1); - }); - - test.each(directEventCases)( - 'does not warn for $name when fired on the emitting element', - async ({ eventType, ui }) => { - const handler = jest.fn(); - await render(ui(handler)); - - await fireEvent(screen.getByTestId('emitter'), eventType); - - expect(handler).toHaveBeenCalledTimes(1); - expect(warnSpy).not.toHaveBeenCalled(); - }, - ); - - test('warns when fired with "on" prefixed event name', async () => { - const onMomentumScrollEnd = jest.fn(); - await render( - - - , - ); - - await fireEvent(screen.getByTestId('child'), 'onMomentumScrollEnd'); - - expect(onMomentumScrollEnd).toHaveBeenCalledTimes(1); - expect(warnSpy).toHaveBeenCalledTimes(1); - }); - - test('warns about stopping bubbling in the next major version', async () => { - await render( - {}}> - - , - ); - - 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: - - - " - `); - }); - - test.each([true, false])( - 'warns about bubbling regardless of eventDiagnostics (%s)', - async (eventDiagnostics) => { - configure({ eventDiagnostics }); - await render( - {}}> - - , - ); - - await fireEvent.scroll(screen.getByTestId('child')); - - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy.mock.calls[0][0]).toContain('"scroll" does not bubble in React Native'); - }, - ); - - test('warns when handler is on composite component above the emitting element', async () => { - const onScroll = jest.fn(); - const Screen = (_props: { onScroll: () => void }) => ( - - - - ); - await render(); - - await fireEvent.scroll(screen.getByTestId('child')); - - expect(onScroll).toHaveBeenCalledTimes(1); - expect(warnSpy).toHaveBeenCalledTimes(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(); - - await fireEvent.scroll(screen.getByTestId('child')); - - expect(onScroll).toHaveBeenCalledTimes(1); - expect(warnSpy).not.toHaveBeenCalled(); - }); - - 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 - - ); - await render(); - - await fireEvent(screen.getByText('Card'), 'load'); - await fireEvent(screen.getByText('Card'), 'show'); - - expect(onLoad).toHaveBeenCalledTimes(1); - expect(onShow).toHaveBeenCalledTimes(1); - expect(warnSpy).not.toHaveBeenCalled(); - }); - - test('does not warn when bubbling to host element that does not emit the event', async () => { - const onLoad = jest.fn(); - await render( - // @ts-expect-error View does not have onLoad prop - - Content - , - ); - - await fireEvent(screen.getByText('Content'), 'load'); - - expect(onLoad).toHaveBeenCalledTimes(1); - expect(warnSpy).not.toHaveBeenCalled(); - }); - - test('does not warn when bubbling load event to ImageBackground handler', async () => { - const onLoad = jest.fn(); - await render( - - Caption - , - ); - - await fireEvent(screen.getByText('Caption'), 'load'); - - expect(onLoad).toHaveBeenCalledTimes(1); - expect(warnSpy).not.toHaveBeenCalled(); - }); -}); - -test('fireEvent fires custom event (onCustomEvent) on composite component', async () => { - const CustomComponent = ({ onCustomEvent }: { onCustomEvent: (data: string) => void }) => ( - onCustomEvent('event data')}> - Custom - - ); - const handler = jest.fn(); - await render(); - await fireEvent(screen.getByText('Custom'), 'customEvent', 'event data'); - expect(handler).toHaveBeenCalledWith('event data'); -}); - -test('fireEvent fires event with custom prop name (handlePress) on composite component', async () => { - const MyButton = ({ handlePress }: { handlePress: () => void }) => ( - - Button - - ); - 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(); -}); - -test('fireEvent calls handler on element when both element and parent have handlers', async () => { - const childHandler = jest.fn(); - const parentHandler = jest.fn(); - await render( - - - Press me - - , - ); - await fireEvent.press(screen.getByTestId('child')); - expect(childHandler).toHaveBeenCalledTimes(1); - expect(parentHandler).not.toHaveBeenCalled(); -}); - -test('fireEvent does nothing when element is unmounted', async () => { - const onPress = jest.fn(); - await render( - - - , - ); - const element = screen.getByTestId('btn'); - - await screen.rerender(); - await fireEvent.press(element); - expect(onPress).not.toHaveBeenCalled(); -}); - -test('fireEvent does not throw when called with non-existent event name', async () => { - await render(); - const element = screen.getByTestId('btn'); - await expect(fireEvent(element, 'nonExistentEvent' as any)).resolves.toBeUndefined(); -}); - -test('fireEvent handles handler that throws gracefully', async () => { - const error = new Error('Handler error'); - const onPress = jest.fn(() => { - throw error; - }); - await render(); - await expect(fireEvent.press(screen.getByTestId('btn'))).rejects.toThrow('Handler error'); - expect(onPress).toHaveBeenCalledTimes(1); -}); - -describe('disabled elements', () => { - test('does not fire on disabled Pressable', async () => { - const onPress = jest.fn(); - await render( - - Trigger - , - ); - await fireEvent.press(screen.getByText('Trigger')); - expect(onPress).not.toHaveBeenCalled(); - }); - - test('does not fire on disabled TouchableOpacity', async () => { - const onPress = jest.fn(); - await render( - - Trigger - , - ); - await fireEvent.press(screen.getByText('Trigger')); - expect(onPress).not.toHaveBeenCalled(); - }); - - test('bubbles event past disabled inner to enabled outer Pressable', async () => { - const handleInnerPress = jest.fn(); - const handleOuterPress = jest.fn(); - await render( - - - Inner Trigger - - , - ); - await fireEvent.press(screen.getByText('Inner Trigger')); - expect(handleInnerPress).not.toHaveBeenCalled(); - expect(handleOuterPress).toHaveBeenCalledTimes(1); - }); - - test('bubbles event past disabled inner to enabled outer TouchableOpacity', async () => { - const handleInnerPress = jest.fn(); - const handleOuterPress = jest.fn(); - await render( - - - Inner Trigger - - , - ); - await fireEvent.press(screen.getByText('Inner Trigger')); - expect(handleInnerPress).not.toHaveBeenCalled(); - expect(handleOuterPress).toHaveBeenCalledTimes(1); - }); - - test('ignores custom disabled prop on composite component (only respects native disabled)', async () => { - const TestComponent = ({ onPress }: { onPress: () => void; disabled?: boolean }) => ( - - Trigger Test - - ); - const handlePress = jest.fn(); - await render(); - await fireEvent.press(screen.getByText('Trigger Test')); - expect(handlePress).toHaveBeenCalledTimes(1); - }); -}); - -describe('unhandled event warning', () => { - let warnSpy: jest.SpyInstance; - - beforeEach(() => { - configure({ eventDiagnostics: true }); - warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => {}); - }); - - afterEach(() => { - warnSpy.mockRestore(); - }); - - test('warns when the handler is on a disabled element', async () => { - await render( - - Trigger - , - ); - - await fireEvent.press(screen.getByText('Trigger')); - - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - "Cannot fire the "press" event on a disabled element. - If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. - - - - Trigger - - " - `); - }); - - test('lists every disabled element the event skipped', async () => { - await render( - - - Trigger - - , - ); - - await fireEvent.press(screen.getByText('Trigger')); - - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - "Cannot fire the "press" event on disabled elements. - If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. - - - - Trigger - - - - - - - Trigger - - - " - `); - }); - - test('warns when no element handles the event', async () => { - await render( - - Trigger - , - ); - - await fireEvent.press(screen.getByText('Trigger')); - - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - "No "onPress" handler found on the element or its ancestors. - If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. - - - Trigger - " - `); - }); - - test('does not warn when the event bubbles to an enabled parent', async () => { - await render( - - - Inner Trigger - - , - ); - - await fireEvent.press(screen.getByText('Inner Trigger')); - - expect(warnSpy).not.toHaveBeenCalled(); - }); - - test('warns when the handler is blocked by pointerEvents="none" on an ancestor', async () => { - await render( - - - , - ); - - await fireEvent.press(screen.getByTestId('btn')); - - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - "Cannot fire the "press" event on an element blocked by pointerEvents. - If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. - - - - Blocked by: - - " - `); - }); - - test('warns when multiple handlers are blocked by pointerEvents="none" on an ancestor', async () => { - await render( - - - - - , - ); - - await fireEvent.press(screen.getByTestId('inner')); - - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - "Cannot fire the "press" event on elements blocked by pointerEvents. - If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. - - - - - - - - Blocked by: - - " - `); - }); - - test('reports pointerEvents rather than disabled when both block the handler', async () => { - await render( - - - , - ); - - await fireEvent.press(screen.getByTestId('btn')); - - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - "Cannot fire the "press" event on an element blocked by pointerEvents. - If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. - - - - Blocked by: - - " - `); - }); - - test('reports the element that blocks with pointerEvents', async () => { - await render( - - - - - - , - ); - - await fireEvent.press(screen.getByTestId('inside-box-only')); - await fireEvent.press(screen.getByTestId('box-none')); - - expect(warnSpy).toHaveBeenCalledTimes(2); - expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - "Cannot fire the "press" event on an element blocked by pointerEvents. - If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. - - - - Blocked by: - - " - `); - expect(warnSpy.mock.calls[1][0]).toMatchInlineSnapshot(` - "Cannot fire the "press" event on an element blocked by pointerEvents. - If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. - - - - Blocked by: - - " - `); - }); - - test('reports non-editable TextInput for events not affected by pointerEvents', async () => { - await render( - - - , - ); - - await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); - - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy.mock.calls[0][0]).toMatch( - /^Cannot fire the "changeText" event on a non-editable TextInput\./, - ); - }); - - test('does not warn when the responder declines the touch', async () => { - const onPress = jest.fn(); - // @ts-expect-error Host View does not declare `onPress`. - await render( false} onPress={onPress} />); - - await fireEvent.press(screen.getByTestId('view')); - - expect(onPress).not.toHaveBeenCalled(); - expect(warnSpy).not.toHaveBeenCalled(); - }); - - test('warns when the handler is blocked by non-editable TextInput', async () => { - await render(); - - await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); - - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - "Cannot fire the "changeText" event on a non-editable TextInput. - If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. - - " - `); - }); - - test.each([ - ['focus', 'onFocus'], - ['blur', 'onBlur'], - ['press', 'onPress'], - ])('warns when "%s" is blocked by non-editable TextInput', async (eventType, handlerName) => { - await render(); - - await fireEvent(screen.getByTestId('input'), eventType); - - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy.mock.calls[0][0]).toMatch( - new RegExp(`^Cannot fire the "${eventType}" event on a non-editable TextInput\\.`), - ); - }); - - test('names the non-editable TextInput when the handler is on its parent', async () => { - const onFocus = jest.fn(); - await render( - // Spread because `View` types include `onFocus` only since RN 0.88. - - - , - ); - - await fireEvent(screen.getByTestId('input'), 'focus'); - - expect(onFocus).not.toHaveBeenCalled(); - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - "Cannot fire the "focus" event on a non-editable TextInput. - If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. - - " - `); - }); - - test('reports disabled elements when they include a non-editable TextInput', async () => { - await render( - - - , - ); - - await fireEvent.press(screen.getByTestId('input')); - - 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(); - - await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); - - expect(warnSpy).not.toHaveBeenCalled(); - }); - - test('does not warn when eventDiagnostics is turned off', async () => { - configure({ eventDiagnostics: false }); - await render( - - - Disabled - - No handler - , - ); - - await fireEvent.press(screen.getByText('Disabled')); - await fireEvent.press(screen.getByText('No handler')); - await fireEvent.layout(screen.getByText('No handler')); - - expect(warnSpy).not.toHaveBeenCalled(); - }); -}); - -describe('pointerEvents prop', () => { - test('does not fire inside View with pointerEvents="none"', 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( - - - Trigger - - , - ); - await fireEvent.press(screen.getByText('Trigger')); - expect(onPress).not.toHaveBeenCalled(); - }); - - test('fires inside View with pointerEvents="box-none"', async () => { - const onPress = jest.fn(); - await render( - - - Trigger - - , - ); - await fireEvent.press(screen.getByText('Trigger')); - expect(onPress).toHaveBeenCalledTimes(1); - }); - - test('fires inside View with pointerEvents="auto"', async () => { - const onPress = jest.fn(); - await render( - - - Trigger - - , - ); - await fireEvent.press(screen.getByText('Trigger')); - expect(onPress).toHaveBeenCalledTimes(1); - }); - - test('does not fire deeply inside View with pointerEvents="box-only"', async () => { - const onPress = jest.fn(); - await render( - - - - Trigger - - - , - ); - await fireEvent.press(screen.getByText('Trigger')); - expect(onPress).not.toHaveBeenCalled(); - }); - - test('fires non-pointer events inside View with pointerEvents="box-none"', async () => { - const onLayout = jest.fn(); - await render(); - await fireEvent(screen.getByTestId('view'), 'layout'); - expect(onLayout).toHaveBeenCalled(); - }); - - test('fires on Pressable with pointerEvents="box-only" on itself', async () => { - const onPress = jest.fn(); - await render(); - await fireEvent.press(screen.getByTestId('pressable')); - expect(onPress).toHaveBeenCalled(); - }); -}); - -describe('non-editable TextInput', () => { - function WrappedTextInput(props: TextInputProps) { - return ; - } - - function DoubleWrappedTextInput(props: TextInputProps) { - return ; - } - - test('blocks touch-related events but allows non-touch events', async () => { - const onFocus = jest.fn(); - const onChangeText = jest.fn(); - const onSubmitEditing = jest.fn(); - const onLayout = jest.fn(); - - await render( - , - ); - - const input = screen.getByTestId('input'); - await fireEvent(input, 'focus'); - await fireEvent.changeText(input, 'Text'); - await fireEvent(input, 'submitEditing', { nativeEvent: { text: 'Text' } }); - await fireEvent(input, 'layout', layoutEvent); - - expect(onFocus).not.toHaveBeenCalled(); - expect(onChangeText).not.toHaveBeenCalled(); - expect(onSubmitEditing).not.toHaveBeenCalled(); - expect(onLayout).toHaveBeenCalledWith(layoutEvent); - }); - - test('blocks touch-related events when firing on nested Text child', async () => { - const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); - const onFocus = jest.fn(); - const onChangeText = jest.fn(); - const onSubmitEditing = jest.fn(); - const onLayout = jest.fn(); - - await render( - - Nested Text - , - ); - - const subject = screen.getByText('Nested Text'); - await fireEvent(subject, 'focus'); - await fireEvent(subject, 'onFocus'); - await fireEvent.changeText(subject, 'Text'); - await fireEvent(subject, 'submitEditing', { - nativeEvent: { text: 'Text' }, - }); - await fireEvent(subject, 'onSubmitEditing', { - nativeEvent: { text: 'Text' }, - }); - await fireEvent(subject, 'layout', layoutEvent); - await fireEvent(subject, 'onLayout', layoutEvent); - - expect(onFocus).not.toHaveBeenCalled(); - expect(onChangeText).not.toHaveBeenCalled(); - expect(onSubmitEditing).not.toHaveBeenCalled(); - // Layout is a direct event, but still bubbles to the parent TextInput with a warning - expect(onLayout).toHaveBeenCalledTimes(2); - expect(onLayout).toHaveBeenCalledWith(layoutEvent); - expect(warnSpy).toHaveBeenCalledTimes(2); - warnSpy.mockRestore(); - }); - - test.each([ - ['WrappedTextInput', WrappedTextInput], - ['DoubleWrappedTextInput', DoubleWrappedTextInput], - ])('blocks touch-related events on %s', async (_, Component) => { - const onFocus = jest.fn(); - const onChangeText = jest.fn(); - const onSubmitEditing = jest.fn(); - const onLayout = jest.fn(); - - await render( - , - ); - - const input = screen.getByTestId('input'); - await fireEvent(input, 'focus'); - await fireEvent.changeText(input, 'Text'); - await fireEvent(input, 'submitEditing', { nativeEvent: { text: 'Text' } }); - await fireEvent(input, 'layout', layoutEvent); - - expect(onFocus).not.toHaveBeenCalled(); - expect(onChangeText).not.toHaveBeenCalled(); - expect(onSubmitEditing).not.toHaveBeenCalled(); - expect(onLayout).toHaveBeenCalledWith(layoutEvent); - }); - - test('fires layout event', async () => { - const onLayout = jest.fn(); - await render(); - await fireEvent(screen.getByTestId('input'), 'layout'); - expect(onLayout).toHaveBeenCalled(); - }); - - test('fires scroll event', async () => { - const onScroll = jest.fn(); - await render(); - await fireEvent(screen.getByTestId('input'), 'scroll'); - expect(onScroll).toHaveBeenCalled(); - }); -}); - -describe('responder system', () => { - test('respects disabled prop through composite wrappers', async () => { - function TestChildTouchableComponent({ - onPress, - someProp, - }: { - onPress: () => void; - someProp: boolean; - }) { - return ( - - - Trigger - - - ); - } - const handlePress = jest.fn(); - await render( - - - , - ); - await fireEvent.press(screen.getByText('Trigger')); - expect(handlePress).not.toHaveBeenCalled(); - }); - - test('fires responderMove on PanResponder component', async () => { - const onDrag = jest.fn(); - function TestDraggableComponent({ onDrag }: { onDrag: () => void }) { - const responderHandlers = PanResponder.create({ - onMoveShouldSetPanResponder: () => true, - onPanResponderMove: onDrag, - }).panHandlers; - return ( - - Trigger - - ); - } - await render(); - await fireEvent(screen.getByText('Trigger'), 'responderMove', { - touchHistory: { mostRecentTimeStamp: '2', touchBank: [] }, - }); - expect(onDrag).toHaveBeenCalled(); - }); -}); diff --git a/src/events/legacy/fire-event.ts b/src/events/legacy/fire-event.ts index 306cc3c0d..d6eb9bfac 100644 --- a/src/events/legacy/fire-event.ts +++ b/src/events/legacy/fire-event.ts @@ -58,7 +58,6 @@ async function fireEventInternal(instance: TestInstance, options: FireEventOptio return; } - // `fireEvent` accepts event types with and without the `on*` prefix. const hasUpdatedNativeState = updateNativeStateFromEvent( instance, normalizeEventType(type), diff --git a/src/events/legacy/index.ts b/src/events/legacy/index.ts index 94a970247..688d02cc0 100644 --- a/src/events/legacy/index.ts +++ b/src/events/legacy/index.ts @@ -1,6 +1,4 @@ export * from './builders/common'; export * from '../shared/handler'; -export { isPointerEventEnabled } from './is-enabled'; +export { isPointerEventEnabled } from '../shared/pointer-events'; export * from '../shared/native-state'; -export type { EventWarning } from './warnings'; -export { formatDisabledTargets, getPointerEventsBlockedTargets, logEventWarning } from './warnings'; diff --git a/src/events/legacy/is-enabled.ts b/src/events/legacy/is-enabled.ts index 67429aa50..87d30dffe 100644 --- a/src/events/legacy/is-enabled.ts +++ b/src/events/legacy/is-enabled.ts @@ -1,27 +1,8 @@ -import { StyleSheet } from 'react-native'; import type { TestInstance } from 'test-renderer'; import { isHostTextInput } from '../../helpers/host-component-names'; import { isEditableTextInput } from '../../helpers/text-input'; - -/** - * pointerEvents controls whether the View can be the target of touch events. - * 'auto': The View and its children can be the target of touch events. - * 'none': The View is never the target of touch events. - * 'box-none': The View is never the target of touch events but its subviews can be - * 'box-only': The view can be the target of touch events but its subviews cannot be - * see the official react native doc https://reactnative.dev/docs/view#pointerevents */ -export function isPointerEventEnabled(instance: TestInstance): boolean { - return getPointerEventsBlocker(instance) == null; -} - -/** - * Returns the element whose `pointerEvents` prevents the instance from being the target of - * touch events: the instance itself or one of its ancestors. Returns `null` if nothing blocks it. - */ -export function getPointerEventsBlocker(instance: TestInstance): TestInstance | null { - return findPointerEventsBlocker(instance, false); -} +import { isPointerEventEnabled } from '../shared/pointer-events'; export function isTouchResponder(instance: TestInstance) { return Boolean(instance.props.onStartShouldSetResponder) || isHostTextInput(instance); @@ -73,21 +54,3 @@ export function isEventEnabled( return touchStart === undefined && touchMove === undefined; } - -function findPointerEventsBlocker(instance: TestInstance, isParent: boolean): TestInstance | null { - // Check both props.pointerEvents and props.style.pointerEvents - const pointerEvents = - instance?.props.pointerEvents ?? StyleSheet.flatten(instance?.props.style)?.pointerEvents; - - const parentCondition = isParent ? pointerEvents === 'box-only' : pointerEvents === 'box-none'; - - if (pointerEvents === 'none' || parentCondition) { - return instance; - } - - if (!instance.parent) { - return null; - } - - return findPointerEventsBlocker(instance.parent, true); -} diff --git a/src/events/legacy/propagation.ts b/src/events/legacy/propagation.ts index d947c5e4b..fe30ea5b7 100644 --- a/src/events/legacy/propagation.ts +++ b/src/events/legacy/propagation.ts @@ -7,7 +7,7 @@ import { isHostText, isHostTextInput, } from '../../helpers/host-component-names'; -import { getEventHandlerFromProps, normalizeEventType } from '../shared/handler'; +import { getEventHandlerByEventType, normalizeEventType } from '../shared/handler'; import type { EventHandler } from '../shared/types'; import { isEventEnabled, isTouchResponder } from './is-enabled'; import { warnAboutBubblingDirectEvent } from './warnings'; @@ -39,7 +39,7 @@ export function findEventHandler( options: FindEventHandlerOptions, ): FindEventHandlerResult { if (!options.bubbles) { - const handler = getEventHandlerFromProps(instance.props, eventType, { loose: true }); + const handler = getEventHandlerByEventType(instance.props, eventType, { loose: true }); return { handler: handler ?? null, skippedTargets: [] }; } @@ -74,7 +74,7 @@ function findBubblingHandlerOwner( const touchResponder = isTouchResponder(instance) ? instance : nearestTouchResponder; const handler = - getEventHandlerFromProps(instance.props, eventType, { loose: true }) ?? + getEventHandlerByEventType(instance.props, eventType, { loose: true }) ?? findEventHandlerFromFiber(instance.unstable_fiber, eventType); if (handler) { if (isEventEnabled(instance, normalizeEventType(eventType), touchResponder)) { @@ -159,7 +159,7 @@ function findEventHandlerFromFiber(fiber: Fiber | null, eventType: string): Even return null; } - const handler = getEventHandlerFromProps(fiber.memoizedProps, eventType, { + const handler = getEventHandlerByEventType(fiber.memoizedProps, eventType, { loose: true, }); if (handler) { diff --git a/src/events/legacy/warnings.ts b/src/events/legacy/warnings.ts index 5a81beade..b1c3faa68 100644 --- a/src/events/legacy/warnings.ts +++ b/src/events/legacy/warnings.ts @@ -3,28 +3,23 @@ import type { TestInstance } from 'test-renderer'; import { getConfig } from '../../config'; import { computeAriaDisabled } from '../../helpers/accessibility'; -import { formatElement, formatJson } from '../../helpers/format-element'; -import { isHostTextInput } from '../../helpers/host-component-names'; +import { formatElement } from '../../helpers/format-element'; import { logger } from '../../helpers/logger'; -import { isEditableTextInput } from '../../helpers/text-input'; import { getEventHandlerName, normalizeEventType } from '../shared/handler'; -import { getPointerEventsBlocker, isEventBlockableByPointerEvents } from './is-enabled'; +import type { EventWarning } from '../shared/warnings'; +import { + formatDisabledTargets, + getPointerEventsBlockedTargets, + logEventWarning, +} from '../shared/warnings'; +import { isEventBlockableByPointerEvents } from './is-enabled'; type UnhandledEventInfo = { skippedTargets: TestInstance[]; hasUpdatedNativeState: boolean; }; -export type EventWarning = { - message: string; - elements: TestInstance[]; - /** Elements whose `pointerEvents` blocked the event. */ - pointerEventsBlockers?: TestInstance[]; -}; - -/** - * Warns when no handler ran because the target is disabled or nothing handles the event. - */ +/** Warns when no handler ran because the target is disabled or nothing handles the event. */ export function warnAboutUnhandledEvent( instance: TestInstance, eventType: string, @@ -51,56 +46,6 @@ export function warnAboutBubblingDirectEvent(eventType: string, owner: TestInsta ); } -export function logEventWarning({ message, elements, pointerEventsBlockers = [] }: EventWarning) { - const header = - `${message}\n` + - 'If this is intentional, you can disable this warning via `configure({ eventDiagnostics: false })`.'; - const elementBlocks = elements - .map((element) => element.toJSON()) - .filter((json) => json != null) - .map((json) => redent(formatJson(json), 2)); - // Blockers are often large containers, so they are printed without their children. - const blockerBlocks = [...new Set(pointerEventsBlockers)].map((blocker) => - redent(formatElement(blocker), 2), - ); - const blockerSection = blockerBlocks.length > 0 ? ['Blocked by:', ...blockerBlocks] : []; - logger.warn([header, ...elementBlocks, ...blockerSection].join('\n\n')); -} - -/** - * Returns the targets that `pointerEvents` blocks along with their blockers, or `null` if it - * blocks none of them. - * - * @param targets Skipped elements, nearest first. - */ -export function getPointerEventsBlockedTargets( - targets: TestInstance[], -): Pick | null { - const elements: TestInstance[] = []; - const pointerEventsBlockers: TestInstance[] = []; - for (const target of targets) { - const blocker = getPointerEventsBlocker(target); - if (blocker != null) { - elements.push(target); - pointerEventsBlockers.push(blocker); - } - } - - return elements.length > 0 ? { elements, pointerEventsBlockers } : null; -} - -/** - * Describes the elements that `computeAriaDisabled` treats as disabled, e.g. "a disabled element". - * Non-editable `TextInput`s are called non-editable, which is what users set (`editable={false}`). - */ -export function formatDisabledTargets(targets: TestInstance[]): string { - if (targets.every((target) => isHostTextInput(target) && !isEditableTextInput(target))) { - return targets.length === 1 ? 'a non-editable TextInput' : 'non-editable TextInputs'; - } - - return targets.length === 1 ? 'a disabled element' : 'disabled elements'; -} - function getUnhandledEventWarning( instance: TestInstance, eventType: string, diff --git a/src/events/modern/__tests__/fire-event.test.tsx b/src/events/modern/__tests__/fire-event.test.tsx deleted file mode 100644 index 0970d6093..000000000 --- a/src/events/modern/__tests__/fire-event.test.tsx +++ /dev/null @@ -1,806 +0,0 @@ -import * as React from 'react'; -import { ScrollView, Text, TextInput, View } from 'react-native'; -import type { TestInstance } from 'test-renderer'; - -import { render, screen } from '../../..'; -import { nativeState } from '../../shared/native-state'; -import type { SyntheticEvent } from '../event'; -import type { FireEventInit } from '../fire-event'; -import { fireEvent } from '../fire-event'; - -const defaultScrollPayload = { - 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 }, -}; - -/** Calls `fireEvent` with arguments its types don't allow, as JavaScript callers can. */ -function fireEventUntyped(instance: TestInstance, eventType: string, ...args: unknown[]) { - return fireEvent(instance, eventType, ...(args as [FireEventInit])); -} - -describe('event object', () => { - test('throws without an event object', async () => { - const onFocus = jest.fn(); - await render(); - - await expect(fireEventUntyped(screen.getByTestId('input'), 'focus')).rejects.toThrow( - `Unable to fire a "focus" event. Please provide an event object, e.g. fireEvent(element, 'focus', { nativeEvent: {} }).`, - ); - expect(onFocus).not.toHaveBeenCalled(); - }); - - test('throws with more than one argument after the event type', async () => { - const onFocus = jest.fn(); - await render(); - - await expect( - fireEventUntyped(screen.getByTestId('input'), 'focus', { nativeEvent: {} }, {}), - ).rejects.toThrow( - 'Unable to fire a "focus" event. Expected a single event object, received 2 arguments.', - ); - expect(onFocus).not.toHaveBeenCalled(); - }); - - test.each([ - ['text', 'string "text"'], - [42, 'number 42'], - [null, 'null'], - [undefined, 'undefined'], - [[{ nativeEvent: {} }], 'an array'], - [new Date(0), 'an instance of Date'], - [() => {}, 'a function'], - ])('throws when the event is %p', async (event, description) => { - const onFocus = jest.fn(); - await render(); - - await expect(fireEventUntyped(screen.getByTestId('input'), 'focus', event)).rejects.toThrow( - `Unable to fire a "focus" event. Expected an event object, received ${description}.`, - ); - expect(onFocus).not.toHaveBeenCalled(); - }); - - test.each([ - [{ target: 1 }, '"target". "target" is set by the dispatch.'], - [ - { target: {}, currentTarget: { measure: () => {} } }, - '"target", "currentTarget". "target", "currentTarget" are set by the dispatch.', - ], - [{ 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" is set by the dispatch. "persist" is provided by the event. Pass "text" in "nativeEvent" instead.', - ], - ])('throws on unsupported keys %p', async (event, message) => { - const onFocus = jest.fn(); - await render(); - - await expect( - fireEventUntyped(screen.getByTestId('input'), 'focus', { nativeEvent: {}, ...event }), - ).rejects.toThrow(`Unable to fire a "focus" event. Unsupported event object keys: ${message}`); - expect(onFocus).not.toHaveBeenCalled(); - }); - - test('throws when nativeEvent is not an object', async () => { - await render(); - - await expect( - fireEventUntyped(screen.getByTestId('input'), 'focus', { nativeEvent: 'text' }), - ).rejects.toThrow( - 'Unable to fire a "focus" event. Expected "nativeEvent" to be an object, received string "text".', - ); - }); - - test.each([ - ['123', 'string "123"'], - [Number.NaN, 'number NaN'], - [Infinity, 'number Infinity'], - [null, 'null'], - ])('throws when timeStamp is %p', async (timeStamp, description) => { - const onFocus = jest.fn(); - await render(); - - await expect( - fireEventUntyped(screen.getByTestId('input'), 'focus', { timeStamp }), - ).rejects.toThrow( - `Unable to fire a "focus" event. Expected "timeStamp" to be a finite number, received ${description}.`, - ); - expect(onFocus).not.toHaveBeenCalled(); - }); - - 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: SyntheticEvent = 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 as event.nativeEvent', 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: SyntheticEvent = onSubmitEditing.mock.calls[0][0]; - expect(event.type).toBe('submitediting'); - expect(event.nativeEvent).toBe(nativeEvent); - expect(event.target).toBe(screen.getByTestId('input')); - }); - - test('uses an empty nativeEvent when the event object has none', async () => { - const onFocus = jest.fn(); - await render(); - - await fireEvent(screen.getByTestId('input'), 'focus', {}); - - expect(onFocus.mock.calls[0][0].nativeEvent).toEqual({}); - }); - - 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); - }); -}); - -test('throws without an element', async () => { - await expect( - fireEventUntyped(null as unknown as TestInstance, 'focus', { nativeEvent: {} }), - ).rejects.toThrow('Unable to fire a "focus" event. Please provide a host element.'); -}); - -test('accepts event type with or without the "on" prefix', async () => { - const onFocus = jest.fn(); - await render(); - - await fireEvent(screen.getByTestId('input'), 'focus', {}); - await fireEvent(screen.getByTestId('input'), 'onFocus', {}); - - expect(onFocus).toHaveBeenCalledTimes(2); - expect(onFocus.mock.calls[1][0].type).toBe('focus'); -}); - -test('dispatches bubbling events through capture and bubble phases', async () => { - const calls: string[] = []; - await render( - calls.push('parent.onPointerDownCapture')} - onPointerDown={() => calls.push('parent.onPointerDown')} - > - calls.push('target.onPointerDown')} /> - , - ); - - await fireEvent(screen.getByTestId('target'), 'pointerDown', {}); - - expect(calls).toEqual([ - 'parent.onPointerDownCapture', - 'target.onPointerDown', - 'parent.onPointerDown', - ]); -}); - -test('dispatches direct events to the target only', 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 call props of composite components', async () => { - const onCompositeFocus = jest.fn(); - function Wrapper({ children }: { children: React.ReactNode; onFocus: () => void }) { - return {children}; - } - - await render( - - - , - ); - - await fireEvent(screen.getByTestId('input'), 'focus', {}); - - expect(onCompositeFocus).not.toHaveBeenCalled(); -}); - -describe('event types unknown to React Native', () => { - test.each([ - ['changeText', 'changeText', '. Use fireEvent.changeText() or userEvent.type() instead.'], - ['onChangeText', 'changeText', '. Use fireEvent.changeText() or userEvent.type() instead.'], - ['pressIn', 'pressIn', '. Use userEvent.press() instead.'], - ['pressOut', 'pressOut', '. Use userEvent.press() instead.'], - ['longPress', 'longPress', '. Use userEvent.longPress() instead.'], - ['customEvent', 'customEvent', ', so no handler would be called.'], - ])('"%s" throws', async (eventType, normalizedType, hint) => { - const handler = jest.fn(); - const handlerProps = { - onChangeText: handler, - onPressIn: handler, - onPressOut: handler, - onLongPress: handler, - onCustomEvent: handler, - }; - await render(); - - await expect( - fireEventUntyped(screen.getByTestId('input'), eventType, { nativeEvent: {} }), - ).rejects.toThrow( - `Unable to fire a "${normalizedType}" event. React Native doesn't dispatch ` + - `"${normalizedType}" natively${hint}`, - ); - expect(handler).not.toHaveBeenCalled(); - }); - - test('throws before checking the event object', async () => { - await render(); - - await expect( - fireEventUntyped(screen.getByTestId('input'), 'changeText', 'Hello'), - ).rejects.toThrow(`React Native doesn't dispatch "changeText" natively`); - }); -}); - -test('returns false when a handler calls preventDefault()', async () => { - await render( - event.preventDefault()}> - Press me - , - ); - - expect(await fireEvent(screen.getByTestId('text'), 'press', {})).toBe(false); -}); - -test('returns true when no handler calls preventDefault()', async () => { - const onPress = jest.fn(); - await render( - - Press me - , - ); - - expect(await fireEvent(screen.getByTestId('text'), 'press', {})).toBe(true); - expect(onPress).toHaveBeenCalledTimes(1); -}); - -test('is a no-op on an unmounted element', async () => { - const onFocus = jest.fn(); - await render(); - const input = screen.getByTestId('input'); - await screen.rerender(); - - expect(await fireEvent(input, 'focus', {})).toBe(true); - expect(onFocus).not.toHaveBeenCalled(); -}); - -test('rejects with the handler error', async () => { - await render( - { - throw new Error('Handler error'); - }} - />, - ); - - await expect(fireEvent(screen.getByTestId('input'), 'focus', {})).rejects.toThrow( - 'Handler error', - ); -}); - -test('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', {}); - - expect(screen.getByText('Count: 1')).toBeOnTheScreen(); -}); - -describe('native state', () => { - test.each([ - 'scroll', - 'onScroll', - 'scrollBeginDrag', - 'scrollEndDrag', - 'momentumScrollBegin', - 'momentumScrollEnd', - ])('saves content offset of ScrollView from %s event', async (eventType) => { - await render(); - const scrollView = screen.getByTestId('scroll'); - - await fireEvent(scrollView, eventType, { nativeEvent: { contentOffset: { y: 200 } } }); - - expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ x: 0, y: 200 }); - }); - - test('does not save content offset from scroll event without one', async () => { - await render(); - const scrollView = screen.getByTestId('scroll'); - - await fireEvent(scrollView, 'scroll', { nativeEvent: {} }); - - expect(nativeState.contentOffsetForInstance.get(scrollView)).toBeUndefined(); - }); - - test('saves layout size from layout event, with non-finite values as 0', async () => { - await render(); - const view = screen.getByTestId('view'); - - await fireEvent(view, 'layout', { nativeEvent: { layout: { width: 50, height: NaN } } }); - - expect(nativeState.layoutSizeForInstance.get(view)).toEqual({ width: 50, height: 0 }); - }); - - test.each(['change', 'onChange'])('saves value of TextInput from %s event', async (eventType) => { - await render(); - const input = screen.getByTestId('input'); - - await fireEvent(input, eventType, { nativeEvent: { text: 'Hello' } }); - - expect(nativeState.valueForInstance.get(input)).toBe('Hello'); - }); - - test('does not save value from change event without text or on non-editable TextInput', async () => { - await render( - <> - - - , - ); - const input = screen.getByTestId('input'); - const nonEditable = screen.getByTestId('non-editable'); - - await fireEvent(input, 'change', { nativeEvent: {} }); - await fireEvent(nonEditable, 'change', { nativeEvent: { text: 'Hello' } }); - - expect(nativeState.valueForInstance.get(input)).toBeUndefined(); - expect(nativeState.valueForInstance.get(nonEditable)).toBeUndefined(); - }); - - test('is saved before handlers run', async () => { - let contentOffset; - const onScroll = jest.fn(() => { - contentOffset = nativeState.contentOffsetForInstance.get(screen.getByTestId('scroll')); - }); - await render(); - - await fireEvent(screen.getByTestId('scroll'), 'scroll', { - nativeEvent: { contentOffset: { y: 200 } }, - }); - - expect(contentOffset).toEqual({ x: 0, y: 200 }); - }); - - test('is not saved for unmounted element', async () => { - await render(); - const scrollView = screen.getByTestId('scroll'); - await screen.rerender(); - - await fireEvent(scrollView, 'scroll', { nativeEvent: { contentOffset: { y: 200 } } }); - - expect(nativeState.contentOffsetForInstance.get(scrollView)).toBeUndefined(); - }); -}); - -describe('fireEvent.changeText', () => { - 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', async () => { - const calls: string[] = []; - const onChange = jest.fn((_event: unknown) => calls.push('onChange')); - const onChangeText = jest.fn((_text: string) => calls.push('onChangeText')); - await render(); - - await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); - - expect(calls).toEqual(['onChange', 'onChangeText']); - expect(onChangeText).toHaveBeenCalledWith('Hello'); - const event = onChange.mock.calls[0][0] as SyntheticEvent; - expect(event.type).toBe('change'); - expect(event.target).toBe(screen.getByTestId('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', async () => { - const calls: string[] = []; - await render( - - calls.push('input.onChangeText')} - /> - , - ); - - await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); - - expect(calls).toEqual([ - 'parent.onChangeCapture', - 'input.onChangeCapture', - 'input.onChange', - 'input.onChangeText', - 'parent.onChange', - ]); - }); - - test('does not call onChangeText of an ancestor', async () => { - const onChangeText = jest.fn(); - await render( - - - , - ); - - await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); - - expect(onChangeText).not.toHaveBeenCalled(); - }); - - test('does not call onChange or onChangeText when a capture handler stops propagation', async () => { - const onChange = jest.fn(); - const onChangeText = jest.fn(); - await render( - event.stopPropagation() }}> - - , - ); - - await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); - - expect(onChange).not.toHaveBeenCalled(); - expect(onChangeText).not.toHaveBeenCalled(); - }); - - 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', async () => { - const onChangeText = jest.fn(); - await render( - { - throw new Error('Change error'); - }} - onChangeText={onChangeText} - />, - ); - - await expect(fireEvent.changeText(screen.getByTestId('input'), 'Hello')).rejects.toThrow( - 'Change error', - ); - expect(onChangeText).not.toHaveBeenCalled(); - }); - - test('calls onChangeText without onChange', async () => { - const onChangeText = jest.fn(); - await render(); - - await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); - - expect(onChangeText).toHaveBeenCalledWith('Hello'); - }); - - 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('returns false when a handler calls preventDefault()', async () => { - await render( event.preventDefault()} />); - - expect(await fireEvent.changeText(screen.getByTestId('input'), 'Hello')).toBe(false); - }); - - test('does nothing on non-editable TextInput', async () => { - const onChange = jest.fn(); - const onChangeText = jest.fn(); - await render( - , - ); - const input = screen.getByTestId('input'); - - expect(await fireEvent.changeText(input, 'Hello')).toBe(true); - - expect(onChange).not.toHaveBeenCalled(); - expect(onChangeText).not.toHaveBeenCalled(); - expect(nativeState.valueForInstance.get(input)).toBeUndefined(); - }); - - test('throws on elements other than TextInput', async () => { - const onChangeText = jest.fn(); - await render(); - - await expect(fireEvent.changeText(screen.getByTestId('view'), 'Hello')).rejects.toThrow( - 'Unable to fire a "changeText" event. Expected a host "TextInput" element, received "View".', - ); - expect(onChangeText).not.toHaveBeenCalled(); - }); - - test('throws when text is not a string', async () => { - await render(); - - await expect( - fireEvent.changeText(screen.getByTestId('input'), 5 as unknown as string), - ).rejects.toThrow( - 'Unable to fire a "changeText" event. Expected text to be a string, received number 5.', - ); - }); - - test('throws without an element', async () => { - await expect(fireEvent.changeText(null as unknown as TestInstance, 'Hello')).rejects.toThrow( - 'Unable to fire a "changeText" event. Please provide a host element.', - ); - }); -}); - -describe('fireEvent.scroll', () => { - test('passes default scroll payload as nativeEvent of a direct event', async () => { - const onScroll = jest.fn(); - await render(); - - await fireEvent.scroll(screen.getByTestId('scroll')); - - const event: SyntheticEvent = onScroll.mock.calls[0][0]; - expect(event.type).toBe('scroll'); - expect(event.rnIsDirect).toBe(true); - expect(event.nativeEvent).toEqual(defaultScrollPayload); - }); - - test('deep merges passed nativeEvent onto default payload', async () => { - const onScroll = jest.fn(); - await render(); - - await fireEvent.scroll(screen.getByTestId('scroll'), { - nativeEvent: { contentOffset: { y: 200 }, zoomScale: 2 }, - }); - - expect(onScroll.mock.calls[0][0].nativeEvent).toEqual({ - ...defaultScrollPayload, - contentOffset: { x: 0, y: 200 }, - zoomScale: 2, - }); - }); - - test('passes timeStamp to handlers', async () => { - const onScroll = jest.fn(); - await render(); - - await fireEvent.scroll(screen.getByTestId('scroll'), { timeStamp: 123 }); - - expect(onScroll.mock.calls[0][0].timeStamp).toBe(123); - }); - - test('saves content offset of ScrollView in native state', async () => { - await render(); - const scrollView = screen.getByTestId('scroll'); - - await fireEvent.scroll(scrollView, { nativeEvent: { contentOffset: { x: 50 } } }); - expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ x: 50, y: 0 }); - - await fireEvent.scroll(scrollView, { nativeEvent: { contentOffset: { y: Infinity } } }); - expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ x: 0, y: 0 }); - }); - - test('uses layout size of ScrollView as default layoutMeasurement', async () => { - const onScroll = jest.fn(); - await render(); - const scrollView = screen.getByTestId('scroll'); - - await fireEvent.layout(scrollView, { width: 390, height: 750 }); - await fireEvent.scroll(scrollView); - await fireEvent.scroll(scrollView, { - nativeEvent: { layoutMeasurement: { width: 100, height: 200 } }, - }); - - expect(onScroll.mock.calls[0][0].nativeEvent.layoutMeasurement).toEqual({ - width: 390, - height: 750, - }); - expect(onScroll.mock.calls[1][0].nativeEvent.layoutMeasurement).toEqual({ - width: 100, - height: 200, - }); - }); - - test('does not use layout size of other elements as layoutMeasurement', async () => { - const onScroll = jest.fn(); - await render(); - const view = screen.getByTestId('view'); - - await fireEvent.layout(view, { width: 390, height: 750 }); - await fireEvent.scroll(view); - - expect(onScroll.mock.calls[0][0].nativeEvent.layoutMeasurement).toEqual({ - width: 0, - height: 0, - }); - }); - - test('does not call onScroll of an ancestor ScrollView', async () => { - const onScroll = jest.fn(); - await render( - - - , - ); - - await fireEvent.scroll(screen.getByTestId('content')); - - expect(onScroll).not.toHaveBeenCalled(); - }); - - test('uses default payload when the event object is undefined', async () => { - const onScroll = jest.fn(); - await render(); - - await fireEvent.scroll(screen.getByTestId('scroll'), undefined); - - expect(onScroll.mock.calls[0][0].nativeEvent).toEqual(defaultScrollPayload); - }); - - test('validates the event object', async () => { - await render(); - - await expect( - fireEvent.scroll(screen.getByTestId('scroll'), { persist: () => {} } as FireEventInit), - ).rejects.toThrow('Unable to fire a "scroll" event. Unsupported event object keys: "persist".'); - }); - - test('throws without an element', async () => { - await expect(fireEvent.scroll(null as unknown as TestInstance)).rejects.toThrow( - 'Unable to fire a "scroll" event. Please provide a host element.', - ); - }); -}); - -describe('fireEvent.layout', () => { - test('passes zeroed layout as nativeEvent of a direct event', async () => { - const onLayout = jest.fn(); - await render(); - - await fireEvent.layout(screen.getByTestId('view')); - - const event: SyntheticEvent = onLayout.mock.calls[0][0]; - expect(event.type).toBe('layout'); - expect(event.rnIsDirect).toBe(true); - expect(event.nativeEvent).toEqual({ layout: { x: 0, y: 0, width: 0, height: 0 }, target: 0 }); - }); - - test('merges passed layout onto zeroed rectangle, with undefined fields as 0', async () => { - const onLayout = jest.fn(); - await render(); - - await fireEvent.layout(screen.getByTestId('view'), { width: 200, height: 80, x: undefined }); - - expect(onLayout.mock.calls[0][0].nativeEvent.layout).toEqual({ - x: 0, - y: 0, - width: 200, - height: 80, - }); - }); - - test('saves layout size in native state, also without onLayout handler', async () => { - await render(); - const view = screen.getByTestId('view'); - - await fireEvent.layout(view, { x: 10, y: 20, width: 100, height: 80 }); - - expect(nativeState.layoutSizeForInstance.get(view)).toEqual({ width: 100, height: 80 }); - }); - - test('does not call onLayout of an ancestor', async () => { - const onLayout = jest.fn(); - await render( - - - , - ); - - await fireEvent.layout(screen.getByTestId('child'), { height: 80 }); - - expect(onLayout).not.toHaveBeenCalled(); - }); - - test('does not call onLayout of composite component that does not forward it', async () => { - const onLayout = jest.fn(); - const Box = (_props: { onLayout: () => void }) => ; - await render(); - - await fireEvent.layout(screen.getByTestId('view')); - - expect(onLayout).not.toHaveBeenCalled(); - }); - - test('throws without an element', async () => { - await expect(fireEvent.layout(null as unknown as TestInstance)).rejects.toThrow( - 'Unable to fire a "layout" event. Please provide a host element.', - ); - }); -}); diff --git a/src/events/modern/__tests__/pressability.test.tsx b/src/events/modern/__tests__/pressability.test.tsx new file mode 100644 index 000000000..3c41d7348 --- /dev/null +++ b/src/events/modern/__tests__/pressability.test.tsx @@ -0,0 +1,134 @@ +import * as React from 'react'; +import { Pressable, Text, TouchableOpacity, View } from 'react-native'; + +import { render, screen } from '../../..'; +import { getEventHandlerName } from '../../shared/handler'; +import type { SyntheticEvent } from '../event'; +import { createEvent } from '../event'; +import { PRESSABILITY_EVENT_TYPES } from '../event-types'; +import { dispatchPressabilityEvent } from '../pressability'; + +function createTouchEvent() { + return createEvent('touchEnd', { nativeEvent: { pageX: 10 } }) as SyntheticEvent; +} + +function logCallbacks(calls: string[], id: string) { + const log = (name: string) => () => { + calls.push(`${id}.${name}`); + }; + return { + onPress: log('onPress'), + onPressIn: log('onPressIn'), + onPressOut: log('onPressOut'), + onLongPress: log('onLongPress'), + }; +} + +test.each(PRESSABILITY_EVENT_TYPES)( + '%s calls only Pressable and TouchableOpacity callback', + async (eventType) => { + const name = getEventHandlerName(eventType); + const calls: string[] = []; + await render( + <> + + Pressable + + + Touchable + + , + ); + + await dispatchPressabilityEvent(screen.getByText('Pressable'), eventType, createTouchEvent()); + await dispatchPressabilityEvent(screen.getByText('Touchable'), eventType, createTouchEvent()); + + expect(calls).toEqual([`pressable.${name}`, `touchable.${name}`]); + }, +); + +test.each(PRESSABILITY_EVENT_TYPES)( + '%s calls only host callback, incl. testOnly_ props', + async (eventType) => { + const name = getEventHandlerName(eventType); + const calls: string[] = []; + await render( + <> + Text + calls.push(`view.testOnly_${name}`) }} + /> + , + ); + + await dispatchPressabilityEvent(screen.getByText('Text'), eventType, createTouchEvent()); + await dispatchPressabilityEvent(screen.getByTestId('view'), eventType, createTouchEvent()); + + expect(calls).toEqual([`text.${name}`, `view.testOnly_${name}`]); + }, +); + +test('passes the event, with the hit target and the responder', async () => { + const onPressIn = jest.fn((event) => ({ + currentTarget: event.currentTarget, + target: event.target, + })); + await render( + + Press me + , + ); + const event = createTouchEvent(); + + await dispatchPressabilityEvent(screen.getByText('Press me'), 'pressIn', event); + + expect(onPressIn).toHaveBeenCalledWith(event); + expect(onPressIn.mock.results[0].value).toEqual({ + currentTarget: screen.getByTestId('pressable'), + target: screen.getByText('Press me'), + }); +}); + +test('host element with another press callback claims the touch, but gets nothing', async () => { + const onPressIn = jest.fn(); + await render( + + Press me + , + ); + + await dispatchPressabilityEvent(screen.getByText('Press me'), 'pressIn', createTouchEvent()); + + expect(onPressIn).not.toHaveBeenCalled(); +}); + +test('responder without the callback keeps the touch from its ancestors', async () => { + const onLongPress = jest.fn(); + await render( + + + Press me + + , + ); + + await dispatchPressabilityEvent(screen.getByText('Press me'), 'longPress', createTouchEvent()); + + expect(onLongPress).not.toHaveBeenCalled(); +}); + +test('does not call callbacks of disabled Pressable', async () => { + const calls: string[] = []; + await render( + + Press me + , + ); + + for (const eventType of PRESSABILITY_EVENT_TYPES) { + await dispatchPressabilityEvent(screen.getByText('Press me'), eventType, createTouchEvent()); + } + + expect(calls).toEqual([]); +}); diff --git a/src/events/modern/dispatch.ts b/src/events/modern/dispatch.ts index 290445c99..178aba44e 100644 --- a/src/events/modern/dispatch.ts +++ b/src/events/modern/dispatch.ts @@ -2,7 +2,7 @@ import type { TestInstance } from 'test-renderer'; import { act } from '../../act'; import { isInstanceMounted } from '../../helpers/component-tree'; -import { getHandlerByName } from '../shared/handler'; +import { getEventHandlerByName } from '../shared/handler'; import type { EventPhase } from './event'; import { getEventInternals, SyntheticEvent } from './event'; @@ -86,7 +86,6 @@ function getEventPath(target: TestInstance, event: SyntheticEvent): TestInstance return path; } -/** From the root down to the target, calling `on*Capture` props. Direct events skip it. */ function runCapturePhase(event: SyntheticEvent, path: TestInstance[], state: DispatchState) { const config = getEventInternals(event).typeConfig; if (config.kind === 'direct') { @@ -99,7 +98,6 @@ function runCapturePhase(event: SyntheticEvent, path: TestInstance[], state: Dis } } -/** From the target up to the root, calling `on*` props. Non-bubbling events stop at the target. */ function runBubblePhase( event: SyntheticEvent, path: TestInstance[], @@ -133,7 +131,7 @@ function callHandler( getEventInternals(event).eventPhase = phase; getEventInternals(event).currentTarget = node; - const handler = getHandlerByName(node.props, handlerName); + const handler = getEventHandlerByName(node.props, handlerName); if (handler == null && afterHandler == null) { return; } @@ -147,9 +145,8 @@ function callHandler( // In the same `try`, as a component's wrapper doesn't reach its own code after the prop throws. afterHandler?.(node, event); } catch (error) { - // Keep dispatching: one failing handler doesn't stop handlers on other elements, as in React - // Native (`handleListenerError()` in `EventTarget.js`) and the DOM. `dispatchEvent()` rethrows - // the first error once the whole dispatch is done. + // Keep dispatching, as React Native (`handleListenerError()` in `EventTarget.js`) and the DOM + // do. `dispatchEvent()` rethrows the first error once the dispatch is done. if (state.firstError == null) { state.firstError = { error }; } @@ -157,7 +154,7 @@ function callHandler( } /** Clears the event's internals, as the event can still be read after the dispatch. */ -function resetEvent(event: SyntheticEvent) { +export function resetEvent(event: SyntheticEvent) { getEventInternals(event).eventPhase = SyntheticEvent.NONE; getEventInternals(event).currentTarget = null; getEventInternals(event).composedPath = []; diff --git a/src/events/modern/event-types.ts b/src/events/modern/event-types.ts index 60dd241bb..1021ab123 100644 --- a/src/events/modern/event-types.ts +++ b/src/events/modern/event-types.ts @@ -82,8 +82,20 @@ export const DIRECT_EVENT_TYPES: readonly string[] = [ ]; /** - * Direct events whose handler prop doesn't follow the `on` + event type pattern. + * Pressability callbacks (`onPress`, `onPressIn`, ...) that modern `fireEvent` calls. They are not + * native events: Pressability calls them on the touch responder from responder events, so + * `dispatchPressabilityEvent()` calls them instead of `dispatchEvent()`. `press` is also a native + * event type, but `fireEvent` calls Pressability's `onPress` for it. */ +export const PRESSABILITY_EVENT_TYPES = ['press', 'pressIn', 'pressOut', 'longPress'] as const; + +export type PressabilityEventType = (typeof PRESSABILITY_EVENT_TYPES)[number]; + +export function isPressabilityEventType(eventType: string): eventType is PressabilityEventType { + return (PRESSABILITY_EVENT_TYPES as readonly string[]).includes(eventType); +} + +/** Direct events whose handler prop doesn't follow the `on` + event type pattern. */ const DIRECT_EVENT_HANDLER_NAMES: Readonly> = { // Added in React Native 0.89. safeAreaInsetsChange: 'experimental_onSafeAreaInsetsChange', @@ -96,7 +108,6 @@ type BubblingEventTypeConfig = { handlerName: string; /** Capture phase prop, e.g. `onFocusCapture`. */ captureHandlerName: string; - /** Runs the capture phase as usual, but calls only the target when bubbling. */ skipBubbling: boolean; }; diff --git a/src/events/modern/event.ts b/src/events/modern/event.ts index 30ee22fd4..a2feb8e1d 100644 --- a/src/events/modern/event.ts +++ b/src/events/modern/event.ts @@ -1,6 +1,9 @@ import type { TestInstance } from 'test-renderer'; -import type { EventTypeConfig } from './event-types'; +import { getEventHandlerName } from '../shared/handler'; +import { mergeEventProps } from '../shared/merge'; +import { buildTouchNativeEvent } from '../shared/payloads'; +import type { EventTypeConfig, PressabilityEventType } from './event-types'; import { getEventTypeConfig } from './event-types'; export type NativeEventPayload = Record; @@ -223,8 +226,32 @@ export function createEvent(eventType: string, init: CreateEventInit = {}): Synt return null; } + return createEventWithConfig(eventType, init, config); +} + +/** + * Creates the event passed to a Pressability callback, ready for `dispatchPressabilityEvent()`. + * React Native passes the responder event derived from a touch, so the passed `nativeEvent` is + * deep merged onto a default touch payload. Pressability calls only the touch responder's + * callback, so the event is direct. + * + * @param eventType e.g. `press` or `pressIn` + */ +export function createPressabilityEvent( + eventType: PressabilityEventType, + init: CreateEventInit, +): SyntheticEvent { + const nativeEvent = mergeEventProps(buildTouchNativeEvent(), init.nativeEvent); + const config: EventTypeConfig = { kind: 'direct', handlerName: getEventHandlerName(eventType) }; + return createEventWithConfig(eventType, { timeStamp: init.timeStamp, nativeEvent }, config); +} + +function createEventWithConfig( + eventType: string, + init: CreateEventInit, + config: EventTypeConfig, +): SyntheticEvent { const nativeEvent = init.nativeEvent ?? {}; - // React Native keeps the native timestamp as the event's `timeStamp`. const nativeTimeStamp = nativeEvent.timeStamp ?? nativeEvent.timestamp; const timeStamp = init.timeStamp ?? (typeof nativeTimeStamp === 'number' ? nativeTimeStamp : undefined); diff --git a/src/events/modern/fire-event-utils.ts b/src/events/modern/fire-event-utils.ts index 6da71db9c..3eed74dda 100644 --- a/src/events/modern/fire-event-utils.ts +++ b/src/events/modern/fire-event-utils.ts @@ -4,7 +4,7 @@ import { ErrorWithStack } from '../../helpers/errors'; import { isHostTextInput } from '../../helpers/host-component-names'; import type { CreateEventInit } from './event'; import { SyntheticEvent } from './event'; -import { getEventTypeConfig } from './event-types'; +import { getEventTypeConfig, isPressabilityEventType } from './event-types'; const ALLOWED_INIT_KEYS = ['nativeEvent', 'timeStamp']; @@ -18,15 +18,13 @@ const EVENT_KEYS = new Set( /** What to use instead of event types that only the legacy system or `userEvent` can fire. */ const UNKNOWN_EVENT_TYPE_ALTERNATIVES: Record = { changeText: 'fireEvent.changeText() or userEvent.type()', - pressIn: 'userEvent.press()', - pressOut: 'userEvent.press()', - longPress: 'userEvent.longPress()', }; /** * Throws for event types React Native doesn't dispatch natively, such as `changeText` or custom - * prop names. React Native drops them, so dropping them here would call no handler, and a test - * asserting that a handler wasn't called would pass for the wrong reason. + * prop names, except Pressability event types (`pressIn`, ...). React Native drops them, so + * dropping them here would call no handler, and a test asserting that a handler wasn't called + * would pass for the wrong reason. * * @param callsite the function to remove from the error stack, e.g. `fireEvent` */ @@ -35,7 +33,7 @@ export function ensureEventType( // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type callsite: Function, ): void { - if (getEventTypeConfig(eventType) != null) { + if (getEventTypeConfig(eventType) != null || isPressabilityEventType(eventType)) { return; } @@ -77,9 +75,6 @@ export function ensureSingleEventArg( } /** - * Validates an event object of modern `fireEvent`: a plain object, with only `nativeEvent` - * (a plain object) and `timeStamp` (a finite number) keys. - * * @param callsite the function to remove from the error stack, e.g. `fireEvent` */ export function validateEventInit( diff --git a/src/events/modern/fire-event.ts b/src/events/modern/fire-event.ts index 1a711c5df..403cf6cb8 100644 --- a/src/events/modern/fire-event.ts +++ b/src/events/modern/fire-event.ts @@ -3,7 +3,7 @@ import type { TestInstance } from 'test-renderer'; import { isInstanceMounted } from '../../helpers/component-tree'; import { ErrorWithStack } from '../../helpers/errors'; import { isEditableTextInput } from '../../helpers/text-input'; -import { getEventHandlerFromProps, normalizeEventType } from '../shared/handler'; +import { getEventHandlerByEventType, normalizeEventType } from '../shared/handler'; import { mergeEventProps } from '../shared/merge'; import { buildLayoutNativeEvent, @@ -18,13 +18,16 @@ import { import type { DispatchOptions } from './dispatch'; import { dispatchEvent } from './dispatch'; import type { CreateEventInit, NativeEventPayload } from './event'; -import { createEvent } from './event'; +import { createEvent, createPressabilityEvent } from './event'; +import type { PressabilityEventType } from './event-types'; +import { isPressabilityEventType } from './event-types'; import { ensureEventType, ensureSingleEventArg, validateChangeTextArgs, validateEventInit, } from './fire-event-utils'; +import { dispatchPressabilityEvent } from './pressability'; /** * Event object passed to modern `fireEvent`. Handlers receive a `SyntheticEvent` from @@ -36,24 +39,34 @@ export type FireEventInit = CreateEventInit; * Fires an event on a host element: `createEvent()`, then `dispatchEvent()`, like Testing Library's * DOM `fireEvent(element, event)`. The event type is needed to find the event's dispatch config. * - * Exactly one event object is required, as React Native handlers receive a single event. Event - * types React Native doesn't dispatch natively (`changeText`, custom prop names) throw. + * Pressability event types (`press`, `pressIn`, `pressOut`, `longPress`) call the callback of the + * element that becomes the touch responder instead, as `fireEvent.press()` does. + * + * Exactly one event object is required, as React Native handlers receive a single event. Other + * event types React Native doesn't dispatch natively (`changeText`, custom prop names) throw. + * + * Resolves to nothing, unlike DOM `fireEvent`: React Native has no default actions that + * `preventDefault()` could cancel. Handlers can read `event.defaultPrevented` themselves. * * @param eventType with or without the `on*` prefix, e.g. `focus` or `onFocus` - * @returns `false` if a handler called `preventDefault()`, otherwise `true`. */ export async function fireEvent( instance: TestInstance, eventType: string, ...args: [event: FireEventInit] -): Promise { +): Promise { const normalizedType = normalizeEventType(eventType); ensureInstance(instance, normalizedType, fireEvent); // Before the event object, as `fireEvent(input, 'changeText', 'Hello')` needs the hint more. ensureEventType(normalizedType, fireEvent); ensureSingleEventArg(normalizedType, args, fireEvent); const init = validateEventInit(normalizedType, args[0], fireEvent); - return await fireEventInternal(instance, normalizedType, init); + if (isPressabilityEventType(normalizedType)) { + await firePressabilityEvent(instance, normalizedType, init); + return; + } + + await fireEventInternal(instance, normalizedType, init); } /** @@ -61,44 +74,60 @@ export async function fireEvent( * and calls `onChangeText(text)` right after the input's own `onChange`, as `TextInput` does from * its `onChange` wrapper. Ancestors receive the bubbling `onChange`, but never `onChangeText`. * A non-editable `TextInput` receives no events, as on a device. - * - * @returns `false` if a handler called `preventDefault()`, otherwise `true`. */ -fireEvent.changeText = async (instance: TestInstance, text: string): Promise => { +fireEvent.changeText = async (instance: TestInstance, text: string): Promise => { ensureInstance(instance, 'changeText', fireEvent.changeText); validateChangeTextArgs(instance, text, fireEvent.changeText); if (!isEditableTextInput(instance)) { - return true; + return; } const selection = { start: text.length, end: text.length }; const payload = buildTextChangeNativeEvent(text, selection); const dispatchOptions: DispatchOptions = { afterTargetHandler: (target) => { - getEventHandlerFromProps(target.props, 'changeText')?.(text); + getEventHandlerByEventType(target.props, 'changeText')?.(text); }, }; - return await fireEventInternal(instance, 'change', {}, payload, dispatchOptions); + await fireEventInternal(instance, 'change', {}, payload, dispatchOptions); +}; + +/** + * Presses the element: calls `onPress` of the element that becomes the touch responder, as a tap + * on a device does. `Pressable` and `Touchable*` get it from their Pressability config, hosts with + * their own `onPress` (mocked `Text`, `testOnly_onPress`) directly. Disabled elements and + * responders that decline the touch pass it on to their ancestors. A `TextInput` always keeps the + * touch, even without `onPress`. `pointerEvents` moves the touch to the nearest ancestor it doesn't + * block. See `dispatchPressabilityEvent()`. + * + * Only `onPress` is called. Use `userEvent.press()` for `onPressIn`, `onPressOut` and timing. + * + * The passed `nativeEvent` is deep merged onto a default touch payload. + */ +fireEvent.press = async (instance: TestInstance, event: FireEventInit = {}): Promise => { + ensureInstance(instance, 'press', fireEvent.press); + const init = validateEventInit('press', event, fireEvent.press); + await firePressabilityEvent(instance, 'press', init); }; /** * Fires a `scroll` event. The passed `nativeEvent` is deep merged onto a default scroll payload, * whose `layoutMeasurement` is the `ScrollView`'s size from its last `layout` event. */ -fireEvent.scroll = async (instance: TestInstance, event: FireEventInit = {}): Promise => { +fireEvent.scroll = async (instance: TestInstance, event: FireEventInit = {}): Promise => { ensureInstance(instance, 'scroll', fireEvent.scroll); const init = validateEventInit('scroll', event, fireEvent.scroll); - return await fireEventInternal(instance, 'scroll', init, buildScrollNativeEvent()); + await fireEventInternal(instance, 'scroll', init, buildScrollNativeEvent()); }; /** Fires a `layout` event. The passed `layout` is merged onto a zeroed rectangle. */ fireEvent.layout = async ( instance: TestInstance, layout?: Partial, -): Promise => { +): Promise => { ensureInstance(instance, 'layout', fireEvent.layout); - return await fireEventInternal(instance, 'layout', {}, buildLayoutNativeEvent(layout)); + await fireEventInternal(instance, 'layout', {}, buildLayoutNativeEvent(layout)); }; /** @@ -114,22 +143,27 @@ async function fireEventInternal( init: CreateEventInit, basePayload?: NativeEventPayload, dispatchOptions?: DispatchOptions, -): Promise { +): Promise { const nativeEvent = basePayload ? mergeEventProps(basePayload, getNativeStateEventProps(instance, eventType), init.nativeEvent) : init.nativeEvent; const event = createEvent(eventType, { ...init, nativeEvent }); - if (event == null) { - return true; + if (event == null || !isInstanceMounted(instance)) { + return; } // Before the dispatch, as a device updates its native views before emitting the event. - if (isInstanceMounted(instance)) { - updateNativeStateFromEvent(instance, eventType, event); - } - + updateNativeStateFromEvent(instance, eventType, event); await dispatchEvent(instance, event, dispatchOptions); - return !event.defaultPrevented; +} + +async function firePressabilityEvent( + instance: TestInstance, + eventType: PressabilityEventType, + init: CreateEventInit, +): Promise { + const event = createPressabilityEvent(eventType, init); + await dispatchPressabilityEvent(instance, eventType, event); } function ensureInstance( diff --git a/src/events/modern/pressability.ts b/src/events/modern/pressability.ts new file mode 100644 index 000000000..680a217b6 --- /dev/null +++ b/src/events/modern/pressability.ts @@ -0,0 +1,180 @@ +import type { TestInstance } from 'test-renderer'; + +import { act } from '../../act'; +import { isInstanceMounted } from '../../helpers/component-tree'; +import { isHostTextInput } from '../../helpers/host-component-names'; +import { isDisabledHostText } from '../../helpers/text'; +import { + getEventHandlerByName, + getEventHandlerName, + hasPressEventHandler, +} from '../shared/handler'; +import { getPointerEventsBlocker } from '../shared/pointer-events'; +import type { EventHandler } from '../shared/types'; +import { resetEvent } from './dispatch'; +import type { SyntheticEvent } from './event'; +import { getEventInternals } from './event'; +import type { PressabilityEventType } from './event-types'; + +/** Callbacks of React Native's `PressabilityConfig` that `dispatchPressabilityEvent()` calls. */ +type PressabilityCallbackName = `on${Capitalize}`; + +/** The part of React Native's `PressabilityConfig` that `dispatchPressabilityEvent()` reads. */ +type PressabilityConfig = { disabled?: boolean | null } & { + [Name in PressabilityCallbackName]?: EventHandler | null; +}; + +type DispatchState = { + /** Wrapped, so a thrown `undefined` is rethrown too. */ + firstError: { error: unknown } | null; +}; + +/** + * Touches the element as on a device, and calls one Pressability callback (`onPress` for `press`, + * `onPressIn` for `pressIn`, ...) of the element that becomes the touch responder: + * + * 1. Hit testing: the touch targets the element, or its nearest ancestor when `pointerEvents` + * blocks it. + * 2. Responder negotiation, as React Native's `ResponderEventPlugin` does on touch start: + * `onStartShouldSetResponder` from the target to the root. The first one returning `true` wins. + * A disabled `Pressable` declines, so the touch goes on to its ancestors. Called without + * arguments, as in legacy `fireEvent` and `userEvent`. + * 3. The responder's callback: from its `PressabilityConfig` for `Pressable` and `Touchable*`, + * which Pressability exposes in tests as `onStartShouldSetResponder.testOnly_pressabilityConfig()`. + * Responders without Pressability, like `PanResponder`, get nothing. + * + * The Jest preset mocks `Text` and `TextInput`, so their host elements get `onPress*` props + * instead of responder props. A host element with any of these props (or their `testOnly_` + * variants) takes part in the negotiation as if it had Pressability. A host `TextInput` always + * claims the touch, as its Pressability does on a device, even without these props. It calls its + * callbacks even when not editable, as on iOS, where `editable={false}` only stops it from + * focusing. Known difference from Android: there, a non-editable `TextInput` is disabled and never + * the touch target, so the touch goes to its ancestors. A disabled `Text` declines the touch. + * + * One `act()` wraps it all, and a callback error is rethrown after it. During the call, + * `event.target` is the hit target and `event.currentTarget` the responder. + * + * Not implemented yet: the capture phase (`onStartShouldSetResponderCapture`), as `PanResponder` + * reads `touchHistory` there, which only the responder system's events have. + * + * Known limitation: `testOnly_pressabilityConfig` exists only when `NODE_ENV` is `test`, which + * Jest sets by default. Without it, `Pressable` and `Touchable*` claim the touch but get nothing. + * + * @param event from `createPressabilityEvent()`, passed to the callback. React Native passes the + * responder event derived from `touchStart` to `onPressIn`, and from `touchEnd` to `onPress` and + * `onPressOut`. + */ +export async function dispatchPressabilityEvent( + target: TestInstance, + eventType: PressabilityEventType, + event: SyntheticEvent, +): Promise { + if (!isInstanceMounted(target)) { + return; + } + + const path = getTouchPath(target); + if (path.length === 0) { + return; + } + + const callbackName = getEventHandlerName(eventType) as PressabilityCallbackName; + const state: DispatchState = { firstError: null }; + await act(() => { + getEventInternals(event).target = path[0]; + getEventInternals(event).composedPath = path; + try { + const responder = findResponder(path); + const callback = responder != null ? getCallback(responder, callbackName) : undefined; + if (responder != null && callback != null) { + const phase = responder === path[0] ? event.AT_TARGET : event.BUBBLING_PHASE; + getEventInternals(event).eventPhase = phase; + getEventInternals(event).currentTarget = responder; + callback.call(responder, event); + } + } catch (error) { + state.firstError = { error }; + } + + resetEvent(event); + }); + + // After `act()`, which would skip rendering state updates if its callback threw. + if (state.firstError != null) { + throw state.firstError.error; + } +} + +/** The hit target, then its host ancestors. Empty when `pointerEvents` blocks every element. */ +export function getTouchPath(target: TestInstance): TestInstance[] { + let hitTarget: TestInstance | null = target; + while (hitTarget?.parent != null && getPointerEventsBlocker(hitTarget) != null) { + hitTarget = hitTarget.parent; + } + + const path: TestInstance[] = []; + let current = hitTarget; + while (current?.parent != null) { + path.push(current); + current = current.parent; + } + + return path; +} + +/** The first element on the touch path that claims the touch, or `null` when none does. */ +export function findResponder(path: TestInstance[]): TestInstance | null { + for (const node of path) { + const handler = getEventHandlerByName(node.props, 'onStartShouldSetResponder'); + if (handler != null ? handler.call(node) : claimsTouchWithoutResponder(node)) { + return node; + } + } + + return null; +} + +function getCallback( + responder: TestInstance, + callbackName: PressabilityCallbackName, +): EventHandler | undefined { + const config = getPressabilityConfig(responder); + if (config == null) { + return getPressabilityEventHandler(responder, callbackName); + } + + return config.disabled !== true ? (config[callbackName] ?? undefined) : undefined; +} + +/** Config of `Pressable` and `Touchable*`, or `null` for elements without Pressability. */ +function getPressabilityConfig(instance: TestInstance): PressabilityConfig | null { + const getConfig: unknown = instance.props.onStartShouldSetResponder?.testOnly_pressabilityConfig; + return typeof getConfig === 'function' ? (getConfig() as PressabilityConfig) : null; +} + +/** + * For mocked hosts. `TextInput` claims the touch whatever its props, as on a device, where its + * Pressability config has no `disabled`. + */ +function claimsTouchWithoutResponder(instance: TestInstance): boolean { + return isHostTextInput(instance) || hasPressabilityEventHandler(instance); +} + +function hasPressabilityEventHandler(instance: TestInstance): boolean { + return !isDisabledHostText(instance) && hasPressEventHandler(instance.props); +} + +/** + * Own `onPress*` prop (or its `testOnly_` variant) of an element without a Pressability config, + * like a mocked `Text` or `TextInput` host. `undefined` for a disabled `Text`. + */ +function getPressabilityEventHandler( + instance: TestInstance, + eventHandlerName: string, +): EventHandler | undefined { + if (isDisabledHostText(instance)) { + return undefined; + } + + return getEventHandlerByName(instance.props, eventHandlerName); +} diff --git a/src/events/shared/__tests__/handler.test.tsx b/src/events/shared/__tests__/handler.test.tsx index 53bd2e03b..052cc75bb 100644 --- a/src/events/shared/__tests__/handler.test.tsx +++ b/src/events/shared/__tests__/handler.test.tsx @@ -2,9 +2,9 @@ import * as React from 'react'; import { Text, View } from 'react-native'; import { render, screen } from '../../..'; -import { getEventHandlerFromProps, getHandlerByName, normalizeEventType } from '../handler'; +import { getEventHandlerByEventType, getEventHandlerByName, normalizeEventType } from '../handler'; -test('getEventHandlerFromProps strict mode', async () => { +test('getEventHandlerByEventType strict mode', async () => { const onPress = jest.fn(); const testOnlyOnPress = jest.fn(); @@ -22,24 +22,24 @@ test('getEventHandlerFromProps strict mode', async () => { const testOnly = screen.getByTestId('testOnly'); const both = screen.getByTestId('both'); - expect(getEventHandlerFromProps(regular.props, 'press')).toBe(onPress); - expect(getEventHandlerFromProps(testOnly.props, 'press')).toBe(testOnlyOnPress); - expect(getEventHandlerFromProps(both.props, 'press')).toBe(onPress); + expect(getEventHandlerByEventType(regular.props, 'press')).toBe(onPress); + expect(getEventHandlerByEventType(testOnly.props, 'press')).toBe(testOnlyOnPress); + expect(getEventHandlerByEventType(both.props, 'press')).toBe(onPress); - expect(getEventHandlerFromProps(regular.props, 'onPress')).toBe(onPress); - expect(getEventHandlerFromProps(testOnly.props, 'onPress')).toBe(testOnlyOnPress); - expect(getEventHandlerFromProps(both.props, 'onPress')).toBe(onPress); + expect(getEventHandlerByEventType(regular.props, 'onPress')).toBe(onPress); + expect(getEventHandlerByEventType(testOnly.props, 'onPress')).toBe(testOnlyOnPress); + expect(getEventHandlerByEventType(both.props, 'onPress')).toBe(onPress); }); -test('getEventHandlerFromProps does not treat event names starting with "on" as prefixed', async () => { +test('getEventHandlerByEventType does not treat event names starting with "on" as prefixed', async () => { const onOnline = jest.fn(); // @ts-expect-error Intentionally passing such props await render(); - expect(getEventHandlerFromProps(screen.getByTestId('view').props, 'online')).toBe(onOnline); + expect(getEventHandlerByEventType(screen.getByTestId('view').props, 'online')).toBe(onOnline); }); -test('getEventHandlerFromProps loose mode', async () => { +test('getEventHandlerByEventType loose mode', async () => { const onPress = jest.fn(); const testOnlyOnPress = jest.fn(); @@ -57,18 +57,20 @@ test('getEventHandlerFromProps loose mode', async () => { const testOnly = screen.getByTestId('testOnly'); const both = screen.getByTestId('both'); - expect(getEventHandlerFromProps(regular.props, 'press', { loose: true })).toBe(onPress); - expect(getEventHandlerFromProps(testOnly.props, 'press', { loose: true })).toBe(testOnlyOnPress); - expect(getEventHandlerFromProps(both.props, 'press', { loose: true })).toBe(onPress); + expect(getEventHandlerByEventType(regular.props, 'press', { loose: true })).toBe(onPress); + expect(getEventHandlerByEventType(testOnly.props, 'press', { loose: true })).toBe( + testOnlyOnPress, + ); + expect(getEventHandlerByEventType(both.props, 'press', { loose: true })).toBe(onPress); - expect(getEventHandlerFromProps(regular.props, 'onPress', { loose: true })).toBe(onPress); - expect(getEventHandlerFromProps(testOnly.props, 'onPress', { loose: true })).toBe( + expect(getEventHandlerByEventType(regular.props, 'onPress', { loose: true })).toBe(onPress); + expect(getEventHandlerByEventType(testOnly.props, 'onPress', { loose: true })).toBe( testOnlyOnPress, ); - expect(getEventHandlerFromProps(both.props, 'onPress', { loose: true })).toBe(onPress); + expect(getEventHandlerByEventType(both.props, 'onPress', { loose: true })).toBe(onPress); }); -test('getEventHandlerFromProps loose mode matches handlers named without the `on*` prefix', async () => { +test('getEventHandlerByEventType loose mode matches handlers named without the `on*` prefix', async () => { const press = jest.fn(); const testOnlyPress = jest.fn(); @@ -84,11 +86,11 @@ test('getEventHandlerFromProps loose mode matches handlers named without the `on const regular = screen.getByTestId('regular'); const testOnly = screen.getByTestId('testOnly'); - expect(getEventHandlerFromProps(regular.props, 'press', { loose: true })).toBe(press); - expect(getEventHandlerFromProps(testOnly.props, 'press', { loose: true })).toBe(testOnlyPress); + expect(getEventHandlerByEventType(regular.props, 'press', { loose: true })).toBe(press); + expect(getEventHandlerByEventType(testOnly.props, 'press', { loose: true })).toBe(testOnlyPress); - expect(getEventHandlerFromProps(regular.props, 'press')).toBeUndefined(); - expect(getEventHandlerFromProps(testOnly.props, 'press')).toBeUndefined(); + expect(getEventHandlerByEventType(regular.props, 'press')).toBeUndefined(); + expect(getEventHandlerByEventType(testOnly.props, 'press')).toBeUndefined(); }); test('normalizeEventType strips the `on*` prefix', () => { @@ -100,7 +102,7 @@ test('normalizeEventType strips the `on*` prefix', () => { expect(normalizeEventType('on')).toBe('on'); }); -test('getHandlerByName returns function props with the exact name or its testOnly_ variant', () => { +test('getEventHandlerByName returns function props with the exact name or its testOnly_ variant', () => { const onFocus = jest.fn(); const testOnlyOnBlur = jest.fn(); const props = { @@ -110,8 +112,8 @@ test('getHandlerByName returns function props with the exact name or its testOnl onLayout: 'not a function', }; - expect(getHandlerByName(props, 'onFocus')).toBe(onFocus); - expect(getHandlerByName(props, 'onBlur')).toBe(testOnlyOnBlur); - expect(getHandlerByName(props, 'focus')).toBeUndefined(); - expect(getHandlerByName(props, 'onLayout')).toBeUndefined(); + expect(getEventHandlerByName(props, 'onFocus')).toBe(onFocus); + expect(getEventHandlerByName(props, 'onBlur')).toBe(testOnlyOnBlur); + expect(getEventHandlerByName(props, 'focus')).toBeUndefined(); + expect(getEventHandlerByName(props, 'onLayout')).toBeUndefined(); }); diff --git a/src/events/shared/handler.ts b/src/events/shared/handler.ts index ca6ce5a4e..9406ae522 100644 --- a/src/events/shared/handler.ts +++ b/src/events/shared/handler.ts @@ -5,14 +5,25 @@ type EventHandlerOptions = { loose?: boolean; }; -export function getEventHandlerFromProps( +export function getEventHandlerByEventType( props: Record, eventType: string, options?: EventHandlerOptions, ): EventHandler | undefined { + const eventHandlerName = getEventHandlerName(eventType); return ( - getHandlerByName(props, getEventHandlerName(eventType)) ?? - (options?.loose ? getHandlerByName(props, eventType) : undefined) + getEventHandlerByName(props, eventHandlerName) ?? + (options?.loose ? getEventHandlerByName(props, eventType) : undefined) + ); +} + +/** + * Whether the props include a Pressability callback (`onPress`, `onPressIn`, `onPressOut` or + * `onLongPress`), or its `testOnly_` variant. + */ +export function hasPressEventHandler(props: Record): boolean { + return ['press', 'pressIn', 'pressOut', 'longPress'].some( + (eventType) => getEventHandlerByEventType(props, eventType) != null, ); } @@ -20,14 +31,17 @@ export function getEventHandlerFromProps( * Returns the handler prop with exactly the given name (e.g. `onFocusCapture`), or else its * `testOnly_` variant (`testOnly_onFocusCapture`), which lets tests handle events on host elements. */ -export function getHandlerByName( +export function getEventHandlerByName( props: Record, - handlerName: string, + eventHandlerName: string, ): EventHandler | undefined { - return getHandlerProp(props, handlerName) ?? getHandlerProp(props, `testOnly_${handlerName}`); + return ( + getFunctionProp(props, eventHandlerName) ?? + getFunctionProp(props, `testOnly_${eventHandlerName}`) + ); } -function getHandlerProp( +function getFunctionProp( props: Record, propName: string, ): EventHandler | undefined { diff --git a/src/events/shared/invoke-event-handler.ts b/src/events/shared/invoke-event-handler.ts index d152acff6..d8bdd1bee 100644 --- a/src/events/shared/invoke-event-handler.ts +++ b/src/events/shared/invoke-event-handler.ts @@ -2,7 +2,7 @@ import type { TestInstance } from 'test-renderer'; import { act } from '../../act'; import { isInstanceMounted } from '../../helpers/component-tree'; -import { getEventHandlerFromProps } from './handler'; +import { getEventHandlerByEventType } from './handler'; /** * Calls the element's own handler prop for the event type (`onChangeText`, or @@ -22,7 +22,7 @@ export async function invokeEventHandler( return false; } - const handler = getEventHandlerFromProps(instance.props, eventType); + const handler = getEventHandlerByEventType(instance.props, eventType); if (!handler) { return false; } diff --git a/src/events/shared/payloads.ts b/src/events/shared/payloads.ts index 8e668971e..efa2f15f1 100644 --- a/src/events/shared/payloads.ts +++ b/src/events/shared/payloads.ts @@ -44,8 +44,6 @@ export function buildBlurNativeEvent() { } /** - * Delivered to `onAccessibilityAction` when an assistive technology triggers an action. - * * Experimental values: * - `{"actionName": "increment"}` */ diff --git a/src/events/shared/pointer-events.ts b/src/events/shared/pointer-events.ts new file mode 100644 index 000000000..3c0ad3e01 --- /dev/null +++ b/src/events/shared/pointer-events.ts @@ -0,0 +1,39 @@ +import { StyleSheet } from 'react-native'; +import type { TestInstance } from 'test-renderer'; + +/** + * pointerEvents controls whether the View can be the target of touch events. + * 'auto': The View and its children can be the target of touch events. + * 'none': The View is never the target of touch events. + * 'box-none': The View is never the target of touch events but its subviews can be + * 'box-only': The view can be the target of touch events but its subviews cannot be + * see the official react native doc https://reactnative.dev/docs/view#pointerevents */ +export function isPointerEventEnabled(instance: TestInstance): boolean { + return getPointerEventsBlocker(instance) == null; +} + +/** + * Returns the element whose `pointerEvents` prevents the instance from being the target of + * touch events: the instance itself or one of its ancestors. Returns `null` if nothing blocks it. + */ +export function getPointerEventsBlocker(instance: TestInstance): TestInstance | null { + return findPointerEventsBlocker(instance, false); +} + +function findPointerEventsBlocker(instance: TestInstance, isParent: boolean): TestInstance | null { + // Check both props.pointerEvents and props.style.pointerEvents + const pointerEvents = + instance?.props.pointerEvents ?? StyleSheet.flatten(instance?.props.style)?.pointerEvents; + + const parentCondition = isParent ? pointerEvents === 'box-only' : pointerEvents === 'box-none'; + + if (pointerEvents === 'none' || parentCondition) { + return instance; + } + + if (!instance.parent) { + return null; + } + + return findPointerEventsBlocker(instance.parent, true); +} diff --git a/src/events/shared/warnings.ts b/src/events/shared/warnings.ts new file mode 100644 index 000000000..35ff74266 --- /dev/null +++ b/src/events/shared/warnings.ts @@ -0,0 +1,65 @@ +import redent from 'redent'; +import type { TestInstance } from 'test-renderer'; + +import { formatElement, formatJson } from '../../helpers/format-element'; +import { isHostTextInput } from '../../helpers/host-component-names'; +import { logger } from '../../helpers/logger'; +import { isEditableTextInput } from '../../helpers/text-input'; +import { getPointerEventsBlocker } from './pointer-events'; + +export type EventWarning = { + message: string; + elements: TestInstance[]; + /** Elements whose `pointerEvents` blocked the event. */ + pointerEventsBlockers?: TestInstance[]; +}; + +export function logEventWarning({ message, elements, pointerEventsBlockers = [] }: EventWarning) { + const header = + `${message}\n` + + 'If this is intentional, you can disable this warning via `configure({ eventDiagnostics: false })`.'; + const elementBlocks = elements + .map((element) => element.toJSON()) + .filter((json) => json != null) + .map((json) => redent(formatJson(json), 2)); + // Blockers are often large containers, so they are printed without their children. + const blockerBlocks = [...new Set(pointerEventsBlockers)].map((blocker) => + redent(formatElement(blocker), 2), + ); + const blockerSection = blockerBlocks.length > 0 ? ['Blocked by:', ...blockerBlocks] : []; + logger.warn([header, ...elementBlocks, ...blockerSection].join('\n\n')); +} + +/** + * Returns the targets that `pointerEvents` blocks along with their blockers, or `null` if it + * blocks none of them. + * + * @param targets Skipped elements, nearest first. + */ +export function getPointerEventsBlockedTargets( + targets: TestInstance[], +): Pick | null { + const elements: TestInstance[] = []; + const pointerEventsBlockers: TestInstance[] = []; + for (const target of targets) { + const blocker = getPointerEventsBlocker(target); + if (blocker != null) { + elements.push(target); + pointerEventsBlockers.push(blocker); + } + } + + return elements.length > 0 ? { elements, pointerEventsBlockers } : null; +} + +/** + * Describes the elements that `computeAriaDisabled` treats as disabled, e.g. "a disabled element". + * Non-editable `TextInput`s are called non-editable, which is what users set (`editable={false}`). + */ +export function formatDisabledTargets(targets: TestInstance[]): string { + if (targets.every((target) => isHostTextInput(target) && !isEditableTextInput(target))) { + return targets.length === 1 ? 'a non-editable TextInput' : 'non-editable TextInputs'; + } + + return targets.length === 1 ? 'a disabled element' : 'disabled elements'; +} diff --git a/src/helpers/text.ts b/src/helpers/text.ts new file mode 100644 index 000000000..ca5351687 --- /dev/null +++ b/src/helpers/text.ts @@ -0,0 +1,17 @@ +import type { TestInstance } from 'test-renderer'; + +import { isHostText } from './host-component-names'; + +/** + * Whether the element is a host `Text` that React Native's `Text` disables. It reads `disabled`, + * then `aria-disabled`, then `accessibilityState.disabled`, so `disabled={false}` wins over + * `aria-disabled`. The Jest preset mocks `Text`, so its host element gets these props unchanged. + */ +export function isDisabledHostText(instance: TestInstance): boolean { + if (!isHostText(instance)) { + return false; + } + + const { props } = instance; + return (props.disabled ?? props['aria-disabled'] ?? props.accessibilityState?.disabled) === true; +} diff --git a/src/test-utils/event-system.ts b/src/test-utils/event-system.ts index f3043be1c..d45709b03 100644 --- a/src/test-utils/event-system.ts +++ b/src/test-utils/event-system.ts @@ -1,4 +1,9 @@ -import { configure } from '../config'; +import { configure, getConfig } from '../config'; + +/** Whether the test runs in the modern event system, for tests that branch on it. */ +export function isModernEventSystem() { + return getConfig().unstable_eventSystem === 'modern'; +} /** * Runs tests in the legacy event system in both Jest projects, for legacy behavior the modern diff --git a/src/user-event/__tests__/clear.test.tsx b/src/user-event/__tests__/clear.test.tsx index 16e581295..78322cb93 100644 --- a/src/user-event/__tests__/clear.test.tsx +++ b/src/user-event/__tests__/clear.test.tsx @@ -3,7 +3,7 @@ import type { TextInputProps } from 'react-native'; import { TextInput, View } from 'react-native'; import { render, screen, userEvent } from '../..'; -import { getConfig } from '../../config'; +import { isModernEventSystem } from '../../test-utils/event-system'; import { createEventLogger, getEventsNames } from '../../test-utils/events'; beforeEach(() => { @@ -206,11 +206,9 @@ describe('clear()', () => { const user = userEvent.setup(); await user.clear(screen.getByTestId('input')); - // `focus`, `keyPress`, `change`, `endEditing` and `blur` are bubbling events in React Native. + // These bubble to the parent in React Native. const modernEvents = ['focus', 'keyPress', 'change', 'endEditing', 'blur']; - expect(getEventsNames(events)).toEqual( - getConfig().unstable_eventSystem === 'modern' ? modernEvents : [], - ); + expect(getEventsNames(events)).toEqual(isModernEventSystem() ? modernEvents : []); }); test('sets native state value for unmanaged text inputs', async () => { diff --git a/src/user-event/__tests__/event-diagnostics.test.tsx b/src/user-event/__tests__/event-diagnostics.test.tsx index b82e80638..5209c8974 100644 --- a/src/user-event/__tests__/event-diagnostics.test.tsx +++ b/src/user-event/__tests__/event-diagnostics.test.tsx @@ -4,6 +4,7 @@ import { Pressable, ScrollView, Text, TextInput, View } from 'react-native'; import { render, screen, userEvent } from '../..'; import { configure } from '../../config'; import { logger } from '../../helpers/logger'; +import { isModernEventSystem } from '../../test-utils/event-system'; let warnSpy: jest.SpyInstance; @@ -182,17 +183,69 @@ test('warns when clearing or pasting into a non-editable TextInput', async () => ); }); -test('warns when pressing a non-editable TextInput', async () => { +test('does not warn when pressing a non-editable TextInput (legacy: warns it is non-editable)', async () => { + const onPressIn = jest.fn(); + await render(); + const user = userEvent.setup(); + + await user.press(screen.getByTestId('input')); + + // Modern matches iOS, where `editable={false}` only stops TextInput from focusing. + const isModern = isModernEventSystem(); + expect(onPressIn).toHaveBeenCalledTimes(isModern ? 1 : 0); + expect(warnSpy).toHaveBeenCalledTimes(isModern ? 0 : 1); + if (isModern) return; + expect(warnSpy.mock.calls[0][0]).toMatch( + /^press\(\) interaction did not call any event handlers\. Cannot interact with a non-editable TextInput\./, + ); +}); + +test('warns when pressing a TextInput without handlers inside a Pressable (legacy: calls onPress of the Pressable)', async () => { + const onPress = jest.fn(); await render( - , + + + , ); const user = userEvent.setup(); await user.press(screen.getByTestId('input')); - expect(warnSpy).toHaveBeenCalledTimes(1); + // Modern matches React Native: the TextInput claims the touch, so the warning names it, not the + // Pressable. Legacy passes the press on to the Pressable. + const isModern = isModernEventSystem(); + expect(onPress).toHaveBeenCalledTimes(isModern ? 0 : 1); + expect(warnSpy).toHaveBeenCalledTimes(isModern ? 1 : 0); + if (!isModern) return; + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "press() interaction did not call any event handlers. The element has no handler for the "pressIn", "pressOut" or "press" events. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + " + `); +}); + +test('warns when pressing a non-editable TextInput without handlers inside a Pressable (legacy: calls onPress of the Pressable)', async () => { + const onPress = jest.fn(); + await render( + + + , + ); + const user = userEvent.setup(); + + await user.press(screen.getByTestId('input')); + + // Modern matches iOS: same as an editable TextInput, it claims the touch, so the warning names + // it. Legacy skips the non-editable TextInput and passes the press on to the Pressable. + const isModern = isModernEventSystem(); + expect(onPress).toHaveBeenCalledTimes(isModern ? 0 : 1); + expect(warnSpy).toHaveBeenCalledTimes(isModern ? 1 : 0); + if (!isModern) return; expect(warnSpy.mock.calls[0][0]).toMatch( - /^press\(\) interaction did not call any event handlers\. Cannot interact with a non-editable TextInput\./, + /^press\(\) interaction did not call any event handlers\. The element has no handler for the "pressIn", "pressOut" or "press" events\./, ); }); diff --git a/src/user-event/__tests__/paste.test.tsx b/src/user-event/__tests__/paste.test.tsx index 289919dea..e4b30fa54 100644 --- a/src/user-event/__tests__/paste.test.tsx +++ b/src/user-event/__tests__/paste.test.tsx @@ -3,7 +3,7 @@ import type { TextInputProps } from 'react-native'; import { TextInput, View } from 'react-native'; import { render, screen, userEvent } from '../..'; -import { getConfig } from '../../config'; +import { isModernEventSystem } from '../../test-utils/event-system'; import { createEventLogger, getEventsNames } from '../../test-utils/events'; beforeEach(() => { @@ -222,11 +222,9 @@ describe('paste()', () => { const user = userEvent.setup(); await user.paste(screen.getByTestId('input'), 'Hi!'); - // `focus`, `keyPress`, `change`, `endEditing` and `blur` are bubbling events in React Native. + // These bubble to the parent in React Native. const modernEvents = ['focus', 'change', 'endEditing', 'blur']; - expect(getEventsNames(events)).toEqual( - getConfig().unstable_eventSystem === 'modern' ? modernEvents : [], - ); + expect(getEventsNames(events)).toEqual(isModernEventSystem() ? modernEvents : []); }); test('sets native state value for unmanaged text inputs', async () => { diff --git a/src/user-event/legacy/press.ts b/src/user-event/legacy/press.ts new file mode 100644 index 000000000..5ed0a7dcc --- /dev/null +++ b/src/user-event/legacy/press.ts @@ -0,0 +1,63 @@ +import type { TestInstance } from 'test-renderer'; + +import { hasPressEventHandler, isPointerEventEnabled } from '../../events/legacy'; +import { isHostText, isHostTextInput } from '../../helpers/host-component-names'; +import type { Interaction } from '../utils'; + +/** + * Finds the element that receives the press, from the instance up to the root, and sets it as + * `interaction.target`: the nearest enabled host element with `onPress*` props (`'host'`), or + * touch responder that accepts the touch (`'responder'`). Elements that could handle the press, + * but are disabled or block touches, are added to `interaction.skippedTargets`. + * + * A non-editable `TextInput` is skipped, and a `TextInput` without `onPress*` props passes the + * press on to its ancestors. Modern `findPressTarget()` keeps it, as React Native does. + * + * @returns how to press the target, or `null` when no element receives the press. + */ +export function findPressTarget( + interaction: Interaction, + instance: TestInstance, +): 'host' | 'responder' | null { + let current: TestInstance | null = instance; + while (current != null) { + if (isEnabledHostElement(current) && hasPressEventHandler(current.props)) { + interaction.target = current; + return 'host'; + } + + if (isEnabledTouchResponder(current)) { + interaction.target = current; + return 'responder'; + } + + // The element could handle the press, but is disabled or blocks touches. + if (hasPressEventHandler(current.props) || current.props.onStartShouldSetResponder) { + interaction.skippedTargets.push(current); + } + + current = current.parent; + } + + return null; +} + +function isEnabledHostElement(instance: TestInstance) { + if (!isPointerEventEnabled(instance)) { + return false; + } + + if (isHostText(instance)) { + return instance.props.disabled !== true; + } + + if (isHostTextInput(instance)) { + return instance.props.editable !== false; + } + + return true; +} + +function isEnabledTouchResponder(instance: TestInstance) { + return isPointerEventEnabled(instance) && instance.props.onStartShouldSetResponder?.(); +} diff --git a/src/user-event/modern/press.ts b/src/user-event/modern/press.ts new file mode 100644 index 000000000..1a06aa509 --- /dev/null +++ b/src/user-event/modern/press.ts @@ -0,0 +1,40 @@ +import type { TestInstance } from 'test-renderer'; + +import { findResponder, getTouchPath } from '../../events/modern/pressability'; +import { getEventHandlerByName, hasPressEventHandler } from '../../events/shared/handler'; +import type { Interaction } from '../utils'; + +/** + * Sets `interaction.target` to the touch responder, the element modern `fireEvent.press()` calls + * (see `dispatchPressabilityEvent()`). A responder with `onStartShouldSetResponder` (`Pressable`, + * `Touchable*`, `PanResponder`) is pressed with responder events (`'responder'`); a mocked host + * without one, like `Text` or `TextInput`, gets its `onPress*` props called (`'host'`). Elements + * between the pressed element and the responder that could handle the press, but are disabled or + * block touches, are added to `interaction.skippedTargets`. + * + * @returns how to press the target, or `null` when no element receives the press. + */ +export function findPressTarget( + interaction: Interaction, + instance: TestInstance, +): 'host' | 'responder' | null { + const touchResponder = findResponder(getTouchPath(instance)); + + let current: TestInstance | null = instance; + while (current != null && current !== touchResponder) { + if (hasPressEventHandler(current.props) || current.props.onStartShouldSetResponder) { + interaction.skippedTargets.push(current); + } + + current = current.parent; + } + + if (touchResponder == null) { + return null; + } + + interaction.target = touchResponder; + return getEventHandlerByName(touchResponder.props, 'onStartShouldSetResponder') != null + ? 'responder' + : 'host'; +} diff --git a/src/user-event/press/__tests__/press.real-timers.test.tsx b/src/user-event/press/__tests__/press.real-timers.test.tsx index db7be70c4..674005fd0 100644 --- a/src/user-event/press/__tests__/press.real-timers.test.tsx +++ b/src/user-event/press/__tests__/press.real-timers.test.tsx @@ -11,6 +11,7 @@ import { import { userEvent } from '../..'; import { render, screen } from '../../..'; +import { isModernEventSystem } from '../../../test-utils/event-system'; import { createEventLogger, getEventsNames } from '../../../test-utils/events'; // Typical event order is pressIn, pressOut, press @@ -272,7 +273,7 @@ describe('userEvent.press with real timers', () => { expect(events).toEqual([]); }); - test('does not call onPressIn and onPressOut on non editable TextInput', async () => { + test('calls onPressIn and onPressOut on non editable TextInput (legacy: calls nothing)', async () => { const { events, logEvent } = createEventLogger(); await render( @@ -284,7 +285,8 @@ describe('userEvent.press with real timers', () => { />, ); await userEvent.press(screen.getByPlaceholderText('email')); - expect(events).toEqual([]); + // Modern matches iOS, where `editable={false}` only stops TextInput from focusing. + expect(getEventsNames(events)).toEqual(isModernEventSystem() ? ['pressIn', 'pressOut'] : []); }); test('does not call onPressIn and onPressOut on TextInput with pointer events disabled', async () => { diff --git a/src/user-event/press/__tests__/press.test.tsx b/src/user-event/press/__tests__/press.test.tsx index 7ab2eb80a..d86918525 100644 --- a/src/user-event/press/__tests__/press.test.tsx +++ b/src/user-event/press/__tests__/press.test.tsx @@ -11,6 +11,7 @@ import { import { userEvent } from '../..'; import { render, screen } from '../../..'; +import { isModernEventSystem } from '../../../test-utils/event-system'; import { createEventLogger, getEventsNames } from '../../../test-utils/events'; describe('userEvent.press with fake timers', () => { @@ -82,6 +83,54 @@ describe('userEvent.press with fake timers', () => { expect(getEventsNames(events)).toEqual(['pressIn', 'pressOut', 'press']); }); + test('passes the same touch event to onPress of Text as to onPressIn', async () => { + const { events, logEvent } = createEventLogger(); + + await render( + + press me + , + ); + + await userEvent.press(screen.getByText('press me')); + const [pressInEvent, pressEvent] = events.map((event) => event.payload); + expect(pressEvent).toMatchObject({ + target: {}, + currentTarget: { measure: expect.any(Function) }, + nativeEvent: pressInEvent.nativeEvent, + }); + }); + + test('does not call onPress of the Text around pressed Text', async () => { + const onPressOuter = jest.fn(); + const onPressInner = jest.fn(); + + await render( + + outer inner + , + ); + + await userEvent.press(screen.getByText('inner')); + expect(onPressInner).toHaveBeenCalledTimes(1); + expect(onPressOuter).not.toHaveBeenCalled(); + }); + + test('does not call testOnly_onPress of the View around pressed View', async () => { + const onPressOuter = jest.fn(); + const onPressInner = jest.fn(); + + await render( + + + , + ); + + await userEvent.press(screen.getByTestId('inner')); + expect(onPressInner).toHaveBeenCalledTimes(1); + expect(onPressOuter).not.toHaveBeenCalled(); + }); + test('works on TextInput', async () => { const { events, logEvent } = createEventLogger(); @@ -218,6 +267,29 @@ describe('userEvent.press with fake timers', () => { expect(getEventsNames(events)).toEqual(['pressIn', 'pressOut', 'press']); }); + test('calls press on pressable with pointer events box-none from its child (legacy: does not call)', async () => { + const { events, logEvent } = createEventLogger(); + const user = userEvent.setup(); + + await render( + + Trigger + , + ); + await user.press(screen.getByText('Trigger')); + + // Modern matches React Native: `box-none` only stops hit testing on the `Pressable` itself, so + // its child is the hit target and the `Pressable` still claims the touch as the responder. + expect(getEventsNames(events)).toEqual( + isModernEventSystem() ? ['pressIn', 'pressOut', 'press'] : [], + ); + }); + test('crawls up in the tree to find an element that responds to touch events', async () => { const mockOnPress = jest.fn(); @@ -281,6 +353,46 @@ describe('userEvent.press with fake timers', () => { expect(events).toEqual([]); }); + test.each([ + ['aria-disabled', { 'aria-disabled': true }], + ['accessibilityState.disabled', { accessibilityState: { disabled: true } }], + ])('does not trigger on Text disabled by %s (legacy: triggers)', async (_, disabledProps) => { + const { events, logEvent } = createEventLogger(); + + await render( + + press me + , + ); + await userEvent.press(screen.getByText('press me')); + + // Modern matches React Native's `Text`, which falls back to these props when `disabled` is + // not set. Legacy checks only `disabled`. + expect(getEventsNames(events)).toEqual( + isModernEventSystem() ? [] : ['pressIn', 'pressOut', 'press'], + ); + }); + + test('triggers on Text with disabled={false} over aria-disabled', async () => { + const { events, logEvent } = createEventLogger(); + + await render( + + press me + , + ); + await userEvent.press(screen.getByText('press me')); + + // React Native's `Text` reads `disabled` first. + expect(getEventsNames(events)).toEqual(['press']); + }); + test('does not trigger on Text with disabled pointer events', async () => { const { events, logEvent } = createEventLogger(); @@ -301,7 +413,41 @@ describe('userEvent.press with fake timers', () => { expect(events).toEqual([]); }); - test('does not call onPressIn and onPressOut on non editable TextInput', async () => { + test('does not call onPress of the Pressable around TextInput (legacy: calls it for TextInput without onPress* props)', async () => { + const { events, logEvent } = createEventLogger(); + + await render( + + + + , + ); + + // Modern matches React Native: TextInput's Pressability always claims the touch, even without + // `onPress*` props, so it never reaches the Pressable. + await userEvent.press(screen.getByPlaceholderText('plain')); + await userEvent.press(screen.getByPlaceholderText('with pressIn')); + expect(getEventsNames(events)).toEqual( + isModernEventSystem() ? ['inputPressIn'] : ['press', 'inputPressIn'], + ); + }); + + test('calls onPressIn of non editable TextInput, not onPress of the Pressable around it (legacy: the reverse)', async () => { + const { events, logEvent } = createEventLogger(); + + await render( + + + , + ); + + await userEvent.press(screen.getByPlaceholderText('email')); + // Modern matches iOS: a non-editable TextInput claims the touch too, and calls its callbacks, + // as `editable={false}` only stops it from focusing. Legacy skips it, as Android does. + expect(getEventsNames(events)).toEqual(isModernEventSystem() ? ['inputPressIn'] : ['press']); + }); + + test('calls onPressIn and onPressOut on non editable TextInput (legacy: calls nothing)', async () => { const { events, logEvent } = createEventLogger(); await render( @@ -314,7 +460,8 @@ describe('userEvent.press with fake timers', () => { ); await userEvent.press(screen.getByPlaceholderText('email')); - expect(events).toEqual([]); + // Modern matches iOS, where `editable={false}` only stops TextInput from focusing. + expect(getEventsNames(events)).toEqual(isModernEventSystem() ? ['pressIn', 'pressOut'] : []); }); test('does not call onPressIn and onPressOut on TextInput with pointer events disabled', async () => { diff --git a/src/user-event/press/press.ts b/src/user-event/press/press.ts index 5d9aa602d..95e172564 100644 --- a/src/user-event/press/press.ts +++ b/src/user-event/press/press.ts @@ -1,16 +1,12 @@ import type { TestInstance } from 'test-renderer'; import { act } from '../../act'; -import { buildPressEvent } from '../../events/create-event'; -import { - buildResponderGrantEvent, - buildResponderReleaseEvent, - getEventHandlerFromProps, - isPointerEventEnabled, -} from '../../events/legacy'; +import { getConfig } from '../../config'; +import { buildResponderGrantEvent, buildResponderReleaseEvent } from '../../events/legacy'; import { isTestInstance } from '../../helpers/component-tree'; import { ErrorWithStack } from '../../helpers/errors'; -import { isHostText, isHostTextInput } from '../../helpers/host-component-names'; +import { findPressTarget as findLegacyPressTarget } from '../legacy/press'; +import { findPressTarget as findModernPressTarget } from '../modern/press'; import type { UserEventConfig, UserEventInstance } from '../setup'; import { Interaction, wait, warnAboutUnhandledInteraction } from '../utils'; @@ -63,59 +59,16 @@ const basePress = async ( instance: TestInstance, options: BasePressOptions, ): Promise => { - if (isEnabledHostElement(instance) && hasPressEventHandler(instance)) { - interaction.target = instance; + const findPressTarget = + getConfig().unstable_eventSystem === 'modern' ? findModernPressTarget : findLegacyPressTarget; + const targetKind = findPressTarget(interaction, instance); + if (targetKind === 'host') { await emitDirectPressEvents(config, interaction, options); - return; - } - - if (isEnabledTouchResponder(instance)) { - interaction.target = instance; + } else if (targetKind === 'responder') { await emitPressabilityPressEvents(config, interaction, options); - return; - } - - // The element could handle the press, but is disabled or blocks touches. - if (hasPressEventHandler(instance) || instance.props.onStartShouldSetResponder) { - interaction.skippedTargets.push(instance); - } - - if (!instance.parent) { - return; } - - await basePress(config, interaction, instance.parent, options); }; -function isEnabledHostElement(instance: TestInstance) { - if (!isPointerEventEnabled(instance)) { - return false; - } - - if (isHostText(instance)) { - return instance.props.disabled !== true; - } - - if (isHostTextInput(instance)) { - return instance.props.editable !== false; - } - - return true; -} - -function isEnabledTouchResponder(instance: TestInstance) { - return isPointerEventEnabled(instance) && instance.props.onStartShouldSetResponder?.(); -} - -function hasPressEventHandler(instance: TestInstance) { - return ( - getEventHandlerFromProps(instance.props, 'press') || - getEventHandlerFromProps(instance.props, 'longPress') || - getEventHandlerFromProps(instance.props, 'pressIn') || - getEventHandlerFromProps(instance.props, 'pressOut') - ); -} - /** * Dispatches a press event sequence for host instances that have `onPress*` event handlers. */ @@ -140,8 +93,10 @@ async function emitDirectPressEvents( // See: https://reactnative.dev/docs/pressable#onpress // Experimentally for very short presses (< 130ms) `press` events are actually emitted before `onPressOut`, but // we will ignore that as in reality most pressed would be above the 130ms threshold. + // Like the touch callbacks above, `press` is called only on the target, with a touch event. + // Dispatching it would bubble it to `onPress` of ancestors, which the responder system doesn't do. if (options.type === 'press') { - await interaction.dispatchEvent('press', buildPressEvent()); + await interaction.dispatchTouchEvent('press'); } } diff --git a/src/user-event/type/__tests__/type.test.tsx b/src/user-event/type/__tests__/type.test.tsx index 88f09a4ba..3d70f7667 100644 --- a/src/user-event/type/__tests__/type.test.tsx +++ b/src/user-event/type/__tests__/type.test.tsx @@ -4,7 +4,7 @@ import { TextInput, View } from 'react-native'; import { userEvent } from '../..'; import { render, screen } from '../../..'; -import { getConfig } from '../../../config'; +import { isModernEventSystem } from '../../../test-utils/event-system'; import { createEventLogger, getEventsNames, lastEventPayload } from '../../../test-utils/events'; beforeEach(() => { @@ -351,16 +351,14 @@ describe('type()', () => { const user = userEvent.setup(); await user.type(screen.getByTestId('input'), 'abc'); - // `focus`, `keyPress`, `change`, `endEditing` and `blur` are bubbling events in React Native. + // These bubble to the parent in React Native. const modernEvents = [ 'focus', ...['keyPress', 'change', 'keyPress', 'change', 'keyPress', 'change'], 'endEditing', 'blur', ]; - expect(getEventsNames(events)).toEqual( - getConfig().unstable_eventSystem === 'modern' ? modernEvents : [], - ); + expect(getEventsNames(events)).toEqual(isModernEventSystem() ? modernEvents : []); }); test('supports direct access', async () => { diff --git a/src/user-event/utils/interaction.ts b/src/user-event/utils/interaction.ts index 6a5c539a0..e986e2a3a 100644 --- a/src/user-event/utils/interaction.ts +++ b/src/user-event/utils/interaction.ts @@ -35,7 +35,7 @@ export class Interaction { /** * Calls a touch callback that `Pressable` calls from JavaScript (`pressIn`, `pressOut`, - * `longPress`) with a touch event, the way Pressability does. + * `longPress`, `press`) with a touch event, the way Pressability does. */ async dispatchTouchEvent(eventName: string) { await this.invokeEventHandler(eventName, buildTouchEvent()); diff --git a/src/user-event/utils/warnings.ts b/src/user-event/utils/warnings.ts index b5eecada8..afa1a36bc 100644 --- a/src/user-event/utils/warnings.ts +++ b/src/user-event/utils/warnings.ts @@ -1,10 +1,10 @@ import { getConfig } from '../../config'; -import type { EventWarning } from '../../events/legacy'; +import type { EventWarning } from '../../events/shared/warnings'; import { formatDisabledTargets, getPointerEventsBlockedTargets, logEventWarning, -} from '../../events/legacy'; +} from '../../events/shared/warnings'; import { computeAriaDisabled } from '../../helpers/accessibility'; import type { Interaction } from './interaction'; From b7b40e5d23009e37e6c220f9c41ce0cf08bd02b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Mon, 12 Oct 2026 22:28:49 +0200 Subject: [PATCH 3/7] refactor: cleanup --- src/events/modern/dispatch.ts | 16 ++++++++-------- src/events/modern/event.ts | 10 +++++----- src/events/shared/handler.ts | 16 ++++++++-------- src/user-event/__tests__/clear.test.tsx | 2 +- src/user-event/__tests__/paste.test.tsx | 2 +- src/user-event/type/__tests__/type.test.tsx | 2 +- 6 files changed, 24 insertions(+), 24 deletions(-) diff --git a/src/events/modern/dispatch.ts b/src/events/modern/dispatch.ts index 178aba44e..614725aef 100644 --- a/src/events/modern/dispatch.ts +++ b/src/events/modern/dispatch.ts @@ -64,6 +64,14 @@ export type DispatchOptions = { afterTargetHandler?: (target: TestInstance, event: SyntheticEvent) => void; }; +/** Clears the event's internals, as the event can still be read after the dispatch. */ +export function resetEvent(event: SyntheticEvent) { + getEventInternals(event).eventPhase = SyntheticEvent.NONE; + getEventInternals(event).currentTarget = null; + getEventInternals(event).composedPath = []; + getEventInternals(event).stopPropagation = false; +} + type DispatchState = { hasCalledHandler: boolean; /** Wrapped, so a thrown `undefined` is rethrown too. */ @@ -152,11 +160,3 @@ function callHandler( } } } - -/** Clears the event's internals, as the event can still be read after the dispatch. */ -export function resetEvent(event: SyntheticEvent) { - getEventInternals(event).eventPhase = SyntheticEvent.NONE; - getEventInternals(event).currentTarget = null; - getEventInternals(event).composedPath = []; - getEventInternals(event).stopPropagation = false; -} diff --git a/src/events/modern/event.ts b/src/events/modern/event.ts index a2feb8e1d..fad540724 100644 --- a/src/events/modern/event.ts +++ b/src/events/modern/event.ts @@ -246,6 +246,11 @@ export function createPressabilityEvent( return createEventWithConfig(eventType, { timeStamp: init.timeStamp, nativeEvent }, config); } +/** @internal */ +export function getEventInternals(event: SyntheticEvent): EventInternals { + return (event as unknown as { [EVENT_INTERNALS]: EventInternals })[EVENT_INTERNALS]; +} + function createEventWithConfig( eventType: string, init: CreateEventInit, @@ -264,11 +269,6 @@ function createEventWithConfig( ); } -/** @internal */ -export function getEventInternals(event: SyntheticEvent): EventInternals { - return (event as unknown as { [EVENT_INTERNALS]: EventInternals })[EVENT_INTERNALS]; -} - function toDispatchConfig(config: EventTypeConfig): DispatchConfig { if (config.kind === 'direct') { return { registrationName: config.handlerName }; diff --git a/src/events/shared/handler.ts b/src/events/shared/handler.ts index 9406ae522..0e8479e17 100644 --- a/src/events/shared/handler.ts +++ b/src/events/shared/handler.ts @@ -41,14 +41,6 @@ export function getEventHandlerByName( ); } -function getFunctionProp( - props: Record, - propName: string, -): EventHandler | undefined { - const handler = props[propName]; - return typeof handler === 'function' ? (handler as EventHandler) : undefined; -} - /** * Returns the event type without the `on*` prefix, e.g. `onLayout` -> `layout`. * Note: `fireEvent` accepts event types with and without the prefix, so use this @@ -70,6 +62,14 @@ export function getEventHandlerName(eventType: string) { return `on${capitalizeFirstLetter(eventType)}`; } +function getFunctionProp( + props: Record, + propName: string, +): EventHandler | undefined { + const handler = props[propName]; + return typeof handler === 'function' ? (handler as EventHandler) : undefined; +} + function hasOnPrefix(eventType: string) { return /^on[A-Z]/.test(eventType); } diff --git a/src/user-event/__tests__/clear.test.tsx b/src/user-event/__tests__/clear.test.tsx index 78322cb93..84a152a92 100644 --- a/src/user-event/__tests__/clear.test.tsx +++ b/src/user-event/__tests__/clear.test.tsx @@ -186,7 +186,7 @@ describe('clear()', () => { // View that ignores props type checking const AnyView = View as React.ComponentType; - test('bubbles up only native bubbling events, in the modern event system', async () => { + test('bubbles up only native bubbling events (legacy: bubbles none)', async () => { const { events, logEvent } = createEventLogger(); await render( { // View that ignores props type checking const AnyView = View as React.ComponentType; - test('bubbles up only native bubbling events, in the modern event system', async () => { + test('bubbles up only native bubbling events (legacy: bubbles none)', async () => { const { events, logEvent } = createEventLogger(); await render( { // View that ignores props type checking const AnyView = View as React.ComponentType; - test('bubbles up only native bubbling events, in the modern event system', async () => { + test('bubbles up only native bubbling events (legacy: bubbles none)', async () => { const { events, logEvent } = createEventLogger(); await render( Date: Tue, 13 Oct 2026 23:04:53 +0200 Subject: [PATCH 4/7] feat: modern userEvent.press (#1949) --- contributing/event-dispatch.md | 7 +- src/events/__tests__/fire-event.test.tsx | 110 +++++++----- src/events/dispatch-event.ts | 8 +- src/events/legacy/__tests__/dispatch.test.tsx | 40 ----- src/events/legacy/builders/base.ts | 1 - src/events/legacy/dispatch.ts | 19 -- src/events/legacy/index.ts | 4 - src/events/modern/__tests__/dispatch.test.tsx | 3 +- src/events/modern/__tests__/event.test.ts | 19 +- .../modern/__tests__/pressability.test.tsx | 65 ++++++- .../modern/__tests__/responder-event.test.ts | 86 +++++++++ src/events/modern/dispatch.ts | 6 +- src/events/modern/event.ts | 63 +++---- src/events/modern/fire-event-utils.ts | 12 +- src/events/modern/fire-event.ts | 18 +- src/events/modern/pressability.ts | 166 ++++++++++-------- src/events/modern/responder-event.ts | 90 ++++++++++ src/events/shared/update-native-state.ts | 4 +- src/helpers/object.ts | 1 - src/helpers/text.ts | 6 +- src/test-utils/event-system.ts | 1 - src/test-utils/events.ts | 2 +- .../__tests__/event-diagnostics.test.tsx | 19 +- src/user-event/clear.ts | 2 +- src/user-event/legacy/press.ts | 130 ++++++++++++-- src/user-event/modern/press.ts | 106 +++++++++-- src/user-event/paste.ts | 3 +- .../__snapshots__/longPress.test.tsx.snap | 75 -------- .../__snapshots__/press.test.tsx.snap | 54 ------ .../__tests__/longPress.real-timers.test.tsx | 8 +- .../press/__tests__/longPress.test.tsx | 89 +++++++++- src/user-event/press/__tests__/press.test.tsx | 114 +++++++++++- src/user-event/press/press.ts | 97 ++-------- src/user-event/scroll/scroll-to.ts | 5 +- src/user-event/type/type.ts | 3 +- src/user-event/utils/interaction.ts | 2 +- 36 files changed, 898 insertions(+), 540 deletions(-) delete mode 100644 src/events/legacy/__tests__/dispatch.test.tsx delete mode 100644 src/events/legacy/dispatch.ts delete mode 100644 src/events/legacy/index.ts create mode 100644 src/events/modern/__tests__/responder-event.test.ts create mode 100644 src/events/modern/responder-event.ts delete mode 100644 src/user-event/press/__tests__/__snapshots__/longPress.test.tsx.snap delete mode 100644 src/user-event/press/__tests__/__snapshots__/press.test.tsx.snap diff --git a/contributing/event-dispatch.md b/contributing/event-dispatch.md index 24dcab5f2..8d75dc7ea 100644 --- a/contributing/event-dispatch.md +++ b/contributing/event-dispatch.md @@ -11,17 +11,16 @@ The files in `src/events/legacy/`: | `fire-event.ts` | Public `fireEvent` API | | `propagation.ts` | Bubbling vs direct events, walking up host and composite elements | | `is-enabled.ts` | Whether a device would deliver the event: `pointerEvents`, `editable`, touch responders | -| `dispatch.ts` | `dispatchEvent()`: calls the target's own handler in `act()`, used by `userEvent` | | `warnings.ts` | `eventDiagnostics` warnings for `fireEvent` | | `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. +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` 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`. Legacy helpers (touch and responder event builders) come from `src/events/legacy/index.ts`, which also re-exports `src/events/shared/handler.ts`, `src/events/shared/native-state.ts` and `isPointerEventEnabled()`. Other code imports `src/events/modern/` and `src/events/shared/` modules directly: `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`). +`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` @@ -37,7 +36,7 @@ Modern `fireEvent` doesn't log `eventDiagnostics` warnings yet. Only legacy `fir Each step dispatches a native event, created for the configured event system by `createEvent()` (`src/events/create-event.ts`: a legacy event object or a `SyntheticEvent`) and dispatched by `dispatchEvent()` (`src/events/dispatch-event.ts`), or calls a JavaScript callback (`changeText`, `pressIn`, ...) with `invokeEventHandler()`, which only calls the target's own handler. Modern `fireEvent.changeText()` instead calls `onChangeText` from the `change` dispatch, through `dispatchEvent()`'s `afterTargetHandler` option, right after the input's own `onChange`, where `TextInput` calls it. Neither checks whether the element is enabled. Each action does those checks itself, so the rules for an interaction live in one place. -Where the two event systems need different rules, the action picks them by `getConfig().unstable_eventSystem` from `src/user-event/legacy/` or `src/user-event/modern/`, e.g. `findPressTarget()` for `press()` and `longPress()`. The modern one 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. +Where the two event systems need different rules, the action picks them by `getConfig().unstable_eventSystem` from `src/user-event/legacy/` or `src/user-event/modern/`, e.g. for `press()` and `longPress()`. Legacy `findPressTarget()` finds the element, then the action calls `onResponderGrant` and `onResponderRelease` of a touch responder, so Pressability runs with its timers, or the `onPress*` props of a host, with stub touch events. Modern `emitPressEvents()` reuses modern `fireEvent`'s hit testing and responder negotiation (`getTouchPath()` and `findResponder()` in `src/events/modern/pressability.ts`), so both press the same element. It keeps the press on a `TextInput`, which claims the touch on a device even without `onPress*` props; the legacy one passes it on to the nearest ancestor that handles it. It then calls the responder's Pressability callbacks itself, in Pressability's order, with `SyntheticEvent`s (`dispatchPressabilityEvent()`): `onPressIn`, `onLongPress` for `longPress()`, `onPressOut`, then `onPress` unless `onLongPress` was called. The action, not the touch duration, decides whether it is a long press, so neither action waits by default: `duration` only holds the touch, for timers in the component. Pressability's timing props (`delayLongPress`, `minPressDuration`, `delayPressIn`, `delayPressOut`) are ignored. For the `eventDiagnostics` warning, each action tracks itself with an `Interaction` from `src/user-event/utils/interaction.ts`: diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index d4bfefc2d..3fb15e83f 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -39,8 +39,7 @@ const horizontalScrollEvent = { nativeEvent: { contentOffset: { x: 50 } } }; const pressEventData = { nativeEvent: { pageX: 20, pageY: 30 } }; const anySyntheticEvent = expect.any(SyntheticEvent); -const defaultTouchPayload = { - changedTouches: [], +const defaultTouch = { identifier: 0, locationX: 0, locationY: 0, @@ -48,8 +47,17 @@ const defaultTouchPayload = { pageY: 0, target: 0, timestamp: expect.any(Number), - touches: [], }; +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); @@ -301,10 +309,10 @@ describe('event object', () => { ); test.each([ - [{ target: 1 }, '"target". "target" is set by the dispatch.'], + [{ target: 1 }, '"target". "target" is provided by the event.'], [ { target: {}, currentTarget: { measure: () => {} } }, - '"target", "currentTarget". "target", "currentTarget" are set by the dispatch.', + '"target", "currentTarget". "target", "currentTarget" are provided by the event.', ], [{ persist: () => {} }, '"persist". "persist" is provided by the event.'], [ @@ -314,7 +322,7 @@ describe('event object', () => { [{ text: 'Hello' }, '"text". Pass "text" in "nativeEvent" instead.'], [ { text: 'Hello', target: 1, persist: () => {} }, - '"text", "target", "persist". "target" is set by the dispatch. "persist" is provided by the event. Pass "text" in "nativeEvent" instead.', + '"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)', @@ -426,19 +434,10 @@ describe('fireEvent.press', () => { const onPress = jest.fn(); await render(); await fireEvent.press(screen.getByTestId('btn')); - expect(onPress.mock.calls[0][0].nativeEvent).toMatchInlineSnapshot(` - { - "changedTouches": [], - "identifier": 0, - "locationX": 0, - "locationY": 0, - "pageX": 0, - "pageY": 0, - "target": 0, - "timestamp": 100100100100, - "touches": [], - } - `); + // 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()); @@ -480,14 +479,20 @@ describe('fireEvent.press', () => { await fireEvent.press(screen.getByText('Press me')); - expect(event?.nativeEvent).toEqual(defaultTouchPayload); + 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('press'); + 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')); - expect(eventPhase).toBe(event?.BUBBLING_PHASE); + // 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(); }); @@ -497,19 +502,11 @@ describe('fireEvent.press', () => { await render(); const customEventData = { nativeEvent: { pageX: 20, pageY: 30 }, timeStamp: 123 }; await fireEvent.press(screen.getByTestId('btn'), customEventData); - expect(onPress.mock.calls[0][0].nativeEvent).toMatchInlineSnapshot(` - { - "changedTouches": [], - "identifier": 0, - "locationX": 0, - "locationY": 0, - "pageX": 20, - "pageY": 30, - "target": 0, - "timestamp": 100100100100, - "touches": [], - } - `); + 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; @@ -1629,7 +1626,6 @@ describe('direct events', () => { ), ]; - // Builds one case per event type, passing the matching `on*` handler prop to `ui`. function buildDirectEventCases( source: string, eventTypes: string[], @@ -1650,8 +1646,7 @@ describe('direct events', () => { await fireEvent(screen.getByTestId('target'), eventType, emptyEvent); - // Modern matches React Native: direct events don't bubble. Legacy still bubbles them, with a - // warning. + // Modern matches React Native: direct events don't bubble. expect(handler).toHaveBeenCalledTimes(isModern() ? 0 : 1); expect(warnSpy).toHaveBeenCalledTimes(isModern() ? 0 : 1); }, @@ -1697,8 +1692,7 @@ describe('direct events', () => { await fireEvent(screen.getByTestId('child'), 'onMomentumScrollEnd', emptyEvent); - // Modern matches React Native: direct events don't bubble. Legacy still bubbles them, with a - // warning. + // Modern matches React Native: direct events don't bubble. expect(onMomentumScrollEnd).toHaveBeenCalledTimes(isModern() ? 0 : 1); expect(warnSpy).toHaveBeenCalledTimes(isModern() ? 0 : 1); }); @@ -1713,8 +1707,7 @@ describe('direct events', () => { await fireEvent.scroll(screen.getByTestId('child')); - // Modern matches React Native: direct events don't bubble. Legacy still bubbles them, with a - // warning. + // Modern matches React Native: direct events don't bubble. expect(onScroll).toHaveBeenCalledTimes(isModern() ? 0 : 1); expect(warnSpy).toHaveBeenCalledTimes(isModern() ? 0 : 1); if (isModern()) return; @@ -1760,7 +1753,6 @@ describe('direct events', () => { await fireEvent.scroll(screen.getByTestId('child')); // Modern matches React Native: scroll doesn't bubble, and composite props are never called. - // Legacy calls the composite prop, with a warning. expect(onScroll).toHaveBeenCalledTimes(isModern() ? 0 : 1); expect(warnSpy).toHaveBeenCalledTimes(isModern() ? 0 : 1); }); @@ -1875,7 +1867,7 @@ describe('Pressability event types', () => { expect(calls).toEqual([`pressable.${name}`, `touchable.${name}`]); }); - test('passes a direct event with default touch payload (legacy: passes the event object as given)', async () => { + test('passes a responder event with default touch payload (legacy: passes the event object as given)', async () => { const onPressIn = jest.fn(); await render( @@ -1891,15 +1883,39 @@ describe('Pressability event types', () => { const event = onPressIn.mock.calls[0][0]; // Modern matches React Native, which derives Pressability events from a touch. expect(event.nativeEvent).toEqual( - isModern() ? { ...defaultTouchPayload, pageX: 20 } : { pageX: 20 }, + isModern() ? getTouchPayload(true, { pageX: 20 }) : { pageX: 20 }, ); expect(event.timeStamp).toBe(123); if (!isModern()) return; - expect(event.type).toBe('pressin'); + 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(true); + expect(event.rnIsDirect).toBe(false); expect(event.target).toBe(screen.getByText('Press me')); }); + + test('calls onLongPress without currentTarget (legacy: passes the event object as given)', async () => { + let currentTarget: unknown; + await render( + { + currentTarget = event.currentTarget; + }} + > + Press me + , + ); + + await fireEvent(screen.getByText('Press me'), 'longPress', emptyEvent); + + // Modern matches React Native, where Pressability calls `onLongPress` from a timer, after the + // dispatch of the responder grant event. + expect(currentTarget).toBe(isModern() ? null : undefined); + }); }); test('fireEvent throws for custom event (onCustomEvent) on composite component (legacy: calls it)', async () => { diff --git a/src/events/dispatch-event.ts b/src/events/dispatch-event.ts index b91b5197d..6f7a3a0a3 100644 --- a/src/events/dispatch-event.ts +++ b/src/events/dispatch-event.ts @@ -1,17 +1,17 @@ import type { TestInstance } from 'test-renderer'; import type { LegacyEvent } from './create-event'; -import { dispatchEvent as dispatchLegacyEvent } from './legacy/dispatch'; import { dispatchEvent as dispatchModernEvent } from './modern/dispatch'; import { SyntheticEvent } from './modern/event'; +import { invokeEventHandler } from './shared/invoke-event-handler'; /** * Dispatches an event from `createEvent()` to a host element. Used by `userEvent`. * - * - Legacy event object: calls the element's own `on*` prop with it. * - `SyntheticEvent`: dispatched as React Native does (capture and bubble phases for bubbling * events). `press` is a native event type (`topPress`), so it reaches host `onPress` props only, * not `Pressable`'s `onPress`. + * - Legacy event object: calls the element's own `on*` prop with it. * * @param eventType a native event type without the `on*` prefix, e.g. `focus` * @returns `true` if a handler was called. @@ -19,11 +19,11 @@ import { SyntheticEvent } from './modern/event'; export async function dispatchEvent( instance: TestInstance, eventType: string, - event: LegacyEvent | SyntheticEvent, + event: SyntheticEvent | LegacyEvent, ): Promise { if (event instanceof SyntheticEvent) { return await dispatchModernEvent(instance, event); } - return await dispatchLegacyEvent(instance, eventType, event); + return await invokeEventHandler(instance, eventType, event); } diff --git a/src/events/legacy/__tests__/dispatch.test.tsx b/src/events/legacy/__tests__/dispatch.test.tsx deleted file mode 100644 index 9befbcee2..000000000 --- a/src/events/legacy/__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/legacy/builders/base.ts b/src/events/legacy/builders/base.ts index d8dfd8192..11debeca6 100644 --- a/src/events/legacy/builders/base.ts +++ b/src/events/legacy/builders/base.ts @@ -20,7 +20,6 @@ export function baseSyntheticEvent(): BaseEvent { }; } -/** Wraps a `nativeEvent` in a legacy event object with `baseSyntheticEvent()` stubs. */ export function wrapNativeEvent(nativeEvent: T) { return { ...baseSyntheticEvent(), nativeEvent }; } diff --git a/src/events/legacy/dispatch.ts b/src/events/legacy/dispatch.ts deleted file mode 100644 index 3f56f3860..000000000 --- a/src/events/legacy/dispatch.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { TestInstance } from 'test-renderer'; - -import { invokeEventHandler } from '../shared/invoke-event-handler'; - -/** - * Basic dispatch event function used by User Event module. - * - * @param instance instance to trigger event on - * @param eventType type of the event - * @param event event payload - * @returns `true` if a handler was called. - */ -export async function dispatchEvent( - instance: TestInstance, - eventType: string, - event: unknown, -): Promise { - return await invokeEventHandler(instance, eventType, event); -} diff --git a/src/events/legacy/index.ts b/src/events/legacy/index.ts deleted file mode 100644 index 688d02cc0..000000000 --- a/src/events/legacy/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './builders/common'; -export * from '../shared/handler'; -export { isPointerEventEnabled } from '../shared/pointer-events'; -export * from '../shared/native-state'; diff --git a/src/events/modern/__tests__/dispatch.test.tsx b/src/events/modern/__tests__/dispatch.test.tsx index a5bd17c9c..6e5667795 100644 --- a/src/events/modern/__tests__/dispatch.test.tsx +++ b/src/events/modern/__tests__/dispatch.test.tsx @@ -341,7 +341,8 @@ test('does nothing when the target is unmounted', async () => { const onFocus = jest.fn(); await render(); const target = screen.getByTestId('target'); - await screen.rerender(); + // A new key remounts the host element, so `target` is detached. + await screen.rerender(); const result = await dispatchEvent(target, createKnownEvent('focus')); diff --git a/src/events/modern/__tests__/event.test.ts b/src/events/modern/__tests__/event.test.ts index 897455c6d..7595ac770 100644 --- a/src/events/modern/__tests__/event.test.ts +++ b/src/events/modern/__tests__/event.test.ts @@ -26,7 +26,24 @@ test('exposes init values', () => { expect(event.timeStamp).toBe(123); expect(event.nativeEvent).toBe(nativeEvent); expect(event.dispatchConfig).toEqual({ registrationName: 'onFocus' }); - expect(event.isTrusted).toBe(true); + expect(event.composed).toBe(false); + // Only `dispatchEvent()` marks events as trusted. + expect(event.isTrusted).toBe(false); +}); + +test('takes bubbles, rnIsDirect and dispatchConfig over the type config', () => { + const dispatchConfig = { registrationName: 'onResponderGrant', dependencies: [] }; + const event = new SyntheticEvent( + 'responderGrant', + { bubbles: false, rnIsDirect: false }, + {}, + typeConfig, + dispatchConfig, + ); + + expect(event.bubbles).toBe(false); + expect(event.rnIsDirect).toBe(false); + expect(event.dispatchConfig).toBe(dispatchConfig); }); test('has no target and phase outside of dispatch', () => { diff --git a/src/events/modern/__tests__/pressability.test.tsx b/src/events/modern/__tests__/pressability.test.tsx index 3c41d7348..34fe2188f 100644 --- a/src/events/modern/__tests__/pressability.test.tsx +++ b/src/events/modern/__tests__/pressability.test.tsx @@ -1,17 +1,32 @@ import * as React from 'react'; import { Pressable, Text, TouchableOpacity, View } from 'react-native'; +import type { TestInstance } from 'test-renderer'; import { render, screen } from '../../..'; import { getEventHandlerName } from '../../shared/handler'; import type { SyntheticEvent } from '../event'; import { createEvent } from '../event'; +import type { PressabilityEventType } from '../event-types'; import { PRESSABILITY_EVENT_TYPES } from '../event-types'; -import { dispatchPressabilityEvent } from '../pressability'; +import { dispatchPressabilityEvent, findResponder, getTouchPath } from '../pressability'; function createTouchEvent() { return createEvent('touchEnd', { nativeEvent: { pageX: 10 } }) as SyntheticEvent; } +/** Touches the element and calls one callback of the touch responder, as modern `fireEvent`. */ +async function touch( + instance: TestInstance, + eventType: PressabilityEventType, + event: SyntheticEvent = createTouchEvent(), +) { + const path = getTouchPath(instance); + const responder = findResponder(path); + return responder != null + ? (await dispatchPressabilityEvent(responder, [eventType], event, path[0])).length > 0 + : false; +} + function logCallbacks(calls: string[], id: string) { const log = (name: string) => () => { calls.push(`${id}.${name}`); @@ -40,8 +55,8 @@ test.each(PRESSABILITY_EVENT_TYPES)( , ); - await dispatchPressabilityEvent(screen.getByText('Pressable'), eventType, createTouchEvent()); - await dispatchPressabilityEvent(screen.getByText('Touchable'), eventType, createTouchEvent()); + await touch(screen.getByText('Pressable'), eventType); + await touch(screen.getByText('Touchable'), eventType); expect(calls).toEqual([`pressable.${name}`, `touchable.${name}`]); }, @@ -62,8 +77,8 @@ test.each(PRESSABILITY_EVENT_TYPES)( , ); - await dispatchPressabilityEvent(screen.getByText('Text'), eventType, createTouchEvent()); - await dispatchPressabilityEvent(screen.getByTestId('view'), eventType, createTouchEvent()); + await touch(screen.getByText('Text'), eventType); + await touch(screen.getByTestId('view'), eventType); expect(calls).toEqual([`text.${name}`, `view.testOnly_${name}`]); }, @@ -81,7 +96,7 @@ test('passes the event, with the hit target and the responder', async () => { ); const event = createTouchEvent(); - await dispatchPressabilityEvent(screen.getByText('Press me'), 'pressIn', event); + await touch(screen.getByText('Press me'), 'pressIn', event); expect(onPressIn).toHaveBeenCalledWith(event); expect(onPressIn.mock.results[0].value).toEqual({ @@ -98,7 +113,7 @@ test('host element with another press callback claims the touch, but gets nothin , ); - await dispatchPressabilityEvent(screen.getByText('Press me'), 'pressIn', createTouchEvent()); + await touch(screen.getByText('Press me'), 'pressIn'); expect(onPressIn).not.toHaveBeenCalled(); }); @@ -113,7 +128,7 @@ test('responder without the callback keeps the touch from its ancestors', async , ); - await dispatchPressabilityEvent(screen.getByText('Press me'), 'longPress', createTouchEvent()); + await touch(screen.getByText('Press me'), 'longPress'); expect(onLongPress).not.toHaveBeenCalled(); }); @@ -127,8 +142,40 @@ test('does not call callbacks of disabled Pressable', async () => { ); for (const eventType of PRESSABILITY_EVENT_TYPES) { - await dispatchPressabilityEvent(screen.getByText('Press me'), eventType, createTouchEvent()); + await touch(screen.getByText('Press me'), eventType); } expect(calls).toEqual([]); }); + +test('does not call callbacks of Text disabled during the touch', async () => { + const onPress = jest.fn(); + function DisabledOnPressIn() { + const [disabled, setDisabled] = React.useState(false); + return ( + setDisabled(true)} onPress={onPress}> + Press me + + ); + } + await render(); + const path = getTouchPath(screen.getByText('Press me')); + const responder = findResponder(path) as TestInstance; + + await dispatchPressabilityEvent(responder, ['pressIn'], createTouchEvent(), path[0]); + const result = await dispatchPressabilityEvent(responder, ['press'], createTouchEvent(), path[0]); + + expect(result).toEqual([]); + expect(onPress).not.toHaveBeenCalled(); +}); + +test('returns whether the responder had the callback', async () => { + await render( + + Press me + , + ); + + expect(await touch(screen.getByText('Press me'), 'press')).toBe(true); + expect(await touch(screen.getByText('Press me'), 'longPress')).toBe(false); +}); diff --git a/src/events/modern/__tests__/responder-event.test.ts b/src/events/modern/__tests__/responder-event.test.ts new file mode 100644 index 000000000..c34fd3b08 --- /dev/null +++ b/src/events/modern/__tests__/responder-event.test.ts @@ -0,0 +1,86 @@ +import { SyntheticEvent } from '../event'; +import { createPressabilityEvent, ResponderEvent } from '../responder-event'; + +afterEach(() => { + jest.restoreAllMocks(); +}); + +const touch = { + identifier: 0, + locationX: 0, + locationY: 0, + pageX: 0, + pageY: 0, + target: 0, + timestamp: 456, +}; + +test.each(['pressIn', 'longPress'] as const)('%s gets the responder grant event', (eventType) => { + jest.spyOn(performance, 'now').mockReturnValue(456); + + const event = createPressabilityEvent(eventType); + + expect(event).toBeInstanceOf(ResponderEvent); + expect(event).toBeInstanceOf(SyntheticEvent); + expect(event.type).toBe('responderGrant'); + expect(event.dispatchConfig).toEqual({ registrationName: 'onResponderGrant', dependencies: [] }); + expect(event.nativeEvent).toEqual({ ...touch, changedTouches: [touch], touches: [touch] }); +}); + +test.each(['pressOut', 'press'] as const)('%s gets the responder release event', (eventType) => { + jest.spyOn(performance, 'now').mockReturnValue(456); + + const event = createPressabilityEvent(eventType); + + expect(event.type).toBe('responderRelease'); + expect(event.dispatchConfig).toEqual({ + registrationName: 'onResponderRelease', + dependencies: ['topTouchCancel', 'topTouchEnd'], + }); + expect(event.nativeEvent).toEqual({ ...touch, changedTouches: [touch], touches: [] }); +}); + +test('creates the event as React Native responder system does', () => { + jest.spyOn(performance, 'now').mockReturnValue(456); + + const event = createPressabilityEvent('press', { nativeEvent: { timestamp: 123 } }); + + expect(event.bubbles).toBe(false); + expect(event.cancelable).toBe(true); + expect(event.composed).toBe(false); + expect(event.rnIsDirect).toBe(false); + expect(event.isTrusted).toBe(false); + // From `performance.now()`, not the native timestamp. + expect(event.timeStamp).toBe(456); + expect(event.target).toBeNull(); + expect(event.currentTarget).toBeNull(); + expect(event.eventPhase).toBe(SyntheticEvent.NONE); + expect(event.composedPath()).toEqual([]); +}); + +test('takes timeStamp from init', () => { + expect(createPressabilityEvent('press', { timeStamp: 123 }).timeStamp).toBe(123); +}); + +test('fills the touch arrays from the passed nativeEvent', () => { + const event = createPressabilityEvent('pressIn', { + nativeEvent: { pageX: 20, pageY: 30, timestamp: 1 }, + }); + + const expectedTouch = { ...touch, pageX: 20, pageY: 30, timestamp: 1 }; + expect(event.nativeEvent.changedTouches).toEqual([expectedTouch]); + expect(event.nativeEvent.touches).toEqual([expectedTouch]); + // Separate objects, as on a device. + expect(event.nativeEvent.touches).not.toBe(event.nativeEvent.changedTouches); +}); + +test('keeps the touch arrays of the passed nativeEvent', () => { + const touches = [{ identifier: 1, pageX: 5, pageY: 5, timestamp: 1 }]; + + const event = createPressabilityEvent('pressOut', { + nativeEvent: { changedTouches: touches, touches }, + }); + + expect(event.nativeEvent.changedTouches).toBe(touches); + expect(event.nativeEvent.touches).toBe(touches); +}); diff --git a/src/events/modern/dispatch.ts b/src/events/modern/dispatch.ts index 614725aef..2c2c96515 100644 --- a/src/events/modern/dispatch.ts +++ b/src/events/modern/dispatch.ts @@ -38,6 +38,7 @@ export async function dispatchEvent( const state: DispatchState = { hasCalledHandler: false, firstError: null }; await act(() => { + getEventInternals(event).isTrusted = true; getEventInternals(event).target = target; getEventInternals(event).composedPath = path; @@ -65,7 +66,7 @@ export type DispatchOptions = { }; /** Clears the event's internals, as the event can still be read after the dispatch. */ -export function resetEvent(event: SyntheticEvent) { +function resetEvent(event: SyntheticEvent) { getEventInternals(event).eventPhase = SyntheticEvent.NONE; getEventInternals(event).currentTarget = null; getEventInternals(event).composedPath = []; @@ -153,8 +154,7 @@ function callHandler( // In the same `try`, as a component's wrapper doesn't reach its own code after the prop throws. afterHandler?.(node, event); } catch (error) { - // Keep dispatching, as React Native (`handleListenerError()` in `EventTarget.js`) and the DOM - // do. `dispatchEvent()` rethrows the first error once the dispatch is done. + // Keep dispatching, as React Native (`handleListenerError()` in `EventTarget.js`) and DOM do. if (state.firstError == null) { state.firstError = { error }; } diff --git a/src/events/modern/event.ts b/src/events/modern/event.ts index fad540724..b10b5f568 100644 --- a/src/events/modern/event.ts +++ b/src/events/modern/event.ts @@ -1,9 +1,6 @@ import type { TestInstance } from 'test-renderer'; -import { getEventHandlerName } from '../shared/handler'; -import { mergeEventProps } from '../shared/merge'; -import { buildTouchNativeEvent } from '../shared/payloads'; -import type { EventTypeConfig, PressabilityEventType } from './event-types'; +import type { EventTypeConfig } from './event-types'; import { getEventTypeConfig } from './event-types'; export type NativeEventPayload = Record; @@ -14,7 +11,7 @@ export type NativeEventPayload = Record; */ export type DispatchConfig = | { phasedRegistrationNames: { bubbled: string; captured: string; skipBubbling?: boolean } } - | { registrationName: string }; + | { registrationName: string; dependencies?: readonly string[] }; export type CreateEventInit = { /** Passed to handlers as `event.nativeEvent`. Defaults to `{}`. */ @@ -26,8 +23,11 @@ export type CreateEventInit = { timeStamp?: number; }; +/** `bubbles` and `rnIsDirect` default to what the type config says. */ type SyntheticEventInit = { + bubbles?: boolean; cancelable?: boolean; + rnIsDirect?: boolean; timeStamp?: number; }; @@ -53,6 +53,7 @@ type EventInternals = { eventPhase: EventPhase; composedPath: TestInstance[]; stopPropagation: boolean; + isTrusted: boolean; }; const EVENT_INTERNALS = Symbol('eventInternals'); @@ -85,14 +86,15 @@ export class SyntheticEvent { init: SyntheticEventInit, nativeEvent: NativeEventPayload, typeConfig: EventTypeConfig, + dispatchConfig: DispatchConfig = toDispatchConfig(typeConfig), ) { this._type = type; - this._bubbles = typeConfig.kind === 'bubbling' && !typeConfig.skipBubbling; + this._bubbles = init.bubbles ?? (typeConfig.kind === 'bubbling' && !typeConfig.skipBubbling); this._cancelable = Boolean(init.cancelable); - this._rnIsDirect = typeConfig.kind === 'direct'; + this._rnIsDirect = init.rnIsDirect ?? typeConfig.kind === 'direct'; this._timeStamp = init.timeStamp ?? performance.now(); this._nativeEvent = nativeEvent; - this._dispatchConfig = toDispatchConfig(typeConfig); + this._dispatchConfig = dispatchConfig; Object.defineProperty(this, EVENT_INTERNALS, { value: { typeConfig, @@ -101,6 +103,7 @@ export class SyntheticEvent { eventPhase: NONE, composedPath: [], stopPropagation: false, + isTrusted: false, } satisfies EventInternals, }); } @@ -130,6 +133,11 @@ export class SyntheticEvent { return this._bubbles; } + /** React Native never creates composed events. */ + get composed(): boolean { + return false; + } + get cancelable(): boolean { return this._cancelable; } @@ -167,9 +175,9 @@ export class SyntheticEvent { return getEventInternals(this).eventPhase; } - /** Only the native event dispatch creates events, and React Native marks those as trusted. */ + /** Set by `dispatchEvent()`, as React Native's native event dispatch marks events as trusted. */ get isTrusted(): boolean { - return true; + return getEventInternals(this).isTrusted; } get cancelBubble(): boolean { @@ -226,36 +234,6 @@ export function createEvent(eventType: string, init: CreateEventInit = {}): Synt return null; } - return createEventWithConfig(eventType, init, config); -} - -/** - * Creates the event passed to a Pressability callback, ready for `dispatchPressabilityEvent()`. - * React Native passes the responder event derived from a touch, so the passed `nativeEvent` is - * deep merged onto a default touch payload. Pressability calls only the touch responder's - * callback, so the event is direct. - * - * @param eventType e.g. `press` or `pressIn` - */ -export function createPressabilityEvent( - eventType: PressabilityEventType, - init: CreateEventInit, -): SyntheticEvent { - const nativeEvent = mergeEventProps(buildTouchNativeEvent(), init.nativeEvent); - const config: EventTypeConfig = { kind: 'direct', handlerName: getEventHandlerName(eventType) }; - return createEventWithConfig(eventType, { timeStamp: init.timeStamp, nativeEvent }, config); -} - -/** @internal */ -export function getEventInternals(event: SyntheticEvent): EventInternals { - return (event as unknown as { [EVENT_INTERNALS]: EventInternals })[EVENT_INTERNALS]; -} - -function createEventWithConfig( - eventType: string, - init: CreateEventInit, - config: EventTypeConfig, -): SyntheticEvent { const nativeEvent = init.nativeEvent ?? {}; const nativeTimeStamp = nativeEvent.timeStamp ?? nativeEvent.timestamp; const timeStamp = @@ -269,6 +247,11 @@ function createEventWithConfig( ); } +/** @internal */ +export function getEventInternals(event: SyntheticEvent): EventInternals { + return (event as unknown as { [EVENT_INTERNALS]: EventInternals })[EVENT_INTERNALS]; +} + function toDispatchConfig(config: EventTypeConfig): DispatchConfig { if (config.kind === 'direct') { return { registrationName: config.handlerName }; diff --git a/src/events/modern/fire-event-utils.ts b/src/events/modern/fire-event-utils.ts index 3eed74dda..49c07088f 100644 --- a/src/events/modern/fire-event-utils.ts +++ b/src/events/modern/fire-event-utils.ts @@ -8,9 +8,6 @@ import { getEventTypeConfig, isPressabilityEventType } from './event-types'; const ALLOWED_INIT_KEYS = ['nativeEvent', 'timeStamp']; -/** Legacy events carried these as stubs. The dispatch sets them to host elements. */ -const DISPATCH_KEYS = ['target', 'currentTarget']; - const EVENT_KEYS = new Set( Object.getOwnPropertyNames(SyntheticEvent.prototype).filter((key) => key !== 'constructor'), ); @@ -156,15 +153,10 @@ export function validateChangeTextArgs( * would leave handlers reading `undefined` with no explanation. */ function describeUnknownKeys(keys: string[]): string { - const dispatchKeys = keys.filter((key) => DISPATCH_KEYS.includes(key)); - const eventKeys = keys.filter((key) => !dispatchKeys.includes(key) && EVENT_KEYS.has(key)); - const otherKeys = keys.filter((key) => !dispatchKeys.includes(key) && !eventKeys.includes(key)); + const eventKeys = keys.filter((key) => EVENT_KEYS.has(key)); + const otherKeys = keys.filter((key) => !EVENT_KEYS.has(key)); const reasons: string[] = []; - if (dispatchKeys.length > 0) { - reasons.push(`${formatKeys(dispatchKeys)} ${isOrAre(dispatchKeys)} set by the dispatch.`); - } - if (eventKeys.length > 0) { reasons.push(`${formatKeys(eventKeys)} ${isOrAre(eventKeys)} provided by the event.`); } diff --git a/src/events/modern/fire-event.ts b/src/events/modern/fire-event.ts index 403cf6cb8..7414e58fd 100644 --- a/src/events/modern/fire-event.ts +++ b/src/events/modern/fire-event.ts @@ -18,7 +18,7 @@ import { import type { DispatchOptions } from './dispatch'; import { dispatchEvent } from './dispatch'; import type { CreateEventInit, NativeEventPayload } from './event'; -import { createEvent, createPressabilityEvent } from './event'; +import { createEvent } from './event'; import type { PressabilityEventType } from './event-types'; import { isPressabilityEventType } from './event-types'; import { @@ -27,7 +27,8 @@ import { validateChangeTextArgs, validateEventInit, } from './fire-event-utils'; -import { dispatchPressabilityEvent } from './pressability'; +import { dispatchPressabilityEvent, findResponder, getTouchPath } from './pressability'; +import { createPressabilityEvent } from './responder-event'; /** * Event object passed to modern `fireEvent`. Handlers receive a `SyntheticEvent` from @@ -99,7 +100,7 @@ fireEvent.changeText = async (instance: TestInstance, text: string): Promise { const event = createPressabilityEvent(eventType, init); - await dispatchPressabilityEvent(instance, eventType, event); + if (!isInstanceMounted(instance)) { + return; + } + + const path = getTouchPath(instance); + const responder = findResponder(path); + if (responder != null) { + await dispatchPressabilityEvent(responder, [eventType], event, path[0]); + } } function ensureInstance( diff --git a/src/events/modern/pressability.ts b/src/events/modern/pressability.ts index 680a217b6..a5167beee 100644 --- a/src/events/modern/pressability.ts +++ b/src/events/modern/pressability.ts @@ -11,7 +11,6 @@ import { } from '../shared/handler'; import { getPointerEventsBlocker } from '../shared/pointer-events'; import type { EventHandler } from '../shared/types'; -import { resetEvent } from './dispatch'; import type { SyntheticEvent } from './event'; import { getEventInternals } from './event'; import type { PressabilityEventType } from './event-types'; @@ -19,8 +18,8 @@ import type { PressabilityEventType } from './event-types'; /** Callbacks of React Native's `PressabilityConfig` that `dispatchPressabilityEvent()` calls. */ type PressabilityCallbackName = `on${Capitalize}`; -/** The part of React Native's `PressabilityConfig` that `dispatchPressabilityEvent()` reads. */ -type PressabilityConfig = { disabled?: boolean | null } & { +/** The part of React Native's `PressabilityConfig` that RNTL reads. */ +type PressabilityConfig = { [Name in PressabilityCallbackName]?: EventHandler | null; }; @@ -30,82 +29,82 @@ type DispatchState = { }; /** - * Touches the element as on a device, and calls one Pressability callback (`onPress` for `press`, - * `onPressIn` for `pressIn`, ...) of the element that becomes the touch responder: + * Calls Pressability callbacks (`onPress` for `press`, `onPressIn` for `pressIn`, ...) of the + * touch responder from `findResponder()`, in order: from its `PressabilityConfig` for `Pressable` and + * `Touchable*`, which Pressability exposes in tests as + * `onStartShouldSetResponder.testOnly_pressabilityConfig()`, or its own `onPress*` prop (or the + * `testOnly_` variant) for mocked hosts. Responders without Pressability, like `PanResponder`, get + * nothing. * - * 1. Hit testing: the touch targets the element, or its nearest ancestor when `pointerEvents` - * blocks it. - * 2. Responder negotiation, as React Native's `ResponderEventPlugin` does on touch start: - * `onStartShouldSetResponder` from the target to the root. The first one returning `true` wins. - * A disabled `Pressable` declines, so the touch goes on to its ancestors. Called without - * arguments, as in legacy `fireEvent` and `userEvent`. - * 3. The responder's callback: from its `PressabilityConfig` for `Pressable` and `Touchable*`, - * which Pressability exposes in tests as `onStartShouldSetResponder.testOnly_pressabilityConfig()`. - * Responders without Pressability, like `PanResponder`, get nothing. + * Doesn't negotiate the responder: callers do it once per touch, as React Native does on touch + * start, so a touch's callbacks all go to the same responder. * - * The Jest preset mocks `Text` and `TextInput`, so their host elements get `onPress*` props - * instead of responder props. A host element with any of these props (or their `testOnly_` - * variants) takes part in the negotiation as if it had Pressability. A host `TextInput` always - * claims the touch, as its Pressability does on a device, even without these props. It calls its - * callbacks even when not editable, as on iOS, where `editable={false}` only stops it from - * focusing. Known difference from Android: there, a non-editable `TextInput` is disabled and never - * the touch target, so the touch goes to its ancestors. A disabled `Text` declines the touch. - * - * One `act()` wraps it all, and a callback error is rethrown after it. During the call, - * `event.target` is the hit target and `event.currentTarget` the responder. - * - * Not implemented yet: the capture phase (`onStartShouldSetResponderCapture`), as `PanResponder` - * reads `touchHistory` there, which only the responder system's events have. + * One `act()` wraps the calls, as React Native batches updates for the whole responder event, so + * `onPress` sees the state from before `onPressOut`, not its updates. The callbacks are looked up + * before the first call. A callback error stops the calls and is rethrown after `act()`. As in + * React Native's responder system, which calls handlers directly instead of dispatching the event, + * `event.target` is the hit target, `event.currentTarget` is the responder during each call, and + * `eventPhase` and `composedPath()` stay unset. `onLongPress` gets a `null` `currentTarget`, as + * Pressability calls it from a timer, after the dispatch. * * Known limitation: `testOnly_pressabilityConfig` exists only when `NODE_ENV` is `test`, which * Jest sets by default. Without it, `Pressable` and `Touchable*` claim the touch but get nothing. * - * @param event from `createPressabilityEvent()`, passed to the callback. React Native passes the - * responder event derived from `touchStart` to `onPressIn`, and from `touchEnd` to `onPress` and - * `onPressOut`. + * @param eventTypes the callbacks to call, in order, e.g. `['pressOut', 'press']` on touch end. + * @param event from `createPressabilityEvent()`, passed to each callback. React Native passes the + * same event to `onPressIn` and `onLongPress`, and another one to `onPressOut` and `onPress`. + * @param hitTarget the first element of the touch path from `getTouchPath()`. + * @returns the event types whose callback was called. */ export async function dispatchPressabilityEvent( - target: TestInstance, - eventType: PressabilityEventType, + responder: TestInstance, + eventTypes: readonly PressabilityEventType[], event: SyntheticEvent, -): Promise { - if (!isInstanceMounted(target)) { - return; + hitTarget: TestInstance, +): Promise { + if (!isInstanceMounted(responder)) { + return []; } - const path = getTouchPath(target); - if (path.length === 0) { - return; + const calls = eventTypes.flatMap((eventType) => { + const callback = getPressabilityCallback(responder, eventType); + return callback != null ? [{ eventType, callback }] : []; + }); + if (calls.length === 0) { + return []; } - const callbackName = getEventHandlerName(eventType) as PressabilityCallbackName; const state: DispatchState = { firstError: null }; + const calledEventTypes: PressabilityEventType[] = []; await act(() => { - getEventInternals(event).target = path[0]; - getEventInternals(event).composedPath = path; - try { - const responder = findResponder(path); - const callback = responder != null ? getCallback(responder, callbackName) : undefined; - if (responder != null && callback != null) { - const phase = responder === path[0] ? event.AT_TARGET : event.BUBBLING_PHASE; - getEventInternals(event).eventPhase = phase; - getEventInternals(event).currentTarget = responder; + getEventInternals(event).target = hitTarget; + for (const { eventType, callback } of calls) { + getEventInternals(event).currentTarget = eventType === 'longPress' ? null : responder; + calledEventTypes.push(eventType); + try { callback.call(responder, event); + } catch (error) { + state.firstError = { error }; + break; } - } catch (error) { - state.firstError = { error }; } - resetEvent(event); + getEventInternals(event).currentTarget = null; }); // After `act()`, which would skip rendering state updates if its callback threw. if (state.firstError != null) { throw state.firstError.error; } + + return calledEventTypes; } -/** The hit target, then its host ancestors. Empty when `pointerEvents` blocks every element. */ +/** + * Hit testing: the elements a touch on the element goes through, from the hit target to the root. + * The hit target is the element, or its nearest ancestor when `pointerEvents` blocks it. Empty + * when `pointerEvents` blocks every element. + */ export function getTouchPath(target: TestInstance): TestInstance[] { let hitTarget: TestInstance | null = target; while (hitTarget?.parent != null && getPointerEventsBlocker(hitTarget) != null) { @@ -122,7 +121,26 @@ export function getTouchPath(target: TestInstance): TestInstance[] { return path; } -/** The first element on the touch path that claims the touch, or `null` when none does. */ +/** + * Responder negotiation, as React Native's `ResponderEventPlugin` does on touch start: + * `onStartShouldSetResponder` from the hit target to the root. The first one returning `true` wins. + * A disabled `Pressable` declines, so the touch goes on to its ancestors. Called without + * arguments, as in legacy `fireEvent` and `userEvent`. + * + * The Jest preset mocks `Text` and `TextInput`, so their host elements get `onPress*` props + * instead of responder props. A host element with any of these props (or their `testOnly_` + * variants) takes part in the negotiation as if it had Pressability. A host `TextInput` always + * claims the touch, as its Pressability does on a device, even without these props. It calls its + * callbacks even when not editable, as on iOS, where `editable={false}` only stops it from + * focusing. Known difference from Android: there, a non-editable `TextInput` is disabled and never + * the touch target, so the touch goes to its ancestors. A disabled `Text` declines the touch. + * + * Not implemented yet: the capture phase (`onStartShouldSetResponderCapture`), as `PanResponder` + * reads `touchHistory` there, which only the responder system's events have. + * + * @param path the touch path from `getTouchPath()`. + * @returns the first element on the path that claims the touch, or `null` when none does. + */ export function findResponder(path: TestInstance[]): TestInstance | null { for (const node of path) { const handler = getEventHandlerByName(node.props, 'onStartShouldSetResponder'); @@ -134,16 +152,29 @@ export function findResponder(path: TestInstance[]): TestInstance | null { return null; } -function getCallback( +/** + * The responder's Pressability callback for the event type, e.g. `onLongPress` for `longPress`, or + * `undefined` when it has none. + * + * Ignores `disabled` of `Pressable` and `Touchable*`: Pressability checks it only in + * `onStartShouldSetResponder`, so one disabled during a touch, e.g. by `onPressIn`, still gets + * `onPressOut` and `onPress`. A disabled `Text` gets nothing, as on a device, where it stops + * rendering its Pressability handlers. + */ +export function getPressabilityCallback( responder: TestInstance, - callbackName: PressabilityCallbackName, + eventType: PressabilityEventType, ): EventHandler | undefined { + const callbackName = getEventHandlerName(eventType) as PressabilityCallbackName; const config = getPressabilityConfig(responder); + // Mocked hosts, like `Text` and `TextInput`, have their own `onPress*` props instead. if (config == null) { - return getPressabilityEventHandler(responder, callbackName); + return isDisabledHostText(responder) + ? undefined + : getEventHandlerByName(responder.props, callbackName); } - return config.disabled !== true ? (config[callbackName] ?? undefined) : undefined; + return config[callbackName] ?? undefined; } /** Config of `Pressable` and `Touchable*`, or `null` for elements without Pressability. */ @@ -157,24 +188,13 @@ function getPressabilityConfig(instance: TestInstance): PressabilityConfig | nul * Pressability config has no `disabled`. */ function claimsTouchWithoutResponder(instance: TestInstance): boolean { - return isHostTextInput(instance) || hasPressabilityEventHandler(instance); -} - -function hasPressabilityEventHandler(instance: TestInstance): boolean { - return !isDisabledHostText(instance) && hasPressEventHandler(instance.props); -} + if (isHostTextInput(instance)) { + return true; + } -/** - * Own `onPress*` prop (or its `testOnly_` variant) of an element without a Pressability config, - * like a mocked `Text` or `TextInput` host. `undefined` for a disabled `Text`. - */ -function getPressabilityEventHandler( - instance: TestInstance, - eventHandlerName: string, -): EventHandler | undefined { if (isDisabledHostText(instance)) { - return undefined; + return false; } - return getEventHandlerByName(instance.props, eventHandlerName); + return hasPressEventHandler(instance.props); } diff --git a/src/events/modern/responder-event.ts b/src/events/modern/responder-event.ts new file mode 100644 index 000000000..e2e66931f --- /dev/null +++ b/src/events/modern/responder-event.ts @@ -0,0 +1,90 @@ +import { mergeEventProps } from '../shared/merge'; +import { buildTouchNativeEvent } from '../shared/payloads'; +import type { CreateEventInit, NativeEventPayload } from './event'; +import { SyntheticEvent } from './event'; +import type { PressabilityEventType } from './event-types'; + +/** The dispatch configs React Native's responder system passes to `ResponderEvent`. */ +const RESPONDER_DISPATCH_CONFIGS = { + responderGrant: { registrationName: 'onResponderGrant', dependencies: [] }, + responderRelease: { + registrationName: 'onResponderRelease', + dependencies: ['topTouchCancel', 'topTouchEnd'], + }, +} as const; + +type ResponderEventType = keyof typeof RESPONDER_DISPATCH_CONFIGS; + +/** + * Event object of responder handlers (`onResponderGrant`, ...), which Pressability passes on to + * its callbacks. Mirrors React Native's `ResponderEvent` + * (`src/private/renderer/events/ResponderEvent.js`). The responder system creates it in + * JavaScript and calls the handler directly, so unlike native events it is neither direct nor + * trusted, has no event phase or composed path, and its `timeStamp` is `performance.now()`. + */ +export class ResponderEvent extends SyntheticEvent { + constructor( + type: ResponderEventType, + init: { timeStamp?: number }, + nativeEvent: NativeEventPayload, + ) { + const dispatchConfig = RESPONDER_DISPATCH_CONFIGS[type]; + super( + type, + { bubbles: false, cancelable: true, rnIsDirect: false, timeStamp: init.timeStamp }, + nativeEvent, + { kind: 'direct', handlerName: dispatchConfig.registrationName }, + dispatchConfig, + ); + } +} + +/** + * Creates the event Pressability passes to the callback, ready for `dispatchPressabilityEvent()`. + * As in React Native, `onPressIn` and `onLongPress` get the `responderGrant` event (touch start), + * and `onPressOut` and `onPress` the `responderRelease` event (touch end). + * + * `nativeEvent` is deep merged onto a default touch payload. As on a device, `changedTouches` has + * the touch, and `touches` has it on touch start and is empty on touch end, unless `nativeEvent` + * sets them. + */ +export function createPressabilityEvent( + eventType: PressabilityEventType, + init: CreateEventInit = {}, +): ResponderEvent { + const isTouchStart = eventType === 'pressIn' || eventType === 'longPress'; + const nativeEvent = buildResponderNativeEvent(isTouchStart, init.nativeEvent); + return new ResponderEvent( + isTouchStart ? 'responderGrant' : 'responderRelease', + { timeStamp: init.timeStamp }, + nativeEvent, + ); +} + +/** + * React Native's touch payload (`touchEventPayload()` in `TouchEventEmitter.cpp`): the changed + * touch's fields, plus the touch arrays. On the New Architecture, `timestamp` uses the clock of + * `performance.now()`. + */ +function buildResponderNativeEvent( + isTouchStart: boolean, + init: NativeEventPayload | undefined, +): NativeEventPayload { + const nativeEvent: NativeEventPayload = mergeEventProps( + buildTouchNativeEvent(), + { timestamp: performance.now() }, + init, + ); + + const touch = { ...nativeEvent }; + delete touch.changedTouches; + delete touch.touches; + if (init == null || !Object.hasOwn(init, 'changedTouches')) { + nativeEvent.changedTouches = [{ ...touch }]; + } + if (init == null || !Object.hasOwn(init, 'touches')) { + nativeEvent.touches = isTouchStart ? [{ ...touch }] : []; + } + + return nativeEvent; +} diff --git a/src/events/shared/update-native-state.ts b/src/events/shared/update-native-state.ts index 486725f73..998c3fb6b 100644 --- a/src/events/shared/update-native-state.ts +++ b/src/events/shared/update-native-state.ts @@ -78,7 +78,9 @@ function tryGetText(event: unknown): string | null { try { // @ts-expect-error: try to extract text from the event value const text = event?.nativeEvent?.text; - return typeof text === 'string' ? text : null; + if (typeof text === 'string') { + return text; + } } catch { // Do nothing } diff --git a/src/helpers/object.ts b/src/helpers/object.ts index 728feefe9..b82a24343 100644 --- a/src/helpers/object.ts +++ b/src/helpers/object.ts @@ -9,7 +9,6 @@ export function pick(object: T, keys: (keyof T)[]): Partial return result; } -/** Returns `true` for non-null objects that are not arrays. */ export function isObject(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } diff --git a/src/helpers/text.ts b/src/helpers/text.ts index ca5351687..d1566b837 100644 --- a/src/helpers/text.ts +++ b/src/helpers/text.ts @@ -3,9 +3,9 @@ import type { TestInstance } from 'test-renderer'; import { isHostText } from './host-component-names'; /** - * Whether the element is a host `Text` that React Native's `Text` disables. It reads `disabled`, - * then `aria-disabled`, then `accessibilityState.disabled`, so `disabled={false}` wins over - * `aria-disabled`. The Jest preset mocks `Text`, so its host element gets these props unchanged. + * Whether the element is a host `Text` that React Native's `Text` disables. As in React Native, + * `disabled={false}` wins over `aria-disabled`. The Jest preset mocks `Text`, so its host element + * gets these props unchanged. */ export function isDisabledHostText(instance: TestInstance): boolean { if (!isHostText(instance)) { diff --git a/src/test-utils/event-system.ts b/src/test-utils/event-system.ts index d45709b03..64fc86046 100644 --- a/src/test-utils/event-system.ts +++ b/src/test-utils/event-system.ts @@ -1,6 +1,5 @@ import { configure, getConfig } from '../config'; -/** Whether the test runs in the modern event system, for tests that branch on it. */ export function isModernEventSystem() { return getConfig().unstable_eventSystem === 'modern'; } diff --git a/src/test-utils/events.ts b/src/test-utils/events.ts index 4b27da782..22a349055 100644 --- a/src/test-utils/events.ts +++ b/src/test-utils/events.ts @@ -1,4 +1,4 @@ -/** A logged handler call. `event-serializer.ts` prints it the same way in both event systems. */ +/** Logged handler call. A class, so `event-serializer.ts` can recognize it with `instanceof`. */ export class EventEntry { constructor( readonly name: string, diff --git a/src/user-event/__tests__/event-diagnostics.test.tsx b/src/user-event/__tests__/event-diagnostics.test.tsx index 5209c8974..cdd972bd5 100644 --- a/src/user-event/__tests__/event-diagnostics.test.tsx +++ b/src/user-event/__tests__/event-diagnostics.test.tsx @@ -68,15 +68,20 @@ test('warns when no element handles the press', async () => { `); }); -test('warns when none of the dispatched events has a handler', async () => { - // `longPress` dispatches `pressIn`, `longPress` and `pressOut`, but not `press`. +test('calls onPress on long press without onLongPress (legacy: warns that none of the dispatched events has a handler)', async () => { + // Legacy `longPress` dispatches `pressIn`, `longPress` and `pressOut`, but not `press`. As + // Pressability, modern `longPress` calls `onPress` when there is no `onLongPress`. + const onPress = jest.fn(); // @ts-expect-error Host View does not declare `onPress`. - await render(); + await render(); const user = userEvent.setup(); await user.longPress(screen.getByTestId('view')); - expect(warnSpy).toHaveBeenCalledTimes(1); + expect(onPress).toHaveBeenCalledTimes(isModernEventSystem() ? 1 : 0); + expect(warnSpy).toHaveBeenCalledTimes(isModernEventSystem() ? 0 : 1); + if (isModernEventSystem()) return; + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` "longPress() interaction did not call any event handlers. The element has no handler for the "pressIn", "longPress" or "pressOut" events. If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. @@ -211,8 +216,7 @@ test('warns when pressing a TextInput without handlers inside a Pressable (legac await user.press(screen.getByTestId('input')); - // Modern matches React Native: the TextInput claims the touch, so the warning names it, not the - // Pressable. Legacy passes the press on to the Pressable. + // Modern matches React Native: the TextInput claims the touch, so the warning names it. const isModern = isModernEventSystem(); expect(onPress).toHaveBeenCalledTimes(isModern ? 0 : 1); expect(warnSpy).toHaveBeenCalledTimes(isModern ? 1 : 0); @@ -238,8 +242,7 @@ test('warns when pressing a non-editable TextInput without handlers inside a Pre await user.press(screen.getByTestId('input')); - // Modern matches iOS: same as an editable TextInput, it claims the touch, so the warning names - // it. Legacy skips the non-editable TextInput and passes the press on to the Pressable. + // Modern matches iOS, where a non-editable TextInput claims the touch too. const isModern = isModernEventSystem(); expect(onPress).toHaveBeenCalledTimes(isModern ? 0 : 1); expect(warnSpy).toHaveBeenCalledTimes(isModern ? 1 : 0); diff --git a/src/user-event/clear.ts b/src/user-event/clear.ts index bca060417..f542b5f0c 100644 --- a/src/user-event/clear.ts +++ b/src/user-event/clear.ts @@ -6,7 +6,7 @@ import { buildFocusEvent, buildTextSelectionChangeEvent, } from '../events/create-event'; -import { isPointerEventEnabled } from '../events/legacy'; +import { isPointerEventEnabled } from '../events/shared/pointer-events'; import { ErrorWithStack } from '../helpers/errors'; import { isHostTextInput } from '../helpers/host-component-names'; import { getTextInputValue, isEditableTextInput } from '../helpers/text-input'; diff --git a/src/user-event/legacy/press.ts b/src/user-event/legacy/press.ts index 5ed0a7dcc..5452412bd 100644 --- a/src/user-event/legacy/press.ts +++ b/src/user-event/legacy/press.ts @@ -1,34 +1,90 @@ import type { TestInstance } from 'test-renderer'; -import { hasPressEventHandler, isPointerEventEnabled } from '../../events/legacy'; +import { act } from '../../act'; +import { + buildResponderGrantEvent, + buildResponderReleaseEvent, +} from '../../events/legacy/builders/common'; +import { hasPressEventHandler } from '../../events/shared/handler'; +import { isPointerEventEnabled } from '../../events/shared/pointer-events'; import { isHostText, isHostTextInput } from '../../helpers/host-component-names'; +import type { UserEventConfig } from '../setup'; import type { Interaction } from '../utils'; +import { wait } from '../utils'; + +// These are constants defined in the React Native repo +// See: https://github.com/facebook/react-native/blob/50e38cc9f1e6713228a91ad50f426c4f65e65e1a/packages/react-native/Libraries/Pressability/Pressability.js#L264 +const DEFAULT_MIN_PRESS_DURATION = 130; +const DEFAULT_LONG_PRESS_DELAY_MS = 500; + +export type EmitPressOptions = { + type: 'press' | 'longPress'; + /** + * How long the touch lasts. Defaults to Pressability's long press delay (500 ms) for + * `longPress()`, and for `press()` to its minimum press duration (130 ms) on a touch responder, + * or the `delay` option of `userEvent.setup()` on a mocked element. + */ + duration?: number; +}; + +/** + * Presses the element from `findPressTarget()`: calls a mocked element's `onPress*` props directly, + * or a touch responder's `onResponderGrant` and `onResponderRelease`, so Pressability's timers + * decide which callbacks to call. + */ +export async function emitPressEvents( + config: UserEventConfig, + interaction: Interaction, + instance: TestInstance, + options: EmitPressOptions, +) { + // Hold a long press for Pressability's long press delay, so its timer calls `onLongPress`. + const resolvedOptions = + options.type === 'longPress' + ? { ...options, duration: options.duration ?? DEFAULT_LONG_PRESS_DELAY_MS } + : options; + + const targetKind = findPressTarget(interaction, instance); + if (targetKind === 'mocked-direct') { + await emitMockedDirectPressEvents(config, interaction, resolvedOptions); + } else if (targetKind === 'touch-responder') { + await emitTouchResponderPressEvents(config, interaction, resolvedOptions); + } +} + +/** + * What kind of element receives the press: + * - `'mocked-direct'`: a host element with `onPress*` props (or their `testOnly_` variants), called + * directly. Real host elements never get these props, as Pressability turns them into responder + * props, but mocks do: the Jest preset's `mockComponent()` passes all props of `Text` and + * `TextInput` on to a host element. + * - `'touch-responder'`: an element whose `onStartShouldSetResponder` accepts the touch, like the + * host element of `Pressable` or `TouchableOpacity`. + */ +type PressTargetKind = 'mocked-direct' | 'touch-responder'; /** * Finds the element that receives the press, from the instance up to the root, and sets it as - * `interaction.target`: the nearest enabled host element with `onPress*` props (`'host'`), or - * touch responder that accepts the touch (`'responder'`). Elements that could handle the press, - * but are disabled or block touches, are added to `interaction.skippedTargets`. + * `interaction.target`: the nearest enabled mocked element (`'mocked-direct'`), or touch + * responder (`'touch-responder'`). Elements that could handle the press, but are disabled or block + * touches, are added to `interaction.skippedTargets`. * * A non-editable `TextInput` is skipped, and a `TextInput` without `onPress*` props passes the * press on to its ancestors. Modern `findPressTarget()` keeps it, as React Native does. * - * @returns how to press the target, or `null` when no element receives the press. + * @returns what kind of element the target is, or `null` when no element receives the press. */ -export function findPressTarget( - interaction: Interaction, - instance: TestInstance, -): 'host' | 'responder' | null { +function findPressTarget(interaction: Interaction, instance: TestInstance): PressTargetKind | null { let current: TestInstance | null = instance; while (current != null) { if (isEnabledHostElement(current) && hasPressEventHandler(current.props)) { interaction.target = current; - return 'host'; + return 'mocked-direct'; } if (isEnabledTouchResponder(current)) { interaction.target = current; - return 'responder'; + return 'touch-responder'; } // The element could handle the press, but is disabled or blocks touches. @@ -61,3 +117,55 @@ function isEnabledHostElement(instance: TestInstance) { function isEnabledTouchResponder(instance: TestInstance) { return isPointerEventEnabled(instance) && instance.props.onStartShouldSetResponder?.(); } + +/** + * Calls the target's `onPress*` props in the order Pressability calls them. Like Pressability, + * calls them only on the target, so `onPress*` props of its ancestors are not called. + */ +async function emitMockedDirectPressEvents( + config: UserEventConfig, + interaction: Interaction, + options: EmitPressOptions, +) { + await wait(config); + await interaction.dispatchTouchEvent('pressIn'); + + await wait(config, options.duration); + + // Long press events are emitted before `pressOut`. + if (options.type === 'longPress') { + await interaction.dispatchTouchEvent('longPress'); + } + + await interaction.dispatchTouchEvent('pressOut'); + + // Regular press events are emitted after `pressOut` according to the React Native docs. + // See: https://reactnative.dev/docs/pressable#onpress + // Experimentally for very short presses (< 130ms) `press` events are actually emitted before `onPressOut`, but + // we will ignore that as in reality most pressed would be above the 130ms threshold. + if (options.type === 'press') { + await interaction.dispatchTouchEvent('press'); + } +} + +async function emitTouchResponderPressEvents( + config: UserEventConfig, + interaction: Interaction, + options: EmitPressOptions, +) { + await wait(config); + + await interaction.invokeEventHandler('responderGrant', buildResponderGrantEvent()); + + const duration = options.duration ?? DEFAULT_MIN_PRESS_DURATION; + await wait(config, duration); + + await interaction.invokeEventHandler('responderRelease', buildResponderReleaseEvent()); + + // React Native will wait for minimal delay of DEFAULT_MIN_PRESS_DURATION + // before emitting the `pressOut` event. We need to wait here, so that + // `press()` function does not return before that. + if (DEFAULT_MIN_PRESS_DURATION - duration > 0) { + await act(() => wait(config, DEFAULT_MIN_PRESS_DURATION - duration)); + } +} diff --git a/src/user-event/modern/press.ts b/src/user-event/modern/press.ts index 1a06aa509..d1d2c8f32 100644 --- a/src/user-event/modern/press.ts +++ b/src/user-event/modern/press.ts @@ -1,24 +1,97 @@ import type { TestInstance } from 'test-renderer'; -import { findResponder, getTouchPath } from '../../events/modern/pressability'; -import { getEventHandlerByName, hasPressEventHandler } from '../../events/shared/handler'; +import type { SyntheticEvent } from '../../events/modern/event'; +import type { PressabilityEventType } from '../../events/modern/event-types'; +import { + dispatchPressabilityEvent, + findResponder, + getPressabilityCallback, + getTouchPath, +} from '../../events/modern/pressability'; +import { createPressabilityEvent } from '../../events/modern/responder-event'; +import { hasPressEventHandler } from '../../events/shared/handler'; +import type { UserEventConfig } from '../setup'; import type { Interaction } from '../utils'; +import { wait } from '../utils'; + +export type EmitPressOptions = { + type: 'press' | 'longPress'; + /** How long the touch lasts. Defaults to the `delay` option of `userEvent.setup()`. */ + duration?: number; +}; + +/** + * Presses the element as a touch on a device does: finds the touch responder as modern + * `fireEvent.press()` does, then calls its Pressability callbacks in the order Pressability calls + * them, each with a `SyntheticEvent`: + * + * 1. `onPressIn` on touch start. + * 2. `onLongPress`, for `longPress()`. + * 3. `onPressOut` on touch end. + * 4. `onPress`, unless `onLongPress` was called. + * + * As in React Native, `onPressIn` and `onLongPress` get the same event (`responderGrant`, created + * when the touch starts), and `onPressOut` and `onPress` another one (`responderRelease`, created + * when it ends). + * + * Calls the callbacks directly instead of the responder's `onResponderGrant` and + * `onResponderRelease`, so Pressability's timers aren't run. The action, not the touch duration, + * decides whether it is a long press: `longPress()` calls `onLongPress` whatever the responder's + * `delayLongPress`, and `press()` never does. The touch lasts `duration`, only so timers in the + * component can run. `onPressOut` comes right on touch end, even for touches shorter than + * `minPressDuration` (130 ms), and `delayPressIn` and `delayPressOut` are ignored. Responders + * without Pressability, like `PanResponder`, get nothing. + */ +export async function emitPressEvents( + config: UserEventConfig, + interaction: Interaction, + instance: TestInstance, + options: EmitPressOptions, +) { + const path = getTouchPath(instance); + const responder = findPressTarget(interaction, instance, path); + if (responder == null) { + return; + } + + /** Calls the callbacks in one `act()`, as React Native calls them from one responder event. */ + const dispatch = async (eventTypes: PressabilityEventType[], event: SyntheticEvent) => { + const calledEventTypes = await dispatchPressabilityEvent(responder, eventTypes, event, path[0]); + for (const eventType of eventTypes) { + interaction.recordEvent(eventType, calledEventTypes.includes(eventType)); + } + }; + + await wait(config); + const pressStartEvent = createPressabilityEvent('pressIn'); + await dispatch(['pressIn'], pressStartEvent); + + await wait(config, options.duration); + const isLongPress = options.type === 'longPress'; + if (isLongPress) { + await dispatch(['longPress'], pressStartEvent); + } + + // Pressability skips `onPress` after a long press only when there is an `onLongPress`. + const isPressCanceledByLongPress = + isLongPress && getPressabilityCallback(responder, 'longPress') != null; + const pressEndEvent = createPressabilityEvent('pressOut'); + await dispatch(isPressCanceledByLongPress ? ['pressOut'] : ['pressOut', 'press'], pressEndEvent); +} /** - * Sets `interaction.target` to the touch responder, the element modern `fireEvent.press()` calls - * (see `dispatchPressabilityEvent()`). A responder with `onStartShouldSetResponder` (`Pressable`, - * `Touchable*`, `PanResponder`) is pressed with responder events (`'responder'`); a mocked host - * without one, like `Text` or `TextInput`, gets its `onPress*` props called (`'host'`). Elements - * between the pressed element and the responder that could handle the press, but are disabled or - * block touches, are added to `interaction.skippedTargets`. + * Sets `interaction.target` to the touch responder, the element modern `fireEvent.press()` calls. + * Elements between the pressed element and the responder that could handle the press, but are + * disabled or block touches, are added to `interaction.skippedTargets`. * - * @returns how to press the target, or `null` when no element receives the press. + * @returns the responder, or `null` when no element receives the press. */ -export function findPressTarget( +function findPressTarget( interaction: Interaction, instance: TestInstance, -): 'host' | 'responder' | null { - const touchResponder = findResponder(getTouchPath(instance)); + path: TestInstance[], +): TestInstance | null { + const touchResponder = findResponder(path); let current: TestInstance | null = instance; while (current != null && current !== touchResponder) { @@ -29,12 +102,9 @@ export function findPressTarget( current = current.parent; } - if (touchResponder == null) { - return null; + if (touchResponder != null) { + interaction.target = touchResponder; } - interaction.target = touchResponder; - return getEventHandlerByName(touchResponder.props, 'onStartShouldSetResponder') != null - ? 'responder' - : 'host'; + return touchResponder; } diff --git a/src/user-event/paste.ts b/src/user-event/paste.ts index 61212b2d5..aee7128a7 100644 --- a/src/user-event/paste.ts +++ b/src/user-event/paste.ts @@ -8,7 +8,8 @@ import { buildTextChangeEvent, buildTextSelectionChangeEvent, } from '../events/create-event'; -import { isPointerEventEnabled, nativeState } from '../events/legacy'; +import { nativeState } from '../events/shared/native-state'; +import { isPointerEventEnabled } from '../events/shared/pointer-events'; import { ErrorWithStack } from '../helpers/errors'; import { isHostTextInput } from '../helpers/host-component-names'; import { getTextInputValue, isEditableTextInput } from '../helpers/text-input'; diff --git a/src/user-event/press/__tests__/__snapshots__/longPress.test.tsx.snap b/src/user-event/press/__tests__/__snapshots__/longPress.test.tsx.snap deleted file mode 100644 index 6f0db84e2..000000000 --- a/src/user-event/press/__tests__/__snapshots__/longPress.test.tsx.snap +++ /dev/null @@ -1,75 +0,0 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing - -exports[`userEvent.longPress with fake timers calls onLongPress if the delayLongPress is the default one 1`] = ` -[ - { - "name": "longPress", - "payload": { - "nativeEvent": { - "changedTouches": [], - "identifier": 0, - "locationX": 0, - "locationY": 0, - "pageX": 0, - "pageY": 0, - "target": 0, - "timestamp": 0, - "touches": [], - }, - }, - }, -] -`; - -exports[`userEvent.longPress with fake timers works on Pressable 1`] = ` -[ - { - "name": "pressIn", - "payload": { - "nativeEvent": { - "changedTouches": [], - "identifier": 0, - "locationX": 0, - "locationY": 0, - "pageX": 0, - "pageY": 0, - "target": 0, - "timestamp": 0, - "touches": [], - }, - }, - }, - { - "name": "longPress", - "payload": { - "nativeEvent": { - "changedTouches": [], - "identifier": 0, - "locationX": 0, - "locationY": 0, - "pageX": 0, - "pageY": 0, - "target": 0, - "timestamp": 0, - "touches": [], - }, - }, - }, - { - "name": "pressOut", - "payload": { - "nativeEvent": { - "changedTouches": [], - "identifier": 0, - "locationX": 0, - "locationY": 0, - "pageX": 0, - "pageY": 0, - "target": 0, - "timestamp": 500, - "touches": [], - }, - }, - }, -] -`; diff --git a/src/user-event/press/__tests__/__snapshots__/press.test.tsx.snap b/src/user-event/press/__tests__/__snapshots__/press.test.tsx.snap deleted file mode 100644 index 06f4dbefc..000000000 --- a/src/user-event/press/__tests__/__snapshots__/press.test.tsx.snap +++ /dev/null @@ -1,54 +0,0 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing - -exports[`userEvent.press with fake timers works on Pressable 1`] = ` -[ - { - "name": "pressIn", - "payload": { - "nativeEvent": { - "changedTouches": [], - "identifier": 0, - "locationX": 0, - "locationY": 0, - "pageX": 0, - "pageY": 0, - "target": 0, - "timestamp": 0, - "touches": [], - }, - }, - }, - { - "name": "pressOut", - "payload": { - "nativeEvent": { - "changedTouches": [], - "identifier": 0, - "locationX": 0, - "locationY": 0, - "pageX": 0, - "pageY": 0, - "target": 0, - "timestamp": 130, - "touches": [], - }, - }, - }, - { - "name": "press", - "payload": { - "nativeEvent": { - "changedTouches": [], - "identifier": 0, - "locationX": 0, - "locationY": 0, - "pageX": 0, - "pageY": 0, - "target": 0, - "timestamp": 130, - "touches": [], - }, - }, - }, -] -`; diff --git a/src/user-event/press/__tests__/longPress.real-timers.test.tsx b/src/user-event/press/__tests__/longPress.real-timers.test.tsx index 6fad2e043..a85871261 100644 --- a/src/user-event/press/__tests__/longPress.real-timers.test.tsx +++ b/src/user-event/press/__tests__/longPress.real-timers.test.tsx @@ -3,6 +3,7 @@ import { Pressable, Text, TouchableHighlight, TouchableOpacity } from 'react-nat import { userEvent } from '../..'; import { render, screen } from '../../..'; +import { isModernEventSystem } from '../../../test-utils/event-system'; import { createEventLogger, getEventsNames } from '../../../test-utils/events'; describe('userEvent.longPress with real timers', () => { @@ -106,7 +107,7 @@ describe('userEvent.longPress with real timers', () => { expect(mockOnPress).not.toHaveBeenCalled(); }); - test('does not calls onLongPress when duration is lesser than specified delayLongPress', async () => { + test('calls onLongPress whatever the delayLongPress (legacy: does not call it when duration is lesser than delayLongPress)', async () => { const mockOnLongPress = jest.fn(); const mockOnPress = jest.fn(); const user = userEvent.setup(); @@ -118,8 +119,9 @@ describe('userEvent.longPress with real timers', () => { ); await user.longPress(screen.getByText('press me')); - expect(mockOnLongPress).not.toHaveBeenCalled(); - expect(mockOnPress).toHaveBeenCalledTimes(1); + // Modern `longPress()` doesn't wait for Pressability's long press timer. + expect(mockOnLongPress).toHaveBeenCalledTimes(isModernEventSystem() ? 1 : 0); + expect(mockOnPress).toHaveBeenCalledTimes(isModernEventSystem() ? 0 : 1); }); test('does not calls onPress when onLongPress is called', async () => { diff --git a/src/user-event/press/__tests__/longPress.test.tsx b/src/user-event/press/__tests__/longPress.test.tsx index f836eeafd..d17851a7b 100644 --- a/src/user-event/press/__tests__/longPress.test.tsx +++ b/src/user-event/press/__tests__/longPress.test.tsx @@ -3,6 +3,7 @@ import { Pressable, Text, TouchableHighlight, TouchableOpacity, View } from 'rea import { userEvent } from '../..'; import { render, screen } from '../../..'; +import { isModernEventSystem } from '../../../test-utils/event-system'; import { createEventLogger, getEventsNames } from '../../../test-utils/events'; describe('userEvent.longPress with fake timers', () => { @@ -11,7 +12,7 @@ describe('userEvent.longPress with fake timers', () => { jest.setSystemTime(0); }); - test('works on Pressable', async () => { + test('works on Pressable without holding the touch (legacy: holds it for 500 ms)', async () => { const { events, logEvent } = createEventLogger(); const user = userEvent.setup(); @@ -27,7 +28,71 @@ describe('userEvent.longPress with fake timers', () => { await user.longPress(screen.getByTestId('pressable')); expect(getEventsNames(events)).toEqual(['pressIn', 'longPress', 'pressOut']); - expect(events).toMatchSnapshot(); + const touch = { + identifier: 0, + locationX: 0, + locationY: 0, + pageX: 0, + pageY: 0, + target: 0, + timestamp: 0, + }; + // Modern matches React Native, which passes the touch in the touch arrays. + expect(events[0].payload.nativeEvent).toEqual({ + ...touch, + changedTouches: isModernEventSystem() ? [touch] : [], + touches: isModernEventSystem() ? [touch] : [], + }); + // Legacy holds the touch for Pressability's long press delay (500 ms). + expect(events.map((event) => event.payload.nativeEvent.timestamp)).toEqual([ + 0, + 0, + isModernEventSystem() ? 0 : 500, + ]); + }); + + test('passes the responder grant event to onPressIn and onLongPress, the release event to onPressOut and onPress', async () => { + const { events, logEvent } = createEventLogger(); + const user = userEvent.setup(); + + await render( + , + ); + + await user.longPress(screen.getByTestId('pressable')); + await user.press(screen.getByTestId('pressable')); + const [pressIn, longPress, pressOut, , pressOut2, press] = events.map((e) => e.payload); + expect(longPress).toBe(pressIn); + expect(pressIn.dispatchConfig).toMatchObject({ registrationName: 'onResponderGrant' }); + expect(pressOut.dispatchConfig).toMatchObject({ registrationName: 'onResponderRelease' }); + expect(press).toBe(pressOut2); + }); + + test('calls onLongPress without currentTarget (legacy: stub events)', async () => { + const currentTargets: unknown[] = []; + const user = userEvent.setup(); + + await render( + { + currentTargets.push(event.currentTarget); + }} + />, + ); + + await user.longPress(screen.getByTestId('pressable')); + if (!isModernEventSystem()) return; + + // Modern matches React Native, where Pressability calls `onLongPress` from a timer, after the + // dispatch of the responder grant event. + expect(currentTargets).toEqual([null]); }); test('works on TouchableOpacity', async () => { @@ -101,7 +166,7 @@ describe('userEvent.longPress with fake timers', () => { ); await user.longPress(screen.getByText('press me')); - expect(events).toMatchSnapshot(); + expect(getEventsNames(events)).toEqual(['longPress']); }); test('calls onLongPress when duration is greater than specified delayLongPress', async () => { @@ -123,7 +188,7 @@ describe('userEvent.longPress with fake timers', () => { expect(mockOnPress).not.toHaveBeenCalled(); }); - test('does not calls onLongPress when duration is lesser than specified delayLongPress', async () => { + test('calls onLongPress whatever the delayLongPress (legacy: does not call it when duration is lesser than delayLongPress)', async () => { const mockOnLongPress = jest.fn(); const mockOnPress = jest.fn(); const user = userEvent.setup(); @@ -135,8 +200,9 @@ describe('userEvent.longPress with fake timers', () => { ); await user.longPress(screen.getByText('press me')); - expect(mockOnLongPress).not.toHaveBeenCalled(); - expect(mockOnPress).toHaveBeenCalledTimes(1); + // Modern `longPress()` doesn't wait for Pressability's long press timer. + expect(mockOnLongPress).toHaveBeenCalledTimes(isModernEventSystem() ? 1 : 0); + expect(mockOnPress).toHaveBeenCalledTimes(isModernEventSystem() ? 0 : 1); }); test('does not calls onPress when onLongPress is called', async () => { @@ -169,7 +235,7 @@ describe('userEvent.longPress with fake timers', () => { expect(mockOnLongPress).toHaveBeenCalled(); }); - test('longPress accepts custom duration', async () => { + test('calls onLongPress after custom duration (legacy: calls onPress instead when shorter than delayLongPress)', async () => { const { events, logEvent } = createEventLogger(); const user = userEvent.setup(); @@ -184,6 +250,13 @@ describe('userEvent.longPress with fake timers', () => { ); await user.longPress(screen.getByTestId('pressable'), { duration: 50 }); - expect(getEventsNames(events)).toEqual(['pressIn', 'press', 'pressOut']); + // Legacy runs Pressability, whose long press timer doesn't fire within 50 ms, and which delays + // `onPressOut` for touches shorter than its `minPressDuration` (130 ms). + expect(getEventsNames(events)).toEqual( + isModernEventSystem() + ? ['pressIn', 'longPress', 'pressOut'] + : ['pressIn', 'press', 'pressOut'], + ); + expect(events.at(-1)?.payload.nativeEvent.timestamp).toBe(50); }); }); diff --git a/src/user-event/press/__tests__/press.test.tsx b/src/user-event/press/__tests__/press.test.tsx index d86918525..990afe05f 100644 --- a/src/user-event/press/__tests__/press.test.tsx +++ b/src/user-event/press/__tests__/press.test.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import type { GestureResponderEvent } from 'react-native'; import { Button, Pressable, @@ -11,6 +12,7 @@ import { import { userEvent } from '../..'; import { render, screen } from '../../..'; +import { SyntheticEvent } from '../../../events/modern/event'; import { isModernEventSystem } from '../../../test-utils/event-system'; import { createEventLogger, getEventsNames } from '../../../test-utils/events'; @@ -20,7 +22,7 @@ describe('userEvent.press with fake timers', () => { jest.setSystemTime(0); }); - test('works on Pressable', async () => { + test('works on Pressable without holding the touch (legacy: holds it for 130 ms)', async () => { const { events, logEvent } = createEventLogger(); const user = userEvent.setup(); @@ -36,7 +38,53 @@ describe('userEvent.press with fake timers', () => { await user.press(screen.getByTestId('pressable')); expect(getEventsNames(events)).toEqual(['pressIn', 'pressOut', 'press']); - expect(events).toMatchSnapshot(); + const touch = { + identifier: 0, + locationX: 0, + locationY: 0, + pageX: 0, + pageY: 0, + target: 0, + timestamp: 0, + }; + // Modern matches React Native, which passes the touch in the touch arrays. + expect(events[0].payload.nativeEvent).toEqual({ + ...touch, + changedTouches: isModernEventSystem() ? [touch] : [], + touches: isModernEventSystem() ? [touch] : [], + }); + // Legacy holds the touch for Pressability's `minPressDuration`. + const releaseTime = isModernEventSystem() ? 0 : 130; + expect(events.map((event) => event.payload.nativeEvent.timestamp)).toEqual([ + 0, + releaseTime, + releaseTime, + ]); + }); + + test('renders state updates from onPressOut after onPress, as one touch end', async () => { + const calls: string[] = []; + function Counter() { + const [count, setCount] = React.useState(0); + calls.push(`render ${count}`); + return ( + { + calls.push('pressOut'); + setCount((value) => value + 1); + }} + onPress={() => calls.push(`press sees ${count}`)} + testID="pressable" + /> + ); + } + + const user = userEvent.setup(); + await render(); + calls.length = 0; + + await user.press(screen.getByTestId('pressable')); + expect(calls).toEqual(['pressOut', 'press sees 0', 'render 1']); }); test('works on TouchableOpacity', async () => { @@ -83,7 +131,7 @@ describe('userEvent.press with fake timers', () => { expect(getEventsNames(events)).toEqual(['pressIn', 'pressOut', 'press']); }); - test('passes the same touch event to onPress of Text as to onPressIn', async () => { + test('passes a SyntheticEvent with the touch of onPressIn to onPress of Text (legacy: a stub event)', async () => { const { events, logEvent } = createEventLogger(); await render( @@ -94,13 +142,41 @@ describe('userEvent.press with fake timers', () => { await userEvent.press(screen.getByText('press me')); const [pressInEvent, pressEvent] = events.map((event) => event.payload); + // The touch has ended by `onPress`, so it's no longer in `touches`. + expect(pressEvent.nativeEvent).toEqual({ ...pressInEvent.nativeEvent, touches: [] }); + expect(pressEvent instanceof SyntheticEvent).toBe(isModernEventSystem()); + if (isModernEventSystem()) return; + expect(pressEvent).toMatchObject({ target: {}, currentTarget: { measure: expect.any(Function) }, - nativeEvent: pressInEvent.nativeEvent, }); }); + test('passes events with the Pressable as currentTarget to its callbacks (legacy: stub events)', async () => { + const currentTargets: unknown[] = []; + const logCurrentTarget = (event: GestureResponderEvent) => { + currentTargets.push(event.currentTarget); + }; + + await render( + + press me + , + ); + + await userEvent.press(screen.getByText('press me')); + const pressable = screen.getByTestId('pressable'); + expect(currentTargets.map((target) => target === pressable)).toEqual( + Array(3).fill(isModernEventSystem()), + ); + }); + test('does not call onPress of the Text around pressed Text', async () => { const onPressOuter = jest.fn(); const onPressInner = jest.fn(); @@ -190,6 +266,32 @@ describe('userEvent.press with fake timers', () => { expect(events).toEqual([]); }); + test('finishes the press on Pressable disabled by onPressIn', async () => { + const { events, logEvent } = createEventLogger(); + function BusyPressable() { + const [busy, setBusy] = React.useState(false); + return ( + { + logEvent('pressIn')(event); + setBusy(true); + }} + onPressOut={logEvent('pressOut')} + onPress={logEvent('press')} + testID="pressable" + /> + ); + } + + const user = userEvent.setup(); + await render(); + await user.press(screen.getByTestId('pressable')); + + // React Native checks `disabled` only when the touch starts. + expect(getEventsNames(events)).toEqual(['pressIn', 'pressOut', 'press']); + }); + test('does not call press when pointer events is none', async () => { const { events, logEvent } = createEventLogger(); const user = userEvent.setup(); @@ -442,8 +544,8 @@ describe('userEvent.press with fake timers', () => { ); await userEvent.press(screen.getByPlaceholderText('email')); - // Modern matches iOS: a non-editable TextInput claims the touch too, and calls its callbacks, - // as `editable={false}` only stops it from focusing. Legacy skips it, as Android does. + // Modern matches iOS, where `editable={false}` only stops TextInput from focusing. Legacy + // skips it, as Android does. expect(getEventsNames(events)).toEqual(isModernEventSystem() ? ['inputPressIn'] : ['press']); }); diff --git a/src/user-event/press/press.ts b/src/user-event/press/press.ts index 95e172564..1fe25f3d3 100644 --- a/src/user-event/press/press.ts +++ b/src/user-event/press/press.ts @@ -1,19 +1,13 @@ import type { TestInstance } from 'test-renderer'; -import { act } from '../../act'; import { getConfig } from '../../config'; -import { buildResponderGrantEvent, buildResponderReleaseEvent } from '../../events/legacy'; import { isTestInstance } from '../../helpers/component-tree'; import { ErrorWithStack } from '../../helpers/errors'; -import { findPressTarget as findLegacyPressTarget } from '../legacy/press'; -import { findPressTarget as findModernPressTarget } from '../modern/press'; +import { emitPressEvents as emitLegacyPressEvents } from '../legacy/press'; +import type { EmitPressOptions } from '../modern/press'; +import { emitPressEvents as emitModernPressEvents } from '../modern/press'; import type { UserEventConfig, UserEventInstance } from '../setup'; -import { Interaction, wait, warnAboutUnhandledInteraction } from '../utils'; - -// These are constants defined in the React Native repo -// See: https://github.com/facebook/react-native/blob/50e38cc9f1e6713228a91ad50f426c4f65e65e1a/packages/react-native/Libraries/Pressability/Pressability.js#L264 -export const DEFAULT_MIN_PRESS_DURATION = 130; -export const DEFAULT_LONG_PRESS_DELAY_MS = 500; +import { Interaction, warnAboutUnhandledInteraction } from '../utils'; export interface PressOptions { duration?: number; @@ -25,9 +19,7 @@ export async function press(this: UserEventInstance, instance: TestInstance): Pr } const interaction = new Interaction('press', instance); - await basePress(this.config, interaction, instance, { - type: 'press', - }); + await emitPressEvents(this.config, interaction, instance, { type: 'press' }); warnAboutUnhandledInteraction(interaction); } @@ -41,83 +33,22 @@ export async function longPress( } const interaction = new Interaction('longPress', instance); - await basePress(this.config, interaction, instance, { + await emitPressEvents(this.config, interaction, instance, { type: 'longPress', - duration: options?.duration ?? DEFAULT_LONG_PRESS_DELAY_MS, + duration: options?.duration, }); warnAboutUnhandledInteraction(interaction); } -interface BasePressOptions { - type: 'press' | 'longPress'; - duration?: number; -} - -const basePress = async ( +async function emitPressEvents( config: UserEventConfig, interaction: Interaction, instance: TestInstance, - options: BasePressOptions, -): Promise => { - const findPressTarget = - getConfig().unstable_eventSystem === 'modern' ? findModernPressTarget : findLegacyPressTarget; - const targetKind = findPressTarget(interaction, instance); - if (targetKind === 'host') { - await emitDirectPressEvents(config, interaction, options); - } else if (targetKind === 'responder') { - await emitPressabilityPressEvents(config, interaction, options); - } -}; - -/** - * Dispatches a press event sequence for host instances that have `onPress*` event handlers. - */ -async function emitDirectPressEvents( - config: UserEventConfig, - interaction: Interaction, - options: BasePressOptions, -) { - await wait(config); - await interaction.dispatchTouchEvent('pressIn'); - - await wait(config, options.duration); - - // Long press events are emitted before `pressOut`. - if (options.type === 'longPress') { - await interaction.dispatchTouchEvent('longPress'); - } - - await interaction.dispatchTouchEvent('pressOut'); - - // Regular press events are emitted after `pressOut` according to the React Native docs. - // See: https://reactnative.dev/docs/pressable#onpress - // Experimentally for very short presses (< 130ms) `press` events are actually emitted before `onPressOut`, but - // we will ignore that as in reality most pressed would be above the 130ms threshold. - // Like the touch callbacks above, `press` is called only on the target, with a touch event. - // Dispatching it would bubble it to `onPress` of ancestors, which the responder system doesn't do. - if (options.type === 'press') { - await interaction.dispatchTouchEvent('press'); - } -} - -async function emitPressabilityPressEvents( - config: UserEventConfig, - interaction: Interaction, - options: BasePressOptions, -) { - await wait(config); - - await interaction.invokeEventHandler('responderGrant', buildResponderGrantEvent()); - - const duration = options.duration ?? DEFAULT_MIN_PRESS_DURATION; - await wait(config, duration); - - await interaction.invokeEventHandler('responderRelease', buildResponderReleaseEvent()); - - // React Native will wait for minimal delay of DEFAULT_MIN_PRESS_DURATION - // before emitting the `pressOut` event. We need to wait here, so that - // `press()` function does not return before that. - if (DEFAULT_MIN_PRESS_DURATION - duration > 0) { - await act(() => wait(config, DEFAULT_MIN_PRESS_DURATION - duration)); + options: EmitPressOptions, +): Promise { + if (getConfig().unstable_eventSystem === 'modern') { + await emitModernPressEvents(config, interaction, instance, options); + } else { + await emitLegacyPressEvents(config, interaction, instance, options); } } diff --git a/src/user-event/scroll/scroll-to.ts b/src/user-event/scroll/scroll-to.ts index 1e03d058d..811b83956 100644 --- a/src/user-event/scroll/scroll-to.ts +++ b/src/user-event/scroll/scroll-to.ts @@ -2,7 +2,7 @@ import { stringify } from 'jest-matcher-utils'; import type { TestInstance } from 'test-renderer'; import { buildScrollEvent } from '../../events/create-event'; -import { nativeState } from '../../events/legacy'; +import { nativeState } from '../../events/shared/native-state'; import { ErrorWithStack } from '../../helpers/errors'; import { isHostScrollView } from '../../helpers/host-component-names'; import { pick } from '../../helpers/object'; @@ -56,8 +56,7 @@ export async function scrollTo( layoutMeasurement: options.layoutMeasurement ?? nativeState.layoutSizeForInstance.get(instance), }; - // `ScrollView` calls `onContentSizeChange(width, height)` from JavaScript, with two arguments - // instead of an event object, so `dispatchEvent` can't call it. + // Not dispatched: `ScrollView` calls `onContentSizeChange(width, height)` from JavaScript. await interaction.invokeEventHandler( 'contentSizeChange', options.contentSize?.width ?? 0, diff --git a/src/user-event/type/type.ts b/src/user-event/type/type.ts index 62657f12e..a10c9710f 100644 --- a/src/user-event/type/type.ts +++ b/src/user-event/type/type.ts @@ -10,7 +10,8 @@ import { buildTextChangeEvent, buildTextSelectionChangeEvent, } from '../../events/create-event'; -import { isPointerEventEnabled, nativeState } from '../../events/legacy'; +import { nativeState } from '../../events/shared/native-state'; +import { isPointerEventEnabled } from '../../events/shared/pointer-events'; import { ErrorWithStack } from '../../helpers/errors'; import { isHostTextInput } from '../../helpers/host-component-names'; import { getTextInputValue, isEditableTextInput } from '../../helpers/text-input'; diff --git a/src/user-event/utils/interaction.ts b/src/user-event/utils/interaction.ts index e986e2a3a..defd4bf67 100644 --- a/src/user-event/utils/interaction.ts +++ b/src/user-event/utils/interaction.ts @@ -2,7 +2,7 @@ import type { TestInstance } from 'test-renderer'; import type { LegacyEvent } from '../../events/create-event'; import { dispatchEvent } from '../../events/dispatch-event'; -import { buildTouchEvent } from '../../events/legacy'; +import { buildTouchEvent } from '../../events/legacy/builders/common'; import type { SyntheticEvent } from '../../events/modern/event'; import { invokeEventHandler } from '../../events/shared/invoke-event-handler'; From 59af3eea23c48d339c2d96930259561b6978477f Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Wed, 14 Oct 2026 13:32:25 +0200 Subject: [PATCH 5/7] feat: `UNSAFE_invokeHandler` (#1950) --- contributing/event-dispatch.md | 4 + docs/api/fire-event.md | 43 +++ src/events/__tests__/fire-event.test.tsx | 11 +- src/events/__tests__/invoke-handler.test.tsx | 248 ++++++++++++++++++ src/events/invoke-handler.ts | 78 ++++++ src/events/legacy/propagation.ts | 32 +-- src/events/modern/fire-event-utils.ts | 7 +- src/helpers/__tests__/fiber.test.tsx | 62 +++++ src/helpers/fiber.ts | 30 +++ src/pure.ts | 1 + .../docs/14.x/docs/api/events/fire-event.mdx | 44 ++++ 11 files changed, 534 insertions(+), 26 deletions(-) create mode 100644 src/events/__tests__/invoke-handler.test.tsx create mode 100644 src/events/invoke-handler.ts create mode 100644 src/helpers/__tests__/fiber.test.tsx create mode 100644 src/helpers/fiber.ts diff --git a/contributing/event-dispatch.md b/contributing/event-dispatch.md index 8d75dc7ea..d4003ac36 100644 --- a/contributing/event-dispatch.md +++ b/contributing/event-dispatch.md @@ -45,6 +45,10 @@ For the `eventDiagnostics` warning, each action tracks itself with an `Interacti - 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. 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/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index 3fb15e83f..d22326903 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -127,7 +127,8 @@ test('fireEvent throws with more than one event argument (legacy: passes them al // 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.' + ? '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']]); @@ -1933,7 +1934,7 @@ test('fireEvent throws for custom event (onCustomEvent) on composite component ( // calls any matching prop, also on composite components. expect(error).toBe( isModern() - ? `Unable to fire a "customEvent" event. React Native doesn't dispatch "customEvent" natively, so no handler would be called.` + ? `Unable to fire a "customEvent" event. React Native doesn't dispatch "customEvent" natively, so no handler would be called. To call a handler prop directly, use UNSAFE_invokeHandler().` : undefined, ); expect(handler.mock.calls).toEqual(isModern() ? [] : [['event data']]); @@ -1952,7 +1953,7 @@ test('fireEvent throws for custom prop name (handlePress) on composite component // calls any matching prop, also on composite components. expect(error).toBe( isModern() - ? `Unable to fire a "handlePress" event. React Native doesn't dispatch "handlePress" natively, so no handler would be called.` + ? `Unable to fire a "handlePress" event. React Native doesn't dispatch "handlePress" natively, so no handler would be called. To call a handler prop directly, use UNSAFE_invokeHandler().` : undefined, ); expect(handler).toHaveBeenCalledTimes(isModern() ? 0 : 1); @@ -2031,7 +2032,7 @@ test('fireEvent throws with non-existent event name (legacy: does nothing)', asy expect(outcome).toStrictEqual( isModern() ? { - error: `Unable to fire a "nonExistentEvent" event. React Native doesn't dispatch "nonExistentEvent" natively, so no handler would be called.`, + error: `Unable to fire a "nonExistentEvent" event. React Native doesn't dispatch "nonExistentEvent" natively, so no handler would be called. To call a handler prop directly, use UNSAFE_invokeHandler().`, } : { result: undefined }, ); @@ -3199,7 +3200,7 @@ describe('responder system', () => { // responder system's touch history, which modern doesn't simulate, so modern throws. expect(error).toBe( isModern() - ? `Unable to fire a "responderMove" event. React Native doesn't dispatch "responderMove" natively, so no handler would be called.` + ? `Unable to fire a "responderMove" event. React Native doesn't dispatch "responderMove" natively, so no handler would be called. To call a handler prop directly, use UNSAFE_invokeHandler().` : undefined, ); expect(onDrag).toHaveBeenCalledTimes(isModern() ? 0 : 1); diff --git a/src/events/__tests__/invoke-handler.test.tsx b/src/events/__tests__/invoke-handler.test.tsx new file mode 100644 index 000000000..34979048a --- /dev/null +++ b/src/events/__tests__/invoke-handler.test.tsx @@ -0,0 +1,248 @@ +import * as React from 'react'; +import { FlatList, Pressable, ScrollView, Text, TextInput, View } from 'react-native'; + +import { render, screen, UNSAFE_invokeHandler } from '../..'; + +test('calls a handler prop of the element with all arguments', async () => { + const onContentSizeChange = jest.fn(); + await render(); + + await UNSAFE_invokeHandler(screen.getByTestId('scroll'), 'onContentSizeChange', 100, 200); + + expect(onContentSizeChange).toHaveBeenCalledTimes(1); + expect(onContentSizeChange).toHaveBeenCalledWith(100, 200); +}); + +test('calls a handler without arguments', async () => { + const onLoadEnd = jest.fn(); + const props = { onLoadEnd }; + await render(); + + await UNSAFE_invokeHandler(screen.getByTestId('view'), 'onLoadEnd'); + + expect(onLoadEnd).toHaveBeenCalledWith(); +}); + +test('resolves to the return value of the handler', async () => { + await render( true} />); + + await expect( + UNSAFE_invokeHandler(screen.getByTestId('view'), 'onStartShouldSetResponder'), + ).resolves.toBe(true); +}); + +test('awaits async handlers and resolves to their value', async () => { + const onRefresh = jest.fn(async () => { + await Promise.resolve(); + return 'refreshed'; + }); + const props = { onRefresh }; + await render(); + + await expect(UNSAFE_invokeHandler(screen.getByTestId('view'), 'onRefresh')).resolves.toBe( + 'refreshed', + ); +}); + +test('calls a handler of a composite component that renders the element', async () => { + const onHoverIn = jest.fn(); + const onHoverOut = jest.fn(); + await render( + + Button + , + ); + + const button = screen.getByRole('button'); + await UNSAFE_invokeHandler(button, 'onHoverIn', { nativeEvent: {} }); + await UNSAFE_invokeHandler(button, 'onHoverOut', { nativeEvent: {} }); + + expect(onHoverIn).toHaveBeenCalledWith({ nativeEvent: {} }); + expect(onHoverOut).toHaveBeenCalledWith({ nativeEvent: {} }); +}); + +test('calls any function prop, not only on* handlers', async () => { + function Form(_props: { handleSubmitForm: (code: string) => void }) { + return ; + } + + const handleSubmitForm = jest.fn(); + await render(
); + + await UNSAFE_invokeHandler(screen.getByTestId('form'), 'handleSubmitForm', '123456'); + + expect(handleSubmitForm).toHaveBeenCalledWith('123456'); +}); + +test('calls FlatList onContentSizeChange through the list internals', async () => { + const onContentSizeChange = jest.fn(); + await render( + {item}} + onContentSizeChange={onContentSizeChange} + />, + ); + + await UNSAFE_invokeHandler(screen.getByTestId('list'), 'onContentSizeChange', 300, 600); + + expect(onContentSizeChange).toHaveBeenCalledWith(300, 600); +}); + +test('prefers the handler of the element over its composite components', async () => { + const onCustom = jest.fn(); + const onCompositeCustom = jest.fn(); + function Composite(_props: { onCustom: () => void }) { + const hostProps = { onCustom }; + return ; + } + + await render(); + + await UNSAFE_invokeHandler(screen.getByTestId('view'), 'onCustom'); + + expect(onCustom).toHaveBeenCalledTimes(1); + expect(onCompositeCustom).not.toHaveBeenCalled(); +}); + +test('prefers the nearest composite component', async () => { + const onInnerCustom = jest.fn(); + const onOuterCustom = jest.fn(); + function Inner(_props: { onCustom: () => void }) { + return ; + } + function Outer(_props: { onCustom: () => void }) { + return ; + } + + await render(); + + await UNSAFE_invokeHandler(screen.getByTestId('view'), 'onCustom'); + + expect(onInnerCustom).toHaveBeenCalledTimes(1); + expect(onOuterCustom).not.toHaveBeenCalled(); +}); + +test('does not call handlers of ancestor host elements or their components', async () => { + const onParentCustom = jest.fn(); + const onCompositeCustom = jest.fn(); + function Composite(props: { onCustom: () => void; children: React.ReactNode }) { + const hostProps = { onCustom: onParentCustom }; + return {props.children}; + } + + await render( + + + , + ); + + await expect(UNSAFE_invokeHandler(screen.getByTestId('child'), 'onCustom')).rejects.toThrow( + 'Unable to invoke "onCustom". No "onCustom" function prop found on the element (View) or the ' + + 'components that render it (View).', + ); + expect(onParentCustom).not.toHaveBeenCalled(); + expect(onCompositeCustom).not.toHaveBeenCalled(); +}); + +test('calls handlers of elements a device would not deliver events to', async () => { + const onChangeText = jest.fn(); + await render( + + + , + ); + + await UNSAFE_invokeHandler(screen.getByTestId('input'), 'onChangeText', 'Hello'); + + expect(onChangeText).toHaveBeenCalledWith('Hello'); +}); + +test('renders state updates from the handler', async () => { + function Subject() { + const [isHovered, setIsHovered] = React.useState(false); + return ( + setIsHovered(true)}> + {isHovered ? 'Hovered' : 'Idle'} + + ); + } + + await render(); + + await UNSAFE_invokeHandler(screen.getByRole('button'), 'onHoverIn'); + + expect(screen.getByText('Hovered')).toBeOnTheScreen(); +}); + +test('rethrows errors from the handler', async () => { + const props = { + onCustom: () => { + throw new Error('Handler failed'); + }, + }; + await render(); + + await expect(UNSAFE_invokeHandler(screen.getByTestId('view'), 'onCustom')).rejects.toThrow( + 'Handler failed', + ); +}); + +test('throws when no handler is found, listing the components it checked', async () => { + function MyButton() { + return ( + + Button + + ); + } + + await render(); + + await expect(UNSAFE_invokeHandler(screen.getByRole('button'), 'onHoverIn')).rejects.toThrow( + 'Unable to invoke "onHoverIn". No "onHoverIn" function prop found on the element (View) or ' + + 'the components that render it (View, Pressable, MyButton).', + ); +}); + +test('throws when the prop is not a function', async () => { + const props = { onCustom: 'not a function' }; + await render(); + + await expect(UNSAFE_invokeHandler(screen.getByTestId('view'), 'onCustom')).rejects.toThrow( + 'Unable to invoke "onCustom". No "onCustom" function prop found', + ); +}); + +test('throws for an event type instead of a handler prop name', async () => { + const onContentSizeChange = jest.fn(); + await render(); + + await expect( + UNSAFE_invokeHandler(screen.getByTestId('scroll'), 'contentSizeChange', 100, 200), + ).rejects.toThrow( + 'Unable to invoke "contentSizeChange". No "contentSizeChange" function prop found', + ); + expect(onContentSizeChange).not.toHaveBeenCalled(); +}); + +test('throws when the element is unmounted', async () => { + const onCustom = jest.fn(); + const props = { onCustom }; + await render(); + const view = screen.getByTestId('view'); + await screen.rerender(Other); + + await expect(UNSAFE_invokeHandler(view, 'onCustom')).rejects.toThrow( + 'Unable to invoke "onCustom". The element is unmounted.', + ); + expect(onCustom).not.toHaveBeenCalled(); +}); + +test('throws without an element', async () => { + // @ts-expect-error Testing invalid input + await expect(UNSAFE_invokeHandler(null, 'onCustom')).rejects.toThrow( + 'Unable to invoke "onCustom". Please provide a host element.', + ); +}); diff --git a/src/events/invoke-handler.ts b/src/events/invoke-handler.ts new file mode 100644 index 000000000..69a7bb63d --- /dev/null +++ b/src/events/invoke-handler.ts @@ -0,0 +1,78 @@ +import type { TestInstance } from 'test-renderer'; + +import { act } from '../act'; +import { isInstanceMounted } from '../helpers/component-tree'; +import { ErrorWithStack } from '../helpers/errors'; +import { getComponentName, getCompositeOwnerFibers } from '../helpers/fiber'; +import type { EventHandler } from './shared/types'; + +/** + * Calls a handler prop with the given arguments, in `act()`, and resolves to its return value. + * Escape hatch for callbacks that no event reaches, like `onContentSizeChange(width, height)`, or + * `onHoverIn` of `Pressable`, which React Native never calls on iOS or Android by default. + * + * Looks up the prop on the element, then on the composite components that render it, nearest + * first, up to the next host element. Doesn't check whether a device would call the handler: + * disabled elements, `pointerEvents` and `editable` are ignored. + * + * @param handlerName the exact prop name, e.g. `onContentSizeChange` + */ +export async function UNSAFE_invokeHandler( + instance: TestInstance, + handlerName: string, + ...args: unknown[] +): Promise { + if (instance == null) { + throw new ErrorWithStack( + `Unable to invoke "${handlerName}". Please provide a host element.`, + UNSAFE_invokeHandler, + ); + } + + if (!isInstanceMounted(instance)) { + throw new ErrorWithStack( + `Unable to invoke "${handlerName}". The element is unmounted.`, + UNSAFE_invokeHandler, + ); + } + + const handler = findHandler(instance, handlerName); + if (handler == null) { + throw new ErrorWithStack( + `Unable to invoke "${handlerName}". No "${handlerName}" function prop found on the element ` + + `(${instance.type}) or the components that render it (${formatCompositeNames(instance)}).`, + UNSAFE_invokeHandler, + ); + } + + return await act(() => handler(...args)); +} + +/** Checks the element's props, then its composite owners' props, up to the next host element. */ +function findHandler(instance: TestInstance, handlerName: string): EventHandler | null { + const ownHandler = instance.props[handlerName]; + if (typeof ownHandler === 'function') { + return ownHandler as EventHandler; + } + + for (const fiber of getCompositeOwnerFibers(instance)) { + const handler: unknown = fiber.memoizedProps?.[handlerName]; + if (typeof handler === 'function') { + return handler as EventHandler; + } + } + + return null; +} + +/** + * E.g. `View, Pressable, MyButton`. Collapses adjacent duplicates, e.g. `memo(forwardRef(Pressable))` + * in older React Native versions renders two fibers named `Pressable`. + */ +function formatCompositeNames(instance: TestInstance): string { + const names = getCompositeOwnerFibers(instance) + .map((fiber) => getComponentName(fiber.type)) + .filter((name) => name != null) + .filter((name, index, all) => name !== all[index - 1]); + return names.length > 0 ? names.join(', ') : 'none'; +} diff --git a/src/events/legacy/propagation.ts b/src/events/legacy/propagation.ts index fe30ea5b7..4bfab30fe 100644 --- a/src/events/legacy/propagation.ts +++ b/src/events/legacy/propagation.ts @@ -1,5 +1,6 @@ -import type { Fiber, TestInstance } from 'test-renderer'; +import type { TestInstance } from 'test-renderer'; +import { getCompositeOwnerFibers } from '../../helpers/fiber'; import { isHostImage, isHostModal, @@ -75,7 +76,7 @@ function findBubblingHandlerOwner( const handler = getEventHandlerByEventType(instance.props, eventType, { loose: true }) ?? - findEventHandlerFromFiber(instance.unstable_fiber, eventType); + findCompositeEventHandler(instance, eventType); if (handler) { if (isEventEnabled(instance, normalizeEventType(eventType), touchResponder)) { return { owner: { handler, instance }, skippedTargets }; @@ -153,23 +154,16 @@ function isDirectEvent(instance: TestInstance, eventType: string) { return false; } -function findEventHandlerFromFiber(fiber: Fiber | null, eventType: string): EventHandler | null { - // Container fibers have memoizedProps set to null - if (!fiber?.memoizedProps) { - return null; - } - - const handler = getEventHandlerByEventType(fiber.memoizedProps, eventType, { - loose: true, - }); - if (handler) { - return handler; - } - - // No parent fiber or we reached another host element - if (fiber.return === null || typeof fiber.return.type === 'string') { - return null; +/** Checks the props of the composite components that render the element, nearest first. */ +function findCompositeEventHandler(instance: TestInstance, eventType: string): EventHandler | null { + for (const fiber of getCompositeOwnerFibers(instance)) { + const handler = getEventHandlerByEventType(fiber.memoizedProps ?? {}, eventType, { + loose: true, + }); + if (handler) { + return handler; + } } - return findEventHandlerFromFiber(fiber.return, eventType); + return null; } diff --git a/src/events/modern/fire-event-utils.ts b/src/events/modern/fire-event-utils.ts index 49c07088f..7d5b08a4e 100644 --- a/src/events/modern/fire-event-utils.ts +++ b/src/events/modern/fire-event-utils.ts @@ -37,7 +37,9 @@ export function ensureEventType( const alternative = UNKNOWN_EVENT_TYPE_ALTERNATIVES[eventType]; throw new ErrorWithStack( `Unable to fire a "${eventType}" event. React Native doesn't dispatch "${eventType}" natively` + - (alternative != null ? `. Use ${alternative} instead.` : ', so no handler would be called.'), + (alternative != null + ? `. Use ${alternative} instead.` + : ', so no handler would be called. To call a handler prop directly, use UNSAFE_invokeHandler().'), callsite, ); } @@ -65,7 +67,8 @@ export function ensureSingleEventArg( if (args.length > 1) { throw new ErrorWithStack( `Unable to fire a "${eventType}" event. Expected a single event object, ` + - `received ${args.length} arguments.`, + `received ${args.length} arguments. To call a handler prop with custom arguments, ` + + 'use UNSAFE_invokeHandler().', callsite, ); } diff --git a/src/helpers/__tests__/fiber.test.tsx b/src/helpers/__tests__/fiber.test.tsx new file mode 100644 index 000000000..08e487114 --- /dev/null +++ b/src/helpers/__tests__/fiber.test.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { View } from 'react-native'; + +import { render, screen } from '../..'; +import { getComponentName, getCompositeOwnerFibers } from '../fiber'; + +function Inner() { + return ; +} + +const Memoized = React.memo(Inner); + +describe('getCompositeOwnerFibers()', () => { + it('returns composites up to the next host element, nearest first', async () => { + function Outer() { + return ; + } + + await render( + + + , + ); + + const names = getCompositeOwnerFibers(screen.getByTestId('view')).map((fiber) => + getComponentName(fiber.type), + ); + expect(names).toEqual(['View', 'Inner', 'Outer']); + }); +}); + +describe('getComponentName()', () => { + it('returns the name of a function component', () => { + expect(getComponentName(Inner)).toBe('Inner'); + }); + + it('prefers displayName', () => { + function Named() { + return null; + } + Named.displayName = 'CustomName'; + expect(getComponentName(Named)).toBe('CustomName'); + }); + + it('unwraps memo() and forwardRef()', () => { + expect(getComponentName(React.memo(Inner))).toBe('Inner'); + expect(getComponentName(React.forwardRef(Inner))).toBe('Inner'); + }); + + it('prefers displayName of the wrapper', () => { + const Wrapped = React.memo(Inner); + Wrapped.displayName = 'Wrapped'; + expect(getComponentName(Wrapped)).toBe('Wrapped'); + }); + + it('returns null for unnamed or unknown types', () => { + expect(getComponentName(() => null)).toBeNull(); + expect(getComponentName('View')).toBeNull(); + expect(getComponentName(null)).toBeNull(); + expect(getComponentName({})).toBeNull(); + }); +}); diff --git a/src/helpers/fiber.ts b/src/helpers/fiber.ts new file mode 100644 index 000000000..845237f40 --- /dev/null +++ b/src/helpers/fiber.ts @@ -0,0 +1,30 @@ +import type { Fiber, TestInstance } from 'test-renderer'; + +/** Fibers of the composite components that render the element, nearest first. */ +export function getCompositeOwnerFibers(instance: TestInstance): Fiber[] { + const fibers: Fiber[] = []; + let fiber = instance.unstable_fiber?.return ?? null; + // Stops at the next host element, or at the root container, which has no parent. + while (fiber?.return != null && typeof fiber.type !== 'string') { + fibers.push(fiber); + fiber = fiber.return; + } + + return fibers; +} + +/** Display name of a component type, e.g. `Pressable`, unwrapping `memo()` and `forwardRef()`. */ +export function getComponentName(type: unknown): string | null { + if (typeof type === 'function') { + const component = type as { displayName?: string; name?: string }; + return component.displayName || component.name || null; + } + + if (type != null && typeof type === 'object') { + // `memo()` and `forwardRef()` wrap the component in `type` or `render`. + const wrapper = type as { displayName?: string; type?: unknown; render?: unknown }; + return wrapper.displayName || getComponentName(wrapper.type ?? wrapper.render); + } + + return null; +} diff --git a/src/pure.ts b/src/pure.ts index db64cd82f..b339d2828 100644 --- a/src/pure.ts +++ b/src/pure.ts @@ -1,6 +1,7 @@ export { act } from './act'; export { cleanup } from './cleanup'; export { fireEvent } from './events/fire-event'; +export { UNSAFE_invokeHandler } from './events/invoke-handler'; export { render } from './render'; export { waitFor } from './wait-for'; export { waitForElementToBeRemoved } from './wait-for-element-to-be-removed'; diff --git a/website/docs/14.x/docs/api/events/fire-event.mdx b/website/docs/14.x/docs/api/events/fire-event.mdx index b00e8f3d3..bdc2b3141 100644 --- a/website/docs/14.x/docs/api/events/fire-event.mdx +++ b/website/docs/14.x/docs/api/events/fire-event.mdx @@ -211,3 +211,47 @@ await render(); await fireEvent.layout(screen.getByTestId('box'), { width: 320, height: 80 }); ``` + +## `UNSAFE_invokeHandler` {#unsafe-invoke-handler} + +_Added in v14.2.0_ + +```ts +function UNSAFE_invokeHandler( + instance: TestInstance, + handlerName: string, + ...args: unknown[] +): Promise; +``` + +Calls the handler prop named `handlerName` with the given arguments, inside async `act`, and resolves to the handler's return value. Use it as an escape hatch for callbacks that no event reaches in a test, for example: + +- callbacks that components call from JavaScript with custom arguments, like `onContentSizeChange(width, height)` of `ScrollView` and `FlatList`, +- props of your own composite components, like `onInputChange(value)` of a date picker, +- `onHoverIn` and `onHoverOut` of `Pressable`, which React Native doesn't call on iOS and Android by default, but which you might want to test for React Native Web. + +:::warning +This API doesn't simulate anything a device does. It doesn't check whether the element is disabled, blocked by `pointerEvents` or non-editable, so a test can pass for behavior that can't happen on a device. Use [User Event](/docs/api/events/user-event) or [`fireEvent`](#fire-event) when they support your case. +::: + +`handlerName` is the exact prop name, e.g. `onContentSizeChange`, not `contentSizeChange`. Any function prop can be called, not only `on*` handlers. The prop is looked up on the given element first, then on the composite components that render it, nearest first, up to the next host element. Handlers of parent host elements are never called. If no function prop with that name is found, or the element is unmounted, it throws. + +```jsx +import { FlatList, Text } from 'react-native'; +import { render, screen, UNSAFE_invokeHandler } from '@testing-library/react-native'; + +const onContentSizeChangeMock = jest.fn(); + +await render( + {item}} + onContentSizeChange={onContentSizeChangeMock} + />, +); + +// `FlatList` calls `onContentSizeChange` from JavaScript, so no event reaches it. +await UNSAFE_invokeHandler(screen.getByTestId('list'), 'onContentSizeChange', 320, 1200); +expect(onContentSizeChangeMock).toHaveBeenCalledWith(320, 1200); +``` From ab3c7b7fb793fbc4580a2062d0d77f35a6fb8246 Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Thu, 15 Oct 2026 12:13:12 +0200 Subject: [PATCH 6/7] feat: support custom events in modern events system (#1951) --- contributing/event-dispatch.md | 2 + src/events/__tests__/fire-event.test.tsx | 293 +++++++++++++++++- .../modern/__tests__/event-types.test.ts | 17 + src/events/modern/__tests__/event.test.ts | 30 ++ src/events/modern/dispatch.ts | 106 ++++--- src/events/modern/event-types.ts | 50 ++- src/events/modern/event.ts | 20 +- src/events/modern/fire-event-utils.ts | 60 +++- src/events/modern/fire-event.ts | 27 +- 9 files changed, 527 insertions(+), 78 deletions(-) diff --git a/contributing/event-dispatch.md b/contributing/event-dispatch.md index d4003ac36..eb5e2842e 100644 --- a/contributing/event-dispatch.md +++ b/contributing/event-dispatch.md @@ -18,6 +18,8 @@ Code used by both event systems lives in `src/events/shared/`: `handler.ts` (fin 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` 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`). diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index d22326903..6c6655135 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -1830,6 +1830,291 @@ describe('direct events', () => { }); }); +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('accepts a custom event type with the "on" prefix', async () => { + const onPageSelected = jest.fn(); + await render(React.createElement('RNCViewPager', { testID: 'pager', onPageSelected })); + + await fireEvent(screen.getByTestId('pager'), 'onPageSelected', { + nativeEvent: { position: 2 }, + dispatch: 'direct', + }); + + expect(onPageSelected).toHaveBeenCalledTimes(1); + expect(onPageSelected.mock.calls[0][0].nativeEvent).toEqual({ position: 2 }); + }); + + test('reaches the user callback through a wrapper that adapts the host prop', async () => { + // Shaped like `react-native-pager-view`: the composite passes its own handler to the host, + // which then calls the user's prop. + type PageSelectedEvent = { nativeEvent: { position: number } }; + class PagerView extends React.Component<{ + testID?: string; + onPageSelected?: (event: PageSelectedEvent) => void; + }> { + _onPageSelected = (event: PageSelectedEvent) => { + this.props.onPageSelected?.(event); + }; + + render() { + return React.createElement('RNCViewPager', { + testID: this.props.testID, + onPageSelected: this._onPageSelected, + }); + } + } + + const onPageSelected = jest.fn(); + await render(); + + await fireEvent(screen.getByTestId('pager'), 'pageSelected', { + nativeEvent: { position: 3 }, + dispatch: 'direct', + }); + + expect(onPageSelected).toHaveBeenCalledTimes(1); + expect(onPageSelected.mock.calls[0][0].nativeEvent).toEqual({ position: 3 }); + }); + + test('throws for a custom event type without dispatch (legacy: calls the handler)', async () => { + const onEndReached = jest.fn(); + // The Jest mock of `ScrollView` puts `FlatList`'s JavaScript callbacks, like `onEndReached`, + // on the host element. + await render( + {item}} + onEndReached={onEndReached} + />, + ); + + const error = await getErrorMessage( + fireEvent(screen.getByTestId('list'), 'endReached', { + nativeEvent: { distanceFromEnd: 0 }, + }), + ); + + expect(error).toBe( + isModern() + ? 'Unable to fire a "endReached" event. React Native doesn\'t know "endReached". For a native event of a third-party component, pass dispatch: "direct" or "bubbling" in the event object, matching its DirectEventHandler or BubblingEventHandler prop type. To call a handler prop directly, use UNSAFE_invokeHandler().' + : undefined, + ); + expect(onEndReached).toHaveBeenCalledTimes(isModern() ? 0 : 1); + }); + + test('dispatch overrides a bubbling built-in event type, skipping Pressability (legacy: calls only the nearest handler)', async () => { + // Shaped like `react-native-maps`: the native map declares `onPress` as a + // `BubblingEventHandler`, so a tap reaches it as a native `press` event, not from Pressability. + const calls: string[] = []; + const getHandlers = (id: string) => ({ + onPressCapture: () => calls.push(`${id}.onPressCapture`), + onPress: () => calls.push(`${id}.onPress`), + }); + await render( + calls.push('pressable.onPress')}> + {React.createElement( + 'AIRMapContainer', + getHandlers('parent'), + React.createElement('AIRMap', { testID: 'map', ...getHandlers('map') }), + )} + , + ); + + await fireEvent(screen.getByTestId('map'), 'press', { + nativeEvent: { coordinate: { latitude: 1, longitude: 2 } }, + dispatch: 'bubbling', + }); + + expect(calls).toEqual( + isModern() + ? ['parent.onPressCapture', 'map.onPressCapture', 'map.onPress', 'parent.onPress'] + : ['map.onPress'], + ); + }); + + test('dispatch overrides a Pressability event type with a direct native event', async () => { + const onLongPress = jest.fn(); + const onParentLongPress = jest.fn(); + await render( + React.createElement( + 'AIRMapContainer', + { onLongPress: onParentLongPress }, + React.createElement('AIRMap', { testID: 'map', onLongPress }), + ), + ); + const map = screen.getByTestId('map'); + + await fireEvent(map, 'longPress', { nativeEvent: { x: 1 }, dispatch: 'direct' }); + + expect(onParentLongPress).not.toHaveBeenCalled(); + expect(onLongPress).toHaveBeenCalledTimes(1); + const received = onLongPress.mock.calls[0][0] as SyntheticEvent; + expect(received.nativeEvent).toEqual({ x: 1 }); + expect(received.rnIsDirect).toBe(isModern() ? true : undefined); + expect(received.target).toBe(isModern() ? map : undefined); + }); + + test('dispatch overrides a bubbling built-in event type with a direct one', async () => { + const onFocus = jest.fn(); + const onParentFocus = jest.fn(); + await render( + React.createElement( + 'RNCustomView', + { onFocus: onParentFocus }, + React.createElement('RNCustomView', { testID: 'target', onFocus }), + ), + ); + + await fireEvent(screen.getByTestId('target'), 'focus', { nativeEvent: {}, dispatch: 'direct' }); + + expect(onFocus).toHaveBeenCalledTimes(1); + expect(onParentFocus).not.toHaveBeenCalled(); + }); + + test('dispatch overrides a direct built-in event type with a bubbling one (legacy: calls only the nearest handler)', async () => { + const onLayout = jest.fn(); + const onParentLayout = jest.fn(); + await render( + React.createElement( + 'RNCustomView', + { onLayout: onParentLayout }, + React.createElement('RNCustomView', { testID: 'target', onLayout }), + ), + ); + + await fireEvent(screen.getByTestId('target'), 'layout', { + nativeEvent: {}, + dispatch: 'bubbling', + }); + + expect(onLayout).toHaveBeenCalledTimes(1); + expect(onParentLayout).toHaveBeenCalledTimes(isModern() ? 1 : 0); + }); + + test('fireEvent.press() with dispatch fires a native press event with the passed payload', async () => { + const onPress = jest.fn(); + await render(React.createElement('AIRMap', { testID: 'map', onPress })); + + await fireEvent.press(screen.getByTestId('map'), { + nativeEvent: { coordinate: { latitude: 1, longitude: 2 } }, + dispatch: 'bubbling', + }); + + expect(onPress).toHaveBeenCalledTimes(1); + // Modern skips the default touch payload, as the event is the component's own. + const { nativeEvent } = onPress.mock.calls[0][0]; + expect(nativeEvent).toMatchObject({ coordinate: { latitude: 1, longitude: 2 } }); + expect('pageX' in nativeEvent).toBe(!isModern()); + }); + + test('dispatch on a Pressable calls no Pressability callback (legacy: calls onPress)', async () => { + const onPress = jest.fn(); + await render(); + + await fireEvent(screen.getByTestId('target'), 'press', { + nativeEvent: {}, + dispatch: 'bubbling', + }); + + // Modern dispatches a native `press` event, which `Pressable`'s host has no prop for. + expect(onPress).toHaveBeenCalledTimes(isModern() ? 0 : 1); + }); + + test.each([ + ['capture', 'string "capture"'], + [null, 'null'], + [true, 'boolean true'], + ])( + 'throws when dispatch is %p (legacy: passes it to the handler)', + async (dispatch, description) => { + const onPageSelected = jest.fn(); + await render(React.createElement('RNCViewPager', { testID: 'pager', onPageSelected })); + + const error = await getErrorMessage( + fireEvent(screen.getByTestId('pager'), 'pageSelected', { nativeEvent: {}, dispatch }), + ); + + expect(error).toBe( + isModern() + ? `Unable to fire a "pageSelected" event. Expected "dispatch" to be "direct" or "bubbling", received ${description}.` + : undefined, + ); + expect(onPageSelected).toHaveBeenCalledTimes(isModern() ? 0 : 1); + }, + ); + + test('dispatches changeText with dispatch as a native event of the component', async () => { + const onChangeText = jest.fn(); + await render(React.createElement('RNCustomInput', { testID: 'input', onChangeText })); + + await fireEvent(screen.getByTestId('input'), 'changeText', { + nativeEvent: { text: 'Hello' }, + dispatch: 'direct', + }); + + expect(onChangeText).toHaveBeenCalledTimes(1); + expect(onChangeText.mock.calls[0][0].nativeEvent).toEqual({ text: 'Hello' }); + }); +}); + describe('Pressability event types', () => { function logCallbacks(calls: string[], id: string) { const log = (name: string) => () => { @@ -1934,7 +2219,7 @@ test('fireEvent throws for custom event (onCustomEvent) on composite component ( // calls any matching prop, also on composite components. expect(error).toBe( isModern() - ? `Unable to fire a "customEvent" event. React Native doesn't dispatch "customEvent" natively, so no handler would be called. To call a handler prop directly, use UNSAFE_invokeHandler().` + ? `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']]); @@ -1953,7 +2238,7 @@ test('fireEvent throws for custom prop name (handlePress) on composite component // calls any matching prop, also on composite components. expect(error).toBe( isModern() - ? `Unable to fire a "handlePress" event. React Native doesn't dispatch "handlePress" natively, so no handler would be called. To call a handler prop directly, use UNSAFE_invokeHandler().` + ? `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); @@ -2032,7 +2317,7 @@ test('fireEvent throws with non-existent event name (legacy: does nothing)', asy expect(outcome).toStrictEqual( isModern() ? { - error: `Unable to fire a "nonExistentEvent" event. React Native doesn't dispatch "nonExistentEvent" natively, so no handler would be called. To call a handler prop directly, use UNSAFE_invokeHandler().`, + 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 }, ); @@ -3200,7 +3485,7 @@ describe('responder system', () => { // responder system's touch history, which modern doesn't simulate, so modern throws. expect(error).toBe( isModern() - ? `Unable to fire a "responderMove" event. React Native doesn't dispatch "responderMove" natively, so no handler would be called. To call a handler prop directly, use UNSAFE_invokeHandler().` + ? `Unable to fire a "responderMove" event. React Native doesn't know "responderMove". For a native event of a third-party component, pass dispatch: "direct" or "bubbling" in the event object, matching its DirectEventHandler or BubblingEventHandler prop type. To call a handler prop directly, use UNSAFE_invokeHandler().` : undefined, ); expect(onDrag).toHaveBeenCalledTimes(isModern() ? 0 : 1); diff --git a/src/events/modern/__tests__/event-types.test.ts b/src/events/modern/__tests__/event-types.test.ts index 571786cab..17b5f089a 100644 --- a/src/events/modern/__tests__/event-types.test.ts +++ b/src/events/modern/__tests__/event-types.test.ts @@ -2,6 +2,7 @@ import { createEvent } from '../event'; import { BUBBLING_EVENT_TYPES, DIRECT_EVENT_TYPES, + getCustomEventTypeConfig, getEventTypeConfig, SKIP_BUBBLING_EVENT_TYPES, } from '../event-types'; @@ -140,3 +141,19 @@ test('getEventTypeConfig() returns null for events unknown to React Native', () expect(getEventTypeConfig('onFocus')).toBeNull(); expect(getEventTypeConfig('toString')).toBeNull(); }); + +test('getCustomEventTypeConfig() returns config of direct custom event', () => { + expect(getCustomEventTypeConfig('pageSelected', 'direct')).toEqual({ + kind: 'direct', + handlerName: 'onPageSelected', + }); +}); + +test('getCustomEventTypeConfig() returns config of bubbling custom event', () => { + expect(getCustomEventTypeConfig('customGesture', 'bubbling')).toEqual({ + kind: 'bubbling', + handlerName: 'onCustomGesture', + captureHandlerName: 'onCustomGestureCapture', + skipBubbling: false, + }); +}); diff --git a/src/events/modern/__tests__/event.test.ts b/src/events/modern/__tests__/event.test.ts index 7595ac770..5a0eb0acd 100644 --- a/src/events/modern/__tests__/event.test.ts +++ b/src/events/modern/__tests__/event.test.ts @@ -194,4 +194,34 @@ describe('createEvent()', () => { test('returns null for events unknown to React Native', () => { expect(createEvent('changeText', { nativeEvent: { text: 'Hello' } })).toBeNull(); }); + + test('creates direct custom event with dispatch', () => { + const nativeEvent = { position: 1 }; + + const event = createEvent('pageSelected', { nativeEvent, dispatch: 'direct' }); + + expect(event?.type).toBe('pageselected'); + expect(event?.bubbles).toBe(false); + expect(event?.rnIsDirect).toBe(true); + expect(event?.nativeEvent).toBe(nativeEvent); + expect(event?.dispatchConfig).toEqual({ registrationName: 'onPageSelected' }); + }); + + test('creates bubbling custom event with dispatch', () => { + const event = createEvent('customGesture', { dispatch: 'bubbling' }); + + expect(event?.bubbles).toBe(true); + expect(event?.rnIsDirect).toBe(false); + expect(event?.dispatchConfig).toEqual({ + phasedRegistrationNames: { bubbled: 'onCustomGesture', captured: 'onCustomGestureCapture' }, + }); + }); + + test('overrides the dispatch of events known to React Native with dispatch', () => { + const event = createEvent('focus', { dispatch: 'direct' }); + + expect(event?.bubbles).toBe(false); + expect(event?.rnIsDirect).toBe(true); + expect(event?.dispatchConfig).toEqual({ registrationName: 'onFocus' }); + }); }); diff --git a/src/events/modern/dispatch.ts b/src/events/modern/dispatch.ts index 2c2c96515..898a802a4 100644 --- a/src/events/modern/dispatch.ts +++ b/src/events/modern/dispatch.ts @@ -5,15 +5,16 @@ import { isInstanceMounted } from '../../helpers/component-tree'; import { getEventHandlerByName } from '../shared/handler'; import type { EventPhase } from './event'; import { getEventInternals, SyntheticEvent } from './event'; +import type { BubblingEventTypeConfig, DirectEventTypeConfig } from './event-types'; /** * Dispatches an event from `createEvent()` as React Native does for native events * (`src/private/renderer/events/dispatchNativeEvent.js`, then `dispatch()` in * `src/private/webapis/dom/events/EventTarget.js`): * + * - Direct events: only the target's `on*` prop. See `dispatchDirectEvent()`. * - Bubbling events: capture phase from the root to the target (`on*Capture`), then bubble phase - * back to the root (`on*`). `skipBubbling` events call only the target when bubbling. - * - Direct events: only the target's `on*` prop. + * back to the root (`on*`). See `dispatchBubblingEvent()`. * * Only host elements are on the path, so composite props are never called. A `testOnly_` prop * (`testOnly_onFocus`) is used when the element has no regular one. Every handler on the @@ -34,16 +35,18 @@ export async function dispatchEvent( return false; } - const path = getEventPath(target, event); + const config = getEventInternals(event).typeConfig; const state: DispatchState = { hasCalledHandler: false, firstError: null }; await act(() => { getEventInternals(event).isTrusted = true; getEventInternals(event).target = target; - getEventInternals(event).composedPath = path; + if (config.kind === 'direct') { + dispatchDirectEvent(event, target, config, state, options?.afterTargetHandler); + } else { + dispatchBubblingEvent(event, target, config, state, options?.afterTargetHandler); + } - runCapturePhase(event, path, state); - runBubblePhase(event, path, state, options?.afterTargetHandler); resetEvent(event); }); @@ -57,10 +60,10 @@ export async function dispatchEvent( export type DispatchOptions = { /** - * Runs at the target in the bubble phase, right after the target's own handler, like a composite - * component wrapping the host prop: `TextInput` passes `onChange={_onChange}`, which calls - * `props.onChange(event)`, then `props.onChangeText(text)`. Skipped when propagation stopped - * before the target, or when the target's handler threw. Runs when the target has no handler. + * Runs at the target, right after the target's own handler, like a composite component wrapping + * the host prop: `TextInput` passes `onChange={_onChange}`, which calls `props.onChange(event)`, + * then `props.onChangeText(text)`. Skipped when propagation stopped before the target, or when + * the target's handler threw. Runs when the target has no handler. */ afterTargetHandler?: (target: TestInstance, event: SyntheticEvent) => void; }; @@ -79,56 +82,73 @@ type DispatchState = { firstError: { error: unknown } | null; }; -/** Target first, then host ancestors. The root container is not an element, so it's excluded. */ -function getEventPath(target: TestInstance, event: SyntheticEvent): TestInstance[] { - if (event.rnIsDirect) { - return [target]; - } - - const path: TestInstance[] = []; - let current: TestInstance | null = target; - while (current?.parent != null) { - path.push(current); - current = current.parent; - } - - return path; +/** Calls only the target's handler: no capture phase, no ancestors. */ +function dispatchDirectEvent( + event: SyntheticEvent, + target: TestInstance, + config: DirectEventTypeConfig, + state: DispatchState, + afterTargetHandler: DispatchOptions['afterTargetHandler'], +) { + getEventInternals(event).composedPath = [target]; + callHandler( + event, + target, + config.handlerName, + SyntheticEvent.AT_TARGET, + state, + afterTargetHandler, + ); } -function runCapturePhase(event: SyntheticEvent, path: TestInstance[], state: DispatchState) { - const config = getEventInternals(event).typeConfig; - if (config.kind === 'direct') { - return; - } +/** + * Capture phase from the root down to the target, then bubble phase from the target back up to the + * root. `skipBubbling` events (`pointerEnter`, `pointerLeave`) run the full capture phase, but + * stop the bubble phase at the target. + */ +function dispatchBubblingEvent( + event: SyntheticEvent, + target: TestInstance, + config: BubblingEventTypeConfig, + state: DispatchState, + afterTargetHandler: DispatchOptions['afterTargetHandler'], +) { + const path = getAncestorPath(target); + getEventInternals(event).composedPath = path; + // Capture: root first, target last. for (let i = path.length - 1; i >= 0 && !event.isPropagationStopped(); i -= 1) { const phase = i === 0 ? SyntheticEvent.AT_TARGET : SyntheticEvent.CAPTURING_PHASE; callHandler(event, path[i], config.captureHandlerName, phase, state); } -} -function runBubblePhase( - event: SyntheticEvent, - path: TestInstance[], - state: DispatchState, - afterTargetHandler: DispatchOptions['afterTargetHandler'], -) { - const handlerName = getEventInternals(event).typeConfig.handlerName; - const nodes = event.bubbles ? path : path.slice(0, 1); - for (let i = 0; i < nodes.length && !event.isPropagationStopped(); i += 1) { + // Bubble: target first, root last. + const bubblePath = config.skipBubbling ? [target] : path; + for (let i = 0; i < bubblePath.length && !event.isPropagationStopped(); i += 1) { const isTarget = i === 0; - const phase = isTarget ? SyntheticEvent.AT_TARGET : SyntheticEvent.BUBBLING_PHASE; callHandler( event, - nodes[i], - handlerName, - phase, + bubblePath[i], + config.handlerName, + isTarget ? SyntheticEvent.AT_TARGET : SyntheticEvent.BUBBLING_PHASE, state, isTarget ? afterTargetHandler : undefined, ); } } +/** Target first, then host ancestors. The root container is not an element, so it's excluded. */ +function getAncestorPath(target: TestInstance): TestInstance[] { + const path: TestInstance[] = []; + let current: TestInstance | null = target; + while (current?.parent != null) { + path.push(current); + current = current.parent; + } + + return path; +} + function callHandler( event: SyntheticEvent, node: TestInstance, diff --git a/src/events/modern/event-types.ts b/src/events/modern/event-types.ts index 1021ab123..ca04bb4d6 100644 --- a/src/events/modern/event-types.ts +++ b/src/events/modern/event-types.ts @@ -102,7 +102,7 @@ const DIRECT_EVENT_HANDLER_NAMES: Readonly> = { }; /** Capture phase from the root to the target, then bubble phase back to the root. */ -type BubblingEventTypeConfig = { +export type BubblingEventTypeConfig = { kind: 'bubbling'; /** Bubble phase prop, e.g. `onFocus`. */ handlerName: string; @@ -112,7 +112,7 @@ type BubblingEventTypeConfig = { }; /** Calls only the target's prop, without the capture phase. */ -type DirectEventTypeConfig = { +export type DirectEventTypeConfig = { kind: 'direct'; /** e.g. `onLayout` */ handlerName: string; @@ -121,19 +121,21 @@ type DirectEventTypeConfig = { /** How React Native dispatches an event type, and which handler props it calls. */ export type EventTypeConfig = BubblingEventTypeConfig | DirectEventTypeConfig; +/** + * How a native component dispatches an event type React Native doesn't know, as its codegen spec + * declares the handler prop: `DirectEventHandler` or `BubblingEventHandler`. + */ +export const CUSTOM_EVENT_DISPATCHES = ['direct', 'bubbling'] as const; + +export type CustomEventDispatch = (typeof CUSTOM_EVENT_DISPATCHES)[number]; + /** * Returns how React Native dispatches the event type (e.g. `focus`), or `null` if React Native * doesn't know it. */ export function getEventTypeConfig(eventType: string): EventTypeConfig | null { if (BUBBLING_EVENT_TYPES.includes(eventType)) { - const handlerName = getEventHandlerName(eventType); - return { - kind: 'bubbling', - handlerName, - captureHandlerName: `${handlerName}Capture`, - skipBubbling: SKIP_BUBBLING_EVENT_TYPES.includes(eventType), - }; + return getBubblingEventTypeConfig(eventType, SKIP_BUBBLING_EVENT_TYPES.includes(eventType)); } if (DIRECT_EVENT_TYPES.includes(eventType)) { @@ -145,3 +147,33 @@ export function getEventTypeConfig(eventType: string): EventTypeConfig | null { return null; } + +/** + * Returns the config React Native builds for an event type a native component declares, e.g. a + * third-party component's `pageSelected`. As codegen generates it (`GenerateViewConfigJs.js`), the + * handler props follow the event type (`onPageSelected`, `onPageSelectedCapture`), and bubbling + * events always bubble. + */ +export function getCustomEventTypeConfig( + eventType: string, + dispatch: CustomEventDispatch, +): EventTypeConfig { + if (dispatch === 'bubbling') { + return getBubblingEventTypeConfig(eventType, false); + } + + return { kind: 'direct', handlerName: getEventHandlerName(eventType) }; +} + +function getBubblingEventTypeConfig( + eventType: string, + skipBubbling: boolean, +): BubblingEventTypeConfig { + const handlerName = getEventHandlerName(eventType); + return { + kind: 'bubbling', + handlerName, + captureHandlerName: `${handlerName}Capture`, + skipBubbling, + }; +} diff --git a/src/events/modern/event.ts b/src/events/modern/event.ts index b10b5f568..cf0983d9e 100644 --- a/src/events/modern/event.ts +++ b/src/events/modern/event.ts @@ -1,7 +1,7 @@ import type { TestInstance } from 'test-renderer'; -import type { EventTypeConfig } from './event-types'; -import { getEventTypeConfig } from './event-types'; +import type { CustomEventDispatch, EventTypeConfig } from './event-types'; +import { getCustomEventTypeConfig, getEventTypeConfig } from './event-types'; export type NativeEventPayload = Record; @@ -21,6 +21,11 @@ export type CreateEventInit = { * (`nativeEvent.timeStamp ?? nativeEvent.timestamp`), then to `performance.now()`. */ timeStamp?: number; + /** + * How to dispatch an event type React Native doesn't know, e.g. a third-party component's + * `pageSelected`. Overrides React Native's dispatch for event types it knows. + */ + dispatch?: CustomEventDispatch; }; /** `bubbles` and `rnIsDirect` default to what the type config says. */ @@ -225,11 +230,18 @@ export class SyntheticEvent { * Creates the event React Native would create for a native event of this type, ready for * `dispatchEvent()`. Like Testing Library's DOM `createEvent()`. * + * `init.dispatch` overrides React Native's config for the event type, as a native component can + * declare its own event with a built-in name, e.g. a direct `press`. + * * @param eventType e.g. `focus` or `pointerUp` - * @returns `null` for events unknown to React Native, as React Native drops them. + * @returns `null` for events unknown to React Native without `init.dispatch`, as React Native drops + * them. */ export function createEvent(eventType: string, init: CreateEventInit = {}): SyntheticEvent | null { - const config = getEventTypeConfig(eventType); + const config = + init.dispatch != null + ? getCustomEventTypeConfig(eventType, init.dispatch) + : getEventTypeConfig(eventType); if (config == null) { return null; } diff --git a/src/events/modern/fire-event-utils.ts b/src/events/modern/fire-event-utils.ts index 7d5b08a4e..7be96df36 100644 --- a/src/events/modern/fire-event-utils.ts +++ b/src/events/modern/fire-event-utils.ts @@ -4,9 +4,13 @@ import { ErrorWithStack } from '../../helpers/errors'; import { isHostTextInput } from '../../helpers/host-component-names'; import type { CreateEventInit } from './event'; import { SyntheticEvent } from './event'; -import { getEventTypeConfig, isPressabilityEventType } from './event-types'; +import { + CUSTOM_EVENT_DISPATCHES, + getEventTypeConfig, + isPressabilityEventType, +} from './event-types'; -const ALLOWED_INIT_KEYS = ['nativeEvent', 'timeStamp']; +const ALLOWED_INIT_KEYS = ['nativeEvent', 'timeStamp', 'dispatch']; const EVENT_KEYS = new Set( Object.getOwnPropertyNames(SyntheticEvent.prototype).filter((key) => key !== 'constructor'), @@ -18,28 +22,47 @@ const UNKNOWN_EVENT_TYPE_ALTERNATIVES: Record = { }; /** - * Throws for event types React Native doesn't dispatch natively, such as `changeText` or custom - * prop names, except Pressability event types (`pressIn`, ...). React Native drops them, so - * dropping them here would call no handler, and a test asserting that a handler wasn't called - * would pass for the wrong reason. + * Throws for event types React Native doesn't know, such as `changeText` or custom prop names, + * except Pressability event types (`pressIn`, ...), and event types passed with `dispatch`. + * React Native drops unknown events, so dropping them here would call no handler, and a test + * asserting that a handler wasn't called would pass for the wrong reason. * + * `dispatch` is required, not defaulted to `direct`: a legacy call such as + * `fireEvent(list, 'endReached', ...)` would otherwise pass a `SyntheticEvent` to `onEndReached`, + * which `FlatList` calls from JavaScript, but the Jest mock of `ScrollView` puts on the host. + * + * @param init the event object, checked by `validateEventInit()` afterwards * @param callsite the function to remove from the error stack, e.g. `fireEvent` */ export function ensureEventType( eventType: string, + init: unknown, // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type callsite: Function, ): void { - if (getEventTypeConfig(eventType) != null || isPressabilityEventType(eventType)) { + if (isKnownEventType(eventType)) { + return; + } + + // Custom event type: the caller declares how it dispatches. + if (isPlainObject(init) && init.dispatch !== undefined) { return; } const alternative = UNKNOWN_EVENT_TYPE_ALTERNATIVES[eventType]; + if (alternative != null) { + throw new ErrorWithStack( + `Unable to fire a "${eventType}" event. React Native doesn't dispatch "${eventType}" ` + + `natively. Use ${alternative} instead.`, + callsite, + ); + } + throw new ErrorWithStack( - `Unable to fire a "${eventType}" event. React Native doesn't dispatch "${eventType}" natively` + - (alternative != null - ? `. Use ${alternative} instead.` - : ', so no handler would be called. To call a handler prop directly, use UNSAFE_invokeHandler().'), + `Unable to fire a "${eventType}" event. React Native doesn't know "${eventType}". ` + + `For a native event of a third-party component, pass dispatch: "direct" or "bubbling" ` + + 'in the event object, matching its DirectEventHandler or BubblingEventHandler prop type. ' + + 'To call a handler prop directly, use UNSAFE_invokeHandler().', callsite, ); } @@ -119,6 +142,17 @@ export function validateEventInit( ); } + if ( + init.dispatch !== undefined && + !(CUSTOM_EVENT_DISPATCHES as readonly unknown[]).includes(init.dispatch) + ) { + throw new ErrorWithStack( + `Unable to fire a "${eventType}" event. Expected "dispatch" to be "direct" or "bubbling", ` + + `received ${describeValue(init.dispatch)}.`, + callsite, + ); + } + return init as CreateEventInit; } @@ -151,6 +185,10 @@ export function validateChangeTextArgs( } } +function isKnownEventType(eventType: string): boolean { + return getEventTypeConfig(eventType) != null || isPressabilityEventType(eventType); +} + /** * Explains why each key can't be passed. Dropping keys silently, as DOM event constructors do, * would leave handlers reading `undefined` with no explanation. diff --git a/src/events/modern/fire-event.ts b/src/events/modern/fire-event.ts index 7414e58fd..161672a0f 100644 --- a/src/events/modern/fire-event.ts +++ b/src/events/modern/fire-event.ts @@ -32,7 +32,8 @@ import { createPressabilityEvent } from './responder-event'; /** * Event object passed to modern `fireEvent`. Handlers receive a `SyntheticEvent` from - * `createEvent()`, with `nativeEvent` as its `nativeEvent`. + * `createEvent()`, with `nativeEvent` as its `nativeEvent`. `dispatch` is required for event types + * React Native doesn't know, and overrides React Native's dispatch for the others. */ export type FireEventInit = CreateEventInit; @@ -43,8 +44,14 @@ export type FireEventInit = CreateEventInit; * Pressability event types (`press`, `pressIn`, `pressOut`, `longPress`) call the callback of the * element that becomes the touch responder instead, as `fireEvent.press()` does. * - * Exactly one event object is required, as React Native handlers receive a single event. Other - * event types React Native doesn't dispatch natively (`changeText`, custom prop names) throw. + * Exactly one event object is required, as React Native handlers receive a single event. Event + * types React Native doesn't know need `dispatch: 'direct' | 'bubbling'` in the event object, as + * declared by the native component, e.g. a third-party component's `pageSelected`. Without it they + * throw, as do `changeText` and custom prop names. + * + * `dispatch` also overrides React Native's dispatch for known event types, for a native component + * that declares its own event with a built-in name, e.g. a map's `BubblingEventHandler` `onPress`. + * Pressability event types are then dispatched as native events, skipping Pressability. * * Resolves to nothing, unlike DOM `fireEvent`: React Native has no default actions that * `preventDefault()` could cancel. Handlers can read `event.defaultPrevented` themselves. @@ -59,10 +66,10 @@ export async function fireEvent( const normalizedType = normalizeEventType(eventType); ensureInstance(instance, normalizedType, fireEvent); // Before the event object, as `fireEvent(input, 'changeText', 'Hello')` needs the hint more. - ensureEventType(normalizedType, fireEvent); + ensureEventType(normalizedType, args[0], fireEvent); ensureSingleEventArg(normalizedType, args, fireEvent); const init = validateEventInit(normalizedType, args[0], fireEvent); - if (isPressabilityEventType(normalizedType)) { + if (isPressabilityEventType(normalizedType) && init.dispatch == null) { await firePressabilityEvent(instance, normalizedType, init); return; } @@ -104,11 +111,17 @@ fireEvent.changeText = async (instance: TestInstance, text: string): Promise => { ensureInstance(instance, 'press', fireEvent.press); const init = validateEventInit('press', event, fireEvent.press); + if (init.dispatch != null) { + await fireEventInternal(instance, 'press', init); + return; + } + await firePressabilityEvent(instance, 'press', init); }; @@ -132,7 +145,7 @@ fireEvent.layout = async ( }; /** - * Expects an event type React Native dispatches natively, without the `on*` prefix. + * Expects an event type without the `on*` prefix, that React Native knows or that has `dispatch`. * * @param basePayload the payload a device would send. Native state, like a `ScrollView`'s * size, is merged onto it, then the passed `nativeEvent` is deep merged on top. Without it, the From 39615fa41499a78f4a0b39e9c9fe32fd815b18d0 Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Thu, 15 Oct 2026 12:52:05 +0200 Subject: [PATCH 7/7] feat: modern events typing (#1952) --- contributing/event-dispatch.md | 2 + contributing/testing.md | 2 + eslint.config.mjs | 8 + modern-types.d.ts | 1 + modern-types.js | 2 + package.json | 4 +- .../modern-types/fire-event.test.tsx | 105 ++++++++ src/events/__tests__/fire-event.test.tsx | 242 +++++++++++------- src/events/fire-event.ts | 67 ++++- src/events/modern/event-types.ts | 32 ++- src/events/modern/fire-event-utils.ts | 19 ++ src/events/modern/fire-event.ts | 6 +- src/events/shared/handler.ts | 3 +- src/modern-types.ts | 13 + tsconfig.json | 6 +- tsconfig.modern-types.json | 5 + website/docs/14.x/docs/api/events/_meta.json | 3 +- .../docs/api/events/modern-event-system.mdx | 20 ++ 18 files changed, 434 insertions(+), 106 deletions(-) create mode 100644 modern-types.d.ts create mode 100644 modern-types.js create mode 100644 src/__tests__/modern-types/fire-event.test.tsx create mode 100644 src/modern-types.ts create mode 100644 tsconfig.modern-types.json create mode 100644 website/docs/14.x/docs/api/events/modern-event-system.mdx diff --git a/contributing/event-dispatch.md b/contributing/event-dispatch.md index eb5e2842e..9bb951c1c 100644 --- a/contributing/event-dispatch.md +++ b/contributing/event-dispatch.md @@ -20,6 +20,8 @@ In the modern event system, `src/events/modern/pressability.ts` calls the `onPre Modern `fireEvent` throws for event types React Native doesn't know (`ensureEventType()` in `src/events/modern/fire-event-utils.ts`), unless the event object has `dispatch: 'direct' | 'bubbling'`. This is for native events of third-party components, which render as host elements with all their props in Jest, e.g. `fireEvent(pager, 'pageSelected', { nativeEvent: { position: 1 }, dispatch: 'direct' })`. `getCustomEventTypeConfig()` in `src/events/modern/event-types.ts` builds the config codegen would: handler props named after the event type, and no `skipBubbling`, which codegen can't declare. `dispatch` is required, not defaulted, so a legacy call for a JavaScript callback that the Jest mocks put on a host, like `fireEvent(list, 'endReached', ...)`, still throws and points to `UNSAFE_invokeHandler()`. For known event types, `dispatch` overrides React Native's config, as a native component can declare its own event under a built-in name with different semantics, e.g. `react-native-maps` declares `onPress` as a `BubblingEventHandler`. `createEvent()` then uses `getCustomEventTypeConfig()`, and Pressability event types skip Pressability and are dispatched with `dispatchEvent()`. `fireEvent.press()` with `dispatch` also skips the default touch payload. `dispatch` on a `Pressable` therefore calls no callback, as its host has no `onPress` prop. +Modern `fireEvent` takes event types without the `on*` prefix (`press`, not `onPress`), as React Native names them. Like legacy `fireEvent`, it still strips the prefix, but logs a deprecation warning (`warnOnPrefix()` in `src/events/modern/fire-event-utils.ts`), after its checks, so a call that throws doesn't also warn. Support for the prefix will be removed in the next major release. Legacy `fireEvent` accepts both without a warning. + Modern `fireEvent` doesn't log `eventDiagnostics` warnings yet. Only legacy `fireEvent` and `userEvent` do. `src/user-event/` is a separate module on top of the event subsystem. It creates and dispatches native events through the facades `src/events/create-event.ts` and `src/events/dispatch-event.ts`. For a legacy event object, `dispatchEvent()` calls the target's own handler with `invokeEventHandler()` from `src/events/shared/invoke-event-handler.ts`. Other modules are imported directly: legacy touch and responder event builders from `src/events/legacy/builders/common.ts`, and `src/events/modern/` and `src/events/shared/` modules, e.g. `src/user-event/modern/press.ts` (hit testing and responder negotiation from `pressability.ts`, handler lookup from `handler.ts`) and `src/user-event/utils/` (`invokeEventHandler()`, the `SyntheticEvent` type and the warning helpers from `warnings.ts`). diff --git a/contributing/testing.md b/contributing/testing.md index cbbdf0697..1992db051 100644 --- a/contributing/testing.md +++ b/contributing/testing.md @@ -26,3 +26,5 @@ Both projects share the same snapshots. `createEventLogger()` entries print only When a test expects different behavior in the two systems, e.g. events bubbling to a parent, branch on `getConfig().unstable_eventSystem` inside the test instead of skipping it, with a short comment saying which system matches React Native. Name the test after the modern behavior, which aims to match React Native, with the legacy difference in a `(legacy: ...)` suffix, e.g. `does not bubble $name (legacy: bubbles with a warning)`. Prefer ternaries in the expectations (`toHaveBeenCalledTimes(isModern ? 0 : 1)`). For assertions only one system can make, like a legacy warning message, end the test with `if (isModern) return;` before them (`jest/no-conditional-expect` forbids `expect()` inside `if` blocks). Generic `fireEvent(element, eventType, event)` calls that run in both systems pass an event object, e.g. `{ nativeEvent: {} }`, as modern `fireEvent` requires one. `src/events/__tests__/fire-event.test.tsx` runs the public `fireEvent` this way. Tests that can't run in the modern event system at all can call `runInLegacyEventSystem()` from `src/test-utils/event-system.ts` at the top of the file (or in a `describe()`). They then run in the legacy event system in both projects. + +`fireEvent` types follow the legacy signatures. Importing `@testing-library/react-native/modern-types` (`src/modern-types.ts`) switches them to the modern ones, for the whole TypeScript program. So `tsconfig.json` excludes it, and `src/__tests__/modern-types/` type-checks the modern signatures in their own program (`tsconfig.modern-types.json`, run by `yarn typecheck`). There, calls the types reject use `// @ts-expect-error` and also assert that they throw at runtime. diff --git a/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/modern-types.d.ts b/modern-types.d.ts new file mode 100644 index 000000000..ac41e5427 --- /dev/null +++ b/modern-types.d.ts @@ -0,0 +1 @@ +export * from './dist/modern-types'; diff --git a/modern-types.js b/modern-types.js new file mode 100644 index 000000000..9d1a89aa6 --- /dev/null +++ b/modern-types.js @@ -0,0 +1,2 @@ +// makes it so people can import from '@testing-library/react-native/modern-types' +module.exports = require('./dist/modern-types'); diff --git a/package.json b/package.json index db2db8305..e82abf834 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "test:codemods": "node scripts/test-codemods.mjs", "docs:generate": "node scripts/generate-package-docs.mjs", "docs:check": "yarn docs:generate && git diff --exit-code -- docs", - "typecheck": "tsc", + "typecheck": "tsc && tsc -p tsconfig.modern-types.json", "typecheck:react-19_2": "tsc -p tsconfig.react-19_2.json", "lint": "eslint src --cache", "format:check": "oxfmt --check --disable-nested-config .", @@ -52,6 +52,8 @@ "matchers.d.ts", "pure.js", "pure.d.ts", + "modern-types.js", + "modern-types.d.ts", "dont-cleanup-after-each.js", "docs/" ], diff --git a/src/__tests__/modern-types/fire-event.test.tsx b/src/__tests__/modern-types/fire-event.test.tsx new file mode 100644 index 000000000..dfe56b248 --- /dev/null +++ b/src/__tests__/modern-types/fire-event.test.tsx @@ -0,0 +1,105 @@ +import '../../modern-types'; + +import * as React from 'react'; +import { Pressable, ScrollView, TextInput, View } from 'react-native'; + +import { fireEvent, render, screen } from '../..'; +import { configure } from '../../config'; +import { _console } from '../../helpers/logger'; + +// Type-checked by `tsconfig.modern-types.json`, where `modern-types` switches `fireEvent` to the +// modern signatures. Calls the types reject also throw at runtime. + +beforeEach(() => { + configure({ unstable_eventSystem: 'modern' }); +}); + +const emptyEvent = { nativeEvent: {} }; + +test('accepts known event types, with or without the deprecated "on" prefix', async () => { + const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); + const onFocus = jest.fn(); + const onLayout = jest.fn(); + await render(); + const input = screen.getByTestId('input'); + + await fireEvent(input, 'focus', emptyEvent); + await fireEvent(input, 'onLayout', { nativeEvent: { layout: { width: 100 } }, timeStamp: 1 }); + + expect(onFocus).toHaveBeenCalledTimes(1); + expect(onLayout).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledTimes(1); + warnSpy.mockRestore(); +}); + +test('accepts custom event types with dispatch', async () => { + const onPageSelected = jest.fn(); + const onChangeText = jest.fn(); + await render(); + const native = screen.getByTestId('native'); + + await fireEvent(native, 'pageSelected', { nativeEvent: { position: 1 }, dispatch: 'direct' }); + // A native component's own `changeText` event. + await fireEvent(native, 'changeText', { nativeEvent: { text: 'Hello' }, dispatch: 'direct' }); + + expect(onPageSelected).toHaveBeenCalledTimes(1); + expect(onChangeText).toHaveBeenCalledTimes(1); +}); + +test('accepts the helper signatures', async () => { + const onPress = jest.fn(); + const onChangeText = jest.fn(); + const onScroll = jest.fn(); + const onLayout = jest.fn(); + await render( + <> + + + + , + ); + + await fireEvent.press(screen.getByTestId('pressable')); + await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); + await fireEvent.scroll(screen.getByTestId('scroll'), { + nativeEvent: { contentOffset: { y: 200 } }, + }); + await fireEvent.layout(screen.getByTestId('scroll'), { width: 100 }); + + expect(onPress).toHaveBeenCalledTimes(1); + expect(onChangeText).toHaveBeenCalledWith('Hello'); + expect(onScroll).toHaveBeenCalledTimes(1); + expect(onLayout).toHaveBeenCalledTimes(1); +}); + +test('rejects custom event types without dispatch', async () => { + await render(); + const pager = screen.getByTestId('pager'); + + // @ts-expect-error `dispatch` is required for event types React Native doesn't know + await expect(fireEvent(pager, 'pageSelected', emptyEvent)).rejects.toThrow(); + // @ts-expect-error `changeText` is not a native event type, the type error suggests alternatives + await expect(fireEvent(pager, 'changeText', 'Hello')).rejects.toThrow(); + // @ts-expect-error `changeText` is not a native event type, the type error suggests alternatives + await expect(fireEvent(pager, 'onChangeText', emptyEvent)).rejects.toThrow(); + // @ts-expect-error unsupported `dispatch` value + await expect(fireEvent(pager, 'pageSelected', { dispatch: 'capture' })).rejects.toThrow(); +}); + +test('rejects anything but a single event object', async () => { + await render(); + const view = screen.getByTestId('view'); + + // @ts-expect-error missing event object + await expect(fireEvent(view, 'focus')).rejects.toThrow(); + // @ts-expect-error more than one argument + await expect(fireEvent(view, 'focus', emptyEvent, emptyEvent)).rejects.toThrow(); + // @ts-expect-error not an event object + await expect(fireEvent(view, 'focus', 'event')).rejects.toThrow(); + // @ts-expect-error `nativeEvent` must be an object + await expect(fireEvent(view, 'focus', { nativeEvent: 1 })).rejects.toThrow(); + // @ts-expect-error unsupported event object key + await expect(fireEvent(view, 'focus', { nativeEvent: {}, target: view })).rejects.toThrow(); + // @ts-expect-error unsupported event object key + await expect(fireEvent.press(view, { pageX: 20 })).rejects.toThrow(); +}); diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index 6c6655135..f736dfb58 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -73,37 +73,21 @@ async function getErrorMessage(promise: Promise) { } } -test('fireEvent accepts event name with or without "on" prefix', async () => { - const onPress = jest.fn(); - await render(); - - await fireEvent(screen.getByTestId('btn'), 'press', emptyEvent); - expect(onPress).toHaveBeenCalledTimes(1); - - await fireEvent(screen.getByTestId('btn'), 'onPress', emptyEvent); - expect(onPress).toHaveBeenCalledTimes(2); -}); - -test('fireEvent sets event type without the "on" prefix (legacy: passes the event object as given)', async () => { +test('fireEvent sets event type (legacy: passes the event object as given)', async () => { const onFocus = jest.fn(); await render(); - await fireEvent(screen.getByTestId('input'), 'onFocus', emptyEvent); + await fireEvent(screen.getByTestId('input'), 'focus', emptyEvent); - // Modern matches React Native, where the event type has no "on" prefix. Legacy passes the event - // object as given, without a type. + // 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 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 fireEvent(screen.getByTestId('view'), 'onPress', emptyEvent); - expect(press).not.toHaveBeenCalled(); - expect(testOnlyPress).not.toHaveBeenCalled(); + await render(); // Modern matches React Native, where only Pressability calls press callbacks. Legacy calls any // `press` prop. @@ -111,6 +95,145 @@ test('fireEvent does not call handler props without the "on" prefix (legacy: cal 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(); + + test('does not call handler props without the "on" prefix', async () => { + const press = jest.fn(); + const testOnlyPress = jest.fn(); + // @ts-expect-error Intentionally passing such props + await render(); + + await fireEvent(screen.getByTestId('view'), 'onPress', emptyEvent); + + expect(press).not.toHaveBeenCalled(); + expect(testOnlyPress).not.toHaveBeenCalled(); + }); + + test('onChange saves value of TextInput in native state', async () => { + await render(); + const input = screen.getByTestId('input'); + + await fireEvent(input, 'onChange', { nativeEvent: { text: 'new text' } }); + + expect(nativeState.valueForInstance.get(input)).toBe('new text'); + }); + + test('onScroll updates native state', async () => { + const onScroll = jest.fn(); + await render(); + const scrollView = screen.getByTestId('scroll'); + + await fireEvent(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 () => { const onPress = jest.fn(); await render(); @@ -959,17 +1082,14 @@ describe('fireEvent.changeText', () => { }); describe('native state', () => { - test.each(['change', 'onChange'])( - '%s event saves value of TextInput in native state', - async (eventType) => { - await render(); - const input = screen.getByTestId('input'); + test('change event saves value of TextInput in native state', async () => { + await render(); + const input = screen.getByTestId('input'); - await fireEvent(input, eventType, { nativeEvent: { text: 'new text' } }); + await fireEvent(input, 'change', { nativeEvent: { text: 'new text' } }); - expect(nativeState.valueForInstance.get(input)).toBe('new text'); - }, - ); + expect(nativeState.valueForInstance.get(input)).toBe('new text'); + }); test('change event without text or on non-editable TextInput does not save value', async () => { await render( @@ -1139,21 +1259,6 @@ describe('fireEvent.scroll', () => { }); }); - test('updates native state when fired with `on*` prefixed name', async () => { - const onScroll = jest.fn(); - await render(); - const scrollView = screen.getByTestId('scroll'); - await fireEvent(scrollView, 'onScroll', verticalScrollEvent); - // Modern matches React Native, which passes a `SyntheticEvent`. Legacy passes the event object - // as given. - expect(onScroll.mock.calls).toEqual([[isModern() ? anySyntheticEvent : verticalScrollEvent]]); - expect(onScroll.mock.calls[0][0]).toMatchObject(verticalScrollEvent); - expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ - x: 0, - y: 200, - }); - }); - test.each([ ['onScroll', 'scroll'], ['onScrollBeginDrag', 'scrollBeginDrag'], @@ -1421,12 +1526,11 @@ describe('fireEvent.layout', () => { ); await fireEvent(screen.getByTestId('child'), 'layout', layoutEvent); - await fireEvent(screen.getByTestId('child'), 'onLayout', layoutEvent); // 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 : 2); - expect(warnSpy).toHaveBeenCalledTimes(isModern() ? 0 : 2); + expect(onLayout).toHaveBeenCalledTimes(isModern() ? 0 : 1); + expect(warnSpy).toHaveBeenCalledTimes(isModern() ? 0 : 1); warnSpy.mockRestore(); }); @@ -1683,21 +1787,6 @@ describe('direct events', () => { expect(onParentLayout).not.toHaveBeenCalled(); }); - test('does not bubble when fired with "on" prefixed event name (legacy: bubbles with a warning)', async () => { - const onMomentumScrollEnd = jest.fn(); - await render( - - - , - ); - - await fireEvent(screen.getByTestId('child'), 'onMomentumScrollEnd', emptyEvent); - - // Modern matches React Native: direct events don't bubble. - expect(onMomentumScrollEnd).toHaveBeenCalledTimes(isModern() ? 0 : 1); - expect(warnSpy).toHaveBeenCalledTimes(isModern() ? 0 : 1); - }); - test('does not bubble scroll (legacy: warns about stopping bubbling in the next major version)', async () => { const onScroll = jest.fn(); await render( @@ -1888,19 +1977,6 @@ describe('custom event types', () => { ); }); - test('accepts a custom event type with the "on" prefix', async () => { - const onPageSelected = jest.fn(); - await render(React.createElement('RNCViewPager', { testID: 'pager', onPageSelected })); - - await fireEvent(screen.getByTestId('pager'), 'onPageSelected', { - nativeEvent: { position: 2 }, - dispatch: 'direct', - }); - - expect(onPageSelected).toHaveBeenCalledTimes(1); - expect(onPageSelected.mock.calls[0][0].nativeEvent).toEqual({ position: 2 }); - }); - 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. @@ -2133,7 +2209,6 @@ describe('Pressability event types', () => { ['pressIn', 'onPressIn'], ['pressOut', 'onPressOut'], ['longPress', 'onLongPress'], - ['onPressIn', 'onPressIn'], ])('"%s" calls only %s of the responder', async (eventType, name) => { const calls: string[] = []; await render( @@ -3231,22 +3306,17 @@ describe('non-editable TextInput', () => { const subject = screen.getByText('Nested Text'); await fireEvent(subject, 'focus', emptyEvent); - await fireEvent(subject, 'onFocus', emptyEvent); const changeTextError = await getErrorMessage(fireEvent.changeText(subject, 'Text')); await fireEvent(subject, 'submitEditing', { nativeEvent: { text: 'Text' }, }); - await fireEvent(subject, 'onSubmitEditing', { - nativeEvent: { text: 'Text' }, - }); await fireEvent(subject, 'layout', layoutEvent); - await fireEvent(subject, 'onLayout', layoutEvent); // Legacy matches a device, where a non-editable TextInput can't be focused or submitted. // Modern `fireEvent()` dispatches the events as given, and they bubble from the Text to the // TextInput. - expect(onFocus).toHaveBeenCalledTimes(isModern() ? 2 : 0); - expect(onSubmitEditing).toHaveBeenCalledTimes(isModern() ? 2 : 0); + expect(onFocus).toHaveBeenCalledTimes(isModern() ? 1 : 0); + expect(onSubmitEditing).toHaveBeenCalledTimes(isModern() ? 1 : 0); // Modern `changeText` accepts only a host TextInput. Legacy walks up to it, and skips it. expect(changeTextError).toBe( isModern() @@ -3256,8 +3326,8 @@ describe('non-editable TextInput', () => { expect(onChangeText).not.toHaveBeenCalled(); // Modern matches React Native: layout is a direct event, so it doesn't reach the parent // TextInput. Legacy still bubbles it, with a warning. - expect(onLayout.mock.calls).toEqual(isModern() ? [] : [[layoutEvent], [layoutEvent]]); - expect(warnSpy).toHaveBeenCalledTimes(isModern() ? 0 : 2); + expect(onLayout.mock.calls).toEqual(isModern() ? [] : [[layoutEvent]]); + expect(warnSpy).toHaveBeenCalledTimes(isModern() ? 0 : 1); warnSpy.mockRestore(); }); diff --git a/src/events/fire-event.ts b/src/events/fire-event.ts index 5a51de7f2..2d0dc1b81 100644 --- a/src/events/fire-event.ts +++ b/src/events/fire-event.ts @@ -1,18 +1,71 @@ import type { TestInstance } from 'test-renderer'; +import type { EventSystem } from '../config'; import { getConfig } from '../config'; import { fireEvent as legacyFireEvent } from './legacy/fire-event'; +import type { CustomEventDispatch, KnownEventType } from './modern/event-types'; import type { FireEventInit } from './modern/fire-event'; import { fireEvent as modernFireEvent } from './modern/fire-event'; import type { EventProps, EventType, LayoutRectangle } from './shared/types'; +/** + * Settings that users augment to change RNTL's types. `@testing-library/react-native/modern-types` + * sets `eventSystem: 'modern'`, so `fireEvent` takes the modern signatures. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface Register {} + +type RegisteredEventSystem = Register extends { eventSystem: infer S extends EventSystem } + ? S + : 'legacy'; + +type LegacyFireEvent = { + (instance: TestInstance, eventType: EventType, ...data: unknown[]): Promise; + changeText: (instance: TestInstance, text: string) => Promise; + press: (instance: TestInstance, eventProps?: EventProps) => Promise; + scroll: (instance: TestInstance, eventProps?: EventProps) => Promise; + layout: (instance: TestInstance, layout?: Partial) => Promise; +}; + +type KnownEventName = KnownEventType | `on${Capitalize}`; + +type CustomFireEventInit = FireEventInit & { dispatch: CustomEventDispatch }; + +/** Shown in the type error of `fireEvent(input, 'changeText', 'Hello')`, as the runtime error. */ +type ChangeTextHint = 'Use fireEvent.changeText() or userEvent.type() instead.'; + +/** + * Event object that modern `fireEvent` accepts at runtime: `dispatch` is required for event types + * React Native doesn't know. Not distributive, so a union with an unknown event type requires it. + */ +type ModernFireEventInit = [T] extends [KnownEventName] + ? FireEventInit + : [T] extends ['changeText' | 'onChangeText'] + ? ChangeTextHint | CustomFireEventInit + : CustomFireEventInit; + +type ModernFireEvent = { + ( + instance: TestInstance, + eventType: T, + event: ModernFireEventInit, + ): Promise; + changeText: (instance: TestInstance, text: string) => Promise; + press: (instance: TestInstance, event?: FireEventInit) => Promise; + scroll: (instance: TestInstance, event?: FireEventInit) => Promise; + layout: (instance: TestInstance, layout?: Partial) => Promise; +}; + +export type FireEvent = RegisteredEventSystem extends 'modern' ? ModernFireEvent : LegacyFireEvent; + /** * Public `fireEvent`. Calls the implementation of the event system selected by * `configure({ unstable_eventSystem })`, read on each call, so `configure()` inside a test applies. * - * Types follow the legacy signatures. The modern implementation checks its arguments at runtime. + * Types follow the legacy signatures, or the modern ones when `Register` sets `eventSystem`. The + * modern implementation also checks its arguments at runtime. */ -async function fireEvent( +async function fireEventImpl( instance: TestInstance, eventType: EventType, ...data: unknown[] @@ -24,7 +77,7 @@ async function fireEvent( return await legacyFireEvent(instance, eventType, ...data); } -fireEvent.changeText = async (instance: TestInstance, text: string): Promise => { +fireEventImpl.changeText = async (instance: TestInstance, text: string): Promise => { if (isModern()) { return await modernFireEvent.changeText(instance, text); } @@ -32,7 +85,7 @@ fireEvent.changeText = async (instance: TestInstance, text: string): Promise => { +fireEventImpl.press = async (instance: TestInstance, eventProps?: EventProps): Promise => { if (isModern()) { await modernFireEvent.press(instance, eventProps as FireEventInit); return; @@ -41,7 +94,7 @@ fireEvent.press = async (instance: TestInstance, eventProps?: EventProps): Promi await legacyFireEvent.press(instance, eventProps); }; -fireEvent.scroll = async (instance: TestInstance, eventProps?: EventProps): Promise => { +fireEventImpl.scroll = async (instance: TestInstance, eventProps?: EventProps): Promise => { if (isModern()) { await modernFireEvent.scroll(instance, eventProps as FireEventInit); return; @@ -50,7 +103,7 @@ fireEvent.scroll = async (instance: TestInstance, eventProps?: EventProps): Prom await legacyFireEvent.scroll(instance, eventProps); }; -fireEvent.layout = async ( +fireEventImpl.layout = async ( instance: TestInstance, layout?: Partial, ): Promise => { @@ -66,4 +119,6 @@ function isModern() { return getConfig().unstable_eventSystem === 'modern'; } +const fireEvent = fireEventImpl as FireEvent; + export { fireEvent }; diff --git a/src/events/modern/event-types.ts b/src/events/modern/event-types.ts index ca04bb4d6..1218d6251 100644 --- a/src/events/modern/event-types.ts +++ b/src/events/modern/event-types.ts @@ -9,7 +9,7 @@ import { getEventHandlerName } from '../shared/handler'; * installed `react-native`. */ -export const BUBBLING_EVENT_TYPES: readonly string[] = [ +export const BUBBLING_EVENT_TYPES = [ 'blur', 'change', 'click', @@ -35,12 +35,17 @@ export const BUBBLING_EVENT_TYPES: readonly string[] = [ 'touchEnd', 'touchMove', 'touchStart', -]; +] as const; + +export type BubblingEventType = (typeof BUBBLING_EVENT_TYPES)[number]; /** Bubbling events that run the capture phase as usual, but call only the target when bubbling. */ -export const SKIP_BUBBLING_EVENT_TYPES: readonly string[] = ['pointerEnter', 'pointerLeave']; +export const SKIP_BUBBLING_EVENT_TYPES: readonly BubblingEventType[] = [ + 'pointerEnter', + 'pointerLeave', +]; -export const DIRECT_EVENT_TYPES: readonly string[] = [ +export const DIRECT_EVENT_TYPES = [ 'accessibilityAction', 'accessibilityEscape', 'accessibilityTap', @@ -79,7 +84,9 @@ export const DIRECT_EVENT_TYPES: readonly string[] = [ 'show', // From the inline view config in `TextNativeComponent.js`, which is not exported. 'textLayout', -]; +] as const; + +export type DirectEventType = (typeof DIRECT_EVENT_TYPES)[number]; /** * Pressability callbacks (`onPress`, `onPressIn`, ...) that modern `fireEvent` calls. They are not @@ -92,9 +99,12 @@ export const PRESSABILITY_EVENT_TYPES = ['press', 'pressIn', 'pressOut', 'longPr export type PressabilityEventType = (typeof PRESSABILITY_EVENT_TYPES)[number]; export function isPressabilityEventType(eventType: string): eventType is PressabilityEventType { - return (PRESSABILITY_EVENT_TYPES as readonly string[]).includes(eventType); + return includes(PRESSABILITY_EVENT_TYPES, eventType); } +/** Event types modern `fireEvent` fires without `dispatch`. */ +export type KnownEventType = BubblingEventType | DirectEventType | PressabilityEventType; + /** Direct events whose handler prop doesn't follow the `on` + event type pattern. */ const DIRECT_EVENT_HANDLER_NAMES: Readonly> = { // Added in React Native 0.89. @@ -134,11 +144,11 @@ export type CustomEventDispatch = (typeof CUSTOM_EVENT_DISPATCHES)[number]; * doesn't know it. */ export function getEventTypeConfig(eventType: string): EventTypeConfig | null { - if (BUBBLING_EVENT_TYPES.includes(eventType)) { - return getBubblingEventTypeConfig(eventType, SKIP_BUBBLING_EVENT_TYPES.includes(eventType)); + if (includes(BUBBLING_EVENT_TYPES, eventType)) { + return getBubblingEventTypeConfig(eventType, includes(SKIP_BUBBLING_EVENT_TYPES, eventType)); } - if (DIRECT_EVENT_TYPES.includes(eventType)) { + if (includes(DIRECT_EVENT_TYPES, eventType)) { return { kind: 'direct', handlerName: DIRECT_EVENT_HANDLER_NAMES[eventType] ?? getEventHandlerName(eventType), @@ -177,3 +187,7 @@ function getBubblingEventTypeConfig( skipBubbling, }; } + +function includes(eventTypes: readonly string[], eventType: string): boolean { + return eventTypes.includes(eventType); +} diff --git a/src/events/modern/fire-event-utils.ts b/src/events/modern/fire-event-utils.ts index 7be96df36..c23e9e368 100644 --- a/src/events/modern/fire-event-utils.ts +++ b/src/events/modern/fire-event-utils.ts @@ -2,6 +2,8 @@ import type { TestInstance } from 'test-renderer'; import { ErrorWithStack } from '../../helpers/errors'; import { isHostTextInput } from '../../helpers/host-component-names'; +import { logger } from '../../helpers/logger'; +import { hasOnPrefix, normalizeEventType } from '../shared/handler'; import type { CreateEventInit } from './event'; import { SyntheticEvent } from './event'; import { @@ -21,6 +23,23 @@ const UNKNOWN_EVENT_TYPE_ALTERNATIVES: Record = { changeText: 'fireEvent.changeText() or userEvent.type()', }; +/** + * Warns for the deprecated `on` prefix (`onPress`), which modern `fireEvent` still strips, as legacy + * `fireEvent` does. To be removed in the next major release. + */ +export function warnOnPrefix(eventType: string): void { + if (!hasOnPrefix(eventType)) { + return; + } + + const unprefixedType = normalizeEventType(eventType); + logger.warn( + `Passing event types with the "on" prefix ("${eventType}") to fireEvent is deprecated and ` + + 'will be removed in the next major release. ' + + `Use "${unprefixedType}" instead, e.g. fireEvent(element, '${unprefixedType}', { nativeEvent: {} }).`, + ); +} + /** * Throws for event types React Native doesn't know, such as `changeText` or custom prop names, * except Pressability event types (`pressIn`, ...), and event types passed with `dispatch`. diff --git a/src/events/modern/fire-event.ts b/src/events/modern/fire-event.ts index 161672a0f..108983ef9 100644 --- a/src/events/modern/fire-event.ts +++ b/src/events/modern/fire-event.ts @@ -26,6 +26,7 @@ import { ensureSingleEventArg, validateChangeTextArgs, validateEventInit, + warnOnPrefix, } from './fire-event-utils'; import { dispatchPressabilityEvent, findResponder, getTouchPath } from './pressability'; import { createPressabilityEvent } from './responder-event'; @@ -56,7 +57,8 @@ export type FireEventInit = CreateEventInit; * Resolves to nothing, unlike DOM `fireEvent`: React Native has no default actions that * `preventDefault()` could cancel. Handlers can read `event.defaultPrevented` themselves. * - * @param eventType with or without the `on*` prefix, e.g. `focus` or `onFocus` + * @param eventType without the `on*` prefix, as React Native names it, e.g. `focus`. The prefix + * (`onFocus`) is deprecated and logs a warning. */ export async function fireEvent( instance: TestInstance, @@ -69,6 +71,8 @@ export async function fireEvent( ensureEventType(normalizedType, args[0], fireEvent); ensureSingleEventArg(normalizedType, args, fireEvent); const init = validateEventInit(normalizedType, args[0], fireEvent); + // After the checks, so a call that throws doesn't also warn. + warnOnPrefix(eventType); if (isPressabilityEventType(normalizedType) && init.dispatch == null) { await firePressabilityEvent(instance, normalizedType, init); return; diff --git a/src/events/shared/handler.ts b/src/events/shared/handler.ts index 0e8479e17..67021f936 100644 --- a/src/events/shared/handler.ts +++ b/src/events/shared/handler.ts @@ -70,7 +70,8 @@ function getFunctionProp( return typeof handler === 'function' ? (handler as EventHandler) : undefined; } -function hasOnPrefix(eventType: string) { +/** Whether the event type is a handler prop name, e.g. `onLayout`, not `layout` or `once`. */ +export function hasOnPrefix(eventType: string) { return /^on[A-Z]/.test(eventType); } diff --git a/src/modern-types.ts b/src/modern-types.ts new file mode 100644 index 000000000..586f056bb --- /dev/null +++ b/src/modern-types.ts @@ -0,0 +1,13 @@ +/** + * Switches `fireEvent` types to the modern event system. Users import it as + * `@testing-library/react-native/modern-types`, next to `configure({ unstable_eventSystem: 'modern' })`. + * + * Not part of the main tsconfig program, as the augmentation would apply to all tests. + */ +export {}; + +declare module './events/fire-event' { + interface Register { + eventSystem: 'modern'; + } +} diff --git a/tsconfig.json b/tsconfig.json index 746bb86db..10282f768 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,5 +16,9 @@ "outDir": "dist" }, "include": ["src/**/*"], - "exclude": ["src/__tests__/react-19_2/**/*"] + "exclude": [ + "src/__tests__/react-19_2/**/*", + "src/__tests__/modern-types/**/*", + "src/modern-types.ts" + ] } diff --git a/tsconfig.modern-types.json b/tsconfig.modern-types.json new file mode 100644 index 000000000..d5179bc09 --- /dev/null +++ b/tsconfig.modern-types.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/__tests__/modern-types/**/*"], + "exclude": [] +} diff --git a/website/docs/14.x/docs/api/events/_meta.json b/website/docs/14.x/docs/api/events/_meta.json index 363342aa2..18633f700 100644 --- a/website/docs/14.x/docs/api/events/_meta.json +++ b/website/docs/14.x/docs/api/events/_meta.json @@ -1,4 +1,5 @@ [ { "type": "file", "name": "user-event", "label": "User Event" }, - { "type": "file", "name": "fire-event", "label": "Fire Event" } + { "type": "file", "name": "fire-event", "label": "Fire Event" }, + { "type": "file", "name": "modern-event-system", "label": "Modern Event System" } ] diff --git a/website/docs/14.x/docs/api/events/modern-event-system.mdx b/website/docs/14.x/docs/api/events/modern-event-system.mdx new file mode 100644 index 000000000..c9d6070e4 --- /dev/null +++ b/website/docs/14.x/docs/api/events/modern-event-system.mdx @@ -0,0 +1,20 @@ +# Modern Event System + +## How to enable the modern event system + +Add these two lines to your Jest setup file: + +```ts title=jest-setup.ts +import { configure } from '@testing-library/react-native'; +// Type-checks `fireEvent` arguments for the modern event system. +import '@testing-library/react-native/modern-types'; + +// Switches `fireEvent` and `userEvent` to the modern event system. +configure({ unstable_eventSystem: 'modern' }); +``` + +Make sure your `tsconfig.json` includes the setup file. If it doesn't, TypeScript won't see the import, and you won't get the stricter checks. + +## Event types + +Pass event types without the `on` prefix: `'focus'`, not `'onFocus'`. The prefix still works but logs a deprecation warning, and will stop working in the next major release.