From b561465b413ced44e338e8efa7e016a281d75b0d Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Fri, 9 Oct 2026 15:57:28 +0200 Subject: [PATCH 1/3] 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/3] 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/3] 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(