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';