From d4db12b2e94686542bd11720eb03e02426dc36db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 8 Oct 2026 12:45:55 +0200 Subject: [PATCH 01/28] modern event subsystem --- src/__tests__/config.test.ts | 14 ++++++++++++++ src/config.ts | 11 +++++++++++ src/pure.ts | 2 +- 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 1fecd4f33..ff4517054 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -13,6 +13,7 @@ afterEach(() => { test('getConfig() returns existing configuration', () => { expect(getConfig().asyncUtilTimeout).toEqual(1000); expect(getConfig().defaultIncludeHiddenElements).toEqual(false); + expect(getConfig().eventSystem).toEqual('legacy'); }); test('configure() overrides existing config values', () => { @@ -23,9 +24,21 @@ test('configure() overrides existing config values', () => { defaultDebugOptions: { message: 'debug message' }, defaultIncludeHiddenElements: false, eventDiagnostics: false, + eventSystem: 'legacy', }); }); +test('configure() sets eventSystem', () => { + configure({ eventSystem: 'modern' }); + expect(getConfig().eventSystem).toEqual('modern'); + + configure({ asyncUtilTimeout: 2000 }); + expect(getConfig().eventSystem).toEqual('modern'); + + resetToDefaults(); + expect(getConfig().eventSystem).toEqual('legacy'); +}); + test('resetToDefaults() resets config to defaults', () => { configure({ asyncUtilTimeout: 5000, @@ -66,6 +79,7 @@ test('does not warn when only valid options are passed', () => { defaultIncludeHiddenElements: true, defaultDebugOptions: { message: 'test' }, defaultHidden: false, + eventSystem: 'modern', }); expect(_console.warn).not.toHaveBeenCalled(); diff --git a/src/config.ts b/src/config.ts index b19cb18cf..5b099c0cd 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,6 +5,8 @@ import { validateOptions } from './helpers/validate-options'; * Global configuration options for React Native Testing Library. */ +export type EventSystem = 'legacy' | 'modern'; + export type Config = { /** Default timeout, in ms, for `waitFor` and `findBy*` queries. */ asyncUtilTimeout: number; @@ -18,6 +20,12 @@ export type Config = { */ eventDiagnostics: boolean; + /** + * Event system used by `fireEvent` and `userEvent`. `'legacy'` is the current simplified + * implementation. `'modern'` follows React Native's event dispatch. Defaults to `'legacy'`. + */ + eventSystem: EventSystem; + /** Default options for `debug` helper. */ defaultDebugOptions?: Partial; }; @@ -31,6 +39,7 @@ const defaultConfig: Config = { asyncUtilTimeout: 1000, defaultIncludeHiddenElements: false, eventDiagnostics: false, + eventSystem: 'legacy', }; let config = { ...defaultConfig }; @@ -45,6 +54,7 @@ export function configure(options: Partial) { defaultHidden, defaultIncludeHiddenElements, eventDiagnostics, + eventSystem, ...rest } = options; @@ -59,6 +69,7 @@ export function configure(options: Partial) { defaultDebugOptions, defaultIncludeHiddenElements: resolvedDefaultIncludeHiddenElements, eventDiagnostics: eventDiagnostics ?? config.eventDiagnostics, + eventSystem: eventSystem ?? config.eventSystem, }; } diff --git a/src/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'; From b78cd8dcb7431f2714f6c4a90e889ba06e19edfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 8 Oct 2026 12:48:19 +0200 Subject: [PATCH 02/28] move existing code to legacy --- contributing/event-dispatch.md | 10 ++++++---- contributing/native-events.md | 4 ++-- contributing/native-state.md | 4 ++-- src/events/{ => legacy}/__tests__/dispatch.test.tsx | 2 +- .../{ => legacy}/__tests__/fire-event.test.tsx | 6 +++--- src/events/{ => legacy}/__tests__/handler.test.tsx | 2 +- .../{ => legacy}/builders/__tests__/base.test.ts | 0 .../{ => legacy}/builders/__tests__/common.test.ts | 0 .../{ => legacy}/builders/__tests__/index.test.ts | 0 .../{ => legacy}/builders/__tests__/merge.test.ts | 0 .../{ => legacy}/builders/__tests__/scroll.test.ts | 0 .../{ => legacy}/builders/__tests__/text.test.ts | 0 src/events/{ => legacy}/builders/base.ts | 0 src/events/{ => legacy}/builders/common.ts | 0 src/events/{ => legacy}/builders/index.ts | 0 src/events/{ => legacy}/builders/merge.ts | 0 src/events/{ => legacy}/builders/scroll.ts | 2 +- src/events/{ => legacy}/builders/text.ts | 2 +- src/events/{ => legacy}/dispatch.ts | 4 ++-- src/events/{ => legacy}/fire-event.ts | 6 +++--- src/events/{ => legacy}/handler.ts | 0 src/events/{ => legacy}/index.ts | 0 src/events/{ => legacy}/is-enabled.ts | 4 ++-- src/events/{ => legacy}/native-state.ts | 2 +- src/events/{ => legacy}/propagation.ts | 2 +- src/events/{ => legacy}/types.ts | 2 +- src/events/{ => legacy}/update-native-state.ts | 6 +++--- src/events/{ => legacy}/warnings.ts | 12 ++++++------ src/helpers/text-input.ts | 2 +- src/pure.ts | 2 +- .../accessibility-action/accessibility-action.ts | 2 +- src/user-event/clear.ts | 2 +- src/user-event/paste.ts | 2 +- src/user-event/press/press.ts | 2 +- src/user-event/scroll/scroll-to.ts | 2 +- src/user-event/type/type.ts | 2 +- src/user-event/utils/interaction.ts | 2 +- src/user-event/utils/warnings.ts | 4 ++-- 38 files changed, 47 insertions(+), 45 deletions(-) rename src/events/{ => legacy}/__tests__/dispatch.test.tsx (96%) rename src/events/{ => legacy}/__tests__/fire-event.test.tsx (99%) rename src/events/{ => legacy}/__tests__/handler.test.tsx (98%) rename src/events/{ => legacy}/builders/__tests__/base.test.ts (100%) rename src/events/{ => legacy}/builders/__tests__/common.test.ts (100%) rename src/events/{ => legacy}/builders/__tests__/index.test.ts (100%) rename src/events/{ => legacy}/builders/__tests__/merge.test.ts (100%) rename src/events/{ => legacy}/builders/__tests__/scroll.test.ts (100%) rename src/events/{ => legacy}/builders/__tests__/text.test.ts (100%) rename src/events/{ => legacy}/builders/base.ts (100%) rename src/events/{ => legacy}/builders/common.ts (100%) rename src/events/{ => legacy}/builders/index.ts (100%) rename src/events/{ => legacy}/builders/merge.ts (100%) rename src/events/{ => legacy}/builders/scroll.ts (96%) rename src/events/{ => legacy}/builders/text.ts (97%) rename src/events/{ => legacy}/dispatch.ts (87%) rename src/events/{ => legacy}/fire-event.ts (94%) rename src/events/{ => legacy}/handler.ts (100%) rename src/events/{ => legacy}/index.ts (100%) rename src/events/{ => legacy}/is-enabled.ts (96%) rename src/events/{ => legacy}/native-state.ts (92%) rename src/events/{ => legacy}/propagation.ts (99%) rename src/events/{ => legacy}/types.ts (93%) rename src/events/{ => legacy}/update-native-state.ts (92%) rename src/events/{ => legacy}/warnings.ts (93%) diff --git a/contributing/event-dispatch.md b/contributing/event-dispatch.md index db90c0c6b..50069947d 100644 --- a/contributing/event-dispatch.md +++ b/contributing/event-dispatch.md @@ -2,7 +2,9 @@ RNTL has two ways to trigger events. Neither goes through React Native's native event system. Both find `on*` props in the rendered tree and call them inside `act()`. -Both are built on the shared event subsystem in `src/events/`, which also holds `fireEvent` itself: +Both are built on the shared event subsystem in `src/events/legacy/`, which also holds `fireEvent` itself. This is the `'legacy'` event system, the default for the `eventSystem` config option. A `'modern'` event system that follows React Native's event dispatch is in progress. + +The files in `src/events/legacy/`: | File | Contents | | ------------------------------------------- | --------------------------------------------------------------------------------------- | @@ -15,11 +17,11 @@ Both are built on the shared event subsystem in `src/events/`, which also holds | `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 | -`src/user-event/` is a separate module on top of `src/events/` and imports it only through `src/events/index.ts`. +`src/user-event/` is a separate module on top of `src/events/legacy/` and imports it only through `src/events/legacy/index.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. @@ -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..f543b2c38 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/legacy/native-state.ts`. ## What is stored @@ -10,7 +10,7 @@ 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. +- **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/legacy/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. - **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. 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/__tests__/fire-event.test.tsx b/src/events/legacy/__tests__/fire-event.test.tsx similarity index 99% rename from src/events/__tests__/fire-event.test.tsx rename to src/events/legacy/__tests__/fire-event.test.tsx index e1e950893..7f479bfaa 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/legacy/__tests__/fire-event.test.tsx @@ -17,9 +17,9 @@ import { View, } from 'react-native'; -import { fireEvent, render, screen } from '../..'; -import { configure } from '../../config'; -import { _console, logger } from '../../helpers/logger'; +import { fireEvent, render, screen } from '../../..'; +import { configure } from '../../../config'; +import { _console, logger } from '../../../helpers/logger'; import { getEventHandlerName } from '../handler'; import { nativeState } from '../native-state'; diff --git a/src/events/__tests__/handler.test.tsx b/src/events/legacy/__tests__/handler.test.tsx similarity index 98% rename from src/events/__tests__/handler.test.tsx rename to src/events/legacy/__tests__/handler.test.tsx index b08ccf496..a3f164fc9 100644 --- a/src/events/__tests__/handler.test.tsx +++ b/src/events/legacy/__tests__/handler.test.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { Text, View } from 'react-native'; -import { render, screen } from '../..'; +import { render, screen } from '../../..'; import { getEventHandlerFromProps, normalizeEventType } from '../handler'; test('getEventHandlerFromProps strict mode', async () => { diff --git a/src/events/builders/__tests__/base.test.ts b/src/events/legacy/builders/__tests__/base.test.ts similarity index 100% rename from src/events/builders/__tests__/base.test.ts rename to src/events/legacy/builders/__tests__/base.test.ts diff --git a/src/events/builders/__tests__/common.test.ts b/src/events/legacy/builders/__tests__/common.test.ts similarity index 100% rename from src/events/builders/__tests__/common.test.ts rename to src/events/legacy/builders/__tests__/common.test.ts diff --git a/src/events/builders/__tests__/index.test.ts b/src/events/legacy/builders/__tests__/index.test.ts similarity index 100% rename from src/events/builders/__tests__/index.test.ts rename to src/events/legacy/builders/__tests__/index.test.ts diff --git a/src/events/builders/__tests__/merge.test.ts b/src/events/legacy/builders/__tests__/merge.test.ts similarity index 100% rename from src/events/builders/__tests__/merge.test.ts rename to src/events/legacy/builders/__tests__/merge.test.ts diff --git a/src/events/builders/__tests__/scroll.test.ts b/src/events/legacy/builders/__tests__/scroll.test.ts similarity index 100% rename from src/events/builders/__tests__/scroll.test.ts rename to src/events/legacy/builders/__tests__/scroll.test.ts diff --git a/src/events/builders/__tests__/text.test.ts b/src/events/legacy/builders/__tests__/text.test.ts similarity index 100% rename from src/events/builders/__tests__/text.test.ts rename to src/events/legacy/builders/__tests__/text.test.ts diff --git a/src/events/builders/base.ts b/src/events/legacy/builders/base.ts similarity index 100% rename from src/events/builders/base.ts rename to src/events/legacy/builders/base.ts diff --git a/src/events/builders/common.ts b/src/events/legacy/builders/common.ts similarity index 100% rename from src/events/builders/common.ts rename to src/events/legacy/builders/common.ts diff --git a/src/events/builders/index.ts b/src/events/legacy/builders/index.ts similarity index 100% rename from src/events/builders/index.ts rename to src/events/legacy/builders/index.ts diff --git a/src/events/builders/merge.ts b/src/events/legacy/builders/merge.ts similarity index 100% rename from src/events/builders/merge.ts rename to src/events/legacy/builders/merge.ts diff --git a/src/events/builders/scroll.ts b/src/events/legacy/builders/scroll.ts similarity index 96% rename from src/events/builders/scroll.ts rename to src/events/legacy/builders/scroll.ts index a9a799371..ad909f6f7 100644 --- a/src/events/builders/scroll.ts +++ b/src/events/legacy/builders/scroll.ts @@ -1,4 +1,4 @@ -import type { Point, Size } from '../../types'; +import type { Point, Size } from '../../../types'; import { baseSyntheticEvent } from './base'; /** diff --git a/src/events/builders/text.ts b/src/events/legacy/builders/text.ts similarity index 97% rename from src/events/builders/text.ts rename to src/events/legacy/builders/text.ts index 8b0346484..9899cab9b 100644 --- a/src/events/builders/text.ts +++ b/src/events/legacy/builders/text.ts @@ -1,4 +1,4 @@ -import type { Size, TextRange } from '../../types'; +import type { Size, TextRange } from '../../../types'; import { baseSyntheticEvent } from './base'; /** diff --git a/src/events/dispatch.ts b/src/events/legacy/dispatch.ts similarity index 87% rename from src/events/dispatch.ts rename to src/events/legacy/dispatch.ts index 400b2c080..e3650b2cd 100644 --- a/src/events/dispatch.ts +++ b/src/events/legacy/dispatch.ts @@ -1,7 +1,7 @@ import type { TestInstance } from 'test-renderer'; -import { act } from '../act'; -import { isInstanceMounted } from '../helpers/component-tree'; +import { act } from '../../act'; +import { isInstanceMounted } from '../../helpers/component-tree'; import { getEventHandlerFromProps } from './handler'; /** diff --git a/src/events/fire-event.ts b/src/events/legacy/fire-event.ts similarity index 94% rename from src/events/fire-event.ts rename to src/events/legacy/fire-event.ts index ee998c9e2..70dfdde25 100644 --- a/src/events/fire-event.ts +++ b/src/events/legacy/fire-event.ts @@ -1,8 +1,8 @@ import type { TestInstance } from 'test-renderer'; -import { act } from '../act'; -import { isInstanceMounted } from '../helpers/component-tree'; -import { isHostScrollView } from '../helpers/host-component-names'; +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'; diff --git a/src/events/handler.ts b/src/events/legacy/handler.ts similarity index 100% rename from src/events/handler.ts rename to src/events/legacy/handler.ts diff --git a/src/events/index.ts b/src/events/legacy/index.ts similarity index 100% rename from src/events/index.ts rename to src/events/legacy/index.ts 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/native-state.ts b/src/events/legacy/native-state.ts similarity index 92% rename from src/events/native-state.ts rename to src/events/legacy/native-state.ts index c4b405c7c..e7f4d5c74 100644 --- a/src/events/native-state.ts +++ b/src/events/legacy/native-state.ts @@ -1,6 +1,6 @@ import type { TestInstance } from 'test-renderer'; -import type { Point, Size } from '../types'; +import type { Point, Size } from '../../types'; /** * Simulated native state for unmanaged controls. diff --git a/src/events/propagation.ts b/src/events/legacy/propagation.ts similarity index 99% rename from src/events/propagation.ts rename to src/events/legacy/propagation.ts index 34396a31e..eece3f7e3 100644 --- a/src/events/propagation.ts +++ b/src/events/legacy/propagation.ts @@ -6,7 +6,7 @@ import { isHostScrollView, isHostText, isHostTextInput, -} from '../helpers/host-component-names'; +} from '../../helpers/host-component-names'; import { getEventHandlerFromProps, normalizeEventType } from './handler'; import { isEventEnabled, isTouchResponder } from './is-enabled'; import type { EventHandler } from './types'; diff --git a/src/events/types.ts b/src/events/legacy/types.ts similarity index 93% rename from src/events/types.ts rename to src/events/legacy/types.ts index d01d50f8d..aee809036 100644 --- a/src/events/types.ts +++ b/src/events/legacy/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/legacy/update-native-state.ts similarity index 92% rename from src/events/update-native-state.ts rename to src/events/legacy/update-native-state.ts index 739905380..0527cf3de 100644 --- a/src/events/update-native-state.ts +++ b/src/events/legacy/update-native-state.ts @@ -1,8 +1,8 @@ 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'; const SCROLL_EVENTS = [ diff --git a/src/events/warnings.ts b/src/events/legacy/warnings.ts similarity index 93% rename from src/events/warnings.ts rename to src/events/legacy/warnings.ts index 14e758618..77c3af180 100644 --- a/src/events/warnings.ts +++ b/src/events/legacy/warnings.ts @@ -1,12 +1,12 @@ 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 { getConfig } from '../../config'; +import { computeAriaDisabled } from '../../helpers/accessibility'; +import { formatElement, formatJson } from '../../helpers/format-element'; +import { isHostTextInput } from '../../helpers/host-component-names'; +import { logger } from '../../helpers/logger'; +import { isEditableTextInput } from '../../helpers/text-input'; import { getEventHandlerName, normalizeEventType } from './handler'; import { getPointerEventsBlocker, isEventBlockableByPointerEvents } from './is-enabled'; diff --git a/src/helpers/text-input.ts b/src/helpers/text-input.ts index d053e0570..5615191e0 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/legacy/native-state'; import { isHostTextInput } from './host-component-names'; export function isEditableTextInput(instance: TestInstance) { diff --git a/src/pure.ts b/src/pure.ts index db64cd82f..9a5dbc76c 100644 --- a/src/pure.ts +++ b/src/pure.ts @@ -1,6 +1,6 @@ export { act } from './act'; export { cleanup } from './cleanup'; -export { fireEvent } from './events/fire-event'; +export { fireEvent } from './events/legacy/fire-event'; export { render } from './render'; export { waitFor } from './wait-for'; export { waitForElementToBeRemoved } from './wait-for-element-to-be-removed'; diff --git a/src/user-event/accessibility-action/accessibility-action.ts b/src/user-event/accessibility-action/accessibility-action.ts index 85f94329e..0ccd94619 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/legacy'; 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..b7bd50c55 100644 --- a/src/user-event/clear.ts +++ b/src/user-event/clear.ts @@ -6,7 +6,7 @@ import { buildFocusEvent, buildTextSelectionChangeEvent, isPointerEventEnabled, -} from '../events'; +} 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..bd5c45ed7 100644 --- a/src/user-event/paste.ts +++ b/src/user-event/paste.ts @@ -9,7 +9,7 @@ import { buildTextSelectionChangeEvent, isPointerEventEnabled, nativeState, -} from '../events'; +} 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/press/press.ts b/src/user-event/press/press.ts index 8d9745935..16bcfa87a 100644 --- a/src/user-event/press/press.ts +++ b/src/user-event/press/press.ts @@ -7,7 +7,7 @@ import { 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'; diff --git a/src/user-event/scroll/scroll-to.ts b/src/user-event/scroll/scroll-to.ts index 6d581d6ef..ea068bfe6 100644 --- a/src/user-event/scroll/scroll-to.ts +++ b/src/user-event/scroll/scroll-to.ts @@ -1,7 +1,7 @@ import { stringify } from 'jest-matcher-utils'; import type { TestInstance } from 'test-renderer'; -import { buildScrollEvent, nativeState } from '../../events'; +import { buildScrollEvent, nativeState } from '../../events/legacy'; import { ErrorWithStack } from '../../helpers/errors'; import { isHostScrollView } from '../../helpers/host-component-names'; import { pick } from '../../helpers/object'; diff --git a/src/user-event/type/type.ts b/src/user-event/type/type.ts index cd78d2be3..cc9482d34 100644 --- a/src/user-event/type/type.ts +++ b/src/user-event/type/type.ts @@ -12,7 +12,7 @@ import { buildTouchEvent, isPointerEventEnabled, nativeState, -} from '../../events'; +} 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/utils/interaction.ts b/src/user-event/utils/interaction.ts index 7ea2f7b3f..9990cf576 100644 --- a/src/user-event/utils/interaction.ts +++ b/src/user-event/utils/interaction.ts @@ -1,6 +1,6 @@ import type { TestInstance } from 'test-renderer'; -import { dispatchEvent } from '../../events'; +import { dispatchEvent } from '../../events/legacy'; /** * Tracks what a single `userEvent` interaction did, so `warnAboutUnhandledInteraction` diff --git a/src/user-event/utils/warnings.ts b/src/user-event/utils/warnings.ts index 1625c92cf..b5eecada8 100644 --- a/src/user-event/utils/warnings.ts +++ b/src/user-event/utils/warnings.ts @@ -1,10 +1,10 @@ import { getConfig } from '../../config'; -import type { EventWarning } from '../../events'; +import type { EventWarning } from '../../events/legacy'; import { formatDisabledTargets, getPointerEventsBlockedTargets, logEventWarning, -} from '../../events'; +} from '../../events/legacy'; import { computeAriaDisabled } from '../../helpers/accessibility'; import type { Interaction } from './interaction'; From 06e1e790a55377d180574a2b9b05d38dd54c3380 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 8 Oct 2026 13:15:48 +0200 Subject: [PATCH 03/28] dispatchEvent impl --- src/events/modern/__tests__/dispatch.test.tsx | 342 ++++++++++++++++++ .../modern/__tests__/event-types.test.ts | 123 +++++++ src/events/modern/__tests__/event.test.ts | 107 ++++++ src/events/modern/dispatch.ts | 183 ++++++++++ src/events/modern/event-types.ts | 127 +++++++ src/events/modern/event.ts | 242 +++++++++++++ 6 files changed, 1124 insertions(+) create mode 100644 src/events/modern/__tests__/dispatch.test.tsx create mode 100644 src/events/modern/__tests__/event-types.test.ts create mode 100644 src/events/modern/__tests__/event.test.ts create mode 100644 src/events/modern/dispatch.ts create mode 100644 src/events/modern/event-types.ts create mode 100644 src/events/modern/event.ts diff --git a/src/events/modern/__tests__/dispatch.test.tsx b/src/events/modern/__tests__/dispatch.test.tsx new file mode 100644 index 000000000..7b1a3b258 --- /dev/null +++ b/src/events/modern/__tests__/dispatch.test.tsx @@ -0,0 +1,342 @@ +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 { SyntheticEvent } from '../event'; + +type HandlerProps = Record void>; + +type Call = { + prop: string; + currentTarget: string | undefined; + target: string | undefined; + eventPhase: number; +}; + +/** Returns handler props for the given prop names that log each call to `calls`. */ +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); +} + +// Handler props are spread, because `View` types don't include all of them. +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'), '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'), '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'), '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'), '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'), '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'), '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'), 'scroll'); + + expect(onScroll).not.toHaveBeenCalled(); + }); +}); + +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'), 'focus'); + + expect(onFocus).not.toHaveBeenCalled(); +}); + +test('drops events unknown to React Native', async () => { + const onChangeText = jest.fn(); + await render(); + + const result = await dispatchEvent(screen.getByTestId('input'), 'changeText', { text: 'Hello' }); + + expect(result).toBe(true); + expect(onChangeText).not.toHaveBeenCalled(); +}); + +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, 'focus'); + + expect(result).toBe(true); + 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, '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'), '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'), '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'), 'focus'); + + expect(onFocus.mock.contexts[0]).toBe(screen.getByTestId('target')); +}); + +test('returns false when a handler prevents default', async () => { + await render( + event.preventDefault() })} + />, + ); + + const result = await dispatchEvent(screen.getByTestId('target'), 'focus'); + + expect(result).toBe(false); +}); + +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'), 'focus')).rejects.toThrow( + 'Target error', + ); + expect(onRootFocus).toHaveBeenCalledTimes(1); +}); + +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'), '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..538c23ee7 --- /dev/null +++ b/src/events/modern/__tests__/event-types.test.ts @@ -0,0 +1,123 @@ +import { + bubblingEventTypes, + directEventTypes, + getEventTypeConfig, + skipBubblingEventTypes, +} from '../event-types'; + +type ViewConfig = { + bubblingEventTypes?: Record< + string, + { phasedRegistrationNames: { bubbled: string; captured: string; skipBubbling?: boolean } } + >; + directEventTypes?: Record; +}; + +// Base view configs and built-in components with events in their view configs. +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', + 'react-native/src/private/components/virtualview/VirtualViewNativeComponent', +]; + +// Not exported by React Native, so listed by hand in `event-types.ts`. +const manualDirectEventTypes = ['textLayout']; + +function toEventType(topLevelType: string) { + return topLevelType.charAt(3).toLowerCase() + topLevelType.slice(4); +} + +function loadReactNativeEventTypes() { + const bubbling = new Set(); + const skipBubbling = new Set(); + const direct = new Set(manualDirectEventTypes); + + for (const modulePath of viewConfigModules) { + const module = jest.requireActual(modulePath); + const viewConfig: ViewConfig = module.__INTERNAL_VIEW_CONFIG ?? module.default; + + for (const [topLevelType, config] of Object.entries(viewConfig.bubblingEventTypes ?? {})) { + const eventType = toEventType(topLevelType); + // `getEventTypeConfig()` derives prop names from the event type. + expect(getEventTypeConfig(eventType)?.dispatchConfig).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 are registered without the `top` prefix and not dispatched by React Native. + if (!topLevelType.startsWith('top')) { + continue; + } + + const eventType = toEventType(topLevelType); + expect(getEventTypeConfig(eventType)?.dispatchConfig).toEqual({ + registrationName: config.registrationName, + }); + direct.add(eventType); + } + } + + return { bubbling, skipBubbling, direct }; +} + +test('event types match React Native view configs', () => { + const reactNative = loadReactNativeEventTypes(); + + expect([...bubblingEventTypes].sort()).toEqual([...reactNative.bubbling].sort()); + expect([...skipBubblingEventTypes].sort()).toEqual([...reactNative.skipBubbling].sort()); + expect([...directEventTypes].sort()).toEqual([...reactNative.direct].sort()); +}); + +test('getEventTypeConfig() returns config of bubbling event', () => { + expect(getEventTypeConfig('pointerUp')).toEqual({ + kind: 'bubbling', + skipBubbling: false, + dispatchConfig: { + phasedRegistrationNames: { bubbled: 'onPointerUp', captured: 'onPointerUpCapture' }, + }, + }); +}); + +test('getEventTypeConfig() returns config of bubbling event with skipBubbling', () => { + expect(getEventTypeConfig('pointerEnter')).toEqual({ + kind: 'bubbling', + skipBubbling: true, + dispatchConfig: { + phasedRegistrationNames: { + bubbled: 'onPointerEnter', + captured: 'onPointerEnterCapture', + skipBubbling: true, + }, + }, + }); +}); + +test('getEventTypeConfig() returns config of direct event', () => { + expect(getEventTypeConfig('layout')).toEqual({ + kind: 'direct', + dispatchConfig: { registrationName: 'onLayout' }, + }); +}); + +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..3546305f8 --- /dev/null +++ b/src/events/modern/__tests__/event.test.ts @@ -0,0 +1,107 @@ +import { SyntheticEvent } from '../event'; + +test('exposes init values', () => { + const nativeEvent = { target: 1 }; + const dispatchConfig = { registrationName: 'onLayout' }; + const event = new SyntheticEvent( + 'layout', + { cancelable: true, rnIsDirect: true, timeStamp: 123 }, + nativeEvent, + dispatchConfig, + ); + + expect(event.type).toBe('layout'); + expect(event.bubbles).toBe(false); + expect(event.cancelable).toBe(true); + expect(event.composed).toBe(false); + expect(event.rnIsDirect).toBe(true); + expect(event.timeStamp).toBe(123); + expect(event.nativeEvent).toBe(nativeEvent); + expect(event.dispatchConfig).toBe(dispatchConfig); +}); + +test('has no target and phase outside of dispatch', () => { + const event = new SyntheticEvent('focus', { bubbles: true }, {}); + + expect(event.target).toBeNull(); + expect(event.currentTarget).toBeNull(); + expect(event.eventPhase).toBe(SyntheticEvent.NONE); + expect(event.composedPath()).toEqual([]); + expect(event.isTrusted).toBe(false); + expect(event.dispatchConfig).toBeNull(); +}); + +test('defaults timeStamp to performance.now()', () => { + jest.spyOn(performance, 'now').mockReturnValue(456); + + const event = new SyntheticEvent('focus', {}, {}); + + expect(event.timeStamp).toBe(456); +}); + +test('throws when direct event bubbles', () => { + expect(() => new SyntheticEvent('layout', { bubbles: true, rnIsDirect: true }, {})).toThrow( + "'rnIsDirect' cannot be true when 'bubbles' is also true", + ); +}); + +test('preventDefault() marks cancelable event as default prevented', () => { + const event = new SyntheticEvent('focus', { cancelable: true }, {}); + + 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', {}, {}); + + 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', {}, {}); + 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', {}, {}); + + event.cancelBubble = true; + event.cancelBubble = false; + + expect(event.isPropagationStopped()).toBe(true); +}); + +test('persist() is a no-op', () => { + const event = new SyntheticEvent('focus', {}, {}); + + expect(() => event.persist()).not.toThrow(); +}); + +test('defines event phase constants on class and prototype', () => { + const event = new SyntheticEvent('focus', {}, {}); + + 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'); +}); diff --git a/src/events/modern/dispatch.ts b/src/events/modern/dispatch.ts new file mode 100644 index 000000000..2489b2b44 --- /dev/null +++ b/src/events/modern/dispatch.ts @@ -0,0 +1,183 @@ +import type { TestInstance } from 'test-renderer'; + +import { act } from '../../act'; +import { isInstanceMounted } from '../../helpers/component-tree'; +import type { NativeEventPayload } from './event'; +import { eventInternals, SyntheticEvent } from './event'; +import { getEventTypeConfig } from './event-types'; + +/** + * Dispatches an event the way React Native dispatches events from native + * (`src/private/renderer/events/dispatchNativeEvent.js`, then `dispatch()` in + * `src/private/webapis/dom/events/EventTarget.js`): + * + * - Bubbling events run the capture phase from the root to the target (`on*Capture` props), then the + * bubble phase from the target to the root (`on*` props). Events with `skipBubbling` run the capture + * phase as usual, but the bubble phase calls only the target. + * - Direct events call only the target's `on*` prop. + * - Events unknown to React Native are dropped. + * + * Only host elements are on the event path, so props of composite components are never called. + * Every handler on the path runs until one stops propagation. The first error thrown by a handler is + * rethrown after the whole dispatch. The dispatch runs in a single `act()`, as React Native batches + * updates for the whole event. + * + * Not implemented yet: responder negotiation, which React Native runs before the dispatch for touch, + * scroll and selection change events. + * + * @param target host element that receives the event + * @param eventType event type, e.g. `focus` or `pointerUp` + * @param payload native event payload, passed to handlers as `event.nativeEvent` + * @returns `false` if a handler called `preventDefault()`, `true` otherwise, as `dispatchEvent()` + * of the DOM does. + */ +export async function dispatchEvent( + target: TestInstance, + eventType: string, + payload: NativeEventPayload = {}, +): Promise { + if (!isInstanceMounted(target)) { + return true; + } + + const event = createEvent(eventType, payload); + if (event == null) { + return true; + } + + await act(() => { + eventInternals.setIsTrusted(event, true); + dispatch(target, event); + }); + + return !event.defaultPrevented; +} + +function createEvent(eventType: string, payload: NativeEventPayload): SyntheticEvent | null { + const config = getEventTypeConfig(eventType); + if (config == null) { + return null; + } + + // React Native keeps the native timestamp as the event's `timeStamp`. + const nativeTimeStamp = payload.timeStamp ?? payload.timestamp; + return new SyntheticEvent( + // React Native's event type is the lowercased name, e.g. `pointerup`. + eventType.toLowerCase(), + { + bubbles: config.kind === 'bubbling' && !config.skipBubbling, + cancelable: true, + rnIsDirect: config.kind === 'direct', + timeStamp: typeof nativeTimeStamp === 'number' ? nativeTimeStamp : undefined, + }, + payload, + config.dispatchConfig, + ); +} + +type ErrorState = { hasError: boolean; error: unknown }; + +function dispatch(target: TestInstance, event: SyntheticEvent) { + const path = getEventPath(target, event); + eventInternals.setComposedPath(event, path); + eventInternals.setTarget(event, target); + + const errorState: ErrorState = { hasError: false, error: undefined }; + + for (let i = path.length - 1; i >= 0; i -= 1) { + if (eventInternals.getStopPropagationFlag(event)) { + break; + } + + const node = path[i]; + eventInternals.setEventPhase( + event, + node === target ? SyntheticEvent.AT_TARGET : SyntheticEvent.CAPTURING_PHASE, + ); + invoke(node, event, true, errorState); + } + + for (const node of path) { + if (eventInternals.getStopPropagationFlag(event)) { + break; + } + + // Events that don't bubble call only the target in the bubble phase. + if (!event.bubbles && node !== target) { + break; + } + + eventInternals.setEventPhase( + event, + node === target ? SyntheticEvent.AT_TARGET : SyntheticEvent.BUBBLING_PHASE, + ); + invoke(node, event, false, errorState); + } + + eventInternals.setEventPhase(event, SyntheticEvent.NONE); + eventInternals.setCurrentTarget(event, null); + eventInternals.setComposedPath(event, []); + eventInternals.resetStopPropagationFlag(event); + + if (errorState.hasError) { + throw errorState.error; + } +} + +/** + * Returns the target followed by its host ancestors. The root container is not an element, so it is + * not on the path. Direct events have only the target on the path. + */ +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; +} + +function invoke( + node: TestInstance, + event: SyntheticEvent, + isCapture: boolean, + errorState: ErrorState, +) { + eventInternals.setCurrentTarget(event, node); + + const propName = getPropName(event, isCapture); + const handler = propName != null ? node.props[propName] : undefined; + if (typeof handler !== 'function') { + return; + } + + try { + handler.call(node, event); + } catch (error) { + if (!errorState.hasError) { + errorState.hasError = true; + errorState.error = error; + } + } +} + +function getPropName(event: SyntheticEvent, isCapture: boolean): string | null { + const config = event.dispatchConfig; + if (config == null) { + return null; + } + + if ('registrationName' in config) { + return isCapture ? null : config.registrationName; + } + + return isCapture + ? config.phasedRegistrationNames.captured + : config.phasedRegistrationNames.bubbled; +} diff --git a/src/events/modern/event-types.ts b/src/events/modern/event-types.ts new file mode 100644 index 000000000..3d68ea7d8 --- /dev/null +++ b/src/events/modern/event-types.ts @@ -0,0 +1,127 @@ +/** + * Event types known to React Native. + * + * React Native fills its view config registry at runtime from the view configs of native + * components, but `@react-native/jest-preset` mocks native components, so the registry stays empty + * in Jest. These are static copies of the union of `BaseViewConfig.{ios,android}.js` and the view + * configs of built-in components, keyed by event type instead of React Native's top-level type + * (`focus` instead of `topFocus`). `__tests__/event-types.test.ts` checks them against the installed + * `react-native`. + * + * Prop names follow from the event type: `onFocus` for the bubble phase and direct events, + * `onFocusCapture` for the capture phase. + */ + +export const bubblingEventTypes: ReadonlySet = new Set([ + '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 skipBubblingEventTypes: ReadonlySet = new Set([ + 'pointerEnter', + 'pointerLeave', +]); + +export const directEventTypes: ReadonlySet = new Set([ + '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', + 'scroll', + 'scrollBeginDrag', + 'scrollEndDrag', + 'scrollToTop', + 'selectionChange', + 'show', + // From the inline view config in `TextNativeComponent.js`, which is not exported. + 'textLayout', +]); + +/** Same shape as React Native's `DispatchConfig`, exposed as `event.dispatchConfig`. */ +export type DispatchConfig = + | { + phasedRegistrationNames: { bubbled: string; captured: string; skipBubbling?: boolean }; + } + | { registrationName: string }; + +export type EventTypeConfig = + | { kind: 'bubbling'; skipBubbling: boolean; dispatchConfig: DispatchConfig } + | { kind: 'direct'; dispatchConfig: DispatchConfig }; + +/** + * 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 { + const propName = `on${eventType.charAt(0).toUpperCase()}${eventType.slice(1)}`; + + if (bubblingEventTypes.has(eventType)) { + const skipBubbling = skipBubblingEventTypes.has(eventType); + return { + kind: 'bubbling', + skipBubbling, + dispatchConfig: { + phasedRegistrationNames: { + bubbled: propName, + captured: `${propName}Capture`, + ...(skipBubbling ? { skipBubbling } : {}), + }, + }, + }; + } + + if (directEventTypes.has(eventType)) { + return { kind: 'direct', dispatchConfig: { registrationName: propName } }; + } + + return null; +} diff --git a/src/events/modern/event.ts b/src/events/modern/event.ts new file mode 100644 index 000000000..0dc9180cf --- /dev/null +++ b/src/events/modern/event.ts @@ -0,0 +1,242 @@ +import type { TestInstance } from 'test-renderer'; + +import type { DispatchConfig } from './event-types'; + +export type NativeEventPayload = Record; + +export type SyntheticEventInit = { + bubbles?: boolean; + cancelable?: boolean; + composed?: boolean; + /** Direct events are dispatched only to the target, without the capture phase. */ + rnIsDirect?: boolean; + /** Defaults to `performance.now()`. */ + 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; + +/** State changed by the dispatch, hidden from the event's own properties. */ +type DispatchState = { + target: TestInstance | null; + currentTarget: TestInstance | null; + eventPhase: EventPhase; + composedPath: TestInstance[]; + isTrusted: boolean; + stopPropagation: boolean; +}; + +const dispatchStates = new WeakMap(); + +function getDispatchState(event: SyntheticEvent): DispatchState { + const state = dispatchStates.get(event); + if (state == null) { + throw new TypeError('Illegal invocation'); + } + + return state; +} + +/** + * 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` and `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 _composed: boolean; + private readonly _rnIsDirect: boolean; + private readonly _timeStamp: number; + private readonly _nativeEvent: NativeEventPayload; + private readonly _dispatchConfig: DispatchConfig | null; + private _defaultPrevented = false; + + constructor( + type: string, + init: SyntheticEventInit, + nativeEvent: NativeEventPayload, + dispatchConfig?: DispatchConfig | null, + ) { + if (init.rnIsDirect && init.bubbles) { + throw new TypeError( + "Failed to construct 'Event': 'rnIsDirect' cannot be true when 'bubbles' is also true.", + ); + } + + this._type = type; + this._bubbles = Boolean(init.bubbles); + this._cancelable = Boolean(init.cancelable); + this._composed = Boolean(init.composed); + this._rnIsDirect = Boolean(init.rnIsDirect); + this._timeStamp = init.timeStamp ?? performance.now(); + this._nativeEvent = nativeEvent; + this._dispatchConfig = dispatchConfig ?? null; + + dispatchStates.set(this, { + target: null, + currentTarget: null, + eventPhase: NONE, + composedPath: [], + isTrusted: false, + stopPropagation: false, + }); + } + + // 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; + } + + get composed(): boolean { + return this._composed; + } + + get rnIsDirect(): boolean { + return this._rnIsDirect; + } + + get timeStamp(): number { + return this._timeStamp; + } + + get nativeEvent(): NativeEventPayload { + return this._nativeEvent; + } + + get dispatchConfig(): DispatchConfig | null { + return this._dispatchConfig; + } + + get defaultPrevented(): boolean { + return this._defaultPrevented; + } + + get target(): TestInstance | null { + return getDispatchState(this).target; + } + + get currentTarget(): TestInstance | null { + return getDispatchState(this).currentTarget; + } + + get eventPhase(): EventPhase { + return getDispatchState(this).eventPhase; + } + + get isTrusted(): boolean { + return getDispatchState(this).isTrusted; + } + + get cancelBubble(): boolean { + return getDispatchState(this).stopPropagation; + } + + set cancelBubble(value: boolean) { + if (value) { + getDispatchState(this).stopPropagation = true; + } + } + + composedPath(): TestInstance[] { + return getDispatchState(this).composedPath.slice(); + } + + preventDefault(): void { + if (this._cancelable) { + this._defaultPrevented = true; + } + } + + stopPropagation(): void { + getDispatchState(this).stopPropagation = true; + } + + /** Same as `stopPropagation()`, as each element has a single prop handler per phase. */ + stopImmediatePropagation(): void { + getDispatchState(this).stopPropagation = true; + } + + /** No-op: React Native no longer pools events. */ + persist(): void {} + + isDefaultPrevented(): boolean { + return this.defaultPrevented; + } + + isPropagationStopped(): boolean { + return this.cancelBubble; + } +} + +/** @internal Lets the dispatch update the event, as React Native's `EventInternals` does. */ +export const eventInternals = { + setTarget(event: SyntheticEvent, target: TestInstance | null) { + getDispatchState(event).target = target; + }, + + setCurrentTarget(event: SyntheticEvent, currentTarget: TestInstance | null) { + getDispatchState(event).currentTarget = currentTarget; + }, + + setEventPhase(event: SyntheticEvent, eventPhase: EventPhase) { + getDispatchState(event).eventPhase = eventPhase; + }, + + setComposedPath(event: SyntheticEvent, composedPath: TestInstance[]) { + getDispatchState(event).composedPath = composedPath; + }, + + setIsTrusted(event: SyntheticEvent, isTrusted: boolean) { + getDispatchState(event).isTrusted = isTrusted; + }, + + getStopPropagationFlag(event: SyntheticEvent): boolean { + return getDispatchState(event).stopPropagation; + }, + + resetStopPropagationFlag(event: SyntheticEvent) { + getDispatchState(event).stopPropagation = false; + }, +}; From a85c74b54f01c04a0ade50dd3413764dbaa58e91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 8 Oct 2026 13:17:36 +0200 Subject: [PATCH 04/28] extract shared parts --- contributing/event-dispatch.md | 5 +-- .../legacy/__tests__/fire-event.test.tsx | 2 +- src/events/legacy/builders/common.ts | 2 +- src/events/legacy/builders/merge.ts | 2 +- src/events/legacy/dispatch.ts | 2 +- src/events/legacy/fire-event.ts | 4 +-- src/events/legacy/index.ts | 2 +- src/events/legacy/propagation.ts | 4 +-- src/events/legacy/warnings.ts | 2 +- src/events/modern/dispatch.ts | 5 +-- src/events/modern/event-types.ts | 7 ++-- .../__tests__/handler.test.tsx | 12 ++++++- src/events/{legacy => shared}/handler.ts | 32 +++++++++---------- src/events/{legacy => shared}/types.ts | 0 14 files changed, 47 insertions(+), 34 deletions(-) rename src/events/{legacy => shared}/__tests__/handler.test.tsx (88%) rename src/events/{legacy => shared}/handler.ts (65%) rename src/events/{legacy => shared}/types.ts (100%) diff --git a/contributing/event-dispatch.md b/contributing/event-dispatch.md index 50069947d..c4a3e3c14 100644 --- a/contributing/event-dispatch.md +++ b/contributing/event-dispatch.md @@ -9,7 +9,6 @@ The files in `src/events/legacy/`: | 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` | @@ -17,7 +16,9 @@ The files in `src/events/legacy/`: | `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 | -`src/user-event/` is a separate module on top of `src/events/legacy/` and imports it only through `src/events/legacy/index.ts`. +Code used by both event systems lives in `src/events/shared/`: `handler.ts` (finding the `on*` handler for an event name in props) and `types.ts`. + +`src/user-event/` is a separate module on top of `src/events/legacy/` and imports it only through `src/events/legacy/index.ts`, which also re-exports `src/events/shared/handler.ts`. ## `fireEvent` diff --git a/src/events/legacy/__tests__/fire-event.test.tsx b/src/events/legacy/__tests__/fire-event.test.tsx index 7f479bfaa..9775f036d 100644 --- a/src/events/legacy/__tests__/fire-event.test.tsx +++ b/src/events/legacy/__tests__/fire-event.test.tsx @@ -20,7 +20,7 @@ import { import { fireEvent, render, screen } from '../../..'; import { configure } from '../../../config'; import { _console, logger } from '../../../helpers/logger'; -import { getEventHandlerName } from '../handler'; +import { getEventHandlerName } from '../../shared/handler'; import { nativeState } from '../native-state'; const layoutEvent = { nativeEvent: { layout: { width: 100, height: 100 } } }; diff --git a/src/events/legacy/builders/common.ts b/src/events/legacy/builders/common.ts index 64f961129..66abcf80d 100644 --- a/src/events/legacy/builders/common.ts +++ b/src/events/legacy/builders/common.ts @@ -1,4 +1,4 @@ -import type { LayoutRectangle } from '../types'; +import type { LayoutRectangle } from '../../shared/types'; import { baseSyntheticEvent } from './base'; /** diff --git a/src/events/legacy/builders/merge.ts b/src/events/legacy/builders/merge.ts index aa068145e..3c8f2c378 100644 --- a/src/events/legacy/builders/merge.ts +++ b/src/events/legacy/builders/merge.ts @@ -1,4 +1,4 @@ -import type { EventProps } from '../types'; +import type { EventProps } from '../../shared/types'; /** * Deep merges custom props into a built event, so tests can override only the fields they need. diff --git a/src/events/legacy/dispatch.ts b/src/events/legacy/dispatch.ts index e3650b2cd..797abb5ce 100644 --- a/src/events/legacy/dispatch.ts +++ b/src/events/legacy/dispatch.ts @@ -2,7 +2,7 @@ import type { TestInstance } from 'test-renderer'; import { act } from '../../act'; import { isInstanceMounted } from '../../helpers/component-tree'; -import { getEventHandlerFromProps } from './handler'; +import { getEventHandlerFromProps } from '../shared/handler'; /** * Basic dispatch event function used by User Event module. diff --git a/src/events/legacy/fire-event.ts b/src/events/legacy/fire-event.ts index 70dfdde25..433ed4037 100644 --- a/src/events/legacy/fire-event.ts +++ b/src/events/legacy/fire-event.ts @@ -3,13 +3,13 @@ import type { TestInstance } from 'test-renderer'; import { act } from '../../act'; import { isInstanceMounted } from '../../helpers/component-tree'; import { isHostScrollView } from '../../helpers/host-component-names'; +import { normalizeEventType } from '../shared/handler'; +import type { EventProps, EventType, LayoutRectangle } from '../shared/types'; 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'; diff --git a/src/events/legacy/index.ts b/src/events/legacy/index.ts index 117425c85..7c720b7a1 100644 --- a/src/events/legacy/index.ts +++ b/src/events/legacy/index.ts @@ -2,7 +2,7 @@ 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 type { EventWarning } from './warnings'; diff --git a/src/events/legacy/propagation.ts b/src/events/legacy/propagation.ts index eece3f7e3..d947c5e4b 100644 --- a/src/events/legacy/propagation.ts +++ b/src/events/legacy/propagation.ts @@ -7,9 +7,9 @@ import { isHostText, isHostTextInput, } from '../../helpers/host-component-names'; -import { getEventHandlerFromProps, normalizeEventType } from './handler'; +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/legacy/warnings.ts b/src/events/legacy/warnings.ts index 77c3af180..5a81beade 100644 --- a/src/events/legacy/warnings.ts +++ b/src/events/legacy/warnings.ts @@ -7,7 +7,7 @@ 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 { getEventHandlerName, normalizeEventType } from '../shared/handler'; import { getPointerEventsBlocker, isEventBlockableByPointerEvents } from './is-enabled'; type UnhandledEventInfo = { diff --git a/src/events/modern/dispatch.ts b/src/events/modern/dispatch.ts index 2489b2b44..4150ec87e 100644 --- a/src/events/modern/dispatch.ts +++ b/src/events/modern/dispatch.ts @@ -2,6 +2,7 @@ import type { TestInstance } from 'test-renderer'; import { act } from '../../act'; import { isInstanceMounted } from '../../helpers/component-tree'; +import { getHandlerProp } from '../shared/handler'; import type { NativeEventPayload } from './event'; import { eventInternals, SyntheticEvent } from './event'; import { getEventTypeConfig } from './event-types'; @@ -152,8 +153,8 @@ function invoke( eventInternals.setCurrentTarget(event, node); const propName = getPropName(event, isCapture); - const handler = propName != null ? node.props[propName] : undefined; - if (typeof handler !== 'function') { + const handler = propName != null ? getHandlerProp(node.props, propName) : undefined; + if (handler == null) { return; } diff --git a/src/events/modern/event-types.ts b/src/events/modern/event-types.ts index 3d68ea7d8..a2391e9a1 100644 --- a/src/events/modern/event-types.ts +++ b/src/events/modern/event-types.ts @@ -1,3 +1,5 @@ +import { getEventHandlerName } from '../shared/handler'; + /** * Event types known to React Native. * @@ -102,9 +104,8 @@ export type EventTypeConfig = * doesn't know it. */ export function getEventTypeConfig(eventType: string): EventTypeConfig | null { - const propName = `on${eventType.charAt(0).toUpperCase()}${eventType.slice(1)}`; - if (bubblingEventTypes.has(eventType)) { + const propName = getEventHandlerName(eventType); const skipBubbling = skipBubblingEventTypes.has(eventType); return { kind: 'bubbling', @@ -120,7 +121,7 @@ export function getEventTypeConfig(eventType: string): EventTypeConfig | null { } if (directEventTypes.has(eventType)) { - return { kind: 'direct', dispatchConfig: { registrationName: propName } }; + return { kind: 'direct', dispatchConfig: { registrationName: getEventHandlerName(eventType) } }; } return null; diff --git a/src/events/legacy/__tests__/handler.test.tsx b/src/events/shared/__tests__/handler.test.tsx similarity index 88% rename from src/events/legacy/__tests__/handler.test.tsx rename to src/events/shared/__tests__/handler.test.tsx index a3f164fc9..c8621583e 100644 --- a/src/events/legacy/__tests__/handler.test.tsx +++ b/src/events/shared/__tests__/handler.test.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { Text, View } from 'react-native'; import { render, screen } from '../../..'; -import { getEventHandlerFromProps, normalizeEventType } from '../handler'; +import { getEventHandlerFromProps, getHandlerProp, normalizeEventType } from '../handler'; test('getEventHandlerFromProps strict mode', async () => { const onPress = jest.fn(); @@ -99,3 +99,13 @@ test('normalizeEventType strips the `on*` prefix', () => { expect(normalizeEventType('once')).toBe('once'); expect(normalizeEventType('on')).toBe('on'); }); + +test('getHandlerProp returns only function props with the exact name', () => { + const onFocus = jest.fn(); + const props = { onFocus, testOnly_onBlur: jest.fn(), onLayout: 'not a function' }; + + expect(getHandlerProp(props, 'onFocus')).toBe(onFocus); + expect(getHandlerProp(props, 'focus')).toBeUndefined(); + expect(getHandlerProp(props, 'onBlur')).toBeUndefined(); + expect(getHandlerProp(props, 'onLayout')).toBeUndefined(); +}); diff --git a/src/events/legacy/handler.ts b/src/events/shared/handler.ts similarity index 65% rename from src/events/legacy/handler.ts rename to src/events/shared/handler.ts index 8e0088942..41f60375c 100644 --- a/src/events/legacy/handler.ts +++ b/src/events/shared/handler.ts @@ -11,23 +11,23 @@ export function getEventHandlerFromProps( options?: EventHandlerOptions, ): EventHandler | undefined { const handlerName = getEventHandlerName(eventType); - if (typeof props[handlerName] === 'function') { - return props[handlerName] as EventHandler; - } - - if (options?.loose && typeof props[eventType] === 'function') { - return props[eventType] as EventHandler; - } - - if (typeof props[`testOnly_${handlerName}`] === 'function') { - return props[`testOnly_${handlerName}`] as EventHandler; - } - - if (options?.loose && typeof props[`testOnly_${eventType}`] === 'function') { - return props[`testOnly_${eventType}`] as EventHandler; - } + return ( + getHandlerProp(props, handlerName) ?? + (options?.loose ? getHandlerProp(props, eventType) : undefined) ?? + getHandlerProp(props, `testOnly_${handlerName}`) ?? + (options?.loose ? getHandlerProp(props, `testOnly_${eventType}`) : undefined) + ); +} - return undefined; +/** + * Returns the prop with exactly the given name (e.g. `onFocusCapture`) if it is a function. + */ +export 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/legacy/types.ts b/src/events/shared/types.ts similarity index 100% rename from src/events/legacy/types.ts rename to src/events/shared/types.ts From 803491135922d967b6b3dde4b72e9fd633b7c46e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 8 Oct 2026 13:23:12 +0200 Subject: [PATCH 05/28] simplify --- src/events/modern/__tests__/dispatch.test.tsx | 2 - .../modern/__tests__/event-types.test.ts | 4 +- src/events/modern/__tests__/event.test.ts | 29 +++++------ src/events/modern/dispatch.ts | 43 ++++++----------- src/events/modern/event-types.ts | 20 ++++---- src/events/modern/event.ts | 48 +++++-------------- 6 files changed, 48 insertions(+), 98 deletions(-) diff --git a/src/events/modern/__tests__/dispatch.test.tsx b/src/events/modern/__tests__/dispatch.test.tsx index 7b1a3b258..30e30a606 100644 --- a/src/events/modern/__tests__/dispatch.test.tsx +++ b/src/events/modern/__tests__/dispatch.test.tsx @@ -15,7 +15,6 @@ type Call = { eventPhase: number; }; -/** Returns handler props for the given prop names that log each call to `calls`. */ function logHandlers(calls: Call[], id: string, propNames: string[]): HandlerProps { return Object.fromEntries( propNames.map((propName) => [ @@ -41,7 +40,6 @@ function getProps(calls: Call[]) { return calls.map((call) => call.prop); } -// Handler props are spread, because `View` types don't include all of them. function renderNestedViews(propNames: string[], calls: Call[]) { return render( diff --git a/src/events/modern/__tests__/event-types.test.ts b/src/events/modern/__tests__/event-types.test.ts index 538c23ee7..86f6d35fb 100644 --- a/src/events/modern/__tests__/event-types.test.ts +++ b/src/events/modern/__tests__/event-types.test.ts @@ -13,7 +13,7 @@ type ViewConfig = { directEventTypes?: Record; }; -// Base view configs and built-in components with events in their view configs. +// Base view configs plus the built-in components that declare events. const viewConfigModules = [ 'react-native/Libraries/NativeComponent/BaseViewConfig.ios', 'react-native/Libraries/NativeComponent/BaseViewConfig.android', @@ -60,7 +60,7 @@ function loadReactNativeEventTypes() { } for (const [topLevelType, config] of Object.entries(viewConfig.directEventTypes ?? {})) { - // Gesture handler events are registered without the `top` prefix and not dispatched by React Native. + // Gesture handler events: registered without `top`, not dispatched by React Native. if (!topLevelType.startsWith('top')) { continue; } diff --git a/src/events/modern/__tests__/event.test.ts b/src/events/modern/__tests__/event.test.ts index 3546305f8..9ad20ff55 100644 --- a/src/events/modern/__tests__/event.test.ts +++ b/src/events/modern/__tests__/event.test.ts @@ -1,8 +1,9 @@ import { SyntheticEvent } from '../event'; +const dispatchConfig = { registrationName: 'onFocus' }; + test('exposes init values', () => { const nativeEvent = { target: 1 }; - const dispatchConfig = { registrationName: 'onLayout' }; const event = new SyntheticEvent( 'layout', { cancelable: true, rnIsDirect: true, timeStamp: 123 }, @@ -13,40 +14,32 @@ test('exposes init values', () => { expect(event.type).toBe('layout'); expect(event.bubbles).toBe(false); expect(event.cancelable).toBe(true); - expect(event.composed).toBe(false); expect(event.rnIsDirect).toBe(true); expect(event.timeStamp).toBe(123); expect(event.nativeEvent).toBe(nativeEvent); expect(event.dispatchConfig).toBe(dispatchConfig); + expect(event.isTrusted).toBe(true); }); test('has no target and phase outside of dispatch', () => { - const event = new SyntheticEvent('focus', { bubbles: true }, {}); + const event = new SyntheticEvent('focus', { bubbles: true }, {}, dispatchConfig); expect(event.target).toBeNull(); expect(event.currentTarget).toBeNull(); expect(event.eventPhase).toBe(SyntheticEvent.NONE); expect(event.composedPath()).toEqual([]); - expect(event.isTrusted).toBe(false); - expect(event.dispatchConfig).toBeNull(); }); test('defaults timeStamp to performance.now()', () => { jest.spyOn(performance, 'now').mockReturnValue(456); - const event = new SyntheticEvent('focus', {}, {}); + const event = new SyntheticEvent('focus', {}, {}, dispatchConfig); expect(event.timeStamp).toBe(456); }); -test('throws when direct event bubbles', () => { - expect(() => new SyntheticEvent('layout', { bubbles: true, rnIsDirect: true }, {})).toThrow( - "'rnIsDirect' cannot be true when 'bubbles' is also true", - ); -}); - test('preventDefault() marks cancelable event as default prevented', () => { - const event = new SyntheticEvent('focus', { cancelable: true }, {}); + const event = new SyntheticEvent('focus', { cancelable: true }, {}, dispatchConfig); event.preventDefault(); @@ -55,7 +48,7 @@ test('preventDefault() marks cancelable event as default prevented', () => { }); test('preventDefault() does nothing on non-cancelable event', () => { - const event = new SyntheticEvent('focus', {}, {}); + const event = new SyntheticEvent('focus', {}, {}, dispatchConfig); event.preventDefault(); @@ -66,7 +59,7 @@ test('preventDefault() does nothing on non-cancelable event', () => { test.each(['stopPropagation', 'stopImmediatePropagation'] as const)( '%s() stops propagation', (method) => { - const event = new SyntheticEvent('focus', {}, {}); + const event = new SyntheticEvent('focus', {}, {}, dispatchConfig); expect(event.isPropagationStopped()).toBe(false); event[method](); @@ -77,7 +70,7 @@ test.each(['stopPropagation', 'stopImmediatePropagation'] as const)( ); test('cancelBubble can only be set to true', () => { - const event = new SyntheticEvent('focus', {}, {}); + const event = new SyntheticEvent('focus', {}, {}, dispatchConfig); event.cancelBubble = true; event.cancelBubble = false; @@ -86,13 +79,13 @@ test('cancelBubble can only be set to true', () => { }); test('persist() is a no-op', () => { - const event = new SyntheticEvent('focus', {}, {}); + const event = new SyntheticEvent('focus', {}, {}, dispatchConfig); expect(() => event.persist()).not.toThrow(); }); test('defines event phase constants on class and prototype', () => { - const event = new SyntheticEvent('focus', {}, {}); + const event = new SyntheticEvent('focus', {}, {}, dispatchConfig); expect([ SyntheticEvent.NONE, diff --git a/src/events/modern/dispatch.ts b/src/events/modern/dispatch.ts index 4150ec87e..28699aa31 100644 --- a/src/events/modern/dispatch.ts +++ b/src/events/modern/dispatch.ts @@ -8,29 +8,25 @@ import { eventInternals, SyntheticEvent } from './event'; import { getEventTypeConfig } from './event-types'; /** - * Dispatches an event the way React Native dispatches events from native + * Dispatches an event 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 run the capture phase from the root to the target (`on*Capture` props), then the - * bubble phase from the target to the root (`on*` props). Events with `skipBubbling` run the capture - * phase as usual, but the bubble phase calls only the target. - * - Direct events call only the target's `on*` prop. + * - 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. * - Events unknown to React Native are dropped. * - * Only host elements are on the event path, so props of composite components are never called. - * Every handler on the path runs until one stops propagation. The first error thrown by a handler is - * rethrown after the whole dispatch. The dispatch runs in a single `act()`, as React Native batches - * updates for the whole event. + * Only host elements are on the path, so composite props are never called. 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 before the dispatch for touch, - * scroll and selection change events. + * Not implemented yet: responder negotiation, which React Native runs first for touch, scroll and + * selection change events. * - * @param target host element that receives the event - * @param eventType event type, e.g. `focus` or `pointerUp` - * @param payload native event payload, passed to handlers as `event.nativeEvent` - * @returns `false` if a handler called `preventDefault()`, `true` otherwise, as `dispatchEvent()` - * of the DOM does. + * @param eventType e.g. `focus` or `pointerUp` + * @param payload passed to handlers as `event.nativeEvent` + * @returns `false` if a handler called `preventDefault()`, like DOM `dispatchEvent()`. */ export async function dispatchEvent( target: TestInstance, @@ -47,7 +43,6 @@ export async function dispatchEvent( } await act(() => { - eventInternals.setIsTrusted(event, true); dispatch(target, event); }); @@ -86,7 +81,7 @@ function dispatch(target: TestInstance, event: SyntheticEvent) { const errorState: ErrorState = { hasError: false, error: undefined }; for (let i = path.length - 1; i >= 0; i -= 1) { - if (eventInternals.getStopPropagationFlag(event)) { + if (event.isPropagationStopped()) { break; } @@ -99,11 +94,10 @@ function dispatch(target: TestInstance, event: SyntheticEvent) { } for (const node of path) { - if (eventInternals.getStopPropagationFlag(event)) { + if (event.isPropagationStopped()) { break; } - // Events that don't bubble call only the target in the bubble phase. if (!event.bubbles && node !== target) { break; } @@ -125,10 +119,7 @@ function dispatch(target: TestInstance, event: SyntheticEvent) { } } -/** - * Returns the target followed by its host ancestors. The root container is not an element, so it is - * not on the path. Direct events have only the target on the path. - */ +/** 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]; @@ -170,10 +161,6 @@ function invoke( function getPropName(event: SyntheticEvent, isCapture: boolean): string | null { const config = event.dispatchConfig; - if (config == null) { - return null; - } - if ('registrationName' in config) { return isCapture ? null : config.registrationName; } diff --git a/src/events/modern/event-types.ts b/src/events/modern/event-types.ts index a2391e9a1..3386fcc60 100644 --- a/src/events/modern/event-types.ts +++ b/src/events/modern/event-types.ts @@ -1,17 +1,15 @@ import { getEventHandlerName } from '../shared/handler'; /** - * Event types known to React Native. + * Event types known to React Native, keyed by event type (`focus`, not `topFocus`). * - * React Native fills its view config registry at runtime from the view configs of native - * components, but `@react-native/jest-preset` mocks native components, so the registry stays empty - * in Jest. These are static copies of the union of `BaseViewConfig.{ios,android}.js` and the view - * configs of built-in components, keyed by event type instead of React Native's top-level type - * (`focus` instead of `topFocus`). `__tests__/event-types.test.ts` checks them against the installed - * `react-native`. + * `@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`. * - * Prop names follow from the event type: `onFocus` for the bubble phase and direct events, - * `onFocusCapture` for the capture phase. + * Prop names follow from the event type: `onFocus` (bubble phase, direct events) and + * `onFocusCapture`. */ export const bubblingEventTypes: ReadonlySet = new Set([ @@ -88,14 +86,14 @@ export const directEventTypes: ReadonlySet = new Set([ 'textLayout', ]); -/** Same shape as React Native's `DispatchConfig`, exposed as `event.dispatchConfig`. */ +// React Native's `DispatchConfig` shape: `TouchableOpacity` reads its `registrationName`. export type DispatchConfig = | { phasedRegistrationNames: { bubbled: string; captured: string; skipBubbling?: boolean }; } | { registrationName: string }; -export type EventTypeConfig = +type EventTypeConfig = | { kind: 'bubbling'; skipBubbling: boolean; dispatchConfig: DispatchConfig } | { kind: 'direct'; dispatchConfig: DispatchConfig }; diff --git a/src/events/modern/event.ts b/src/events/modern/event.ts index 0dc9180cf..9ff3b0ae9 100644 --- a/src/events/modern/event.ts +++ b/src/events/modern/event.ts @@ -4,13 +4,11 @@ import type { DispatchConfig } from './event-types'; export type NativeEventPayload = Record; -export type SyntheticEventInit = { +type SyntheticEventInit = { bubbles?: boolean; cancelable?: boolean; - composed?: boolean; /** Direct events are dispatched only to the target, without the capture phase. */ rnIsDirect?: boolean; - /** Defaults to `performance.now()`. */ timeStamp?: number; }; @@ -19,19 +17,14 @@ 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; +type EventPhase = typeof NONE | typeof CAPTURING_PHASE | typeof AT_TARGET | typeof BUBBLING_PHASE; -/** State changed by the dispatch, hidden from the event's own properties. */ +/** Kept off the event's own properties, so printed events don't include the element tree. */ type DispatchState = { target: TestInstance | null; currentTarget: TestInstance | null; eventPhase: EventPhase; composedPath: TestInstance[]; - isTrusted: boolean; stopPropagation: boolean; }; @@ -50,7 +43,8 @@ function getDispatchState(event: SyntheticEvent): DispatchState { * 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` and `src/private/webapis/dom/events/Event.js`). + * (`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 { @@ -62,40 +56,31 @@ export class SyntheticEvent { private readonly _type: string; private readonly _bubbles: boolean; private readonly _cancelable: boolean; - private readonly _composed: boolean; private readonly _rnIsDirect: boolean; private readonly _timeStamp: number; private readonly _nativeEvent: NativeEventPayload; - private readonly _dispatchConfig: DispatchConfig | null; + private readonly _dispatchConfig: DispatchConfig; private _defaultPrevented = false; constructor( type: string, init: SyntheticEventInit, nativeEvent: NativeEventPayload, - dispatchConfig?: DispatchConfig | null, + dispatchConfig: DispatchConfig, ) { - if (init.rnIsDirect && init.bubbles) { - throw new TypeError( - "Failed to construct 'Event': 'rnIsDirect' cannot be true when 'bubbles' is also true.", - ); - } - this._type = type; this._bubbles = Boolean(init.bubbles); this._cancelable = Boolean(init.cancelable); - this._composed = Boolean(init.composed); this._rnIsDirect = Boolean(init.rnIsDirect); this._timeStamp = init.timeStamp ?? performance.now(); this._nativeEvent = nativeEvent; - this._dispatchConfig = dispatchConfig ?? null; + this._dispatchConfig = dispatchConfig; dispatchStates.set(this, { target: null, currentTarget: null, eventPhase: NONE, composedPath: [], - isTrusted: false, stopPropagation: false, }); } @@ -129,10 +114,6 @@ export class SyntheticEvent { return this._cancelable; } - get composed(): boolean { - return this._composed; - } - get rnIsDirect(): boolean { return this._rnIsDirect; } @@ -145,7 +126,7 @@ export class SyntheticEvent { return this._nativeEvent; } - get dispatchConfig(): DispatchConfig | null { + get dispatchConfig(): DispatchConfig { return this._dispatchConfig; } @@ -165,8 +146,9 @@ export class SyntheticEvent { return getDispatchState(this).eventPhase; } + /** Only the native event dispatch creates events, and React Native marks those as trusted. */ get isTrusted(): boolean { - return getDispatchState(this).isTrusted; + return true; } get cancelBubble(): boolean { @@ -228,14 +210,6 @@ export const eventInternals = { getDispatchState(event).composedPath = composedPath; }, - setIsTrusted(event: SyntheticEvent, isTrusted: boolean) { - getDispatchState(event).isTrusted = isTrusted; - }, - - getStopPropagationFlag(event: SyntheticEvent): boolean { - return getDispatchState(event).stopPropagation; - }, - resetStopPropagationFlag(event: SyntheticEvent) { getDispatchState(event).stopPropagation = false; }, From 5953fbee3c7fa26b1f28d435be2a23fcf8b8d865 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 8 Oct 2026 13:42:58 +0200 Subject: [PATCH 06/28] fireEvent v0 --- src/events/modern/__tests__/dispatch.test.tsx | 64 +++-- src/events/modern/__tests__/event.test.ts | 45 +++- .../modern/__tests__/fire-event.test.tsx | 253 ++++++++++++++++++ src/events/modern/dispatch.ts | 40 +-- src/events/modern/event.ts | 36 +++ src/events/modern/fire-event-utils.ts | 91 +++++++ src/events/modern/fire-event.ts | 46 ++++ 7 files changed, 507 insertions(+), 68 deletions(-) create mode 100644 src/events/modern/__tests__/fire-event.test.tsx create mode 100644 src/events/modern/fire-event-utils.ts create mode 100644 src/events/modern/fire-event.ts diff --git a/src/events/modern/__tests__/dispatch.test.tsx b/src/events/modern/__tests__/dispatch.test.tsx index 30e30a606..c9d826b3a 100644 --- a/src/events/modern/__tests__/dispatch.test.tsx +++ b/src/events/modern/__tests__/dispatch.test.tsx @@ -4,7 +4,17 @@ import type { TestInstance } from 'test-renderer'; import { render, screen } from '../../..'; import { dispatchEvent } from '../dispatch'; -import type { SyntheticEvent } from '../event'; +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>; @@ -55,7 +65,7 @@ describe('bubbling events', () => { const calls: Call[] = []; await renderNestedViews(['onFocus', 'onFocusCapture'], calls); - await dispatchEvent(screen.getByTestId('target'), 'focus'); + await dispatchEvent(screen.getByTestId('target'), createKnownEvent('focus')); expect(calls).toEqual([ { prop: 'root.onFocusCapture', currentTarget: 'root', target: 'target', eventPhase: 1 }, @@ -77,7 +87,10 @@ describe('bubbling events', () => { , ); - await dispatchEvent(screen.getByTestId('input'), 'submitEditing', { text: 'Hello' }); + await dispatchEvent( + screen.getByTestId('input'), + createKnownEvent('submitEditing', { text: 'Hello' }), + ); expect(onSubmitEditing).toHaveBeenCalledTimes(1); expect(onSubmitEditing.mock.calls[0][0].nativeEvent).toEqual({ text: 'Hello' }); @@ -101,7 +114,7 @@ describe('bubbling events', () => { , ); - await dispatchEvent(screen.getByTestId('target'), 'focus'); + await dispatchEvent(screen.getByTestId('target'), createKnownEvent('focus')); expect(getProps(calls)).toEqual(['parent.onFocusCapture']); }); @@ -126,7 +139,7 @@ describe('bubbling events', () => { , ); - await dispatchEvent(screen.getByTestId('target'), 'focus'); + await dispatchEvent(screen.getByTestId('target'), createKnownEvent('focus')); expect(getProps(calls)).toEqual(['target.onFocus', 'parent.onFocus']); }, @@ -136,7 +149,7 @@ describe('bubbling events', () => { const calls: Call[] = []; await renderNestedViews(['onPointerEnter', 'onPointerEnterCapture'], calls); - await dispatchEvent(screen.getByTestId('target'), 'pointerEnter'); + await dispatchEvent(screen.getByTestId('target'), createKnownEvent('pointerEnter')); expect(getProps(calls)).toEqual([ 'root.onPointerEnterCapture', @@ -152,7 +165,7 @@ describe('direct events', () => { const calls: Call[] = []; await renderNestedViews(['onLayout', 'onLayoutCapture'], calls); - await dispatchEvent(screen.getByTestId('target'), 'layout'); + await dispatchEvent(screen.getByTestId('target'), createKnownEvent('layout')); expect(calls).toEqual([ { prop: 'target.onLayout', currentTarget: 'target', target: 'target', eventPhase: 2 }, @@ -167,7 +180,7 @@ describe('direct events', () => { , ); - await dispatchEvent(screen.getByTestId('target'), 'scroll'); + await dispatchEvent(screen.getByTestId('target'), createKnownEvent('scroll')); expect(onScroll).not.toHaveBeenCalled(); }); @@ -178,28 +191,18 @@ test('does not call props of composite components', async () => { const Box = (_props: { onFocus: () => void }) => ; await render(); - await dispatchEvent(screen.getByTestId('target'), 'focus'); + await dispatchEvent(screen.getByTestId('target'), createKnownEvent('focus')); expect(onFocus).not.toHaveBeenCalled(); }); -test('drops events unknown to React Native', async () => { - const onChangeText = jest.fn(); - await render(); - - const result = await dispatchEvent(screen.getByTestId('input'), 'changeText', { text: 'Hello' }); - - expect(result).toBe(true); - expect(onChangeText).not.toHaveBeenCalled(); -}); - 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, 'focus'); + const result = await dispatchEvent(target, createKnownEvent('focus')); expect(result).toBe(true); expect(onFocus).not.toHaveBeenCalled(); @@ -211,7 +214,7 @@ test('passes React Native event object to handlers', async () => { const target = screen.getByTestId('target'); const payload = { pageX: 10, pageY: 20, timestamp: 1234 }; - await dispatchEvent(target, 'pointerUp', payload); + await dispatchEvent(target, createKnownEvent('pointerUp', payload)); const event: SyntheticEvent = onPointerUp.mock.calls[0][0]; expect(event.type).toBe('pointerup'); @@ -230,7 +233,10 @@ test('passes direct event object to handlers', async () => { const onLayout = jest.fn(); await render(); - await dispatchEvent(screen.getByTestId('target'), 'layout', { layout: { width: 100 } }); + await dispatchEvent( + screen.getByTestId('target'), + createKnownEvent('layout', { layout: { width: 100 } }), + ); const event: SyntheticEvent = onLayout.mock.calls[0][0]; expect(event.type).toBe('layout'); @@ -250,7 +256,7 @@ test('exposes composed path during dispatch and resets the event after it', asyn , ); - await dispatchEvent(screen.getByTestId('target'), 'focus'); + 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]; @@ -265,7 +271,7 @@ test('calls handlers with the current element as `this`', async () => { const onFocus = jest.fn(); await render(); - await dispatchEvent(screen.getByTestId('target'), 'focus'); + await dispatchEvent(screen.getByTestId('target'), createKnownEvent('focus')); expect(onFocus.mock.contexts[0]).toBe(screen.getByTestId('target')); }); @@ -278,7 +284,7 @@ test('returns false when a handler prevents default', async () => { />, ); - const result = await dispatchEvent(screen.getByTestId('target'), 'focus'); + const result = await dispatchEvent(screen.getByTestId('target'), createKnownEvent('focus')); expect(result).toBe(false); }); @@ -306,9 +312,9 @@ test('rethrows the first handler error after calling all handlers', async () => , ); - await expect(dispatchEvent(screen.getByTestId('target'), 'focus')).rejects.toThrow( - 'Target error', - ); + await expect( + dispatchEvent(screen.getByTestId('target'), createKnownEvent('focus')), + ).rejects.toThrow('Target error'); expect(onRootFocus).toHaveBeenCalledTimes(1); }); @@ -333,7 +339,7 @@ test('renders state updates from all handlers once', async () => { await render(); const initialRenderCount = renderCount; - await dispatchEvent(screen.getByTestId('target'), 'focus'); + 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.test.ts b/src/events/modern/__tests__/event.test.ts index 9ad20ff55..01ec970ea 100644 --- a/src/events/modern/__tests__/event.test.ts +++ b/src/events/modern/__tests__/event.test.ts @@ -1,4 +1,4 @@ -import { SyntheticEvent } from '../event'; +import { createEvent, SyntheticEvent } from '../event'; const dispatchConfig = { registrationName: 'onFocus' }; @@ -98,3 +98,46 @@ test('defines event phase constants on class and prototype', () => { ]); 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); + }); + + 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('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..82460c092 --- /dev/null +++ b/src/events/modern/__tests__/fire-event.test.tsx @@ -0,0 +1,253 @@ +import * as React from 'react'; +import { Text, TextInput, View } from 'react-native'; +import type { TestInstance } from 'test-renderer'; + +import { render, screen } from '../../..'; +import type { SyntheticEvent } from '../event'; +import type { FireEventInit } from '../fire-event'; +import { fireEvent } from '../fire-event'; + +/** 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('throws on keys other than nativeEvent', async () => { + await render(); + + await expect( + fireEventUntyped(screen.getByTestId('input'), 'focus', { + nativeEvent: {}, + target: 1, + persist: jest.fn(), + }), + ).rejects.toThrow( + 'Unable to fire a "focus" event - unsupported event object keys: "target", "persist". Pass the event data as "nativeEvent".', + ); + }); + + 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('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.onFocusCapture')} + onFocus={() => calls.push('parent.onFocus')} + > + calls.push('input.onFocus')} /> + , + ); + + await fireEvent(screen.getByTestId('input'), 'focus', {}); + + expect(calls).toEqual(['parent.onFocusCapture', 'input.onFocus', 'parent.onFocus']); +}); + +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(); +}); + +test('drops events unknown to React Native', async () => { + const onChangeText = jest.fn(); + await render(); + + const result = await fireEvent(screen.getByTestId('input'), 'changeText', { + nativeEvent: { text: 'Hello' }, + }); + + expect(result).toBe(true); + expect(onChangeText).not.toHaveBeenCalled(); +}); + +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(); +}); diff --git a/src/events/modern/dispatch.ts b/src/events/modern/dispatch.ts index 28699aa31..a98fbf8c0 100644 --- a/src/events/modern/dispatch.ts +++ b/src/events/modern/dispatch.ts @@ -3,19 +3,16 @@ import type { TestInstance } from 'test-renderer'; import { act } from '../../act'; import { isInstanceMounted } from '../../helpers/component-tree'; import { getHandlerProp } from '../shared/handler'; -import type { NativeEventPayload } from './event'; import { eventInternals, SyntheticEvent } from './event'; -import { getEventTypeConfig } from './event-types'; /** - * Dispatches an event as React Native does for native events + * 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. - * - Events unknown to React Native are dropped. * * Only host elements are on the path, so composite props are never called. Every handler on the * path runs until one stops propagation. The first handler error is rethrown after all handlers @@ -24,24 +21,13 @@ import { getEventTypeConfig } from './event-types'; * Not implemented yet: responder negotiation, which React Native runs first for touch, scroll and * selection change events. * - * @param eventType e.g. `focus` or `pointerUp` - * @param payload passed to handlers as `event.nativeEvent` * @returns `false` if a handler called `preventDefault()`, like DOM `dispatchEvent()`. */ -export async function dispatchEvent( - target: TestInstance, - eventType: string, - payload: NativeEventPayload = {}, -): Promise { +export async function dispatchEvent(target: TestInstance, event: SyntheticEvent): Promise { if (!isInstanceMounted(target)) { return true; } - const event = createEvent(eventType, payload); - if (event == null) { - return true; - } - await act(() => { dispatch(target, event); }); @@ -49,28 +35,6 @@ export async function dispatchEvent( return !event.defaultPrevented; } -function createEvent(eventType: string, payload: NativeEventPayload): SyntheticEvent | null { - const config = getEventTypeConfig(eventType); - if (config == null) { - return null; - } - - // React Native keeps the native timestamp as the event's `timeStamp`. - const nativeTimeStamp = payload.timeStamp ?? payload.timestamp; - return new SyntheticEvent( - // React Native's event type is the lowercased name, e.g. `pointerup`. - eventType.toLowerCase(), - { - bubbles: config.kind === 'bubbling' && !config.skipBubbling, - cancelable: true, - rnIsDirect: config.kind === 'direct', - timeStamp: typeof nativeTimeStamp === 'number' ? nativeTimeStamp : undefined, - }, - payload, - config.dispatchConfig, - ); -} - type ErrorState = { hasError: boolean; error: unknown }; function dispatch(target: TestInstance, event: SyntheticEvent) { diff --git a/src/events/modern/event.ts b/src/events/modern/event.ts index 9ff3b0ae9..35265654a 100644 --- a/src/events/modern/event.ts +++ b/src/events/modern/event.ts @@ -1,9 +1,15 @@ import type { TestInstance } from 'test-renderer'; import type { DispatchConfig } from './event-types'; +import { getEventTypeConfig } from './event-types'; export type NativeEventPayload = Record; +export type CreateEventInit = { + /** Passed to handlers as `event.nativeEvent`. Defaults to `{}`. */ + nativeEvent?: NativeEventPayload; +}; + type SyntheticEventInit = { bubbles?: boolean; cancelable?: boolean; @@ -214,3 +220,33 @@ export const eventInternals = { getDispatchState(event).stopPropagation = false; }, }; + +/** + * 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; + return new SyntheticEvent( + // React Native's event type is the lowercased name, e.g. `pointerup`. + eventType.toLowerCase(), + { + bubbles: config.kind === 'bubbling' && !config.skipBubbling, + cancelable: true, + rnIsDirect: config.kind === 'direct', + timeStamp: typeof nativeTimeStamp === 'number' ? nativeTimeStamp : undefined, + }, + nativeEvent, + config.dispatchConfig, + ); +} diff --git a/src/events/modern/fire-event-utils.ts b/src/events/modern/fire-event-utils.ts new file mode 100644 index 000000000..03dee4908 --- /dev/null +++ b/src/events/modern/fire-event-utils.ts @@ -0,0 +1,91 @@ +import { ErrorWithStack } from '../../helpers/errors'; +import type { CreateEventInit } from './event'; + +const ALLOWED_INIT_KEYS = ['nativeEvent']; + +/** + * Validates the arguments following the event type in modern `fireEvent`: exactly one plain + * object, with only a `nativeEvent` key, which must be a plain object if set. + * + * @param callsite the function to remove from the error stack, e.g. `fireEvent` + */ +export function validateEventInit( + eventType: string, + args: unknown[], + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + callsite: Function, +): CreateEventInit { + 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, + ); + } + + const [init] = args; + 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: ` + + `${unknownKeys.map((key) => `"${key}"`).join(', ')}. ` + + `Pass the event data as "nativeEvent".`, + 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, + ); + } + + return init as CreateEventInit; +} + +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..8f2516b9d --- /dev/null +++ b/src/events/modern/fire-event.ts @@ -0,0 +1,46 @@ +import type { TestInstance } from 'test-renderer'; + +import { ErrorWithStack } from '../../helpers/errors'; +import { normalizeEventType } from '../shared/handler'; +import type { EventType } from '../shared/types'; +import { dispatchEvent } from './dispatch'; +import type { CreateEventInit } from './event'; +import { createEvent } from './event'; +import { 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. + * + * @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: EventType, + ...args: [event: FireEventInit] +): Promise { + const normalizedType = normalizeEventType(eventType); + if (instance == null) { + throw new ErrorWithStack( + `Unable to fire a "${normalizedType}" event - please provide a host element.`, + fireEvent, + ); + } + + const init = validateEventInit(normalizedType, args, fireEvent); + const event = createEvent(normalizedType, init); + if (event == null) { + return true; + } + + return await dispatchEvent(instance, event); +} From cf0906c93ae51ec2168ec58d7508cc205fdd2063 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 8 Oct 2026 13:44:20 +0200 Subject: [PATCH 07/28] refs --- .gitmodules | 16 ++++++++++++++++ .oxfmtrc.json | 2 +- AGENTS.md | 16 ++++++++++++++++ contributing/build-and-validation.md | 1 + eslint.config.mjs | 9 ++++++++- jest.config.js | 3 ++- refs/dom-testing-library | 1 + refs/react | 1 + refs/react-native | 1 + refs/react-testing-library | 1 + 10 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 .gitmodules create mode 160000 refs/dom-testing-library create mode 160000 refs/react create mode 160000 refs/react-native create mode 160000 refs/react-testing-library diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..a51ada436 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,16 @@ +[submodule "refs/dom-testing-library"] + path = refs/dom-testing-library + url = https://github.com/testing-library/dom-testing-library.git + shallow = true +[submodule "refs/react-testing-library"] + path = refs/react-testing-library + url = https://github.com/testing-library/react-testing-library.git + shallow = true +[submodule "refs/react-native"] + path = refs/react-native + url = https://github.com/facebook/react-native.git + shallow = true +[submodule "refs/react"] + path = refs/react + url = https://github.com/facebook/react.git + shallow = true diff --git a/.oxfmtrc.json b/.oxfmtrc.json index dcdc60d7f..510e08bf8 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -11,5 +11,5 @@ ] }, "sortPackageJson": false, - "ignorePatterns": ["node_modules/", ".yarn", "codemods/**/tests/fixtures/**"] + "ignorePatterns": ["node_modules/", ".yarn", "refs/", "codemods/**/tests/fixtures/**"] } diff --git a/AGENTS.md b/AGENTS.md index 223aab95b..2796f7c7d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,22 @@ - [Example app regeneration](contributing/example-apps.md) - [Git, releases, and PR workflow](contributing/git-workflow.md) +## Reference sources + +Upstream sources are checked out as shallow git submodules under `refs/` for code research. Read and search them to see how upstream implements something (event dispatch, renderer internals, query semantics) instead of guessing or fetching from the web. + +- `refs/react-native/`: [facebook/react-native](https://github.com/facebook/react-native) (core components in `packages/react-native/Libraries/`) +- `refs/react/`: [facebook/react](https://github.com/facebook/react) (reconciler, test renderer, and RN renderer in `packages/`) +- `refs/dom-testing-library/`: [testing-library/dom-testing-library](https://github.com/testing-library/dom-testing-library) (queries, `fireEvent`, `waitFor`) +- `refs/react-testing-library/`: [testing-library/react-testing-library](https://github.com/testing-library/react-testing-library) (`render`, `act` integration) + +Notes: + +- Treat `refs/` as read-only. Never edit files there or import from it in `src/`. +- Submodules track upstream `main`, which can differ from the versions installed in `node_modules/`. For behavior that must match what this library runs against, check the installed package in `node_modules/` too. +- If `refs/` is empty, ask the human to run `git submodule update --init --depth 1`. +- Tooling ignores `refs/` (Jest, ESLint, oxfmt, `tsc`). Keep it that way when changing configs. + ## Agent rules ### Git restrictions diff --git a/contributing/build-and-validation.md b/contributing/build-and-validation.md index 15f8b720e..b1b47ad96 100644 --- a/contributing/build-and-validation.md +++ b/contributing/build-and-validation.md @@ -29,3 +29,4 @@ After changing docs, run `yarn docs:generate` and commit the result together wit - `examples/`: example Expo apps - `codemods/`: codemods for upgrading user code - `contributing/`: these guides +- `refs/`: upstream sources (React, React Native, Testing Library) as shallow git submodules, for reading only. Fetch them with `git submodule update --init --depth 1`. diff --git a/eslint.config.mjs b/eslint.config.mjs index 160322fa5..893704448 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -21,7 +21,14 @@ const patchedCallstackConfig = callstackConfig.map((configItem) => { export default [ { - ignores: ['dist/', 'experiments-rtl/', 'website/', 'eslint.config.mjs', 'jest-setup.ts'], + ignores: [ + 'dist/', + 'refs/', + 'experiments-rtl/', + 'website/', + 'eslint.config.mjs', + 'jest-setup.ts', + ], }, ...patchedCallstackConfig, ...tseslint.configs.strict, diff --git a/jest.config.js b/jest.config.js index 4549c54f6..978adc0fc 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,7 +1,8 @@ module.exports = { preset: '@react-native/jest-preset', setupFilesAfterEnv: ['./jest-setup.ts'], - testPathIgnorePatterns: ['dist/', 'examples/', 'experiments-app/', 'codemods/'], + testPathIgnorePatterns: ['dist/', 'examples/', 'experiments-app/', 'codemods/', 'refs/'], + modulePathIgnorePatterns: ['/refs/'], testTimeout: 60000, transformIgnorePatterns: ['/node_modules/(?!(@react-native|react-native)/).*/'], snapshotSerializers: ['@relmify/jest-serializer-strip-ansi/always'], diff --git a/refs/dom-testing-library b/refs/dom-testing-library new file mode 160000 index 000000000..6049cc0bc --- /dev/null +++ b/refs/dom-testing-library @@ -0,0 +1 @@ +Subproject commit 6049cc0bc7cf2201625c476c95fa6299d0e2fa8b diff --git a/refs/react b/refs/react new file mode 160000 index 000000000..b618bbb44 --- /dev/null +++ b/refs/react @@ -0,0 +1 @@ +Subproject commit b618bbb4422693bbb8d387180687b3f510300f42 diff --git a/refs/react-native b/refs/react-native new file mode 160000 index 000000000..db42fda57 --- /dev/null +++ b/refs/react-native @@ -0,0 +1 @@ +Subproject commit db42fda57495eecb35fa2fa8f09a0f6c42ec2434 diff --git a/refs/react-testing-library b/refs/react-testing-library new file mode 160000 index 000000000..20ce75f29 --- /dev/null +++ b/refs/react-testing-library @@ -0,0 +1 @@ +Subproject commit 20ce75f2907ca0e5c5a8ae595c0e9a4e368c7800 From c4c8b3e2f2676c9064fd8981f488a80b1d313aa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 8 Oct 2026 13:51:49 +0200 Subject: [PATCH 08/28] refs: expensify --- .gitmodules | 4 ++++ AGENTS.md | 1 + contributing/build-and-validation.md | 2 +- package.json | 4 ++-- refs/expensify-app | 1 + 5 files changed, 9 insertions(+), 3 deletions(-) create mode 160000 refs/expensify-app diff --git a/.gitmodules b/.gitmodules index a51ada436..fd1b113a4 100644 --- a/.gitmodules +++ b/.gitmodules @@ -14,3 +14,7 @@ path = refs/react url = https://github.com/facebook/react.git shallow = true +[submodule "refs/expensify-app"] + path = refs/expensify-app + url = https://github.com/Expensify/App.git + shallow = true diff --git a/AGENTS.md b/AGENTS.md index 2796f7c7d..af37c4cfe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,7 @@ Upstream sources are checked out as shallow git submodules under `refs/` for cod - `refs/react/`: [facebook/react](https://github.com/facebook/react) (reconciler, test renderer, and RN renderer in `packages/`) - `refs/dom-testing-library/`: [testing-library/dom-testing-library](https://github.com/testing-library/dom-testing-library) (queries, `fireEvent`, `waitFor`) - `refs/react-testing-library/`: [testing-library/react-testing-library](https://github.com/testing-library/react-testing-library) (`render`, `act` integration) +- `refs/expensify-app/`: [Expensify/App](https://github.com/Expensify/App), a large production React Native app with about 1,000 test files that use this library (in `tests/ui/`, `tests/unit/`, `tests/perf-test/`). Use it to see how real-world tests call the API and to judge the impact of behavior or API changes. Check its `package.json` for the version it uses. Notes: diff --git a/contributing/build-and-validation.md b/contributing/build-and-validation.md index b1b47ad96..953ffa076 100644 --- a/contributing/build-and-validation.md +++ b/contributing/build-and-validation.md @@ -29,4 +29,4 @@ After changing docs, run `yarn docs:generate` and commit the result together wit - `examples/`: example Expo apps - `codemods/`: codemods for upgrading user code - `contributing/`: these guides -- `refs/`: upstream sources (React, React Native, Testing Library) as shallow git submodules, for reading only. Fetch them with `git submodule update --init --depth 1`. +- `refs/`: upstream sources (React, React Native, Testing Library) and the Expensify app as a real-world test suite, as shallow git submodules, for reading only. Fetch them with `git submodule update --init --depth 1`. diff --git a/package.json b/package.json index 3a0b4fc3c..02a7c990f 100644 --- a/package.json +++ b/package.json @@ -33,8 +33,8 @@ "typecheck": "tsc", "typecheck:react-19_2": "tsc -p tsconfig.react-19_2.json", "lint": "eslint src --cache", - "format:check": "oxfmt --check .", - "format:fix": "oxfmt --write .", + "format:check": "oxfmt --check --disable-nested-config .", + "format:fix": "oxfmt --write --disable-nested-config .", "validate": "yarn typecheck && yarn test && yarn lint && yarn format:check", "validate:examples": "yarn --cwd examples/basic validate && yarn --cwd examples/cookbook validate", "validate:all": "yarn validate && yarn validate:examples && yarn docs:check && yarn --cwd website validate && yarn --cwd experiments-app validate", diff --git a/refs/expensify-app b/refs/expensify-app new file mode 160000 index 000000000..8976530e6 --- /dev/null +++ b/refs/expensify-app @@ -0,0 +1 @@ +Subproject commit 8976530e6b70985b157f8245f5c9d0d6ae3d043d From d1b924a4c7149c5141a92cf84a2d09f9db6454b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 8 Oct 2026 13:52:05 +0200 Subject: [PATCH 09/28] fireEvent allow timestamp --- src/events/modern/__tests__/event.test.ts | 16 +++++ .../modern/__tests__/fire-event.test.tsx | 62 ++++++++++++++++--- src/events/modern/event.ts | 9 ++- src/events/modern/fire-event-utils.ts | 60 ++++++++++++++++-- 4 files changed, 132 insertions(+), 15 deletions(-) diff --git a/src/events/modern/__tests__/event.test.ts b/src/events/modern/__tests__/event.test.ts index 01ec970ea..3101368f3 100644 --- a/src/events/modern/__tests__/event.test.ts +++ b/src/events/modern/__tests__/event.test.ts @@ -2,6 +2,10 @@ import { createEvent, SyntheticEvent } from '../event'; const dispatchConfig = { registrationName: 'onFocus' }; +afterEach(() => { + jest.restoreAllMocks(); +}); + test('exposes init values', () => { const nativeEvent = { target: 1 }; const event = new SyntheticEvent( @@ -137,6 +141,18 @@ describe('createEvent()', () => { 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 index 82460c092..3a030e25e 100644 --- a/src/events/modern/__tests__/fire-event.test.tsx +++ b/src/events/modern/__tests__/fire-event.test.tsx @@ -53,18 +53,30 @@ describe('event object', () => { expect(onFocus).not.toHaveBeenCalled(); }); - test('throws on keys other than nativeEvent', async () => { - await render(); + 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: {}, - target: 1, - persist: jest.fn(), - }), - ).rejects.toThrow( - 'Unable to fire a "focus" event - unsupported event object keys: "target", "persist". Pass the event data as "nativeEvent".', - ); + 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 () => { @@ -77,6 +89,36 @@ describe('event object', () => { ); }); + 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(); diff --git a/src/events/modern/event.ts b/src/events/modern/event.ts index 35265654a..e7aff13ad 100644 --- a/src/events/modern/event.ts +++ b/src/events/modern/event.ts @@ -8,6 +8,11 @@ export type NativeEventPayload = Record; 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 = { @@ -237,6 +242,8 @@ export function createEvent(eventType: string, init: CreateEventInit = {}): Synt 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(), @@ -244,7 +251,7 @@ export function createEvent(eventType: string, init: CreateEventInit = {}): Synt bubbles: config.kind === 'bubbling' && !config.skipBubbling, cancelable: true, rnIsDirect: config.kind === 'direct', - timeStamp: typeof nativeTimeStamp === 'number' ? nativeTimeStamp : undefined, + timeStamp, }, nativeEvent, config.dispatchConfig, diff --git a/src/events/modern/fire-event-utils.ts b/src/events/modern/fire-event-utils.ts index 03dee4908..168d055ff 100644 --- a/src/events/modern/fire-event-utils.ts +++ b/src/events/modern/fire-event-utils.ts @@ -1,11 +1,20 @@ import { ErrorWithStack } from '../../helpers/errors'; import type { CreateEventInit } from './event'; +import { SyntheticEvent } from './event'; -const ALLOWED_INIT_KEYS = ['nativeEvent']; +const ALLOWED_INIT_KEYS = ['nativeEvent', 'timeStamp']; + +/** Legacy events carried these as stubs. The dispatch sets them to host elements. */ +const DISPATCH_KEYS = ['target', 'currentTarget']; + +/** Members of the event itself, such as `persist` or `preventDefault`. */ +const EVENT_KEYS = new Set( + Object.getOwnPropertyNames(SyntheticEvent.prototype).filter((key) => key !== 'constructor'), +); /** * Validates the arguments following the event type in modern `fireEvent`: exactly one plain - * object, with only a `nativeEvent` key, which must be a plain object if set. + * 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` */ @@ -44,8 +53,7 @@ export function validateEventInit( if (unknownKeys.length > 0) { throw new ErrorWithStack( `Unable to fire a "${eventType}" event - unsupported event object keys: ` + - `${unknownKeys.map((key) => `"${key}"`).join(', ')}. ` + - `Pass the event data as "nativeEvent".`, + `${formatKeys(unknownKeys)}. ${describeUnknownKeys(unknownKeys)}`, callsite, ); } @@ -58,9 +66,53 @@ export function validateEventInit( ); } + 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; } +/** + * 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; From 1f99d68655cc67e42c1634eaa5abee11b42059bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 8 Oct 2026 15:29:53 +0200 Subject: [PATCH 10/28] fireEvent improvemetns --- .../modern/__tests__/event-types.test.ts | 12 ++--- .../modern/__tests__/fire-event.test.tsx | 53 +++++++++++++++---- src/events/modern/event-types.ts | 28 ++++++---- src/events/modern/fire-event-utils.ts | 33 ++++++++++++ src/events/modern/fire-event.ts | 17 ++++-- 5 files changed, 113 insertions(+), 30 deletions(-) diff --git a/src/events/modern/__tests__/event-types.test.ts b/src/events/modern/__tests__/event-types.test.ts index 86f6d35fb..3ffe53da2 100644 --- a/src/events/modern/__tests__/event-types.test.ts +++ b/src/events/modern/__tests__/event-types.test.ts @@ -1,8 +1,8 @@ import { - bubblingEventTypes, - directEventTypes, + BUBBLING_EVENT_TYPES, + DIRECT_EVENT_TYPES, getEventTypeConfig, - skipBubblingEventTypes, + SKIP_BUBBLING_EVENT_TYPES, } from '../event-types'; type ViewConfig = { @@ -79,9 +79,9 @@ function loadReactNativeEventTypes() { test('event types match React Native view configs', () => { const reactNative = loadReactNativeEventTypes(); - expect([...bubblingEventTypes].sort()).toEqual([...reactNative.bubbling].sort()); - expect([...skipBubblingEventTypes].sort()).toEqual([...reactNative.skipBubbling].sort()); - expect([...directEventTypes].sort()).toEqual([...reactNative.direct].sort()); + expect([...BUBBLING_EVENT_TYPES].sort()).toEqual([...reactNative.bubbling].sort()); + expect([...SKIP_BUBBLING_EVENT_TYPES].sort()).toEqual([...reactNative.skipBubbling].sort()); + expect([...DIRECT_EVENT_TYPES].sort()).toEqual([...reactNative.direct].sort()); }); test('getEventTypeConfig() returns config of bubbling event', () => { diff --git a/src/events/modern/__tests__/fire-event.test.tsx b/src/events/modern/__tests__/fire-event.test.tsx index 3a030e25e..85e6e7711 100644 --- a/src/events/modern/__tests__/fire-event.test.tsx +++ b/src/events/modern/__tests__/fire-event.test.tsx @@ -4,12 +4,12 @@ import type { TestInstance } from 'test-renderer'; import { render, screen } from '../../..'; import type { SyntheticEvent } from '../event'; -import type { FireEventInit } from '../fire-event'; +import type { FireEventInit, FireEventType } from '../fire-event'; import { fireEvent } from '../fire-event'; /** 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])); + return fireEvent(instance, eventType as FireEventType, ...(args as [FireEventInit])); } describe('event object', () => { @@ -219,16 +219,51 @@ test('does not call props of composite components', async () => { expect(onCompositeFocus).not.toHaveBeenCalled(); }); -test('drops events unknown to React Native', async () => { - const onChangeText = jest.fn(); - await render(); +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(); + }); - const result = await fireEvent(screen.getByTestId('input'), 'changeText', { - nativeEvent: { text: 'Hello' }, + test('are type errors', async () => { + await render(); + const input = screen.getByTestId('input'); + + // @ts-expect-error `changeText` isn't dispatched natively. + await expect(fireEvent(input, 'changeText', {})).rejects.toThrow(); + // @ts-expect-error `onChangeText` isn't dispatched natively. + await expect(fireEvent(input, 'onChangeText', {})).rejects.toThrow(); }); - expect(result).toBe(true); - expect(onChangeText).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 () => { diff --git a/src/events/modern/event-types.ts b/src/events/modern/event-types.ts index 3386fcc60..a46b82f90 100644 --- a/src/events/modern/event-types.ts +++ b/src/events/modern/event-types.ts @@ -12,7 +12,7 @@ import { getEventHandlerName } from '../shared/handler'; * `onFocusCapture`. */ -export const bubblingEventTypes: ReadonlySet = new Set([ +export const BUBBLING_EVENT_TYPES = [ 'blur', 'change', 'click', @@ -38,15 +38,12 @@ export const bubblingEventTypes: ReadonlySet = new Set([ 'touchEnd', 'touchMove', 'touchStart', -]); +] as const; /** Bubbling events that run the capture phase as usual, but call only the target when bubbling. */ -export const skipBubblingEventTypes: ReadonlySet = new Set([ - 'pointerEnter', - 'pointerLeave', -]); +export const SKIP_BUBBLING_EVENT_TYPES = ['pointerEnter', 'pointerLeave'] as const; -export const directEventTypes: ReadonlySet = new Set([ +export const DIRECT_EVENT_TYPES = [ 'accessibilityAction', 'accessibilityEscape', 'accessibilityTap', @@ -84,7 +81,16 @@ export const directEventTypes: ReadonlySet = new Set([ 'show', // From the inline view config in `TextNativeComponent.js`, which is not exported. 'textLayout', -]); +] as const; + +/** Event type React Native dispatches natively, e.g. `focus` or `layout`. */ +export type NativeEventType = + | (typeof BUBBLING_EVENT_TYPES)[number] + | (typeof DIRECT_EVENT_TYPES)[number]; + +const bubblingEventTypes: readonly string[] = BUBBLING_EVENT_TYPES; +const skipBubblingEventTypes: readonly string[] = SKIP_BUBBLING_EVENT_TYPES; +const directEventTypes: readonly string[] = DIRECT_EVENT_TYPES; // React Native's `DispatchConfig` shape: `TouchableOpacity` reads its `registrationName`. export type DispatchConfig = @@ -102,9 +108,9 @@ type EventTypeConfig = * doesn't know it. */ export function getEventTypeConfig(eventType: string): EventTypeConfig | null { - if (bubblingEventTypes.has(eventType)) { + if (bubblingEventTypes.includes(eventType)) { const propName = getEventHandlerName(eventType); - const skipBubbling = skipBubblingEventTypes.has(eventType); + const skipBubbling = skipBubblingEventTypes.includes(eventType); return { kind: 'bubbling', skipBubbling, @@ -118,7 +124,7 @@ export function getEventTypeConfig(eventType: string): EventTypeConfig | null { }; } - if (directEventTypes.has(eventType)) { + if (directEventTypes.includes(eventType)) { return { kind: 'direct', dispatchConfig: { registrationName: getEventHandlerName(eventType) } }; } diff --git a/src/events/modern/fire-event-utils.ts b/src/events/modern/fire-event-utils.ts index 168d055ff..b0439c393 100644 --- a/src/events/modern/fire-event-utils.ts +++ b/src/events/modern/fire-event-utils.ts @@ -1,6 +1,7 @@ import { ErrorWithStack } from '../../helpers/errors'; import type { CreateEventInit } from './event'; import { SyntheticEvent } from './event'; +import { getEventTypeConfig } from './event-types'; const ALLOWED_INIT_KEYS = ['nativeEvent', 'timeStamp']; @@ -12,6 +13,38 @@ 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, + ); +} + /** * Validates the arguments following the event type in modern `fireEvent`: exactly one plain * object, with only `nativeEvent` (a plain object) and `timeStamp` (a finite number) keys. diff --git a/src/events/modern/fire-event.ts b/src/events/modern/fire-event.ts index 8f2516b9d..5957db56d 100644 --- a/src/events/modern/fire-event.ts +++ b/src/events/modern/fire-event.ts @@ -2,11 +2,11 @@ import type { TestInstance } from 'test-renderer'; import { ErrorWithStack } from '../../helpers/errors'; import { normalizeEventType } from '../shared/handler'; -import type { EventType } from '../shared/types'; import { dispatchEvent } from './dispatch'; import type { CreateEventInit } from './event'; import { createEvent } from './event'; -import { validateEventInit } from './fire-event-utils'; +import type { NativeEventType } from './event-types'; +import { ensureEventType, validateEventInit } from './fire-event-utils'; /** * Event object passed to modern `fireEvent`. Handlers receive a `SyntheticEvent` from @@ -14,18 +14,25 @@ import { validateEventInit } from './fire-event-utils'; */ export type FireEventInit = CreateEventInit; +/** + * Event type React Native dispatches natively, with or without the `on*` prefix. JavaScript + * callers can pass any string, so `fireEvent()` still checks it at runtime. + */ +export type FireEventType = NativeEventType | `on${Capitalize}`; + /** * 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. + * 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: EventType, + eventType: FireEventType, ...args: [event: FireEventInit] ): Promise { const normalizedType = normalizeEventType(eventType); @@ -36,6 +43,8 @@ export async function fireEvent( ); } + // Before the event object, as `fireEvent(input, 'changeText', 'Hello')` needs the hint more. + ensureEventType(normalizedType, fireEvent); const init = validateEventInit(normalizedType, args, fireEvent); const event = createEvent(normalizedType, init); if (event == null) { From 3fa8b596336db6228706e4829d3897cc290dffa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 8 Oct 2026 15:47:17 +0200 Subject: [PATCH 11/28] invokeHandler --- src/events/legacy/dispatch.ts | 23 ++----- .../shared/__tests__/invoke-handler.test.tsx | 64 +++++++++++++++++++ src/events/shared/invoke-handler.ts | 35 ++++++++++ src/user-event/scroll/scroll-to.ts | 4 +- src/user-event/utils/interaction.ts | 17 ++++- 5 files changed, 120 insertions(+), 23 deletions(-) create mode 100644 src/events/shared/__tests__/invoke-handler.test.tsx create mode 100644 src/events/shared/invoke-handler.ts diff --git a/src/events/legacy/dispatch.ts b/src/events/legacy/dispatch.ts index 797abb5ce..aacd1e5c8 100644 --- a/src/events/legacy/dispatch.ts +++ b/src/events/legacy/dispatch.ts @@ -1,34 +1,19 @@ import type { TestInstance } from 'test-renderer'; -import { act } from '../../act'; -import { isInstanceMounted } from '../../helpers/component-tree'; -import { getEventHandlerFromProps } from '../shared/handler'; +import { invokeHandler } from '../shared/invoke-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) + * @param event event payload * @returns `true` if a handler was called. */ export async function dispatchEvent( instance: TestInstance, eventType: string, - ...event: unknown[] + event: unknown, ): Promise { - if (!isInstanceMounted(instance)) { - return false; - } - - const handler = getEventHandlerFromProps(instance.props, eventType); - if (!handler) { - return false; - } - - await act(() => { - handler(...event); - }); - - return true; + return await invokeHandler(instance, eventType, event); } diff --git a/src/events/shared/__tests__/invoke-handler.test.tsx b/src/events/shared/__tests__/invoke-handler.test.tsx new file mode 100644 index 000000000..65317b1a5 --- /dev/null +++ b/src/events/shared/__tests__/invoke-handler.test.tsx @@ -0,0 +1,64 @@ +import * as React from 'react'; +import { ScrollView, Text, TextInput, View } from 'react-native'; + +import { render, screen } from '../../..'; +import { invokeHandler } from '../invoke-handler'; + +test('calls the element handler with all params', async () => { + const onContentSizeChange = jest.fn(); + await render(); + + const result = await invokeHandler(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 invokeHandler(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 invokeHandler(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 invokeHandler(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 invokeHandler(screen.getByTestId('input'), 'changeText', 'Hello'); + + expect(screen.getByText('Text: Hello')).toBeOnTheScreen(); +}); diff --git a/src/events/shared/invoke-handler.ts b/src/events/shared/invoke-handler.ts new file mode 100644 index 000000000..9c0ccc76f --- /dev/null +++ b/src/events/shared/invoke-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 invokeHandler( + 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/user-event/scroll/scroll-to.ts b/src/user-event/scroll/scroll-to.ts index ea068bfe6..ab275d97b 100644 --- a/src/user-event/scroll/scroll-to.ts +++ b/src/user-event/scroll/scroll-to.ts @@ -55,7 +55,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.invokeHandler( 'contentSizeChange', options.contentSize?.width ?? 0, options.contentSize?.height ?? 0, diff --git a/src/user-event/utils/interaction.ts b/src/user-event/utils/interaction.ts index 9990cf576..565aa775a 100644 --- a/src/user-event/utils/interaction.ts +++ b/src/user-event/utils/interaction.ts @@ -1,6 +1,7 @@ import type { TestInstance } from 'test-renderer'; -import { dispatchEvent } from '../../events/legacy'; +import { dispatchEvent } from '../../events/dispatch-event'; +import { invokeHandler } from '../../events/shared/invoke-handler'; /** * Tracks what a single `userEvent` interaction did, so `warnAboutUnhandledInteraction` @@ -23,8 +24,18 @@ export class Interaction { public target: TestInstance, ) {} - async dispatchEvent(eventName: string, ...event: unknown[]) { - const hasCalledHandler = await dispatchEvent(this.target, eventName, ...event); + async dispatchEvent(eventName: string, event: unknown) { + const hasCalledHandler = await dispatchEvent(this.target, eventName, event); + this.recordEvent(eventName, hasCalledHandler); + } + + /** + * Calls the target's own handler with the given params, for callbacks that components call + * from JavaScript instead of dispatching events, e.g. `ScrollView`'s + * `onContentSizeChange(width, height)`. + */ + async invokeHandler(eventName: string, ...params: unknown[]) { + const hasCalledHandler = await invokeHandler(this.target, eventName, ...params); this.recordEvent(eventName, hasCalledHandler); } From 59a28add3746f285f7db40cca5201c8f7216d725 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 8 Oct 2026 17:47:50 +0200 Subject: [PATCH 12/28] dispatchEvent facade --- src/events/__tests__/dispatch-event.test.tsx | 110 ++++++++++++++ src/events/dispatch-event.ts | 62 ++++++++ src/events/legacy/builders/merge.ts | 5 +- src/events/legacy/dispatch.ts | 4 +- src/events/modern/__tests__/dispatch.test.tsx | 65 +++++++- src/events/modern/dispatch.ts | 143 ++++++++++-------- src/events/modern/event.ts | 51 ++++--- src/events/modern/fire-event.ts | 3 +- src/events/shared/__tests__/handler.test.tsx | 22 ++- ...test.tsx => invoke-event-handler.test.tsx} | 17 ++- src/events/shared/handler.ts | 19 ++- ...oke-handler.ts => invoke-event-handler.ts} | 2 +- src/helpers/object.ts | 3 +- src/user-event/scroll/scroll-to.ts | 2 +- src/user-event/utils/interaction.ts | 6 +- 15 files changed, 388 insertions(+), 126 deletions(-) create mode 100644 src/events/__tests__/dispatch-event.test.tsx create mode 100644 src/events/dispatch-event.ts rename src/events/shared/__tests__/{invoke-handler.test.tsx => invoke-event-handler.test.tsx} (75%) rename src/events/shared/{invoke-handler.ts => invoke-event-handler.ts} (95%) diff --git a/src/events/__tests__/dispatch-event.test.tsx b/src/events/__tests__/dispatch-event.test.tsx new file mode 100644 index 000000000..a6c87520d --- /dev/null +++ b/src/events/__tests__/dispatch-event.test.tsx @@ -0,0 +1,110 @@ +import * as React from 'react'; +import { TextInput, View } from 'react-native'; + +import { configure, render, screen } from '../..'; +import { dispatchEvent } from '../dispatch-event'; +import { buildFocusEvent, buildTouchEvent } from '../legacy'; +import { SyntheticEvent } from '../modern/event'; + +describe('legacy event system', () => { + test('calls only the target prop with the event as it is', async () => { + const onFocus = jest.fn(); + const onParentFocus = jest.fn(); + await render( + + + , + ); + + const event = buildFocusEvent(); + expect(await dispatchEvent(screen.getByTestId('input'), 'focus', event)).toBe(true); + expect(onFocus).toHaveBeenCalledWith(event); + expect(onParentFocus).not.toHaveBeenCalled(); + }); + + test('returns false without a handler', async () => { + await render(); + expect(await dispatchEvent(screen.getByTestId('input'), 'focus', buildFocusEvent())).toBe( + false, + ); + }); +}); + +describe('modern event system', () => { + beforeEach(() => { + configure({ eventSystem: 'modern' }); + }); + + test('dispatches native events as SyntheticEvent with capture and bubble phases', async () => { + const calls: string[] = []; + await render( + calls.push('parent.onFocusCapture')} + onFocus={(event) => { + calls.push('parent.onFocus'); + expect(event).toBeInstanceOf(SyntheticEvent); + expect(event.nativeEvent).toEqual({ target: 0 }); + expect(event.target).toBe(screen.getByTestId('input')); + }} + > + calls.push('input.onFocus')} /> + , + ); + + expect(await dispatchEvent(screen.getByTestId('input'), 'focus', buildFocusEvent())).toBe(true); + expect(calls).toEqual(['parent.onFocusCapture', 'input.onFocus', 'parent.onFocus']); + }); + + test('returns true when only an ancestor handles the event', async () => { + const onParentFocus = jest.fn(); + await render( + + + , + ); + + expect(await dispatchEvent(screen.getByTestId('input'), 'focus', buildFocusEvent())).toBe(true); + expect(onParentFocus).toHaveBeenCalledTimes(1); + }); + + test('returns false without a handler on the path', async () => { + await render( + + + , + ); + + expect(await dispatchEvent(screen.getByTestId('input'), 'focus', buildFocusEvent())).toBe( + false, + ); + }); + + test('calls JavaScript callbacks on the target only, with their arguments', async () => { + const onChangeText = jest.fn(); + const onParentChangeText = jest.fn(); + await render( + // @ts-expect-error `onChangeText` is not a `View` prop, but it shouldn't be called anyway. + + + , + ); + + expect(await dispatchEvent(screen.getByTestId('input'), 'changeText', 'Hello')).toBe(true); + expect(onChangeText).toHaveBeenCalledWith('Hello'); + expect(onParentChangeText).not.toHaveBeenCalled(); + }); + + test('calls testOnly_ props for native events and JavaScript callbacks', async () => { + const onFocus = jest.fn(); + const onPressIn = jest.fn(); + const testOnlyProps = { testOnly_onFocus: onFocus, testOnly_onPressIn: onPressIn }; + await render(); + + expect(await dispatchEvent(screen.getByTestId('view'), 'focus', buildFocusEvent())).toBe(true); + const touchEvent = buildTouchEvent(); + expect(await dispatchEvent(screen.getByTestId('view'), 'pressIn', touchEvent)).toBe(true); + expect(onFocus).toHaveBeenCalledWith(expect.any(SyntheticEvent)); + expect(onPressIn).toHaveBeenCalledWith(touchEvent); + }); +}); diff --git a/src/events/dispatch-event.ts b/src/events/dispatch-event.ts new file mode 100644 index 000000000..c17f81081 --- /dev/null +++ b/src/events/dispatch-event.ts @@ -0,0 +1,62 @@ +import type { TestInstance } from 'test-renderer'; + +import { getConfig } from '../config'; +import { isObject } from '../helpers/object'; +import { dispatchEvent as dispatchLegacyEvent } from './legacy/dispatch'; +import { dispatchEvent as dispatchModernEvent } from './modern/dispatch'; +import type { NativeEventPayload } from './modern/event'; +import { createEvent } from './modern/event'; +import { getEventTypeConfig } from './modern/event-types'; +import { invokeEventHandler } from './shared/invoke-event-handler'; + +/** + * Dispatches an event to a host element with the event system selected by + * `configure({ eventSystem })`. Used by `userEvent`. + * + * - `legacy`: calls the element's own `on*` prop with `event` as it is. + * - `modern`: native events (`focus`, `change`, `scroll`, ...) get a `SyntheticEvent` created from + * `event.nativeEvent`, dispatched as React Native does, through the capture and bubble phases + * for bubbling events. Callbacks that components call from JavaScript (`changeText`, `pressIn`, + * `responderGrant`, ...) call the element's own `on*` prop with `event` as it is, as in + * `legacy`. `press` is a native event type (`topPress`), so it reaches host `onPress` props + * only, not `Pressable`'s `onPress`. + * + * @param eventType without the `on*` prefix, e.g. `focus` + * @param event the event object, e.g. from `buildFocusEvent()`, or the callback's argument, e.g. the + * text for `changeText` + * @returns `true` if a handler was called. + */ +export async function dispatchEvent( + instance: TestInstance, + eventType: string, + event: unknown, +): Promise { + if (getConfig().eventSystem === 'legacy') { + return await dispatchLegacyEvent(instance, eventType, event); + } + + const nativeEvent = extractNativeEvent(eventType, event); + const syntheticEvent = nativeEvent != null ? createEvent(eventType, { nativeEvent }) : null; + if (syntheticEvent == null) { + // JavaScript callbacks, e.g. `changeText`, are called as in the legacy event system. + return await invokeEventHandler(instance, eventType, event); + } + + return await dispatchModernEvent(instance, syntheticEvent); +} + +/** + * Returns the `nativeEvent` payload if this is a native event: an event type React Native + * dispatches natively, with an event object that has a `nativeEvent`. + */ +function extractNativeEvent(eventType: string, event: unknown): NativeEventPayload | null { + if (getEventTypeConfig(eventType) == null) { + return null; + } + + if (!isObject(event) || !isObject(event.nativeEvent)) { + return null; + } + + return event.nativeEvent; +} diff --git a/src/events/legacy/builders/merge.ts b/src/events/legacy/builders/merge.ts index 3c8f2c378..69ad2c87b 100644 --- a/src/events/legacy/builders/merge.ts +++ b/src/events/legacy/builders/merge.ts @@ -1,3 +1,4 @@ +import { isObject } from '../../../helpers/object'; import type { EventProps } from '../../shared/types'; /** @@ -24,7 +25,3 @@ function mergeInto(target: EventProps, source: EventProps) { } } } - -function isObject(value: unknown): value is EventProps { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} diff --git a/src/events/legacy/dispatch.ts b/src/events/legacy/dispatch.ts index aacd1e5c8..3f56f3860 100644 --- a/src/events/legacy/dispatch.ts +++ b/src/events/legacy/dispatch.ts @@ -1,6 +1,6 @@ import type { TestInstance } from 'test-renderer'; -import { invokeHandler } from '../shared/invoke-handler'; +import { invokeEventHandler } from '../shared/invoke-event-handler'; /** * Basic dispatch event function used by User Event module. @@ -15,5 +15,5 @@ export async function dispatchEvent( eventType: string, event: unknown, ): Promise { - return await invokeHandler(instance, eventType, event); + return await invokeEventHandler(instance, eventType, event); } diff --git a/src/events/modern/__tests__/dispatch.test.tsx b/src/events/modern/__tests__/dispatch.test.tsx index c9d826b3a..9fc8b3ade 100644 --- a/src/events/modern/__tests__/dispatch.test.tsx +++ b/src/events/modern/__tests__/dispatch.test.tsx @@ -196,6 +196,23 @@ test('does not call props of composite components', async () => { 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(); @@ -204,7 +221,7 @@ test('does nothing when the target is unmounted', async () => { const result = await dispatchEvent(target, createKnownEvent('focus')); - expect(result).toBe(true); + expect(result).toBe(false); expect(onFocus).not.toHaveBeenCalled(); }); @@ -276,17 +293,32 @@ test('calls handlers with the current element as `this`', async () => { expect(onFocus.mock.contexts[0]).toBe(screen.getByTestId('target')); }); -test('returns false when a handler prevents default', async () => { +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'); - const result = await dispatchEvent(screen.getByTestId('target'), createKnownEvent('focus')); + await dispatchEvent(screen.getByTestId('target'), event); - expect(result).toBe(false); + expect(event.defaultPrevented).toBe(true); }); test('rethrows the first handler error after calling all handlers', async () => { @@ -318,6 +350,31 @@ test('rethrows the first handler error after calling all handlers', async () => 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() { diff --git a/src/events/modern/dispatch.ts b/src/events/modern/dispatch.ts index a98fbf8c0..f6fa9a50f 100644 --- a/src/events/modern/dispatch.ts +++ b/src/events/modern/dispatch.ts @@ -2,7 +2,8 @@ import type { TestInstance } from 'test-renderer'; import { act } from '../../act'; import { isInstanceMounted } from '../../helpers/component-tree'; -import { getHandlerProp } from '../shared/handler'; +import { getHandlerByName } from '../shared/handler'; +import type { EventPhase } from './event'; import { eventInternals, SyntheticEvent } from './event'; /** @@ -14,75 +15,48 @@ import { eventInternals, SyntheticEvent } from './event'; * 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. Every handler on the + * 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 `false` if a handler called `preventDefault()`, like DOM `dispatchEvent()`. + * @returns `true` if a handler was called. Check `event.defaultPrevented` for `preventDefault()`. */ export async function dispatchEvent(target: TestInstance, event: SyntheticEvent): Promise { if (!isInstanceMounted(target)) { - return true; + return false; } - await act(() => { - dispatch(target, event); - }); - - return !event.defaultPrevented; -} - -type ErrorState = { hasError: boolean; error: unknown }; - -function dispatch(target: TestInstance, event: SyntheticEvent) { const path = getEventPath(target, event); - eventInternals.setComposedPath(event, path); - eventInternals.setTarget(event, target); - - const errorState: ErrorState = { hasError: false, error: undefined }; - - for (let i = path.length - 1; i >= 0; i -= 1) { - if (event.isPropagationStopped()) { - break; - } - - const node = path[i]; - eventInternals.setEventPhase( - event, - node === target ? SyntheticEvent.AT_TARGET : SyntheticEvent.CAPTURING_PHASE, - ); - invoke(node, event, true, errorState); - } + const state: DispatchState = { hasCalledHandler: false, firstError: null }; - for (const node of path) { - if (event.isPropagationStopped()) { - break; - } + await act(() => { + eventInternals.setTarget(event, target); + eventInternals.setComposedPath(event, path); - if (!event.bubbles && node !== target) { - break; - } + runCapturePhase(event, path, state); + runBubblePhase(event, path, state); + resetEvent(event); + }); - eventInternals.setEventPhase( - event, - node === target ? SyntheticEvent.AT_TARGET : SyntheticEvent.BUBBLING_PHASE, - ); - invoke(node, event, false, errorState); + // After `act()`, which would skip rendering other handlers' updates if its callback threw. + if (state.firstError != null) { + throw state.firstError.error; } - eventInternals.setEventPhase(event, SyntheticEvent.NONE); - eventInternals.setCurrentTarget(event, null); - eventInternals.setComposedPath(event, []); - eventInternals.resetStopPropagationFlag(event); - - if (errorState.hasError) { - throw errorState.error; - } + return state.hasCalledHandler; } +/** What the handlers did during one dispatch. */ +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) { @@ -99,37 +73,74 @@ function getEventPath(target: TestInstance, event: SyntheticEvent): TestInstance return path; } -function invoke( - node: TestInstance, +/** From the root down to the target, calling `on*Capture` props. Direct events skip it. */ +function runCapturePhase(event: SyntheticEvent, path: TestInstance[], state: DispatchState) { + const handlerName = getCaptureHandlerName(event); + if (handlerName == null) { + 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], handlerName, 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) { + const handlerName = getBubbleHandlerName(event); + const nodes = event.bubbles ? path : path.slice(0, 1); + for (let i = 0; i < nodes.length && !event.isPropagationStopped(); i += 1) { + const phase = i === 0 ? SyntheticEvent.AT_TARGET : SyntheticEvent.BUBBLING_PHASE; + callHandler(event, nodes[i], handlerName, phase, state); + } +} + +function callHandler( event: SyntheticEvent, - isCapture: boolean, - errorState: ErrorState, + node: TestInstance, + handlerName: string, + phase: EventPhase, + state: DispatchState, ) { + eventInternals.setEventPhase(event, phase); eventInternals.setCurrentTarget(event, node); - const propName = getPropName(event, isCapture); - const handler = propName != null ? getHandlerProp(node.props, propName) : undefined; + const handler = getHandlerByName(node.props, handlerName); if (handler == null) { return; } + state.hasCalledHandler = true; try { handler.call(node, event); } catch (error) { - if (!errorState.hasError) { - errorState.hasError = true; - errorState.error = 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 }; } } } -function getPropName(event: SyntheticEvent, isCapture: boolean): string | null { +/** Direct events have no capture phase. */ +function getCaptureHandlerName(event: SyntheticEvent): string | null { const config = event.dispatchConfig; - if ('registrationName' in config) { - return isCapture ? null : config.registrationName; - } + return 'registrationName' in config ? null : config.phasedRegistrationNames.captured; +} - return isCapture - ? config.phasedRegistrationNames.captured +function getBubbleHandlerName(event: SyntheticEvent): string { + const config = event.dispatchConfig; + return 'registrationName' in config + ? config.registrationName : config.phasedRegistrationNames.bubbled; } + +/** Clears the event's internals, as the event can still be read after the dispatch. */ +function resetEvent(event: SyntheticEvent) { + eventInternals.setEventPhase(event, SyntheticEvent.NONE); + eventInternals.setCurrentTarget(event, null); + eventInternals.setComposedPath(event, []); + eventInternals.resetStopPropagationFlag(event); +} diff --git a/src/events/modern/event.ts b/src/events/modern/event.ts index e7aff13ad..3d7f0271a 100644 --- a/src/events/modern/event.ts +++ b/src/events/modern/event.ts @@ -28,10 +28,17 @@ const CAPTURING_PHASE = 1; const AT_TARGET = 2; const BUBBLING_PHASE = 3; -type EventPhase = typeof NONE | typeof CAPTURING_PHASE | typeof AT_TARGET | typeof BUBBLING_PHASE; +export type EventPhase = + | typeof NONE + | typeof CAPTURING_PHASE + | typeof AT_TARGET + | typeof BUBBLING_PHASE; -/** Kept off the event's own properties, so printed events don't include the element tree. */ -type DispatchState = { +/** + * Event fields that the dispatch updates, as in React Native's `EventInternals`. Kept off the + * event's own properties, so printed events don't include the element tree. + */ +type EventInternals = { target: TestInstance | null; currentTarget: TestInstance | null; eventPhase: EventPhase; @@ -39,15 +46,15 @@ type DispatchState = { stopPropagation: boolean; }; -const dispatchStates = new WeakMap(); +const eventInternalsMap = new WeakMap(); -function getDispatchState(event: SyntheticEvent): DispatchState { - const state = dispatchStates.get(event); - if (state == null) { +function getEventInternals(event: SyntheticEvent): EventInternals { + const internals = eventInternalsMap.get(event); + if (internals == null) { throw new TypeError('Illegal invocation'); } - return state; + return internals; } /** @@ -87,7 +94,7 @@ export class SyntheticEvent { this._nativeEvent = nativeEvent; this._dispatchConfig = dispatchConfig; - dispatchStates.set(this, { + eventInternalsMap.set(this, { target: null, currentTarget: null, eventPhase: NONE, @@ -146,15 +153,15 @@ export class SyntheticEvent { } get target(): TestInstance | null { - return getDispatchState(this).target; + return getEventInternals(this).target; } get currentTarget(): TestInstance | null { - return getDispatchState(this).currentTarget; + return getEventInternals(this).currentTarget; } get eventPhase(): EventPhase { - return getDispatchState(this).eventPhase; + return getEventInternals(this).eventPhase; } /** Only the native event dispatch creates events, and React Native marks those as trusted. */ @@ -163,17 +170,17 @@ export class SyntheticEvent { } get cancelBubble(): boolean { - return getDispatchState(this).stopPropagation; + return getEventInternals(this).stopPropagation; } set cancelBubble(value: boolean) { if (value) { - getDispatchState(this).stopPropagation = true; + getEventInternals(this).stopPropagation = true; } } composedPath(): TestInstance[] { - return getDispatchState(this).composedPath.slice(); + return getEventInternals(this).composedPath.slice(); } preventDefault(): void { @@ -183,12 +190,12 @@ export class SyntheticEvent { } stopPropagation(): void { - getDispatchState(this).stopPropagation = true; + getEventInternals(this).stopPropagation = true; } /** Same as `stopPropagation()`, as each element has a single prop handler per phase. */ stopImmediatePropagation(): void { - getDispatchState(this).stopPropagation = true; + getEventInternals(this).stopPropagation = true; } /** No-op: React Native no longer pools events. */ @@ -206,23 +213,23 @@ export class SyntheticEvent { /** @internal Lets the dispatch update the event, as React Native's `EventInternals` does. */ export const eventInternals = { setTarget(event: SyntheticEvent, target: TestInstance | null) { - getDispatchState(event).target = target; + getEventInternals(event).target = target; }, setCurrentTarget(event: SyntheticEvent, currentTarget: TestInstance | null) { - getDispatchState(event).currentTarget = currentTarget; + getEventInternals(event).currentTarget = currentTarget; }, setEventPhase(event: SyntheticEvent, eventPhase: EventPhase) { - getDispatchState(event).eventPhase = eventPhase; + getEventInternals(event).eventPhase = eventPhase; }, setComposedPath(event: SyntheticEvent, composedPath: TestInstance[]) { - getDispatchState(event).composedPath = composedPath; + getEventInternals(event).composedPath = composedPath; }, resetStopPropagationFlag(event: SyntheticEvent) { - getDispatchState(event).stopPropagation = false; + getEventInternals(event).stopPropagation = false; }, }; diff --git a/src/events/modern/fire-event.ts b/src/events/modern/fire-event.ts index 5957db56d..c6e418885 100644 --- a/src/events/modern/fire-event.ts +++ b/src/events/modern/fire-event.ts @@ -51,5 +51,6 @@ export async function fireEvent( return true; } - return await dispatchEvent(instance, event); + await dispatchEvent(instance, event); + return !event.defaultPrevented; } diff --git a/src/events/shared/__tests__/handler.test.tsx b/src/events/shared/__tests__/handler.test.tsx index c8621583e..53bd2e03b 100644 --- a/src/events/shared/__tests__/handler.test.tsx +++ b/src/events/shared/__tests__/handler.test.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { Text, View } from 'react-native'; import { render, screen } from '../../..'; -import { getEventHandlerFromProps, getHandlerProp, normalizeEventType } from '../handler'; +import { getEventHandlerFromProps, getHandlerByName, normalizeEventType } from '../handler'; test('getEventHandlerFromProps strict mode', async () => { const onPress = jest.fn(); @@ -100,12 +100,18 @@ test('normalizeEventType strips the `on*` prefix', () => { expect(normalizeEventType('on')).toBe('on'); }); -test('getHandlerProp returns only function props with the exact name', () => { +test('getHandlerByName returns function props with the exact name or its testOnly_ variant', () => { const onFocus = jest.fn(); - const props = { onFocus, testOnly_onBlur: jest.fn(), onLayout: 'not a function' }; - - expect(getHandlerProp(props, 'onFocus')).toBe(onFocus); - expect(getHandlerProp(props, 'focus')).toBeUndefined(); - expect(getHandlerProp(props, 'onBlur')).toBeUndefined(); - expect(getHandlerProp(props, 'onLayout')).toBeUndefined(); + 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-handler.test.tsx b/src/events/shared/__tests__/invoke-event-handler.test.tsx similarity index 75% rename from src/events/shared/__tests__/invoke-handler.test.tsx rename to src/events/shared/__tests__/invoke-event-handler.test.tsx index 65317b1a5..7793bb29c 100644 --- a/src/events/shared/__tests__/invoke-handler.test.tsx +++ b/src/events/shared/__tests__/invoke-event-handler.test.tsx @@ -2,13 +2,18 @@ import * as React from 'react'; import { ScrollView, Text, TextInput, View } from 'react-native'; import { render, screen } from '../../..'; -import { invokeHandler } from '../invoke-handler'; +import { invokeEventHandler } from '../invoke-event-handler'; test('calls the element handler with all params', async () => { const onContentSizeChange = jest.fn(); await render(); - const result = await invokeHandler(screen.getByTestId('scroll'), 'contentSizeChange', 100, 200); + const result = await invokeEventHandler( + screen.getByTestId('scroll'), + 'contentSizeChange', + 100, + 200, + ); expect(result).toBe(true); expect(onContentSizeChange).toHaveBeenCalledWith(100, 200); @@ -19,7 +24,7 @@ test('calls testOnly_ handlers', async () => { const testOnlyProps = { testOnly_onChangeText: onChangeText }; await render(); - expect(await invokeHandler(screen.getByTestId('view'), 'changeText', 'Hello')).toBe(true); + expect(await invokeEventHandler(screen.getByTestId('view'), 'changeText', 'Hello')).toBe(true); expect(onChangeText).toHaveBeenCalledWith('Hello'); }); @@ -32,7 +37,7 @@ test('does not call ancestor handlers', async () => { , ); - expect(await invokeHandler(screen.getByTestId('input'), 'changeText', 'Hello')).toBe(false); + expect(await invokeEventHandler(screen.getByTestId('input'), 'changeText', 'Hello')).toBe(false); expect(onParentChangeText).not.toHaveBeenCalled(); }); @@ -42,7 +47,7 @@ test('returns false when the element is unmounted', async () => { const input = screen.getByTestId('input'); await screen.rerender(); - expect(await invokeHandler(input, 'changeText', 'Hello')).toBe(false); + expect(await invokeEventHandler(input, 'changeText', 'Hello')).toBe(false); expect(onChangeText).not.toHaveBeenCalled(); }); @@ -58,7 +63,7 @@ test('renders state updates from the handler', async () => { } await render(); - await invokeHandler(screen.getByTestId('input'), 'changeText', 'Hello'); + await invokeEventHandler(screen.getByTestId('input'), 'changeText', 'Hello'); expect(screen.getByText('Text: Hello')).toBeOnTheScreen(); }); diff --git a/src/events/shared/handler.ts b/src/events/shared/handler.ts index 41f60375c..f89eb1c9b 100644 --- a/src/events/shared/handler.ts +++ b/src/events/shared/handler.ts @@ -10,19 +10,24 @@ export function getEventHandlerFromProps( eventType: string, options?: EventHandlerOptions, ): EventHandler | undefined { - const handlerName = getEventHandlerName(eventType); return ( - getHandlerProp(props, handlerName) ?? - (options?.loose ? getHandlerProp(props, eventType) : undefined) ?? - getHandlerProp(props, `testOnly_${handlerName}`) ?? - (options?.loose ? getHandlerProp(props, `testOnly_${eventType}`) : undefined) + getHandlerByName(props, getEventHandlerName(eventType)) ?? + (options?.loose ? getHandlerByName(props, eventType) : undefined) ); } /** - * Returns the prop with exactly the given name (e.g. `onFocusCapture`) if it is a function. + * 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 getHandlerProp( +export function getHandlerByName( + props: Record, + handlerName: string, +): EventHandler | undefined { + return getHandlerProp(props, handlerName) ?? getHandlerProp(props, `testOnly_${handlerName}`); +} + +function getHandlerProp( props: Record, propName: string, ): EventHandler | undefined { diff --git a/src/events/shared/invoke-handler.ts b/src/events/shared/invoke-event-handler.ts similarity index 95% rename from src/events/shared/invoke-handler.ts rename to src/events/shared/invoke-event-handler.ts index 9c0ccc76f..d152acff6 100644 --- a/src/events/shared/invoke-handler.ts +++ b/src/events/shared/invoke-event-handler.ts @@ -13,7 +13,7 @@ import { getEventHandlerFromProps } from './handler'; * @param eventType without the `on*` prefix, e.g. `changeText` * @returns `true` if a handler was called. */ -export async function invokeHandler( +export async function invokeEventHandler( instance: TestInstance, eventType: string, ...params: unknown[] 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/user-event/scroll/scroll-to.ts b/src/user-event/scroll/scroll-to.ts index ab275d97b..a091da4ec 100644 --- a/src/user-event/scroll/scroll-to.ts +++ b/src/user-event/scroll/scroll-to.ts @@ -57,7 +57,7 @@ export async function scrollTo( // `ScrollView` calls `onContentSizeChange(width, height)` from JavaScript, with two arguments // instead of an event object, so `dispatchEvent` can't call it. - await interaction.invokeHandler( + await interaction.invokeEventHandler( 'contentSizeChange', options.contentSize?.width ?? 0, options.contentSize?.height ?? 0, diff --git a/src/user-event/utils/interaction.ts b/src/user-event/utils/interaction.ts index 565aa775a..7900f2311 100644 --- a/src/user-event/utils/interaction.ts +++ b/src/user-event/utils/interaction.ts @@ -1,7 +1,7 @@ import type { TestInstance } from 'test-renderer'; import { dispatchEvent } from '../../events/dispatch-event'; -import { invokeHandler } from '../../events/shared/invoke-handler'; +import { invokeEventHandler } from '../../events/shared/invoke-event-handler'; /** * Tracks what a single `userEvent` interaction did, so `warnAboutUnhandledInteraction` @@ -34,8 +34,8 @@ export class Interaction { * from JavaScript instead of dispatching events, e.g. `ScrollView`'s * `onContentSizeChange(width, height)`. */ - async invokeHandler(eventName: string, ...params: unknown[]) { - const hasCalledHandler = await invokeHandler(this.target, eventName, ...params); + async invokeEventHandler(eventName: string, ...params: unknown[]) { + const hasCalledHandler = await invokeEventHandler(this.target, eventName, ...params); this.recordEvent(eventName, hasCalledHandler); } From 8c01e8c4ce66ce535bdc4fef3d93fbcff6c7a5d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 8 Oct 2026 18:28:44 +0200 Subject: [PATCH 13/28] modern event testing --- contributing/testing.md | 11 + jest-setup-modern.ts | 6 + jest.config.js | 22 +- src/events/__tests__/dispatch-event.test.tsx | 11 + src/events/dispatch-event.ts | 12 +- src/test-utils/event-serializer.ts | 29 ++ src/test-utils/events.ts | 16 +- .../__snapshots__/clear.test.tsx.snap | 207 -------- .../__snapshots__/paste.test.tsx.snap | 234 --------- src/user-event/__tests__/clear.test.tsx | 29 +- src/user-event/__tests__/paste.test.tsx | 29 +- .../__snapshots__/longPress.test.tsx.snap | 56 --- .../__snapshots__/press.test.tsx.snap | 42 -- .../scroll-to-flat-list.test.tsx.snap | 54 -- .../__snapshots__/scroll-to.test.tsx.snap | 54 -- .../__snapshots__/type-managed.test.tsx.snap | 260 ---------- .../__snapshots__/type.test.tsx.snap | 466 ------------------ src/user-event/type/__tests__/type.test.tsx | 34 +- 18 files changed, 150 insertions(+), 1422 deletions(-) create mode 100644 jest-setup-modern.ts create mode 100644 src/test-utils/event-serializer.ts diff --git a/contributing/testing.md b/contributing/testing.md index 7ff84b537..59a4502c0 100644 --- a/contributing/testing.md +++ b/contributing/testing.md @@ -13,3 +13,14 @@ 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 + +`userEvent` works with both event systems (`configure({ eventSystem })`), so `jest.config.js` has two projects: + +- `legacy` runs all tests with the default `'legacy'` event system. +- `modern` runs `src/user-event/` tests again with `'modern'` (`jest-setup-modern.ts`). + +Both projects share the same snapshots. `createEventLogger()` entries print only the `nativeEvent` of event payloads (`src/test-utils/event-serializer.ts`), so a snapshot is the same for a legacy event object and a modern `SyntheticEvent`, and a difference between the two systems fails the snapshot. Use `--selectProjects legacy` or `--selectProjects modern` to run one of them. + +When a test expects different behavior in the two systems, e.g. events bubbling to a parent, branch on `getConfig().eventSystem` inside the test instead of skipping it. diff --git a/jest-setup-modern.ts b/jest-setup-modern.ts new file mode 100644 index 000000000..5526da7f2 --- /dev/null +++ b/jest-setup-modern.ts @@ -0,0 +1,6 @@ +import { configure } from './src/pure'; + +// Runs after `resetToDefaults()` in `jest-setup.ts`. +beforeEach(() => { + configure({ eventSystem: 'modern' }); +}); diff --git a/jest.config.js b/jest.config.js index 978adc0fc..d990a3e7c 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,16 +1,34 @@ -module.exports = { +const baseProject = { preset: '@react-native/jest-preset', setupFilesAfterEnv: ['./jest-setup.ts'], testPathIgnorePatterns: ['dist/', 'examples/', 'experiments-app/', 'codemods/', 'refs/'], 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' }, + // `userEvent` tests again with `configure({ eventSystem: 'modern' })`. They share snapshots with + // the legacy run, so both event systems must call the same handlers with the same native events. + { + ...baseProject, + displayName: 'modern', + roots: ['/src/user-event'], + setupFilesAfterEnv: [...baseProject.setupFilesAfterEnv, './jest-setup-modern.ts'], + }, + ], }; diff --git a/src/events/__tests__/dispatch-event.test.tsx b/src/events/__tests__/dispatch-event.test.tsx index a6c87520d..9a4e05f44 100644 --- a/src/events/__tests__/dispatch-event.test.tsx +++ b/src/events/__tests__/dispatch-event.test.tsx @@ -56,6 +56,17 @@ describe('modern event system', () => { expect(calls).toEqual(['parent.onFocusCapture', 'input.onFocus', 'parent.onFocus']); }); + test('keeps the event timeStamp', async () => { + const onFocus = jest.fn(); + await render(); + + await dispatchEvent(screen.getByTestId('input'), 'focus', { + ...buildFocusEvent(), + timeStamp: 42, + }); + expect(onFocus.mock.calls[0][0].timeStamp).toBe(42); + }); + test('returns true when only an ancestor handles the event', async () => { const onParentFocus = jest.fn(); await render( diff --git a/src/events/dispatch-event.ts b/src/events/dispatch-event.ts index c17f81081..c2ce6515f 100644 --- a/src/events/dispatch-event.ts +++ b/src/events/dispatch-event.ts @@ -15,7 +15,7 @@ import { invokeEventHandler } from './shared/invoke-event-handler'; * * - `legacy`: calls the element's own `on*` prop with `event` as it is. * - `modern`: native events (`focus`, `change`, `scroll`, ...) get a `SyntheticEvent` created from - * `event.nativeEvent`, dispatched as React Native does, through the capture and bubble phases + * `event.nativeEvent` and `event.timeStamp`, dispatched as React Native does, through the capture and bubble phases * for bubbling events. Callbacks that components call from JavaScript (`changeText`, `pressIn`, * `responderGrant`, ...) call the element's own `on*` prop with `event` as it is, as in * `legacy`. `press` is a native event type (`topPress`), so it reaches host `onPress` props @@ -36,7 +36,10 @@ export async function dispatchEvent( } const nativeEvent = extractNativeEvent(eventType, event); - const syntheticEvent = nativeEvent != null ? createEvent(eventType, { nativeEvent }) : null; + const syntheticEvent = + nativeEvent != null + ? createEvent(eventType, { nativeEvent, timeStamp: extractTimeStamp(event) }) + : null; if (syntheticEvent == null) { // JavaScript callbacks, e.g. `changeText`, are called as in the legacy event system. return await invokeEventHandler(instance, eventType, event); @@ -60,3 +63,8 @@ function extractNativeEvent(eventType: string, event: unknown): NativeEventPaylo return event.nativeEvent; } + +/** Returns the event's top-level `timeStamp`, e.g. `0` from the event builders. */ +function extractTimeStamp(event: unknown): number | undefined { + return isObject(event) && typeof event.timeStamp === 'number' ? event.timeStamp : undefined; +} 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/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..3b63a0816 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(() => { @@ -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(); + it('bubbles up only native bubbling events, in the modern event system', async () => { + const { events, logEvent } = createEventLogger(); await render( , @@ -205,7 +206,11 @@ 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().eventSystem === 'modern' ? modernEvents : [], + ); }); it('sets native state value for unmanaged text inputs', async () => { diff --git a/src/user-event/__tests__/paste.test.tsx b/src/user-event/__tests__/paste.test.tsx index d49395b58..3362c1560 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(() => { @@ -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(); + it('bubbles up only native bubbling events, in the modern event system', async () => { + const { events, logEvent } = createEventLogger(); await render( , @@ -221,7 +222,11 @@ 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().eventSystem === 'modern' ? modernEvents : [], + ); }); it('sets native state value for unmanaged text inputs', async () => { 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/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/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..993d79b03 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(() => { @@ -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(); + it('bubbles up only native bubbling events, in the modern event system', async () => { + const { events, logEvent } = createEventLogger(); await render( , @@ -350,7 +351,16 @@ 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().eventSystem === 'modern' ? modernEvents : [], + ); }); it('supports direct access', async () => { From 21da18923fe9994573ed74ababd4824e1b3e678e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 8 Oct 2026 19:51:15 +0200 Subject: [PATCH 14/28] fix ci --- src/events/__tests__/dispatch-event.test.tsx | 48 +++++++++++-------- .../modern/__tests__/event-types.test.ts | 33 +++++++++---- .../modern/__tests__/fire-event.test.tsx | 14 ++++-- 3 files changed, 61 insertions(+), 34 deletions(-) diff --git a/src/events/__tests__/dispatch-event.test.tsx b/src/events/__tests__/dispatch-event.test.tsx index 9a4e05f44..e3bffe139 100644 --- a/src/events/__tests__/dispatch-event.test.tsx +++ b/src/events/__tests__/dispatch-event.test.tsx @@ -8,18 +8,18 @@ import { SyntheticEvent } from '../modern/event'; describe('legacy event system', () => { test('calls only the target prop with the event as it is', async () => { - const onFocus = jest.fn(); - const onParentFocus = jest.fn(); + const onPointerDown = jest.fn(); + const onParentPointerDown = jest.fn(); await render( - - + + , ); - const event = buildFocusEvent(); - expect(await dispatchEvent(screen.getByTestId('input'), 'focus', event)).toBe(true); - expect(onFocus).toHaveBeenCalledWith(event); - expect(onParentFocus).not.toHaveBeenCalled(); + const event = { nativeEvent: { pointerId: 1 } }; + expect(await dispatchEvent(screen.getByTestId('target'), 'pointerDown', event)).toBe(true); + expect(onPointerDown).toHaveBeenCalledWith(event); + expect(onParentPointerDown).not.toHaveBeenCalled(); }); test('returns false without a handler', async () => { @@ -40,20 +40,25 @@ describe('modern event system', () => { await render( calls.push('parent.onFocusCapture')} - onFocus={(event) => { - calls.push('parent.onFocus'); + onPointerDownCapture={() => calls.push('parent.onPointerDownCapture')} + onPointerDown={(event) => { + calls.push('parent.onPointerDown'); expect(event).toBeInstanceOf(SyntheticEvent); - expect(event.nativeEvent).toEqual({ target: 0 }); - expect(event.target).toBe(screen.getByTestId('input')); + expect(event.nativeEvent).toEqual({ pointerId: 1 }); + expect(event.target).toBe(screen.getByTestId('target')); }} > - calls.push('input.onFocus')} /> + calls.push('target.onPointerDown')} /> , ); - expect(await dispatchEvent(screen.getByTestId('input'), 'focus', buildFocusEvent())).toBe(true); - expect(calls).toEqual(['parent.onFocusCapture', 'input.onFocus', 'parent.onFocus']); + const event = { nativeEvent: { pointerId: 1 } }; + expect(await dispatchEvent(screen.getByTestId('target'), 'pointerDown', event)).toBe(true); + expect(calls).toEqual([ + 'parent.onPointerDownCapture', + 'target.onPointerDown', + 'parent.onPointerDown', + ]); }); test('keeps the event timeStamp', async () => { @@ -68,15 +73,16 @@ describe('modern event system', () => { }); test('returns true when only an ancestor handles the event', async () => { - const onParentFocus = jest.fn(); + const onParentPointerDown = jest.fn(); await render( - - + + , ); - expect(await dispatchEvent(screen.getByTestId('input'), 'focus', buildFocusEvent())).toBe(true); - expect(onParentFocus).toHaveBeenCalledTimes(1); + const event = { nativeEvent: { pointerId: 1 } }; + expect(await dispatchEvent(screen.getByTestId('target'), 'pointerDown', event)).toBe(true); + expect(onParentPointerDown).toHaveBeenCalledTimes(1); }); test('returns false without a handler on the path', async () => { diff --git a/src/events/modern/__tests__/event-types.test.ts b/src/events/modern/__tests__/event-types.test.ts index 3ffe53da2..7e8264ea1 100644 --- a/src/events/modern/__tests__/event-types.test.ts +++ b/src/events/modern/__tests__/event-types.test.ts @@ -28,6 +28,11 @@ const viewConfigModules = [ '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', ]; @@ -38,15 +43,25 @@ function toEventType(topLevelType: string) { return topLevelType.charAt(3).toLowerCase() + topLevelType.slice(4); } +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 loadReactNativeEventTypes() { const bubbling = new Set(); const skipBubbling = new Set(); const direct = new Set(manualDirectEventTypes); - for (const modulePath of viewConfigModules) { - const module = jest.requireActual(modulePath); - const viewConfig: ViewConfig = module.__INTERNAL_VIEW_CONFIG ?? module.default; - + for (const viewConfig of loadViewConfigs()) { for (const [topLevelType, config] of Object.entries(viewConfig.bubblingEventTypes ?? {})) { const eventType = toEventType(topLevelType); // `getEventTypeConfig()` derives prop names from the event type. @@ -76,12 +91,14 @@ function loadReactNativeEventTypes() { return { bubbling, skipBubbling, direct }; } -test('event types match React Native view configs', () => { +// 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].sort()).toEqual([...reactNative.bubbling].sort()); - expect([...SKIP_BUBBLING_EVENT_TYPES].sort()).toEqual([...reactNative.skipBubbling].sort()); - expect([...DIRECT_EVENT_TYPES].sort()).toEqual([...reactNative.direct].sort()); + 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', () => { diff --git a/src/events/modern/__tests__/fire-event.test.tsx b/src/events/modern/__tests__/fire-event.test.tsx index 85e6e7711..e860ec875 100644 --- a/src/events/modern/__tests__/fire-event.test.tsx +++ b/src/events/modern/__tests__/fire-event.test.tsx @@ -173,16 +173,20 @@ test('dispatches bubbling events through capture and bubble phases', async () => const calls: string[] = []; await render( calls.push('parent.onFocusCapture')} - onFocus={() => calls.push('parent.onFocus')} + onPointerDownCapture={() => calls.push('parent.onPointerDownCapture')} + onPointerDown={() => calls.push('parent.onPointerDown')} > - calls.push('input.onFocus')} /> + calls.push('target.onPointerDown')} /> , ); - await fireEvent(screen.getByTestId('input'), 'focus', {}); + await fireEvent(screen.getByTestId('target'), 'pointerDown', {}); - expect(calls).toEqual(['parent.onFocusCapture', 'input.onFocus', 'parent.onFocus']); + expect(calls).toEqual([ + 'parent.onPointerDownCapture', + 'target.onPointerDown', + 'parent.onPointerDown', + ]); }); test('dispatches direct events to the target only', async () => { From 965a880aebc200db7c5dfcd3abebce5645dc63f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Fri, 9 Oct 2026 11:07:57 +0200 Subject: [PATCH 15/28] cleanup --- src/events/dispatch-event.ts | 20 ++-- .../modern/__tests__/event-types.test.ts | 65 +++++------ src/events/modern/__tests__/event.test.ts | 47 +++++--- .../modern/__tests__/fire-event.test.tsx | 14 +-- src/events/modern/dispatch.ts | 40 +++---- src/events/modern/event-types.ts | 70 ++++++------ src/events/modern/event.ts | 105 ++++++++---------- src/events/modern/fire-event-utils.ts | 1 - src/events/modern/fire-event.ts | 9 +- 9 files changed, 166 insertions(+), 205 deletions(-) diff --git a/src/events/dispatch-event.ts b/src/events/dispatch-event.ts index c2ce6515f..a0a218598 100644 --- a/src/events/dispatch-event.ts +++ b/src/events/dispatch-event.ts @@ -14,16 +14,15 @@ import { invokeEventHandler } from './shared/invoke-event-handler'; * `configure({ eventSystem })`. Used by `userEvent`. * * - `legacy`: calls the element's own `on*` prop with `event` as it is. - * - `modern`: native events (`focus`, `change`, `scroll`, ...) get a `SyntheticEvent` created from - * `event.nativeEvent` and `event.timeStamp`, dispatched as React Native does, through the capture and bubble phases - * for bubbling events. Callbacks that components call from JavaScript (`changeText`, `pressIn`, - * `responderGrant`, ...) call the element's own `on*` prop with `event` as it is, as in - * `legacy`. `press` is a native event type (`topPress`), so it reaches host `onPress` props - * only, not `Pressable`'s `onPress`. + * - `modern`: native events (`focus`, `change`, `scroll`, ...) get a `SyntheticEvent` from + * `event.nativeEvent` and `event.timeStamp`, dispatched as React Native does (capture and bubble + * phases for bubbling events). Callbacks that components call from JavaScript (`changeText`, + * `pressIn`, `responderGrant`, ...) are called as in `legacy`. `press` is a native event type + * (`topPress`), so it reaches host `onPress` props only, not `Pressable`'s `onPress`. * * @param eventType without the `on*` prefix, e.g. `focus` - * @param event the event object, e.g. from `buildFocusEvent()`, or the callback's argument, e.g. the - * text for `changeText` + * @param event the event object, e.g. from `buildFocusEvent()`, or the callback's argument, e.g. + * the text for `changeText` * @returns `true` if a handler was called. */ export async function dispatchEvent( @@ -41,17 +40,12 @@ export async function dispatchEvent( ? createEvent(eventType, { nativeEvent, timeStamp: extractTimeStamp(event) }) : null; if (syntheticEvent == null) { - // JavaScript callbacks, e.g. `changeText`, are called as in the legacy event system. return await invokeEventHandler(instance, eventType, event); } return await dispatchModernEvent(instance, syntheticEvent); } -/** - * Returns the `nativeEvent` payload if this is a native event: an event type React Native - * dispatches natively, with an event object that has a `nativeEvent`. - */ function extractNativeEvent(eventType: string, event: unknown): NativeEventPayload | null { if (getEventTypeConfig(eventType) == null) { return null; diff --git a/src/events/modern/__tests__/event-types.test.ts b/src/events/modern/__tests__/event-types.test.ts index 7e8264ea1..2a4309cb6 100644 --- a/src/events/modern/__tests__/event-types.test.ts +++ b/src/events/modern/__tests__/event-types.test.ts @@ -1,3 +1,4 @@ +import { createEvent } from '../event'; import { BUBBLING_EVENT_TYPES, DIRECT_EVENT_TYPES, @@ -13,7 +14,6 @@ type ViewConfig = { directEventTypes?: Record; }; -// Base view configs plus the built-in components that declare events. const viewConfigModules = [ 'react-native/Libraries/NativeComponent/BaseViewConfig.ios', 'react-native/Libraries/NativeComponent/BaseViewConfig.android', @@ -39,23 +39,6 @@ const optionalViewConfigModules = [ // Not exported by React Native, so listed by hand in `event-types.ts`. const manualDirectEventTypes = ['textLayout']; -function toEventType(topLevelType: string) { - return topLevelType.charAt(3).toLowerCase() + topLevelType.slice(4); -} - -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 loadReactNativeEventTypes() { const bubbling = new Set(); const skipBubbling = new Set(); @@ -64,8 +47,8 @@ function loadReactNativeEventTypes() { for (const viewConfig of loadViewConfigs()) { for (const [topLevelType, config] of Object.entries(viewConfig.bubblingEventTypes ?? {})) { const eventType = toEventType(topLevelType); - // `getEventTypeConfig()` derives prop names from the event type. - expect(getEventTypeConfig(eventType)?.dispatchConfig).toEqual({ + // Prop names derived from the event type match React Native's. + expect(getDispatchConfig(eventType)).toEqual({ phasedRegistrationNames: config.phasedRegistrationNames, }); bubbling.add(eventType); @@ -81,7 +64,7 @@ function loadReactNativeEventTypes() { } const eventType = toEventType(topLevelType); - expect(getEventTypeConfig(eventType)?.dispatchConfig).toEqual({ + expect(getDispatchConfig(eventType)).toEqual({ registrationName: config.registrationName, }); direct.add(eventType); @@ -91,6 +74,27 @@ function loadReactNativeEventTypes() { 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', () => { @@ -104,32 +108,23 @@ test('event types cover React Native view configs', () => { test('getEventTypeConfig() returns config of bubbling event', () => { expect(getEventTypeConfig('pointerUp')).toEqual({ kind: 'bubbling', + handlerName: 'onPointerUp', + captureHandlerName: 'onPointerUpCapture', skipBubbling: false, - dispatchConfig: { - phasedRegistrationNames: { bubbled: 'onPointerUp', captured: 'onPointerUpCapture' }, - }, }); }); test('getEventTypeConfig() returns config of bubbling event with skipBubbling', () => { expect(getEventTypeConfig('pointerEnter')).toEqual({ kind: 'bubbling', + handlerName: 'onPointerEnter', + captureHandlerName: 'onPointerEnterCapture', skipBubbling: true, - dispatchConfig: { - phasedRegistrationNames: { - bubbled: 'onPointerEnter', - captured: 'onPointerEnterCapture', - skipBubbling: true, - }, - }, }); }); test('getEventTypeConfig() returns config of direct event', () => { - expect(getEventTypeConfig('layout')).toEqual({ - kind: 'direct', - dispatchConfig: { registrationName: 'onLayout' }, - }); + expect(getEventTypeConfig('layout')).toEqual({ kind: 'direct', handlerName: 'onLayout' }); }); test('getEventTypeConfig() returns null for events unknown to React Native', () => { diff --git a/src/events/modern/__tests__/event.test.ts b/src/events/modern/__tests__/event.test.ts index 3101368f3..897455c6d 100644 --- a/src/events/modern/__tests__/event.test.ts +++ b/src/events/modern/__tests__/event.test.ts @@ -1,6 +1,10 @@ -import { createEvent, SyntheticEvent } from '../event'; +import { format } from 'pretty-format'; +import type { TestInstance } from 'test-renderer'; -const dispatchConfig = { registrationName: 'onFocus' }; +import { createEvent, getEventInternals, SyntheticEvent } from '../event'; +import type { EventTypeConfig } from '../event-types'; + +const typeConfig: EventTypeConfig = { kind: 'direct', handlerName: 'onFocus' }; afterEach(() => { jest.restoreAllMocks(); @@ -10,9 +14,9 @@ test('exposes init values', () => { const nativeEvent = { target: 1 }; const event = new SyntheticEvent( 'layout', - { cancelable: true, rnIsDirect: true, timeStamp: 123 }, + { cancelable: true, timeStamp: 123 }, nativeEvent, - dispatchConfig, + typeConfig, ); expect(event.type).toBe('layout'); @@ -21,12 +25,12 @@ test('exposes init values', () => { expect(event.rnIsDirect).toBe(true); expect(event.timeStamp).toBe(123); expect(event.nativeEvent).toBe(nativeEvent); - expect(event.dispatchConfig).toBe(dispatchConfig); + 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', { bubbles: true }, {}, dispatchConfig); + const event = new SyntheticEvent('focus', {}, {}, typeConfig); expect(event.target).toBeNull(); expect(event.currentTarget).toBeNull(); @@ -34,16 +38,26 @@ test('has no target and phase outside of dispatch', () => { 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', {}, {}, dispatchConfig); + 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 }, {}, dispatchConfig); + const event = new SyntheticEvent('focus', { cancelable: true }, {}, typeConfig); event.preventDefault(); @@ -52,7 +66,7 @@ test('preventDefault() marks cancelable event as default prevented', () => { }); test('preventDefault() does nothing on non-cancelable event', () => { - const event = new SyntheticEvent('focus', {}, {}, dispatchConfig); + const event = new SyntheticEvent('focus', {}, {}, typeConfig); event.preventDefault(); @@ -63,7 +77,7 @@ test('preventDefault() does nothing on non-cancelable event', () => { test.each(['stopPropagation', 'stopImmediatePropagation'] as const)( '%s() stops propagation', (method) => { - const event = new SyntheticEvent('focus', {}, {}, dispatchConfig); + const event = new SyntheticEvent('focus', {}, {}, typeConfig); expect(event.isPropagationStopped()).toBe(false); event[method](); @@ -74,7 +88,7 @@ test.each(['stopPropagation', 'stopImmediatePropagation'] as const)( ); test('cancelBubble can only be set to true', () => { - const event = new SyntheticEvent('focus', {}, {}, dispatchConfig); + const event = new SyntheticEvent('focus', {}, {}, typeConfig); event.cancelBubble = true; event.cancelBubble = false; @@ -83,13 +97,13 @@ test('cancelBubble can only be set to true', () => { }); test('persist() is a no-op', () => { - const event = new SyntheticEvent('focus', {}, {}, dispatchConfig); + 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', {}, {}, dispatchConfig); + const event = new SyntheticEvent('focus', {}, {}, typeConfig); expect([ SyntheticEvent.NONE, @@ -126,6 +140,13 @@ describe('createEvent()', () => { 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', () => { diff --git a/src/events/modern/__tests__/fire-event.test.tsx b/src/events/modern/__tests__/fire-event.test.tsx index e860ec875..91d6969a5 100644 --- a/src/events/modern/__tests__/fire-event.test.tsx +++ b/src/events/modern/__tests__/fire-event.test.tsx @@ -4,12 +4,12 @@ import type { TestInstance } from 'test-renderer'; import { render, screen } from '../../..'; import type { SyntheticEvent } from '../event'; -import type { FireEventInit, FireEventType } from '../fire-event'; +import type { FireEventInit } from '../fire-event'; import { fireEvent } from '../fire-event'; /** 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 as FireEventType, ...(args as [FireEventInit])); + return fireEvent(instance, eventType, ...(args as [FireEventInit])); } describe('event object', () => { @@ -251,16 +251,6 @@ describe('event types unknown to React Native', () => { expect(handler).not.toHaveBeenCalled(); }); - test('are type errors', async () => { - await render(); - const input = screen.getByTestId('input'); - - // @ts-expect-error `changeText` isn't dispatched natively. - await expect(fireEvent(input, 'changeText', {})).rejects.toThrow(); - // @ts-expect-error `onChangeText` isn't dispatched natively. - await expect(fireEvent(input, 'onChangeText', {})).rejects.toThrow(); - }); - test('throws before checking the event object', async () => { await render(); diff --git a/src/events/modern/dispatch.ts b/src/events/modern/dispatch.ts index f6fa9a50f..b0995bb64 100644 --- a/src/events/modern/dispatch.ts +++ b/src/events/modern/dispatch.ts @@ -4,7 +4,7 @@ import { act } from '../../act'; import { isInstanceMounted } from '../../helpers/component-tree'; import { getHandlerByName } from '../shared/handler'; import type { EventPhase } from './event'; -import { eventInternals, SyntheticEvent } from './event'; +import { getEventInternals, SyntheticEvent } from './event'; /** * Dispatches an event from `createEvent()` as React Native does for native events @@ -34,8 +34,8 @@ export async function dispatchEvent(target: TestInstance, event: SyntheticEvent) const state: DispatchState = { hasCalledHandler: false, firstError: null }; await act(() => { - eventInternals.setTarget(event, target); - eventInternals.setComposedPath(event, path); + getEventInternals(event).target = target; + getEventInternals(event).composedPath = path; runCapturePhase(event, path, state); runBubblePhase(event, path, state); @@ -50,7 +50,6 @@ export async function dispatchEvent(target: TestInstance, event: SyntheticEvent) return state.hasCalledHandler; } -/** What the handlers did during one dispatch. */ type DispatchState = { hasCalledHandler: boolean; /** Wrapped, so a thrown `undefined` is rethrown too. */ @@ -75,20 +74,20 @@ function getEventPath(target: TestInstance, event: SyntheticEvent): TestInstance /** From the root down to the target, calling `on*Capture` props. Direct events skip it. */ function runCapturePhase(event: SyntheticEvent, path: TestInstance[], state: DispatchState) { - const handlerName = getCaptureHandlerName(event); - if (handlerName == null) { + 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], handlerName, phase, state); + 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) { - const handlerName = getBubbleHandlerName(event); + 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 phase = i === 0 ? SyntheticEvent.AT_TARGET : SyntheticEvent.BUBBLING_PHASE; @@ -103,8 +102,8 @@ function callHandler( phase: EventPhase, state: DispatchState, ) { - eventInternals.setEventPhase(event, phase); - eventInternals.setCurrentTarget(event, node); + getEventInternals(event).eventPhase = phase; + getEventInternals(event).currentTarget = node; const handler = getHandlerByName(node.props, handlerName); if (handler == null) { @@ -124,23 +123,10 @@ function callHandler( } } -/** Direct events have no capture phase. */ -function getCaptureHandlerName(event: SyntheticEvent): string | null { - const config = event.dispatchConfig; - return 'registrationName' in config ? null : config.phasedRegistrationNames.captured; -} - -function getBubbleHandlerName(event: SyntheticEvent): string { - const config = event.dispatchConfig; - return 'registrationName' in config - ? config.registrationName - : config.phasedRegistrationNames.bubbled; -} - /** Clears the event's internals, as the event can still be read after the dispatch. */ function resetEvent(event: SyntheticEvent) { - eventInternals.setEventPhase(event, SyntheticEvent.NONE); - eventInternals.setCurrentTarget(event, null); - eventInternals.setComposedPath(event, []); - eventInternals.resetStopPropagationFlag(event); + 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 index a46b82f90..285a56fd8 100644 --- a/src/events/modern/event-types.ts +++ b/src/events/modern/event-types.ts @@ -1,18 +1,15 @@ import { getEventHandlerName } from '../shared/handler'; /** - * Event types known to React Native, keyed by event type (`focus`, not `topFocus`). + * 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`. - * - * Prop names follow from the event type: `onFocus` (bubble phase, direct events) and - * `onFocusCapture`. */ -export const BUBBLING_EVENT_TYPES = [ +export const BUBBLING_EVENT_TYPES: readonly string[] = [ 'blur', 'change', 'click', @@ -38,12 +35,12 @@ export const BUBBLING_EVENT_TYPES = [ 'touchEnd', 'touchMove', 'touchStart', -] as const; +]; /** Bubbling events that run the capture phase as usual, but call only the target when bubbling. */ -export const SKIP_BUBBLING_EVENT_TYPES = ['pointerEnter', 'pointerLeave'] as const; +export const SKIP_BUBBLING_EVENT_TYPES: readonly string[] = ['pointerEnter', 'pointerLeave']; -export const DIRECT_EVENT_TYPES = [ +export const DIRECT_EVENT_TYPES: readonly string[] = [ 'accessibilityAction', 'accessibilityEscape', 'accessibilityTap', @@ -81,51 +78,46 @@ export const DIRECT_EVENT_TYPES = [ 'show', // From the inline view config in `TextNativeComponent.js`, which is not exported. 'textLayout', -] as const; - -/** Event type React Native dispatches natively, e.g. `focus` or `layout`. */ -export type NativeEventType = - | (typeof BUBBLING_EVENT_TYPES)[number] - | (typeof DIRECT_EVENT_TYPES)[number]; +]; -const bubblingEventTypes: readonly string[] = BUBBLING_EVENT_TYPES; -const skipBubblingEventTypes: readonly string[] = SKIP_BUBBLING_EVENT_TYPES; -const directEventTypes: readonly string[] = DIRECT_EVENT_TYPES; +/** 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; +}; -// React Native's `DispatchConfig` shape: `TouchableOpacity` reads its `registrationName`. -export type DispatchConfig = - | { - phasedRegistrationNames: { bubbled: string; captured: string; skipBubbling?: boolean }; - } - | { registrationName: string }; +/** Calls only the target's prop, without the capture phase. */ +type DirectEventTypeConfig = { + kind: 'direct'; + /** e.g. `onLayout` */ + handlerName: string; +}; -type EventTypeConfig = - | { kind: 'bubbling'; skipBubbling: boolean; dispatchConfig: DispatchConfig } - | { kind: 'direct'; dispatchConfig: DispatchConfig }; +/** 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 (bubblingEventTypes.includes(eventType)) { - const propName = getEventHandlerName(eventType); - const skipBubbling = skipBubblingEventTypes.includes(eventType); + if (BUBBLING_EVENT_TYPES.includes(eventType)) { + const handlerName = getEventHandlerName(eventType); return { kind: 'bubbling', - skipBubbling, - dispatchConfig: { - phasedRegistrationNames: { - bubbled: propName, - captured: `${propName}Capture`, - ...(skipBubbling ? { skipBubbling } : {}), - }, - }, + handlerName, + captureHandlerName: `${handlerName}Capture`, + skipBubbling: SKIP_BUBBLING_EVENT_TYPES.includes(eventType), }; } - if (directEventTypes.includes(eventType)) { - return { kind: 'direct', dispatchConfig: { registrationName: getEventHandlerName(eventType) } }; + if (DIRECT_EVENT_TYPES.includes(eventType)) { + return { kind: 'direct', handlerName: getEventHandlerName(eventType) }; } return null; diff --git a/src/events/modern/event.ts b/src/events/modern/event.ts index 3d7f0271a..30ee22fd4 100644 --- a/src/events/modern/event.ts +++ b/src/events/modern/event.ts @@ -1,10 +1,18 @@ import type { TestInstance } from 'test-renderer'; -import type { DispatchConfig } from './event-types'; +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; @@ -16,10 +24,7 @@ export type CreateEventInit = { }; type SyntheticEventInit = { - bubbles?: boolean; cancelable?: boolean; - /** Direct events are dispatched only to the target, without the capture phase. */ - rnIsDirect?: boolean; timeStamp?: number; }; @@ -35,10 +40,11 @@ export type EventPhase = | typeof BUBBLING_PHASE; /** - * Event fields that the dispatch updates, as in React Native's `EventInternals`. Kept off the - * event's own properties, so printed events don't include the element tree. + * 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; @@ -46,16 +52,7 @@ type EventInternals = { stopPropagation: boolean; }; -const eventInternalsMap = new WeakMap(); - -function getEventInternals(event: SyntheticEvent): EventInternals { - const internals = eventInternalsMap.get(event); - if (internals == null) { - throw new TypeError('Illegal invocation'); - } - - return internals; -} +const EVENT_INTERNALS = Symbol('eventInternals'); /** * Event object passed to handlers by the modern event system. @@ -84,22 +81,24 @@ export class SyntheticEvent { type: string, init: SyntheticEventInit, nativeEvent: NativeEventPayload, - dispatchConfig: DispatchConfig, + typeConfig: EventTypeConfig, ) { this._type = type; - this._bubbles = Boolean(init.bubbles); + this._bubbles = typeConfig.kind === 'bubbling' && !typeConfig.skipBubbling; this._cancelable = Boolean(init.cancelable); - this._rnIsDirect = Boolean(init.rnIsDirect); + this._rnIsDirect = typeConfig.kind === 'direct'; this._timeStamp = init.timeStamp ?? performance.now(); this._nativeEvent = nativeEvent; - this._dispatchConfig = dispatchConfig; - - eventInternalsMap.set(this, { - target: null, - currentTarget: null, - eventPhase: NONE, - composedPath: [], - stopPropagation: false, + this._dispatchConfig = toDispatchConfig(typeConfig); + Object.defineProperty(this, EVENT_INTERNALS, { + value: { + typeConfig, + target: null, + currentTarget: null, + eventPhase: NONE, + composedPath: [], + stopPropagation: false, + } satisfies EventInternals, }); } @@ -132,6 +131,7 @@ export class SyntheticEvent { return this._cancelable; } + /** Direct events are dispatched only to the target, without the capture phase. */ get rnIsDirect(): boolean { return this._rnIsDirect; } @@ -210,29 +210,6 @@ export class SyntheticEvent { } } -/** @internal Lets the dispatch update the event, as React Native's `EventInternals` does. */ -export const eventInternals = { - setTarget(event: SyntheticEvent, target: TestInstance | null) { - getEventInternals(event).target = target; - }, - - setCurrentTarget(event: SyntheticEvent, currentTarget: TestInstance | null) { - getEventInternals(event).currentTarget = currentTarget; - }, - - setEventPhase(event: SyntheticEvent, eventPhase: EventPhase) { - getEventInternals(event).eventPhase = eventPhase; - }, - - setComposedPath(event: SyntheticEvent, composedPath: TestInstance[]) { - getEventInternals(event).composedPath = composedPath; - }, - - resetStopPropagationFlag(event: SyntheticEvent) { - getEventInternals(event).stopPropagation = false; - }, -}; - /** * Creates the event React Native would create for a native event of this type, ready for * `dispatchEvent()`. Like Testing Library's DOM `createEvent()`. @@ -254,13 +231,27 @@ export function createEvent(eventType: string, init: CreateEventInit = {}): Synt return new SyntheticEvent( // React Native's event type is the lowercased name, e.g. `pointerup`. eventType.toLowerCase(), - { - bubbles: config.kind === 'bubbling' && !config.skipBubbling, - cancelable: true, - rnIsDirect: config.kind === 'direct', - timeStamp, - }, + { cancelable: true, timeStamp }, nativeEvent, - config.dispatchConfig, + 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 index b0439c393..f8458cfb6 100644 --- a/src/events/modern/fire-event-utils.ts +++ b/src/events/modern/fire-event-utils.ts @@ -8,7 +8,6 @@ const ALLOWED_INIT_KEYS = ['nativeEvent', 'timeStamp']; /** Legacy events carried these as stubs. The dispatch sets them to host elements. */ const DISPATCH_KEYS = ['target', 'currentTarget']; -/** Members of the event itself, such as `persist` or `preventDefault`. */ const EVENT_KEYS = new Set( Object.getOwnPropertyNames(SyntheticEvent.prototype).filter((key) => key !== 'constructor'), ); diff --git a/src/events/modern/fire-event.ts b/src/events/modern/fire-event.ts index c6e418885..0b0a549ee 100644 --- a/src/events/modern/fire-event.ts +++ b/src/events/modern/fire-event.ts @@ -5,7 +5,6 @@ import { normalizeEventType } from '../shared/handler'; import { dispatchEvent } from './dispatch'; import type { CreateEventInit } from './event'; import { createEvent } from './event'; -import type { NativeEventType } from './event-types'; import { ensureEventType, validateEventInit } from './fire-event-utils'; /** @@ -14,12 +13,6 @@ import { ensureEventType, validateEventInit } from './fire-event-utils'; */ export type FireEventInit = CreateEventInit; -/** - * Event type React Native dispatches natively, with or without the `on*` prefix. JavaScript - * callers can pass any string, so `fireEvent()` still checks it at runtime. - */ -export type FireEventType = NativeEventType | `on${Capitalize}`; - /** * 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. @@ -32,7 +25,7 @@ export type FireEventType = NativeEventType | `on${Capitalize}` */ export async function fireEvent( instance: TestInstance, - eventType: FireEventType, + eventType: string, ...args: [event: FireEventInit] ): Promise { const normalizedType = normalizeEventType(eventType); From b23682fd71044d3a477942da0a3aba60af76953d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Fri, 9 Oct 2026 11:54:40 +0200 Subject: [PATCH 16/28] move native state to shared --- contributing/event-dispatch.md | 21 +- contributing/native-state.md | 4 +- .../legacy/__tests__/fire-event.test.tsx | 2 +- src/events/legacy/fire-event.ts | 4 +- src/events/legacy/index.ts | 2 +- .../modern/__tests__/fire-event.test.tsx | 252 +++++++++++++++++- src/events/modern/fire-event.ts | 76 +++++- src/events/{legacy => shared}/native-state.ts | 0 .../{legacy => shared}/update-native-state.ts | 0 src/helpers/text-input.ts | 2 +- 10 files changed, 336 insertions(+), 27 deletions(-) rename src/events/{legacy => shared}/native-state.ts (100%) rename src/events/{legacy => shared}/update-native-state.ts (100%) diff --git a/contributing/event-dispatch.md b/contributing/event-dispatch.md index c4a3e3c14..7658a2d2c 100644 --- a/contributing/event-dispatch.md +++ b/contributing/event-dispatch.md @@ -6,17 +6,16 @@ Both are built on the shared event subsystem in `src/events/legacy/`, which also The files in `src/events/legacy/`: -| File | Contents | -| ------------------------------------------- | --------------------------------------------------------------------------------------- | -| `fire-event.ts` | Public `fireEvent` API | -| `propagation.ts` | Bubbling vs direct events, walking up host and composite elements | -| `is-enabled.ts` | Whether a device would deliver the event: `pointerEvents`, `editable`, touch responders | -| `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 | - -Code used by both event systems lives in `src/events/shared/`: `handler.ts` (finding the `on*` handler for an event name in props) and `types.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/` | Event payloads, matching what React Native sends on a device | + +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), and `types.ts`. `src/user-event/` is a separate module on top of `src/events/legacy/` and imports it only through `src/events/legacy/index.ts`, which also re-exports `src/events/shared/handler.ts`. diff --git a/contributing/native-state.md b/contributing/native-state.md index f543b2c38..359a64e7d 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/legacy/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,7 +10,7 @@ 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/legacy/update-native-state.ts`. Each `userEvent` action writes it 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`. 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. - **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. diff --git a/src/events/legacy/__tests__/fire-event.test.tsx b/src/events/legacy/__tests__/fire-event.test.tsx index 9775f036d..0ccc30be2 100644 --- a/src/events/legacy/__tests__/fire-event.test.tsx +++ b/src/events/legacy/__tests__/fire-event.test.tsx @@ -21,7 +21,7 @@ import { fireEvent, render, screen } from '../../..'; import { configure } from '../../../config'; import { _console, logger } from '../../../helpers/logger'; import { getEventHandlerName } from '../../shared/handler'; -import { nativeState } from '../native-state'; +import { nativeState } from '../../shared/native-state'; const layoutEvent = { nativeEvent: { layout: { width: 100, height: 100 } } }; const verticalScrollEvent = { nativeEvent: { contentOffset: { y: 200 } } }; diff --git a/src/events/legacy/fire-event.ts b/src/events/legacy/fire-event.ts index 433ed4037..e04f4c310 100644 --- a/src/events/legacy/fire-event.ts +++ b/src/events/legacy/fire-event.ts @@ -4,13 +4,13 @@ import { act } from '../../act'; import { isInstanceMounted } from '../../helpers/component-tree'; import { isHostScrollView } from '../../helpers/host-component-names'; import { normalizeEventType } from '../shared/handler'; +import { nativeState } from '../shared/native-state'; import type { EventProps, EventType, LayoutRectangle } from '../shared/types'; +import { updateNativeStateFromEvent } from '../shared/update-native-state'; import { buildLayoutEvent, buildTouchEvent } from './builders/common'; import { mergeEventProps } from './builders/merge'; import { buildScrollEvent } from './builders/scroll'; -import { nativeState } from './native-state'; import { findEventHandler } from './propagation'; -import { updateNativeStateFromEvent } from './update-native-state'; import { warnAboutUnhandledEvent } from './warnings'; async function fireEvent(instance: TestInstance, eventType: EventType, ...data: unknown[]) { diff --git a/src/events/legacy/index.ts b/src/events/legacy/index.ts index 7c720b7a1..b70811fb5 100644 --- a/src/events/legacy/index.ts +++ b/src/events/legacy/index.ts @@ -4,6 +4,6 @@ export * from './builders/text'; export * from './dispatch'; 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/modern/__tests__/fire-event.test.tsx b/src/events/modern/__tests__/fire-event.test.tsx index 91d6969a5..9a8511348 100644 --- a/src/events/modern/__tests__/fire-event.test.tsx +++ b/src/events/modern/__tests__/fire-event.test.tsx @@ -1,12 +1,23 @@ import * as React from 'react'; -import { Text, TextInput, View } from 'react-native'; +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])); @@ -322,3 +333,242 @@ test('renders state updates from the handler', async () => { 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('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.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('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/fire-event.ts b/src/events/modern/fire-event.ts index 0b0a549ee..73e4f7fab 100644 --- a/src/events/modern/fire-event.ts +++ b/src/events/modern/fire-event.ts @@ -1,7 +1,15 @@ import type { TestInstance } from 'test-renderer'; +import { isInstanceMounted } from '../../helpers/component-tree'; import { ErrorWithStack } from '../../helpers/errors'; +import { isHostScrollView } from '../../helpers/host-component-names'; +import { buildLayoutEvent } from '../legacy/builders/common'; +import { mergeEventProps } from '../legacy/builders/merge'; +import { buildScrollEvent } from '../legacy/builders/scroll'; import { normalizeEventType } from '../shared/handler'; +import { nativeState } from '../shared/native-state'; +import type { LayoutRectangle } from '../shared/types'; +import { updateNativeStateFromEvent } from '../shared/update-native-state'; import { dispatchEvent } from './dispatch'; import type { CreateEventInit } from './event'; import { createEvent } from './event'; @@ -29,21 +37,73 @@ export async function fireEvent( ...args: [event: FireEventInit] ): Promise { const normalizedType = normalizeEventType(eventType); - if (instance == null) { - throw new ErrorWithStack( - `Unable to fire a "${normalizedType}" event - please provide a host element.`, - fireEvent, - ); - } - + ensureInstance(instance, normalizedType, fireEvent); // Before the event object, as `fireEvent(input, 'changeText', 'Hello')` needs the hint more. ensureEventType(normalizedType, fireEvent); const init = validateEventInit(normalizedType, args, fireEvent); - const event = createEvent(normalizedType, init); + return await fireEventInternal(instance, normalizedType, init); +} + +/** + * Fires a `scroll` event. The passed `nativeEvent` is deep merged onto a default scroll payload, + * whose `layoutMeasurement` is the `ScrollView`'s size from its last `layout` event. + */ +fireEvent.scroll = async ( + instance: TestInstance, + ...args: [event?: FireEventInit] +): Promise => { + ensureInstance(instance, 'scroll', fireEvent.scroll); + const init = validateEventInit('scroll', args.length > 0 ? args : [{}], fireEvent.scroll); + const layoutMeasurement = isHostScrollView(instance) + ? nativeState.layoutSizeForInstance.get(instance) + : undefined; + const { nativeEvent } = buildScrollEvent(undefined, { layoutMeasurement }); + return await fireEventInternal(instance, 'scroll', { + ...init, + nativeEvent: mergeEventProps(nativeEvent, init.nativeEvent), + }); +}; + +/** 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); + const { nativeEvent } = buildLayoutEvent(layout); + return await fireEventInternal(instance, 'layout', { nativeEvent }); +}; + +/** Expects an event type React Native dispatches natively, without the `on*` prefix. */ +async function fireEventInternal( + instance: TestInstance, + eventType: string, + init: CreateEventInit, +): Promise { + const event = createEvent(eventType, init); 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); 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/legacy/native-state.ts b/src/events/shared/native-state.ts similarity index 100% rename from src/events/legacy/native-state.ts rename to src/events/shared/native-state.ts diff --git a/src/events/legacy/update-native-state.ts b/src/events/shared/update-native-state.ts similarity index 100% rename from src/events/legacy/update-native-state.ts rename to src/events/shared/update-native-state.ts diff --git a/src/helpers/text-input.ts b/src/helpers/text-input.ts index 5615191e0..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/legacy/native-state'; +import { nativeState } from '../events/shared/native-state'; import { isHostTextInput } from './host-component-names'; export function isEditableTextInput(instance: TestInstance) { From f47dd0898d5174b2666dfc2bedd097962ee1009c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Fri, 9 Oct 2026 12:31:54 +0200 Subject: [PATCH 17/28] shared createEvent --- contributing/event-dispatch.md | 22 +-- src/events/__tests__/create-event.test.ts | 88 ++++++++++ src/events/__tests__/dispatch-event.test.tsx | 64 ++----- src/events/create-event.ts | 110 ++++++++++++ src/events/dispatch-event.ts | 59 ++----- .../legacy/builders/__tests__/base.test.ts | 11 +- .../legacy/builders/__tests__/common.test.ts | 33 +--- .../legacy/builders/__tests__/index.test.ts | 11 +- .../legacy/builders/__tests__/scroll.test.ts | 37 ----- .../legacy/builders/__tests__/text.test.ts | 52 ------ src/events/legacy/builders/base.ts | 5 + src/events/legacy/builders/common.ts | 89 +--------- src/events/legacy/builders/index.ts | 5 +- src/events/legacy/builders/scroll.ts | 36 ---- src/events/legacy/builders/text.ts | 74 --------- src/events/legacy/fire-event.ts | 11 +- src/events/legacy/index.ts | 4 +- src/events/modern/fire-event.ts | 12 +- .../__tests__/merge.test.ts | 17 +- src/events/shared/__tests__/payloads.test.ts | 105 ++++++++++++ .../{legacy/builders => shared}/merge.ts | 4 +- src/events/shared/payloads.ts | 156 ++++++++++++++++++ .../accessibility-action.ts | 2 +- src/user-event/clear.ts | 4 +- src/user-event/paste.ts | 7 +- src/user-event/press/press.ts | 14 +- src/user-event/scroll/scroll-to.ts | 27 ++- src/user-event/type/type.ts | 12 +- src/user-event/utils/interaction.ts | 16 +- 29 files changed, 602 insertions(+), 485 deletions(-) create mode 100644 src/events/__tests__/create-event.test.ts create mode 100644 src/events/create-event.ts delete mode 100644 src/events/legacy/builders/__tests__/scroll.test.ts delete mode 100644 src/events/legacy/builders/__tests__/text.test.ts delete mode 100644 src/events/legacy/builders/scroll.ts delete mode 100644 src/events/legacy/builders/text.ts rename src/events/{legacy/builders => shared}/__tests__/merge.test.ts (68%) create mode 100644 src/events/shared/__tests__/payloads.test.ts rename src/events/{legacy/builders => shared}/merge.ts (87%) create mode 100644 src/events/shared/payloads.ts diff --git a/contributing/event-dispatch.md b/contributing/event-dispatch.md index 7658a2d2c..f37c6a593 100644 --- a/contributing/event-dispatch.md +++ b/contributing/event-dispatch.md @@ -6,16 +6,16 @@ Both are built on the shared event subsystem in `src/events/legacy/`, which also The files in `src/events/legacy/`: -| File | Contents | -| ---------------- | --------------------------------------------------------------------------------------- | -| `fire-event.ts` | Public `fireEvent` API | -| `propagation.ts` | Bubbling vs direct events, walking up host and composite elements | -| `is-enabled.ts` | Whether a device would deliver the event: `pointerEvents`, `editable`, touch responders | -| `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 | +| 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), and `types.ts`. +Code used by both event systems lives in `src/events/shared/`: `handler.ts` (finding the `on*` handler for an event name in props), `native-state.ts` and `update-native-state.ts` ([native state](native-state.md) and how `fireEvent` updates it), `payloads.ts` (`nativeEvent` payloads matching what React Native sends on a device), `merge.ts` (deep merging custom props into them), and `types.ts`. `src/user-event/` is a separate module on top of `src/events/legacy/` and imports it only through `src/events/legacy/index.ts`, which also re-exports `src/events/shared/handler.ts`. @@ -31,11 +31,11 @@ Code used by both event systems lives in `src/events/shared/`: `handler.ts` (fin `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, built for the configured event system by `buildEvent()` (`src/events/build-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. 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/build-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. diff --git a/src/events/__tests__/create-event.test.ts b/src/events/__tests__/create-event.test.ts new file mode 100644 index 000000000..2d8a1c2c0 --- /dev/null +++ b/src/events/__tests__/create-event.test.ts @@ -0,0 +1,88 @@ +import { configure } from '../..'; +import { + buildAccessibilityActionEvent, + buildBlurEvent, + buildContentSizeChangeEvent, + buildEndEditingEvent, + createEvent, + buildFocusEvent, + buildKeyPressEvent, + buildLayoutEvent, + 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', () => { + const nativeEvent = { target: 0 }; + + const event = createEvent('focus', nativeEvent); + + expect(event).not.toBeInstanceOf(SyntheticEvent); + expect(event).toMatchObject({ nativeEvent, timeStamp: 0, preventDefault: expect.any(Function) }); +}); + +test('builds a SyntheticEvent in the modern event system', () => { + configure({ eventSystem: 'modern' }); + const nativeEvent = { target: 0 }; + + const event = createEvent('focus', nativeEvent); + + expect(event).toBeInstanceOf(SyntheticEvent); + expect(event.nativeEvent).toBe(nativeEvent); + expect(event.type).toBe('focus'); +}); + +test.each(['legacy', 'modern'] as const)( + 'throws for event types that are not native in the %s event system', + (eventSystem) => { + configure({ eventSystem }); + + 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')], + ['layout', () => buildLayoutEvent({ width: 100 })], + ['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); + const legacyEvent = build(); + configure({ eventSystem: 'modern' }); + const modernEvent = build(); + + expect(legacyEvent).not.toBeInstanceOf(SyntheticEvent); + expect(modernEvent).toBeInstanceOf(SyntheticEvent); + expect(modernEvent.type).toBe(type); + expect(modernEvent.nativeEvent).toEqual(legacyEvent.nativeEvent); +}); + +test('buildLayoutEvent and buildScrollEvent pass their arguments to the native event', () => { + expect(buildLayoutEvent({ width: 100 }).nativeEvent).toEqual({ + layout: { x: 0, y: 0, width: 100, height: 0 }, + target: 0, + }); + 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 index e3bffe139..b5a171587 100644 --- a/src/events/__tests__/dispatch-event.test.tsx +++ b/src/events/__tests__/dispatch-event.test.tsx @@ -2,12 +2,13 @@ import * as React from 'react'; import { TextInput, View } from 'react-native'; import { configure, render, screen } from '../..'; +import { createEvent } from '../create-event'; import { dispatchEvent } from '../dispatch-event'; -import { buildFocusEvent, buildTouchEvent } from '../legacy'; +import { buildFocusNativeEvent } from '../legacy'; import { SyntheticEvent } from '../modern/event'; describe('legacy event system', () => { - test('calls only the target prop with the event as it is', async () => { + test('calls only the target prop with the legacy event object', async () => { const onPointerDown = jest.fn(); const onParentPointerDown = jest.fn(); await render( @@ -16,7 +17,7 @@ describe('legacy event system', () => { , ); - const event = { nativeEvent: { pointerId: 1 } }; + const event = createEvent('pointerDown', { pointerId: 1 }); expect(await dispatchEvent(screen.getByTestId('target'), 'pointerDown', event)).toBe(true); expect(onPointerDown).toHaveBeenCalledWith(event); expect(onParentPointerDown).not.toHaveBeenCalled(); @@ -24,9 +25,9 @@ describe('legacy event system', () => { test('returns false without a handler', async () => { await render(); - expect(await dispatchEvent(screen.getByTestId('input'), 'focus', buildFocusEvent())).toBe( - false, - ); + + const event = createEvent('focus', buildFocusNativeEvent()); + expect(await dispatchEvent(screen.getByTestId('input'), 'focus', event)).toBe(false); }); }); @@ -35,7 +36,7 @@ describe('modern event system', () => { configure({ eventSystem: 'modern' }); }); - test('dispatches native events as SyntheticEvent with capture and bubble phases', async () => { + test('dispatches SyntheticEvent through capture and bubble phases', async () => { const calls: string[] = []; await render( { , ); - const event = { nativeEvent: { pointerId: 1 } }; + const event = createEvent('pointerDown', { pointerId: 1 }); expect(await dispatchEvent(screen.getByTestId('target'), 'pointerDown', event)).toBe(true); expect(calls).toEqual([ 'parent.onPointerDownCapture', @@ -61,17 +62,6 @@ describe('modern event system', () => { ]); }); - test('keeps the event timeStamp', async () => { - const onFocus = jest.fn(); - await render(); - - await dispatchEvent(screen.getByTestId('input'), 'focus', { - ...buildFocusEvent(), - timeStamp: 42, - }); - expect(onFocus.mock.calls[0][0].timeStamp).toBe(42); - }); - test('returns true when only an ancestor handles the event', async () => { const onParentPointerDown = jest.fn(); await render( @@ -80,7 +70,7 @@ describe('modern event system', () => { , ); - const event = { nativeEvent: { pointerId: 1 } }; + const event = createEvent('pointerDown', { pointerId: 1 }); expect(await dispatchEvent(screen.getByTestId('target'), 'pointerDown', event)).toBe(true); expect(onParentPointerDown).toHaveBeenCalledTimes(1); }); @@ -92,36 +82,16 @@ describe('modern event system', () => { , ); - expect(await dispatchEvent(screen.getByTestId('input'), 'focus', buildFocusEvent())).toBe( - false, - ); - }); - - test('calls JavaScript callbacks on the target only, with their arguments', async () => { - const onChangeText = jest.fn(); - const onParentChangeText = jest.fn(); - await render( - // @ts-expect-error `onChangeText` is not a `View` prop, but it shouldn't be called anyway. - - - , - ); - - expect(await dispatchEvent(screen.getByTestId('input'), 'changeText', 'Hello')).toBe(true); - expect(onChangeText).toHaveBeenCalledWith('Hello'); - expect(onParentChangeText).not.toHaveBeenCalled(); + const event = createEvent('focus', buildFocusNativeEvent()); + expect(await dispatchEvent(screen.getByTestId('input'), 'focus', event)).toBe(false); }); - test('calls testOnly_ props for native events and JavaScript callbacks', async () => { + test('calls testOnly_ props', async () => { const onFocus = jest.fn(); - const onPressIn = jest.fn(); - const testOnlyProps = { testOnly_onFocus: onFocus, testOnly_onPressIn: onPressIn }; - await render(); + await render(); - expect(await dispatchEvent(screen.getByTestId('view'), 'focus', buildFocusEvent())).toBe(true); - const touchEvent = buildTouchEvent(); - expect(await dispatchEvent(screen.getByTestId('view'), 'pressIn', touchEvent)).toBe(true); - expect(onFocus).toHaveBeenCalledWith(expect.any(SyntheticEvent)); - expect(onPressIn).toHaveBeenCalledWith(touchEvent); + const event = createEvent('focus', buildFocusNativeEvent()); + expect(await dispatchEvent(screen.getByTestId('view'), 'focus', event)).toBe(true); + expect(onFocus).toHaveBeenCalledWith(event); }); }); diff --git a/src/events/create-event.ts b/src/events/create-event.ts new file mode 100644 index 000000000..2ee19563d --- /dev/null +++ b/src/events/create-event.ts @@ -0,0 +1,110 @@ +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, + buildLayoutNativeEvent, + buildScrollNativeEvent, + buildSubmitEditingNativeEvent, + buildTextChangeNativeEvent, + buildTextSelectionChangeNativeEvent, + buildTouchNativeEvent, +} from './shared/payloads'; +import type { LayoutRectangle } from './shared/types'; + +export type LegacyEvent = ReturnType>; + +/** + * Creates the event object for a native event (`focus`, `change`, `scroll`, ...) in the event + * system selected by `configure({ 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().eventSystem === 'legacy') { + return wrapNativeEvent(nativeEvent); + } + + // Not `null`: the event type is known. + return createModernEvent(eventType, { nativeEvent }) as SyntheticEvent; +} + +export type ScrollEventType = + | 'scroll' + | 'scrollBeginDrag' + | 'scrollEndDrag' + | 'momentumScrollBegin' + | 'momentumScrollEnd'; + +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 buildLayoutEvent(layout?: Partial) { + return createEvent('layout', buildLayoutNativeEvent(layout)); +} + +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)); +} diff --git a/src/events/dispatch-event.ts b/src/events/dispatch-event.ts index a0a218598..b91b5197d 100644 --- a/src/events/dispatch-event.ts +++ b/src/events/dispatch-event.ts @@ -1,64 +1,29 @@ import type { TestInstance } from 'test-renderer'; -import { getConfig } from '../config'; -import { isObject } from '../helpers/object'; +import type { LegacyEvent } from './create-event'; import { dispatchEvent as dispatchLegacyEvent } from './legacy/dispatch'; import { dispatchEvent as dispatchModernEvent } from './modern/dispatch'; -import type { NativeEventPayload } from './modern/event'; -import { createEvent } from './modern/event'; -import { getEventTypeConfig } from './modern/event-types'; -import { invokeEventHandler } from './shared/invoke-event-handler'; +import { SyntheticEvent } from './modern/event'; /** - * Dispatches an event to a host element with the event system selected by - * `configure({ eventSystem })`. Used by `userEvent`. + * Dispatches an event from `createEvent()` to a host element. Used by `userEvent`. * - * - `legacy`: calls the element's own `on*` prop with `event` as it is. - * - `modern`: native events (`focus`, `change`, `scroll`, ...) get a `SyntheticEvent` from - * `event.nativeEvent` and `event.timeStamp`, dispatched as React Native does (capture and bubble - * phases for bubbling events). Callbacks that components call from JavaScript (`changeText`, - * `pressIn`, `responderGrant`, ...) are called as in `legacy`. `press` is a native event type - * (`topPress`), so it reaches host `onPress` props only, not `Pressable`'s `onPress`. + * - Legacy event object: calls the element's own `on*` prop with it. + * - `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 without the `on*` prefix, e.g. `focus` - * @param event the event object, e.g. from `buildFocusEvent()`, or the callback's argument, e.g. - * the text for `changeText` + * @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: unknown, + event: LegacyEvent | SyntheticEvent, ): Promise { - if (getConfig().eventSystem === 'legacy') { - return await dispatchLegacyEvent(instance, eventType, event); + if (event instanceof SyntheticEvent) { + return await dispatchModernEvent(instance, event); } - const nativeEvent = extractNativeEvent(eventType, event); - const syntheticEvent = - nativeEvent != null - ? createEvent(eventType, { nativeEvent, timeStamp: extractTimeStamp(event) }) - : null; - if (syntheticEvent == null) { - return await invokeEventHandler(instance, eventType, event); - } - - return await dispatchModernEvent(instance, syntheticEvent); -} - -function extractNativeEvent(eventType: string, event: unknown): NativeEventPayload | null { - if (getEventTypeConfig(eventType) == null) { - return null; - } - - if (!isObject(event) || !isObject(event.nativeEvent)) { - return null; - } - - return event.nativeEvent; -} - -/** Returns the event's top-level `timeStamp`, e.g. `0` from the event builders. */ -function extractTimeStamp(event: unknown): number | undefined { - return isObject(event) && typeof event.timeStamp === 'number' ? event.timeStamp : undefined; + return await dispatchLegacyEvent(instance, eventType, event); } diff --git a/src/events/legacy/builders/__tests__/base.test.ts b/src/events/legacy/builders/__tests__/base.test.ts index 47ecedc24..761efd3b3 100644 --- a/src/events/legacy/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/legacy/builders/__tests__/common.test.ts b/src/events/legacy/builders/__tests__/common.test.ts index 42066a66d..64bf6a405 100644 --- a/src/events/legacy/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/legacy/builders/__tests__/index.test.ts b/src/events/legacy/builders/__tests__/index.test.ts index 548ed471a..b947f45cf 100644 --- a/src/events/legacy/builders/__tests__/index.test.ts +++ b/src/events/legacy/builders/__tests__/index.test.ts @@ -1,17 +1,10 @@ import * as eventBuilder from '..'; test('re-exports all event builders', () => { + expect(eventBuilder.baseSyntheticEvent).toBeInstanceOf(Function); + expect(eventBuilder.wrapNativeEvent).toBeInstanceOf(Function); 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/legacy/builders/__tests__/scroll.test.ts b/src/events/legacy/builders/__tests__/scroll.test.ts deleted file mode 100644 index 7ae6d6c70..000000000 --- a/src/events/legacy/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/legacy/builders/__tests__/text.test.ts b/src/events/legacy/builders/__tests__/text.test.ts deleted file mode 100644 index 8970d4060..000000000 --- a/src/events/legacy/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/legacy/builders/base.ts b/src/events/legacy/builders/base.ts index ff45e9828..d8dfd8192 100644 --- a/src/events/legacy/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 index 66abcf80d..513a818c4 100644 --- a/src/events/legacy/builders/common.ts +++ b/src/events/legacy/builders/common.ts @@ -1,25 +1,9 @@ -import type { LayoutRectangle } from '../../shared/types'; -import { baseSyntheticEvent } from './base'; +import { buildTouchNativeEvent } from '../../shared/payloads'; +import { wrapNativeEvent } 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[], - }, + ...wrapNativeEvent(buildTouchNativeEvent()), currentTarget: { measure: () => {} }, }; } @@ -39,70 +23,3 @@ export function buildResponderReleaseEvent() { 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/legacy/builders/index.ts b/src/events/legacy/builders/index.ts index 813d38feb..1091230af 100644 --- a/src/events/legacy/builders/index.ts +++ b/src/events/legacy/builders/index.ts @@ -1,4 +1,3 @@ +export * from './base'; export * from './common'; -export * from './merge'; -export * from './scroll'; -export * from './text'; +export * from '../../shared/merge'; diff --git a/src/events/legacy/builders/scroll.ts b/src/events/legacy/builders/scroll.ts deleted file mode 100644 index ad909f6f7..000000000 --- a/src/events/legacy/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/legacy/builders/text.ts b/src/events/legacy/builders/text.ts deleted file mode 100644 index 9899cab9b..000000000 --- a/src/events/legacy/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/legacy/fire-event.ts b/src/events/legacy/fire-event.ts index e04f4c310..e981a83b6 100644 --- a/src/events/legacy/fire-event.ts +++ b/src/events/legacy/fire-event.ts @@ -4,12 +4,13 @@ import { act } from '../../act'; import { isInstanceMounted } from '../../helpers/component-tree'; import { isHostScrollView } from '../../helpers/host-component-names'; import { normalizeEventType } from '../shared/handler'; +import { mergeEventProps } from '../shared/merge'; import { nativeState } from '../shared/native-state'; +import { buildLayoutNativeEvent, buildScrollNativeEvent } from '../shared/payloads'; import type { EventProps, EventType, LayoutRectangle } from '../shared/types'; import { updateNativeStateFromEvent } from '../shared/update-native-state'; -import { buildLayoutEvent, buildTouchEvent } from './builders/common'; -import { mergeEventProps } from './builders/merge'; -import { buildScrollEvent } from './builders/scroll'; +import { wrapNativeEvent } from './builders/base'; +import { buildTouchEvent } from './builders/common'; import { findEventHandler } from './propagation'; import { warnAboutUnhandledEvent } from './warnings'; @@ -28,7 +29,7 @@ fireEvent.scroll = async (instance: TestInstance, eventProps?: EventProps) => { const layoutMeasurement = isHostScrollView(instance) ? nativeState.layoutSizeForInstance.get(instance) : undefined; - const event = buildScrollEvent(undefined, { layoutMeasurement }); + const event = wrapNativeEvent(buildScrollNativeEvent(undefined, { layoutMeasurement })); await fireEvent(instance, 'scroll', mergeEventProps(event, eventProps)); }; @@ -39,7 +40,7 @@ fireEvent.scroll = async (instance: TestInstance, eventProps?: EventProps) => { fireEvent.layout = async (instance: TestInstance, layout?: Partial) => { await fireEventInternal(instance, { type: 'layout', - data: [buildLayoutEvent(layout)], + data: [wrapNativeEvent(buildLayoutNativeEvent(layout))], bubbles: false, }); }; diff --git a/src/events/legacy/index.ts b/src/events/legacy/index.ts index b70811fb5..7e3d3e751 100644 --- a/src/events/legacy/index.ts +++ b/src/events/legacy/index.ts @@ -1,6 +1,6 @@ +export { wrapNativeEvent } from './builders/base'; export * from './builders/common'; -export * from './builders/scroll'; -export * from './builders/text'; +export * from '../shared/payloads'; export * from './dispatch'; export * from '../shared/handler'; export { isPointerEventEnabled } from './is-enabled'; diff --git a/src/events/modern/fire-event.ts b/src/events/modern/fire-event.ts index 73e4f7fab..6bd41be7d 100644 --- a/src/events/modern/fire-event.ts +++ b/src/events/modern/fire-event.ts @@ -3,11 +3,10 @@ import type { TestInstance } from 'test-renderer'; import { isInstanceMounted } from '../../helpers/component-tree'; import { ErrorWithStack } from '../../helpers/errors'; import { isHostScrollView } from '../../helpers/host-component-names'; -import { buildLayoutEvent } from '../legacy/builders/common'; -import { mergeEventProps } from '../legacy/builders/merge'; -import { buildScrollEvent } from '../legacy/builders/scroll'; import { normalizeEventType } from '../shared/handler'; +import { mergeEventProps } from '../shared/merge'; import { nativeState } from '../shared/native-state'; +import { buildLayoutNativeEvent, buildScrollNativeEvent } from '../shared/payloads'; import type { LayoutRectangle } from '../shared/types'; import { updateNativeStateFromEvent } from '../shared/update-native-state'; import { dispatchEvent } from './dispatch'; @@ -57,7 +56,7 @@ fireEvent.scroll = async ( const layoutMeasurement = isHostScrollView(instance) ? nativeState.layoutSizeForInstance.get(instance) : undefined; - const { nativeEvent } = buildScrollEvent(undefined, { layoutMeasurement }); + const nativeEvent = buildScrollNativeEvent(undefined, { layoutMeasurement }); return await fireEventInternal(instance, 'scroll', { ...init, nativeEvent: mergeEventProps(nativeEvent, init.nativeEvent), @@ -70,8 +69,9 @@ fireEvent.layout = async ( layout?: Partial, ): Promise => { ensureInstance(instance, 'layout', fireEvent.layout); - const { nativeEvent } = buildLayoutEvent(layout); - return await fireEventInternal(instance, 'layout', { nativeEvent }); + return await fireEventInternal(instance, 'layout', { + nativeEvent: buildLayoutNativeEvent(layout), + }); }; /** Expects an event type React Native dispatches natively, without the `on*` prefix. */ diff --git a/src/events/legacy/builders/__tests__/merge.test.ts b/src/events/shared/__tests__/merge.test.ts similarity index 68% rename from src/events/legacy/builders/__tests__/merge.test.ts rename to src/events/shared/__tests__/merge.test.ts index eec683e2f..e5e241170 100644 --- a/src/events/legacy/builders/__tests__/merge.test.ts +++ b/src/events/shared/__tests__/merge.test.ts @@ -1,8 +1,15 @@ -import { buildTouchEvent } from '../common'; 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 = buildTouchEvent(); + const event = buildEvent(); const nativeEvent = { ...event.nativeEvent }; expect(mergeEventProps(event)).toBe(event); @@ -10,7 +17,7 @@ test('returns the same event when no props are passed', () => { }); test('deep merges nested objects and keeps other default fields', () => { - const event = mergeEventProps(buildTouchEvent(), { nativeEvent: { pageX: 10, pageY: 20 } }); + const event = mergeEventProps(buildEvent(), { nativeEvent: { pageX: 10, pageY: 20 } }); expect(event.nativeEvent.pageX).toBe(10); expect(event.nativeEvent.pageY).toBe(20); @@ -19,7 +26,7 @@ test('deep merges nested objects and keeps other default fields', () => { }); test('replaces arrays and primitive values instead of merging them', () => { - const event = mergeEventProps(buildTouchEvent(), { + const event = mergeEventProps(buildEvent(), { nativeEvent: { touches: [{ identifier: 1 }] }, timeStamp: 42, }); @@ -29,7 +36,7 @@ test('replaces arrays and primitive values instead of merging them', () => { }); test('adds props that the event does not have', () => { - const event = mergeEventProps(buildTouchEvent(), { custom: { value: 1 } }); + const event = mergeEventProps(buildEvent(), { custom: { value: 1 } }); expect(event).toMatchObject({ custom: { value: 1 } }); }); 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/legacy/builders/merge.ts b/src/events/shared/merge.ts similarity index 87% rename from src/events/legacy/builders/merge.ts rename to src/events/shared/merge.ts index 69ad2c87b..7e1f50847 100644 --- a/src/events/legacy/builders/merge.ts +++ b/src/events/shared/merge.ts @@ -1,5 +1,5 @@ -import { isObject } from '../../../helpers/object'; -import type { EventProps } from '../../shared/types'; +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. 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/user-event/accessibility-action/accessibility-action.ts b/src/user-event/accessibility-action/accessibility-action.ts index 0ccd94619..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/legacy'; +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 b7bd50c55..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/legacy'; +} 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 bd5c45ed7..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/legacy'; +} 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/press.ts b/src/user-event/press/press.ts index 16bcfa87a..5d9aa602d 100644 --- a/src/user-event/press/press.ts +++ b/src/user-event/press/press.ts @@ -1,10 +1,10 @@ import type { TestInstance } from 'test-renderer'; import { act } from '../../act'; +import { buildPressEvent } from '../../events/create-event'; import { buildResponderGrantEvent, buildResponderReleaseEvent, - buildTouchEvent, getEventHandlerFromProps, isPointerEventEnabled, } from '../../events/legacy'; @@ -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/scroll-to.ts b/src/user-event/scroll/scroll-to.ts index a091da4ec..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/legacy'; +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'; @@ -102,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( @@ -129,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/type.ts b/src/user-event/type/type.ts index cc9482d34..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/legacy'; +} 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 7900f2311..6a5c539a0 100644 --- a/src/user-event/utils/interaction.ts +++ b/src/user-event/utils/interaction.ts @@ -1,6 +1,9 @@ import type { TestInstance } from 'test-renderer'; +import type { LegacyEvent } from '../../events/create-event'; import { dispatchEvent } from '../../events/dispatch-event'; +import { buildTouchEvent } from '../../events/legacy'; +import type { SyntheticEvent } from '../../events/modern/event'; import { invokeEventHandler } from '../../events/shared/invoke-event-handler'; /** @@ -24,14 +27,23 @@ export class Interaction { public target: TestInstance, ) {} - async dispatchEvent(eventName: string, event: unknown) { + /** 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. `ScrollView`'s + * from JavaScript instead of dispatching events, e.g. `onChangeText(text)` or `ScrollView`'s * `onContentSizeChange(width, height)`. */ async invokeEventHandler(eventName: string, ...params: unknown[]) { From 7c92c903e5e93ffa9a7fe8b7d521fa550c9c9b3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Fri, 9 Oct 2026 12:36:16 +0200 Subject: [PATCH 18/28] simplify --- contributing/event-dispatch.md | 6 +++--- src/events/__tests__/create-event.test.ts | 8 +------- src/events/__tests__/dispatch-event.test.tsx | 2 +- src/events/create-event.ts | 8 +------- src/events/legacy/builders/__tests__/index.test.ts | 10 ---------- src/events/legacy/builders/common.ts | 2 -- src/events/legacy/builders/index.ts | 3 --- src/events/legacy/index.ts | 3 --- src/events/shared/handler.ts | 2 +- src/events/shared/native-state.ts | 2 +- 10 files changed, 8 insertions(+), 38 deletions(-) delete mode 100644 src/events/legacy/builders/__tests__/index.test.ts delete mode 100644 src/events/legacy/builders/index.ts diff --git a/contributing/event-dispatch.md b/contributing/event-dispatch.md index f37c6a593..98c8174d3 100644 --- a/contributing/event-dispatch.md +++ b/contributing/event-dispatch.md @@ -17,7 +17,7 @@ The files in `src/events/legacy/`: 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 `src/events/legacy/` and imports it only through `src/events/legacy/index.ts`, which also re-exports `src/events/shared/handler.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` @@ -31,11 +31,11 @@ Code used by both event systems lives in `src/events/shared/`: `handler.ts` (fin `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 dispatches a native event, built for the configured event system by `buildEvent()` (`src/events/build-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. Neither checks 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. 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 native events with `interaction.dispatchEvent(eventType, buildFocusEvent())`, using the `build*Event()` helpers from `src/events/build-event.ts`, and call JavaScript callbacks with `interaction.invokeEventHandler(eventType, ...params)` (or `interaction.dispatchTouchEvent()` for `pressIn`, `pressOut` and `longPress`, which passes a touch event), so it records whether any handler ran. Events go to `interaction.target`, which is the element the action was called with, unless the action moves it (as `press()` does when an ancestor handles the press). If the action has to call a handler itself, record it with `interaction.recordEvent()` (as `pullToRefresh()` does for `onRefresh` on the `refreshControl` prop). +- Dispatch native events with `interaction.dispatchEvent(eventType, buildFocusEvent())`, using the `build*Event()` helpers from `src/events/create-event.ts`, and call JavaScript callbacks with `interaction.invokeEventHandler(eventType, ...params)` (or `interaction.dispatchTouchEvent()` for `pressIn`, `pressOut` 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. diff --git a/src/events/__tests__/create-event.test.ts b/src/events/__tests__/create-event.test.ts index 2d8a1c2c0..20bd71de9 100644 --- a/src/events/__tests__/create-event.test.ts +++ b/src/events/__tests__/create-event.test.ts @@ -7,7 +7,6 @@ import { createEvent, buildFocusEvent, buildKeyPressEvent, - buildLayoutEvent, buildPressEvent, buildScrollEvent, buildSubmitEditingEvent, @@ -56,7 +55,6 @@ test.each([ ['focus', () => buildFocusEvent()], ['blur', () => buildBlurEvent()], ['accessibilityaction', () => buildAccessibilityActionEvent('increment')], - ['layout', () => buildLayoutEvent({ width: 100 })], ['momentumscrollend', () => buildScrollEvent('momentumScrollEnd', { x: 0, y: 100 })], ['change', () => buildTextChangeEvent('Hello', { start: 5, end: 5 })], ['keypress', () => buildKeyPressEvent('a')], @@ -77,11 +75,7 @@ test.each([ expect(modernEvent.nativeEvent).toEqual(legacyEvent.nativeEvent); }); -test('buildLayoutEvent and buildScrollEvent pass their arguments to the native event', () => { - expect(buildLayoutEvent({ width: 100 }).nativeEvent).toEqual({ - layout: { x: 0, y: 0, width: 100, height: 0 }, - target: 0, - }); +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 index b5a171587..09b351a5d 100644 --- a/src/events/__tests__/dispatch-event.test.tsx +++ b/src/events/__tests__/dispatch-event.test.tsx @@ -4,8 +4,8 @@ import { TextInput, View } from 'react-native'; import { configure, render, screen } from '../..'; import { createEvent } from '../create-event'; import { dispatchEvent } from '../dispatch-event'; -import { buildFocusNativeEvent } from '../legacy'; import { SyntheticEvent } from '../modern/event'; +import { buildFocusNativeEvent } from '../shared/payloads'; describe('legacy event system', () => { test('calls only the target prop with the legacy event object', async () => { diff --git a/src/events/create-event.ts b/src/events/create-event.ts index 2ee19563d..3ccdf86ec 100644 --- a/src/events/create-event.ts +++ b/src/events/create-event.ts @@ -12,14 +12,12 @@ import { buildEndEditingNativeEvent, buildFocusNativeEvent, buildKeyPressNativeEvent, - buildLayoutNativeEvent, buildScrollNativeEvent, buildSubmitEditingNativeEvent, buildTextChangeNativeEvent, buildTextSelectionChangeNativeEvent, buildTouchNativeEvent, } from './shared/payloads'; -import type { LayoutRectangle } from './shared/types'; export type LegacyEvent = ReturnType>; @@ -50,7 +48,7 @@ export function createEvent( return createModernEvent(eventType, { nativeEvent }) as SyntheticEvent; } -export type ScrollEventType = +type ScrollEventType = | 'scroll' | 'scrollBeginDrag' | 'scrollEndDrag' @@ -73,10 +71,6 @@ export function buildAccessibilityActionEvent(actionName: string) { return createEvent('accessibilityAction', buildAccessibilityActionNativeEvent(actionName)); } -export function buildLayoutEvent(layout?: Partial) { - return createEvent('layout', buildLayoutNativeEvent(layout)); -} - export function buildScrollEvent( eventType: ScrollEventType, offset?: Point, diff --git a/src/events/legacy/builders/__tests__/index.test.ts b/src/events/legacy/builders/__tests__/index.test.ts deleted file mode 100644 index b947f45cf..000000000 --- a/src/events/legacy/builders/__tests__/index.test.ts +++ /dev/null @@ -1,10 +0,0 @@ -import * as eventBuilder from '..'; - -test('re-exports all event builders', () => { - expect(eventBuilder.baseSyntheticEvent).toBeInstanceOf(Function); - expect(eventBuilder.wrapNativeEvent).toBeInstanceOf(Function); - expect(eventBuilder.buildTouchEvent).toBeInstanceOf(Function); - expect(eventBuilder.buildResponderGrantEvent).toBeInstanceOf(Function); - expect(eventBuilder.buildResponderReleaseEvent).toBeInstanceOf(Function); - expect(eventBuilder.mergeEventProps).toBeInstanceOf(Function); -}); diff --git a/src/events/legacy/builders/common.ts b/src/events/legacy/builders/common.ts index 513a818c4..6cd4c3d6c 100644 --- a/src/events/legacy/builders/common.ts +++ b/src/events/legacy/builders/common.ts @@ -8,8 +8,6 @@ export function buildTouchEvent() { }; } -export type TouchEvent = ReturnType; - export function buildResponderGrantEvent() { return { ...buildTouchEvent(), diff --git a/src/events/legacy/builders/index.ts b/src/events/legacy/builders/index.ts deleted file mode 100644 index 1091230af..000000000 --- a/src/events/legacy/builders/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './base'; -export * from './common'; -export * from '../../shared/merge'; diff --git a/src/events/legacy/index.ts b/src/events/legacy/index.ts index 7e3d3e751..94a970247 100644 --- a/src/events/legacy/index.ts +++ b/src/events/legacy/index.ts @@ -1,7 +1,4 @@ -export { wrapNativeEvent } from './builders/base'; export * from './builders/common'; -export * from '../shared/payloads'; -export * from './dispatch'; export * from '../shared/handler'; export { isPointerEventEnabled } from './is-enabled'; export * from '../shared/native-state'; diff --git a/src/events/shared/handler.ts b/src/events/shared/handler.ts index f89eb1c9b..ca6ce5a4e 100644 --- a/src/events/shared/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; }; diff --git a/src/events/shared/native-state.ts b/src/events/shared/native-state.ts index e7f4d5c74..501840bb3 100644 --- a/src/events/shared/native-state.ts +++ b/src/events/shared/native-state.ts @@ -7,7 +7,7 @@ import type { Point, Size } from '../../types'; * * Values from `value` props (managed controls) should take precedence over these values. */ -export type NativeState = { +type NativeState = { valueForInstance: WeakMap; contentOffsetForInstance: WeakMap; layoutSizeForInstance: WeakMap; From 115789172ede0986039bd5cf6a0855e4e116e4b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Fri, 9 Oct 2026 12:43:13 +0200 Subject: [PATCH 19/28] improve msgs --- src/events/__tests__/create-event.test.ts | 2 +- src/events/create-event.ts | 2 +- .../modern/__tests__/fire-event.test.tsx | 38 +++++++++---------- src/events/modern/fire-event-utils.ts | 16 ++++---- src/events/modern/fire-event.ts | 2 +- 5 files changed, 29 insertions(+), 31 deletions(-) diff --git a/src/events/__tests__/create-event.test.ts b/src/events/__tests__/create-event.test.ts index 20bd71de9..e43364ac6 100644 --- a/src/events/__tests__/create-event.test.ts +++ b/src/events/__tests__/create-event.test.ts @@ -45,7 +45,7 @@ test.each(['legacy', 'modern'] as const)( configure({ eventSystem }); expect(() => createEvent('changeText', {})).toThrow( - '"changeText" is not a native event type, use invokeEventHandler() instead.', + '"changeText" is not a native event type. Use invokeEventHandler() instead.', ); }, ); diff --git a/src/events/create-event.ts b/src/events/create-event.ts index 3ccdf86ec..122b64735 100644 --- a/src/events/create-event.ts +++ b/src/events/create-event.ts @@ -37,7 +37,7 @@ export function createEvent( nativeEvent: NativeEventPayload, ): LegacyEvent | SyntheticEvent { if (getEventTypeConfig(eventType) == null) { - throw new Error(`"${eventType}" is not a native event type, use invokeEventHandler() instead.`); + throw new Error(`"${eventType}" is not a native event type. Use invokeEventHandler() instead.`); } if (getConfig().eventSystem === 'legacy') { diff --git a/src/events/modern/__tests__/fire-event.test.tsx b/src/events/modern/__tests__/fire-event.test.tsx index 9a8511348..371af3731 100644 --- a/src/events/modern/__tests__/fire-event.test.tsx +++ b/src/events/modern/__tests__/fire-event.test.tsx @@ -29,7 +29,7 @@ describe('event object', () => { 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: {} }).`, + `Unable to fire a "focus" event. Please provide an event object, e.g. fireEvent(element, 'focus', { nativeEvent: {} }).`, ); expect(onFocus).not.toHaveBeenCalled(); }); @@ -41,7 +41,7 @@ describe('event object', () => { await expect( fireEventUntyped(screen.getByTestId('input'), 'focus', { nativeEvent: {} }, {}), ).rejects.toThrow( - 'Unable to fire a "focus" event - expected a single event object, received 2 arguments.', + 'Unable to fire a "focus" event. Expected a single event object, received 2 arguments.', ); expect(onFocus).not.toHaveBeenCalled(); }); @@ -59,7 +59,7 @@ describe('event object', () => { await render(); await expect(fireEventUntyped(screen.getByTestId('input'), 'focus', event)).rejects.toThrow( - `Unable to fire a "focus" event - expected an event object, received ${description}.`, + `Unable to fire a "focus" event. Expected an event object, received ${description}.`, ); expect(onFocus).not.toHaveBeenCalled(); }); @@ -86,7 +86,7 @@ describe('event object', () => { await expect( fireEventUntyped(screen.getByTestId('input'), 'focus', { nativeEvent: {}, ...event }), - ).rejects.toThrow(`Unable to fire a "focus" event - unsupported event object keys: ${message}`); + ).rejects.toThrow(`Unable to fire a "focus" event. Unsupported event object keys: ${message}`); expect(onFocus).not.toHaveBeenCalled(); }); @@ -96,7 +96,7 @@ describe('event object', () => { 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".', + 'Unable to fire a "focus" event. Expected "nativeEvent" to be an object, received string "text".', ); }); @@ -112,7 +112,7 @@ describe('event object', () => { 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}.`, + `Unable to fire a "focus" event. Expected "timeStamp" to be a finite number, received ${description}.`, ); expect(onFocus).not.toHaveBeenCalled(); }); @@ -166,7 +166,7 @@ describe('event object', () => { 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.'); + ).rejects.toThrow('Unable to fire a "focus" event. Please provide a host element.'); }); test('accepts event type with or without the "on" prefix', async () => { @@ -236,12 +236,12 @@ test('does not call props of composite components', async () => { 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.'], + ['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 = { @@ -256,8 +256,8 @@ describe('event types unknown to React Native', () => { await expect( fireEventUntyped(screen.getByTestId('input'), eventType, { nativeEvent: {} }), ).rejects.toThrow( - `Unable to fire a "${normalizedType}" event - React Native doesn't dispatch ` + - `"${normalizedType}" natively, ${hint}`, + `Unable to fire a "${normalizedType}" event. React Native doesn't dispatch ` + + `"${normalizedType}" natively${hint}`, ); expect(handler).not.toHaveBeenCalled(); }); @@ -495,14 +495,12 @@ describe('fireEvent.scroll', () => { await expect( fireEvent.scroll(screen.getByTestId('scroll'), { persist: () => {} } as FireEventInit), - ).rejects.toThrow( - 'Unable to fire a "scroll" event - unsupported event object keys: "persist".', - ); + ).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.', + 'Unable to fire a "scroll" event. Please provide a host element.', ); }); }); @@ -568,7 +566,7 @@ describe('fireEvent.layout', () => { 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.', + 'Unable to fire a "layout" event. Please provide a host element.', ); }); }); diff --git a/src/events/modern/fire-event-utils.ts b/src/events/modern/fire-event-utils.ts index f8458cfb6..1059d125a 100644 --- a/src/events/modern/fire-event-utils.ts +++ b/src/events/modern/fire-event-utils.ts @@ -38,8 +38,8 @@ export function ensureEventType( const alternative = UNKNOWN_EVENT_TYPE_ALTERNATIVES[eventType]; throw new ErrorWithStack( - `Unable to fire a "${eventType}" event - React Native doesn't dispatch "${eventType}" natively` + - (alternative != null ? `, use ${alternative} instead.` : ', so no handler would be called.'), + `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, ); } @@ -58,7 +58,7 @@ export function validateEventInit( ): CreateEventInit { if (args.length === 0) { throw new ErrorWithStack( - `Unable to fire a "${eventType}" event - please provide an event object, ` + + `Unable to fire a "${eventType}" event. Please provide an event object, ` + `e.g. fireEvent(element, '${eventType}', { nativeEvent: {} }).`, callsite, ); @@ -66,7 +66,7 @@ export function validateEventInit( if (args.length > 1) { throw new ErrorWithStack( - `Unable to fire a "${eventType}" event - expected a single event object, ` + + `Unable to fire a "${eventType}" event. Expected a single event object, ` + `received ${args.length} arguments.`, callsite, ); @@ -75,7 +75,7 @@ export function validateEventInit( const [init] = args; if (!isPlainObject(init)) { throw new ErrorWithStack( - `Unable to fire a "${eventType}" event - expected an event object, ` + + `Unable to fire a "${eventType}" event. Expected an event object, ` + `received ${describeValue(init)}.`, callsite, ); @@ -84,7 +84,7 @@ export function validateEventInit( 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: ` + + `Unable to fire a "${eventType}" event. Unsupported event object keys: ` + `${formatKeys(unknownKeys)}. ${describeUnknownKeys(unknownKeys)}`, callsite, ); @@ -92,7 +92,7 @@ export function validateEventInit( if (init.nativeEvent !== undefined && !isPlainObject(init.nativeEvent)) { throw new ErrorWithStack( - `Unable to fire a "${eventType}" event - expected "nativeEvent" to be an object, ` + + `Unable to fire a "${eventType}" event. Expected "nativeEvent" to be an object, ` + `received ${describeValue(init.nativeEvent)}.`, callsite, ); @@ -103,7 +103,7 @@ export function validateEventInit( (typeof init.timeStamp !== 'number' || !Number.isFinite(init.timeStamp)) ) { throw new ErrorWithStack( - `Unable to fire a "${eventType}" event - expected "timeStamp" to be a finite number, ` + + `Unable to fire a "${eventType}" event. Expected "timeStamp" to be a finite number, ` + `received ${describeValue(init.timeStamp)}.`, callsite, ); diff --git a/src/events/modern/fire-event.ts b/src/events/modern/fire-event.ts index 6bd41be7d..5d8232696 100644 --- a/src/events/modern/fire-event.ts +++ b/src/events/modern/fire-event.ts @@ -102,7 +102,7 @@ function ensureInstance( ) { if (instance == null) { throw new ErrorWithStack( - `Unable to fire a "${eventType}" event - please provide a host element.`, + `Unable to fire a "${eventType}" event. Please provide a host element.`, callsite, ); } From 23d12cf725ff7d5b6161d45005c7d7645c8ae3f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Fri, 9 Oct 2026 12:57:23 +0200 Subject: [PATCH 20/28] centralize moden fireEvent native state handling --- contributing/native-state.md | 2 +- src/events/legacy/fire-event.ts | 17 ++++++----- src/events/modern/fire-event.ts | 36 ++++++++++++----------- src/events/shared/__tests__/merge.test.ts | 11 +++++++ src/events/shared/merge.ts | 15 ++++++---- src/events/shared/update-native-state.ts | 18 ++++++++++++ 6 files changed, 68 insertions(+), 31 deletions(-) diff --git a/contributing/native-state.md b/contributing/native-state.md index 359a64e7d..f551847b6 100644 --- a/contributing/native-state.md +++ b/contributing/native-state.md @@ -11,7 +11,7 @@ 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. Both `fireEvent` implementations do it through `updateNativeStateFromEvent()` in `src/events/shared/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. +- **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/src/events/legacy/fire-event.ts b/src/events/legacy/fire-event.ts index e981a83b6..306cc3c0d 100644 --- a/src/events/legacy/fire-event.ts +++ b/src/events/legacy/fire-event.ts @@ -2,13 +2,14 @@ import type { TestInstance } from 'test-renderer'; import { act } from '../../act'; import { isInstanceMounted } from '../../helpers/component-tree'; -import { isHostScrollView } from '../../helpers/host-component-names'; import { normalizeEventType } from '../shared/handler'; import { mergeEventProps } from '../shared/merge'; -import { nativeState } from '../shared/native-state'; import { buildLayoutNativeEvent, buildScrollNativeEvent } from '../shared/payloads'; import type { EventProps, EventType, LayoutRectangle } from '../shared/types'; -import { updateNativeStateFromEvent } from '../shared/update-native-state'; +import { + getNativeStateEventProps, + updateNativeStateFromEvent, +} from '../shared/update-native-state'; import { wrapNativeEvent } from './builders/base'; import { buildTouchEvent } from './builders/common'; import { findEventHandler } from './propagation'; @@ -26,11 +27,11 @@ fireEvent.press = async (instance: TestInstance, eventProps?: EventProps) => { }; fireEvent.scroll = async (instance: TestInstance, eventProps?: EventProps) => { - const layoutMeasurement = isHostScrollView(instance) - ? nativeState.layoutSizeForInstance.get(instance) - : undefined; - const event = wrapNativeEvent(buildScrollNativeEvent(undefined, { layoutMeasurement })); - await fireEvent(instance, 'scroll', mergeEventProps(event, 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 diff --git a/src/events/modern/fire-event.ts b/src/events/modern/fire-event.ts index 5d8232696..46c8899e8 100644 --- a/src/events/modern/fire-event.ts +++ b/src/events/modern/fire-event.ts @@ -2,15 +2,16 @@ import type { TestInstance } from 'test-renderer'; import { isInstanceMounted } from '../../helpers/component-tree'; import { ErrorWithStack } from '../../helpers/errors'; -import { isHostScrollView } from '../../helpers/host-component-names'; import { normalizeEventType } from '../shared/handler'; import { mergeEventProps } from '../shared/merge'; -import { nativeState } from '../shared/native-state'; import { buildLayoutNativeEvent, buildScrollNativeEvent } from '../shared/payloads'; import type { LayoutRectangle } from '../shared/types'; -import { updateNativeStateFromEvent } from '../shared/update-native-state'; +import { + getNativeStateEventProps, + updateNativeStateFromEvent, +} from '../shared/update-native-state'; import { dispatchEvent } from './dispatch'; -import type { CreateEventInit } from './event'; +import type { CreateEventInit, NativeEventPayload } from './event'; import { createEvent } from './event'; import { ensureEventType, validateEventInit } from './fire-event-utils'; @@ -53,14 +54,7 @@ fireEvent.scroll = async ( ): Promise => { ensureInstance(instance, 'scroll', fireEvent.scroll); const init = validateEventInit('scroll', args.length > 0 ? args : [{}], fireEvent.scroll); - const layoutMeasurement = isHostScrollView(instance) - ? nativeState.layoutSizeForInstance.get(instance) - : undefined; - const nativeEvent = buildScrollNativeEvent(undefined, { layoutMeasurement }); - return await fireEventInternal(instance, 'scroll', { - ...init, - nativeEvent: mergeEventProps(nativeEvent, init.nativeEvent), - }); + return await fireEventInternal(instance, 'scroll', init, buildScrollNativeEvent()); }; /** Fires a `layout` event. The passed `layout` is merged onto a zeroed rectangle. */ @@ -69,18 +63,26 @@ fireEvent.layout = async ( layout?: Partial, ): Promise => { ensureInstance(instance, 'layout', fireEvent.layout); - return await fireEventInternal(instance, 'layout', { - nativeEvent: buildLayoutNativeEvent(layout), - }); + return await fireEventInternal(instance, 'layout', {}, buildLayoutNativeEvent(layout)); }; -/** Expects an event type React Native dispatches natively, without the `on*` prefix. */ +/** + * 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, ): Promise { - const event = createEvent(eventType, init); + const nativeEvent = basePayload + ? mergeEventProps(basePayload, getNativeStateEventProps(instance, eventType), init.nativeEvent) + : init.nativeEvent; + const event = createEvent(eventType, { ...init, nativeEvent }); if (event == null) { return true; } diff --git a/src/events/shared/__tests__/merge.test.ts b/src/events/shared/__tests__/merge.test.ts index e5e241170..6b3fafb25 100644 --- a/src/events/shared/__tests__/merge.test.ts +++ b/src/events/shared/__tests__/merge.test.ts @@ -40,3 +40,14 @@ test('adds props that the event does not have', () => { 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 }); +}); diff --git a/src/events/shared/merge.ts b/src/events/shared/merge.ts index 7e1f50847..9b983b71a 100644 --- a/src/events/shared/merge.ts +++ b/src/events/shared/merge.ts @@ -3,12 +3,17 @@ 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. + * 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?: EventProps): T { - if (eventProps) { - mergeInto(event as EventProps, eventProps); +export function mergeEventProps( + event: T, + ...eventProps: Array +): T { + for (const props of eventProps) { + if (props) { + mergeInto(event as EventProps, props); + } } return event; diff --git a/src/events/shared/update-native-state.ts b/src/events/shared/update-native-state.ts index 0527cf3de..18409cb43 100644 --- a/src/events/shared/update-native-state.ts +++ b/src/events/shared/update-native-state.ts @@ -4,6 +4,7 @@ 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', @@ -48,6 +49,23 @@ 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 tryGetContentOffset(event: unknown): Point | null { try { // @ts-expect-error: try to extract contentOffset from the event value From 8c09d2e9d4860c792625bbd8506ed4385cb74a63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Fri, 9 Oct 2026 13:24:45 +0200 Subject: [PATCH 21/28] fireEvent.changeText --- contributing/event-dispatch.md | 2 +- contributing/native-state.md | 2 +- .../legacy/__tests__/fire-event.test.tsx | 9 + src/events/modern/__tests__/dispatch.test.tsx | 124 ++++++++++ .../modern/__tests__/fire-event.test.tsx | 234 ++++++++++++++++++ src/events/modern/dispatch.ts | 49 +++- src/events/modern/fire-event-utils.ts | 54 +++- src/events/modern/fire-event.ts | 56 ++++- src/events/shared/update-native-state.ts | 20 ++ 9 files changed, 525 insertions(+), 25 deletions(-) diff --git a/contributing/event-dispatch.md b/contributing/event-dispatch.md index 98c8174d3..cfa799e35 100644 --- a/contributing/event-dispatch.md +++ b/contributing/event-dispatch.md @@ -31,7 +31,7 @@ Code used by both event systems lives in `src/events/shared/`: `handler.ts` (fin `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 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. Neither checks 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`: diff --git a/contributing/native-state.md b/contributing/native-state.md index f551847b6..dfbbc7c94 100644 --- a/contributing/native-state.md +++ b/contributing/native-state.md @@ -10,7 +10,7 @@ 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. Both `fireEvent` implementations do it through `updateNativeStateFromEvent()` in `src/events/shared/update-native-state.ts`. Each `userEvent` action writes it 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. diff --git a/src/events/legacy/__tests__/fire-event.test.tsx b/src/events/legacy/__tests__/fire-event.test.tsx index 0ccc30be2..9149b73a1 100644 --- a/src/events/legacy/__tests__/fire-event.test.tsx +++ b/src/events/legacy/__tests__/fire-event.test.tsx @@ -233,6 +233,15 @@ describe('fireEvent.changeText', () => { }); }); +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(); diff --git a/src/events/modern/__tests__/dispatch.test.tsx b/src/events/modern/__tests__/dispatch.test.tsx index 9fc8b3ade..a5bd17c9c 100644 --- a/src/events/modern/__tests__/dispatch.test.tsx +++ b/src/events/modern/__tests__/dispatch.test.tsx @@ -186,6 +186,130 @@ describe('direct events', () => { }); }); +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 }) => ; diff --git a/src/events/modern/__tests__/fire-event.test.tsx b/src/events/modern/__tests__/fire-event.test.tsx index 371af3731..0970d6093 100644 --- a/src/events/modern/__tests__/fire-event.test.tsx +++ b/src/events/modern/__tests__/fire-event.test.tsx @@ -369,6 +369,32 @@ describe('native state', () => { 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(() => { @@ -394,6 +420,205 @@ describe('native state', () => { }); }); +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(); @@ -490,6 +715,15 @@ describe('fireEvent.scroll', () => { 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(); diff --git a/src/events/modern/dispatch.ts b/src/events/modern/dispatch.ts index b0995bb64..290445c99 100644 --- a/src/events/modern/dispatch.ts +++ b/src/events/modern/dispatch.ts @@ -25,7 +25,11 @@ import { getEventInternals, SyntheticEvent } from './event'; * * @returns `true` if a handler was called. Check `event.defaultPrevented` for `preventDefault()`. */ -export async function dispatchEvent(target: TestInstance, event: SyntheticEvent): Promise { +export async function dispatchEvent( + target: TestInstance, + event: SyntheticEvent, + options?: DispatchOptions, +): Promise { if (!isInstanceMounted(target)) { return false; } @@ -38,7 +42,7 @@ export async function dispatchEvent(target: TestInstance, event: SyntheticEvent) getEventInternals(event).composedPath = path; runCapturePhase(event, path, state); - runBubblePhase(event, path, state); + runBubblePhase(event, path, state, options?.afterTargetHandler); resetEvent(event); }); @@ -50,6 +54,16 @@ export async function dispatchEvent(target: TestInstance, event: SyntheticEvent) 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. */ @@ -86,12 +100,25 @@ function runCapturePhase(event: SyntheticEvent, path: TestInstance[], state: Dis } /** From the target up to the root, calling `on*` props. Non-bubbling events stop at the target. */ -function runBubblePhase(event: SyntheticEvent, path: TestInstance[], state: DispatchState) { +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 phase = i === 0 ? SyntheticEvent.AT_TARGET : SyntheticEvent.BUBBLING_PHASE; - callHandler(event, nodes[i], handlerName, phase, state); + const isTarget = i === 0; + const phase = isTarget ? SyntheticEvent.AT_TARGET : SyntheticEvent.BUBBLING_PHASE; + callHandler( + event, + nodes[i], + handlerName, + phase, + state, + isTarget ? afterTargetHandler : undefined, + ); } } @@ -101,18 +128,24 @@ function callHandler( 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) { + if (handler == null && afterHandler == null) { return; } - state.hasCalledHandler = true; try { - handler.call(node, event); + 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 diff --git a/src/events/modern/fire-event-utils.ts b/src/events/modern/fire-event-utils.ts index 1059d125a..6da71db9c 100644 --- a/src/events/modern/fire-event-utils.ts +++ b/src/events/modern/fire-event-utils.ts @@ -1,4 +1,7 @@ +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'; @@ -45,17 +48,17 @@ export function ensureEventType( } /** - * Validates the arguments following the event type in modern `fireEvent`: exactly one plain - * object, with only `nativeEvent` (a plain object) and `timeStamp` (a finite number) keys. + * 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 validateEventInit( +export function ensureSingleEventArg( eventType: string, args: unknown[], // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type callsite: Function, -): CreateEventInit { +): void { if (args.length === 0) { throw new ErrorWithStack( `Unable to fire a "${eventType}" event. Please provide an event object, ` + @@ -71,8 +74,20 @@ export function validateEventInit( callsite, ); } +} - const [init] = args; +/** + * 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, ` + @@ -112,6 +127,35 @@ export function validateEventInit( 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. diff --git a/src/events/modern/fire-event.ts b/src/events/modern/fire-event.ts index 46c8899e8..1a711c5df 100644 --- a/src/events/modern/fire-event.ts +++ b/src/events/modern/fire-event.ts @@ -2,18 +2,29 @@ import type { TestInstance } from 'test-renderer'; import { isInstanceMounted } from '../../helpers/component-tree'; import { ErrorWithStack } from '../../helpers/errors'; -import { normalizeEventType } from '../shared/handler'; +import { isEditableTextInput } from '../../helpers/text-input'; +import { getEventHandlerFromProps, normalizeEventType } from '../shared/handler'; import { mergeEventProps } from '../shared/merge'; -import { buildLayoutNativeEvent, buildScrollNativeEvent } from '../shared/payloads'; +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, validateEventInit } from './fire-event-utils'; +import { + ensureEventType, + ensureSingleEventArg, + validateChangeTextArgs, + validateEventInit, +} from './fire-event-utils'; /** * Event object passed to modern `fireEvent`. Handlers receive a `SyntheticEvent` from @@ -40,20 +51,44 @@ export async function fireEvent( ensureInstance(instance, normalizedType, fireEvent); // Before the event object, as `fireEvent(input, 'changeText', 'Hello')` needs the hint more. ensureEventType(normalizedType, fireEvent); - const init = validateEventInit(normalizedType, args, 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, - ...args: [event?: FireEventInit] -): Promise => { +fireEvent.scroll = async (instance: TestInstance, event: FireEventInit = {}): Promise => { ensureInstance(instance, 'scroll', fireEvent.scroll); - const init = validateEventInit('scroll', args.length > 0 ? args : [{}], fireEvent.scroll); + const init = validateEventInit('scroll', event, fireEvent.scroll); return await fireEventInternal(instance, 'scroll', init, buildScrollNativeEvent()); }; @@ -78,6 +113,7 @@ async function fireEventInternal( eventType: string, init: CreateEventInit, basePayload?: NativeEventPayload, + dispatchOptions?: DispatchOptions, ): Promise { const nativeEvent = basePayload ? mergeEventProps(basePayload, getNativeStateEventProps(instance, eventType), init.nativeEvent) @@ -92,7 +128,7 @@ async function fireEventInternal( updateNativeStateFromEvent(instance, eventType, event); } - await dispatchEvent(instance, event); + await dispatchEvent(instance, event, dispatchOptions); return !event.defaultPrevented; } diff --git a/src/events/shared/update-native-state.ts b/src/events/shared/update-native-state.ts index 18409cb43..486725f73 100644 --- a/src/events/shared/update-native-state.ts +++ b/src/events/shared/update-native-state.ts @@ -30,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) { @@ -66,6 +74,18 @@ export function getNativeStateEventProps( 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 From 61cfe7b345b0ee2de47580679511d41bb34c4f62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Fri, 9 Oct 2026 13:56:48 +0200 Subject: [PATCH 22/28] edge case --- src/events/shared/__tests__/merge.test.ts | 10 ++++++++++ src/events/shared/merge.ts | 3 ++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/events/shared/__tests__/merge.test.ts b/src/events/shared/__tests__/merge.test.ts index 6b3fafb25..848e30b07 100644 --- a/src/events/shared/__tests__/merge.test.ts +++ b/src/events/shared/__tests__/merge.test.ts @@ -51,3 +51,13 @@ test('merges multiple props in order, skipping undefined ones', () => { 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/merge.ts b/src/events/shared/merge.ts index 9b983b71a..de9cfce52 100644 --- a/src/events/shared/merge.ts +++ b/src/events/shared/merge.ts @@ -23,7 +23,8 @@ 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)) { + // 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; From 1e7b1d2a6a49e8cae6c45ca4d708b4dba0abb298 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Fri, 9 Oct 2026 14:01:36 +0200 Subject: [PATCH 23/28] fireEvent.press setup --- src/events/modern/__tests__/fire-event.test.tsx | 12 ++++++++++++ src/events/modern/fire-event.ts | 13 +++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/events/modern/__tests__/fire-event.test.tsx b/src/events/modern/__tests__/fire-event.test.tsx index 0970d6093..2747c9837 100644 --- a/src/events/modern/__tests__/fire-event.test.tsx +++ b/src/events/modern/__tests__/fire-event.test.tsx @@ -619,6 +619,18 @@ describe('fireEvent.changeText', () => { }); }); +describe('fireEvent.press', () => { + test('throws as not supported and calls nothing', async () => { + const onPress = jest.fn(); + await render(); + + await expect(fireEvent.press(screen.getByTestId('text'))).rejects.toThrow( + 'fireEvent.press() is not supported yet in the modern event system. Use userEvent.press() instead.', + ); + expect(onPress).not.toHaveBeenCalled(); + }); +}); + describe('fireEvent.scroll', () => { test('passes default scroll payload as nativeEvent of a direct event', async () => { const onScroll = jest.fn(); diff --git a/src/events/modern/fire-event.ts b/src/events/modern/fire-event.ts index 1a711c5df..9aa7eb900 100644 --- a/src/events/modern/fire-event.ts +++ b/src/events/modern/fire-event.ts @@ -82,6 +82,19 @@ fireEvent.changeText = async (instance: TestInstance, text: string): Promise => + Promise.reject( + new ErrorWithStack( + 'fireEvent.press() is not supported yet in the modern event system. Use userEvent.press() instead.', + fireEvent.press, + ), + ); + /** * 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. From 9bf26310b78c1ba99846d2fe7d8370718ab21312 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Fri, 9 Oct 2026 14:09:12 +0200 Subject: [PATCH 24/28] fireEvent facade --- src/events/__tests__/fire-event.test.tsx | 130 +++++++++++++++++++++++ src/events/fire-event.ts | 75 +++++++++++++ src/pure.ts | 2 +- 3 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 src/events/__tests__/fire-event.test.tsx create mode 100644 src/events/fire-event.ts diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx new file mode 100644 index 000000000..35c048460 --- /dev/null +++ b/src/events/__tests__/fire-event.test.tsx @@ -0,0 +1,130 @@ +import * as React from 'react'; +import { ScrollView, Text, TextInput, View } from 'react-native'; + +import { configure, fireEvent, render, screen } from '../..'; +import { SyntheticEvent } from '../modern/event'; + +describe('legacy event system', () => { + test('fireEvent calls only the target prop with the passed arguments', async () => { + const onPointerDown = jest.fn(); + const onParentPointerDownCapture = jest.fn(); + await render( + + + , + ); + + await fireEvent(screen.getByTestId('target'), 'pointerDown', 'a', 'b'); + expect(onPointerDown).toHaveBeenCalledWith('a', 'b'); + expect(onParentPointerDownCapture).not.toHaveBeenCalled(); + }); + + test('fireEvent.changeText calls onChangeText without onChange', async () => { + const onChange = jest.fn(); + const onChangeText = jest.fn(); + await render(); + + await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); + expect(onChangeText).toHaveBeenCalledWith('Hello'); + expect(onChange).not.toHaveBeenCalled(); + }); + + test('fireEvent.press calls onPress', async () => { + const onPress = jest.fn(); + await render(); + + expect(await fireEvent.press(screen.getByTestId('text'))).toBeUndefined(); + expect(onPress).toHaveBeenCalledTimes(1); + }); + + test('fireEvent.scroll and fireEvent.layout pass legacy event objects', async () => { + const onScroll = jest.fn(); + const onLayout = jest.fn(); + await render(); + + expect(await fireEvent.scroll(screen.getByTestId('scrollView'))).toBeUndefined(); + expect(await fireEvent.layout(screen.getByTestId('scrollView'))).toBeUndefined(); + expect(onScroll.mock.calls[0][0]).not.toBeInstanceOf(SyntheticEvent); + expect(onLayout.mock.calls[0][0]).not.toBeInstanceOf(SyntheticEvent); + }); +}); + +describe('modern event system', () => { + beforeEach(() => { + configure({ eventSystem: 'modern' }); + }); + + test('fireEvent dispatches a SyntheticEvent through capture and bubble phases', async () => { + const onPointerDown = jest.fn(); + const onParentPointerDownCapture = jest.fn(); + await render( + + + , + ); + + expect( + await fireEvent(screen.getByTestId('target'), 'pointerDown', { + nativeEvent: { pointerId: 1 }, + }), + ).toBe(true); + expect(onParentPointerDownCapture).toHaveBeenCalledTimes(1); + expect(onPointerDown.mock.calls[0][0]).toBeInstanceOf(SyntheticEvent); + expect(onPointerDown.mock.calls[0][0].nativeEvent).toEqual({ pointerId: 1 }); + }); + + test('fireEvent throws for event types React Native does not dispatch natively', async () => { + const onChangeText = jest.fn(); + await render(); + + await expect(fireEvent(screen.getByTestId('input'), 'changeText', 'Hello')).rejects.toThrow( + `React Native doesn't dispatch "changeText" natively`, + ); + expect(onChangeText).not.toHaveBeenCalled(); + }); + + test('fireEvent.changeText fires onChange, then onChangeText', async () => { + const calls: string[] = []; + await render( + calls.push(`onChange: ${event.nativeEvent.text}`)} + onChangeText={(text) => calls.push(`onChangeText: ${text}`)} + />, + ); + + expect(await fireEvent.changeText(screen.getByTestId('input'), 'Hello')).toBe(true); + expect(calls).toEqual(['onChange: Hello', 'onChangeText: Hello']); + }); + + test('fireEvent.press throws as not supported', async () => { + const onPress = jest.fn(); + await render(); + + await expect(fireEvent.press(screen.getByTestId('text'))).rejects.toThrow( + 'fireEvent.press() is not supported yet in the modern event system.', + ); + expect(onPress).not.toHaveBeenCalled(); + }); + + test('fireEvent.scroll and fireEvent.layout pass SyntheticEvents', async () => { + const onScroll = jest.fn(); + const onLayout = jest.fn(); + await render(); + + expect(await fireEvent.scroll(screen.getByTestId('scrollView'))).toBe(true); + expect(await fireEvent.layout(screen.getByTestId('scrollView'))).toBe(true); + expect(onScroll.mock.calls[0][0]).toBeInstanceOf(SyntheticEvent); + expect(onLayout.mock.calls[0][0]).toBeInstanceOf(SyntheticEvent); + }); +}); + +test('reads the event system on each call', async () => { + const onPress = jest.fn(); + await render(); + + await fireEvent.press(screen.getByTestId('text')); + configure({ eventSystem: 'modern' }); + await expect(fireEvent.press(screen.getByTestId('text'))).rejects.toThrow('not supported'); + expect(onPress).toHaveBeenCalledTimes(1); +}); diff --git a/src/events/fire-event.ts b/src/events/fire-event.ts new file mode 100644 index 000000000..6de8506a6 --- /dev/null +++ b/src/events/fire-event.ts @@ -0,0 +1,75 @@ +import type { TestInstance } from 'test-renderer'; + +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'; + +function isModern() { + return getConfig().eventSystem === 'modern'; +} + +/** + * Public `fireEvent`. Calls the implementation of the event system selected by + * `configure({ 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])); + } + + return await legacyFireEvent(instance, eventType, ...data); +} + +fireEvent.changeText = async (instance: TestInstance, text: string): Promise => { + if (isModern()) { + return await modernFireEvent.changeText(instance, text); + } + + return await legacyFireEvent.changeText(instance, text); +}; + +fireEvent.press = async ( + instance: TestInstance, + eventProps?: EventProps, +): Promise => { + if (isModern()) { + return await modernFireEvent.press(instance, eventProps as FireEventInit); + } + + await legacyFireEvent.press(instance, eventProps); + return undefined; +}; + +fireEvent.scroll = async ( + instance: TestInstance, + eventProps?: EventProps, +): Promise => { + if (isModern()) { + return await modernFireEvent.scroll(instance, eventProps as FireEventInit); + } + + await legacyFireEvent.scroll(instance, eventProps); + return undefined; +}; + +fireEvent.layout = async ( + instance: TestInstance, + layout?: Partial, +): Promise => { + if (isModern()) { + return await modernFireEvent.layout(instance, layout); + } + + await legacyFireEvent.layout(instance, layout); + return undefined; +}; + +export { fireEvent }; diff --git a/src/pure.ts b/src/pure.ts index 9a5dbc76c..db64cd82f 100644 --- a/src/pure.ts +++ b/src/pure.ts @@ -1,6 +1,6 @@ export { act } from './act'; export { cleanup } from './cleanup'; -export { fireEvent } from './events/legacy/fire-event'; +export { fireEvent } from './events/fire-event'; export { render } from './render'; export { waitFor } from './wait-for'; export { waitForElementToBeRemoved } from './wait-for-element-to-be-removed'; From bced7fdb622a8b04bec126127a1d7ab8e4afb29e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Fri, 9 Oct 2026 14:28:53 +0200 Subject: [PATCH 25/28] fireEvent tests --- contributing/testing.md | 6 ++-- jest.config.js | 5 ++- src/events/__tests__/create-event.test.ts | 2 ++ src/events/__tests__/dispatch-event.test.tsx | 4 +++ src/events/__tests__/fire-event.test.tsx | 33 ++++++++++++------- src/events/fire-event.ts | 12 ++----- .../legacy/__tests__/fire-event.test.tsx | 3 ++ .../modern/__tests__/fire-event.test.tsx | 12 ------- src/events/modern/fire-event.ts | 13 -------- src/test-utils/event-system.ts | 11 +++++++ 10 files changed, 50 insertions(+), 51 deletions(-) create mode 100644 src/test-utils/event-system.ts diff --git a/contributing/testing.md b/contributing/testing.md index 59a4502c0..5d7df8d4e 100644 --- a/contributing/testing.md +++ b/contributing/testing.md @@ -16,11 +16,13 @@ Every change in `src/` should come with tests. Tests use Jest and live next to t ## Event systems -`userEvent` works with both event systems (`configure({ eventSystem })`), so `jest.config.js` has two projects: +`fireEvent` and `userEvent` work with both event systems (`configure({ eventSystem })`), so `jest.config.js` has two projects: - `legacy` runs all tests with the default `'legacy'` event system. -- `modern` runs `src/user-event/` tests again with `'modern'` (`jest-setup-modern.ts`). +- `modern` runs all tests again with `'modern'` (`jest-setup-modern.ts`). Both projects share the same snapshots. `createEventLogger()` entries print only the `nativeEvent` of event payloads (`src/test-utils/event-serializer.ts`), so a snapshot is the same for a legacy event object and a modern `SyntheticEvent`, and a difference between the two systems fails the snapshot. Use `--selectProjects legacy` or `--selectProjects modern` to run one of them. When a test expects different behavior in the two systems, e.g. events bubbling to a parent, branch on `getConfig().eventSystem` inside the test instead of skipping it. + +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.config.js b/jest.config.js index d990a3e7c..a318a8680 100644 --- a/jest.config.js +++ b/jest.config.js @@ -22,12 +22,11 @@ module.exports = { ], projects: [ { ...baseProject, displayName: 'legacy' }, - // `userEvent` tests again with `configure({ eventSystem: 'modern' })`. They share snapshots with - // the legacy run, so both event systems must call the same handlers with the same native events. + // All tests again with `configure({ eventSystem: 'modern' })`. They share snapshots with the + // legacy run, so both event systems must call the same handlers with the same native events. { ...baseProject, displayName: 'modern', - roots: ['/src/user-event'], setupFilesAfterEnv: [...baseProject.setupFilesAfterEnv, './jest-setup-modern.ts'], }, ], diff --git a/src/events/__tests__/create-event.test.ts b/src/events/__tests__/create-event.test.ts index e43364ac6..f26d47ca1 100644 --- a/src/events/__tests__/create-event.test.ts +++ b/src/events/__tests__/create-event.test.ts @@ -20,6 +20,7 @@ afterEach(() => { }); test('builds a legacy event object in the legacy event system', () => { + configure({ eventSystem: 'legacy' }); const nativeEvent = { target: 0 }; const event = createEvent('focus', nativeEvent); @@ -65,6 +66,7 @@ test.each([ ])('builds %s event for the configured event system', (type, build) => { // The touch payload has a `Date.now()` timestamp. jest.spyOn(Date, 'now').mockReturnValue(1); + configure({ eventSystem: 'legacy' }); const legacyEvent = build(); configure({ eventSystem: 'modern' }); const modernEvent = build(); diff --git a/src/events/__tests__/dispatch-event.test.tsx b/src/events/__tests__/dispatch-event.test.tsx index 09b351a5d..b56c1cc12 100644 --- a/src/events/__tests__/dispatch-event.test.tsx +++ b/src/events/__tests__/dispatch-event.test.tsx @@ -8,6 +8,10 @@ import { SyntheticEvent } from '../modern/event'; import { buildFocusNativeEvent } from '../shared/payloads'; describe('legacy event system', () => { + beforeEach(() => { + configure({ eventSystem: 'legacy' }); + }); + test('calls only the target prop with the legacy event object', async () => { const onPointerDown = jest.fn(); const onParentPointerDown = jest.fn(); diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index 35c048460..7710b9217 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -1,10 +1,14 @@ import * as React from 'react'; -import { ScrollView, Text, TextInput, View } from 'react-native'; +import { Pressable, ScrollView, Text, TextInput, View } from 'react-native'; import { configure, fireEvent, render, screen } from '../..'; import { SyntheticEvent } from '../modern/event'; describe('legacy event system', () => { + beforeEach(() => { + configure({ eventSystem: 'legacy' }); + }); + test('fireEvent calls only the target prop with the passed arguments', async () => { const onPointerDown = jest.fn(); const onParentPointerDownCapture = jest.fn(); @@ -97,14 +101,17 @@ describe('modern event system', () => { expect(calls).toEqual(['onChange: Hello', 'onChangeText: Hello']); }); - test('fireEvent.press throws as not supported', async () => { + test('fireEvent.press uses the legacy implementation', async () => { const onPress = jest.fn(); - await render(); - - await expect(fireEvent.press(screen.getByTestId('text'))).rejects.toThrow( - 'fireEvent.press() is not supported yet in the modern event system.', + await render( + + Press me + , ); - expect(onPress).not.toHaveBeenCalled(); + + expect(await fireEvent.press(screen.getByText('Press me'))).toBeUndefined(); + expect(onPress).toHaveBeenCalledTimes(1); + expect(onPress.mock.calls[0][0]).not.toBeInstanceOf(SyntheticEvent); }); test('fireEvent.scroll and fireEvent.layout pass SyntheticEvents', async () => { @@ -120,11 +127,13 @@ describe('modern event system', () => { }); test('reads the event system on each call', async () => { - const onPress = jest.fn(); - await render(); + const onLayout = jest.fn(); + await render(); - await fireEvent.press(screen.getByTestId('text')); + configure({ eventSystem: 'legacy' }); + await fireEvent.layout(screen.getByTestId('view')); configure({ eventSystem: 'modern' }); - await expect(fireEvent.press(screen.getByTestId('text'))).rejects.toThrow('not supported'); - expect(onPress).toHaveBeenCalledTimes(1); + 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/fire-event.ts b/src/events/fire-event.ts index 6de8506a6..6b6931534 100644 --- a/src/events/fire-event.ts +++ b/src/events/fire-event.ts @@ -36,16 +36,10 @@ fireEvent.changeText = async (instance: TestInstance, text: string): Promise => { - if (isModern()) { - return await modernFireEvent.press(instance, eventProps as FireEventInit); - } - +// 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); - return undefined; }; fireEvent.scroll = async ( diff --git a/src/events/legacy/__tests__/fire-event.test.tsx b/src/events/legacy/__tests__/fire-event.test.tsx index 9149b73a1..ec5c1c74a 100644 --- a/src/events/legacy/__tests__/fire-event.test.tsx +++ b/src/events/legacy/__tests__/fire-event.test.tsx @@ -20,6 +20,7 @@ import { 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'; @@ -28,6 +29,8 @@ 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); }); diff --git a/src/events/modern/__tests__/fire-event.test.tsx b/src/events/modern/__tests__/fire-event.test.tsx index 2747c9837..0970d6093 100644 --- a/src/events/modern/__tests__/fire-event.test.tsx +++ b/src/events/modern/__tests__/fire-event.test.tsx @@ -619,18 +619,6 @@ describe('fireEvent.changeText', () => { }); }); -describe('fireEvent.press', () => { - test('throws as not supported and calls nothing', async () => { - const onPress = jest.fn(); - await render(); - - await expect(fireEvent.press(screen.getByTestId('text'))).rejects.toThrow( - 'fireEvent.press() is not supported yet in the modern event system. Use userEvent.press() instead.', - ); - expect(onPress).not.toHaveBeenCalled(); - }); -}); - describe('fireEvent.scroll', () => { test('passes default scroll payload as nativeEvent of a direct event', async () => { const onScroll = jest.fn(); diff --git a/src/events/modern/fire-event.ts b/src/events/modern/fire-event.ts index 9aa7eb900..1a711c5df 100644 --- a/src/events/modern/fire-event.ts +++ b/src/events/modern/fire-event.ts @@ -82,19 +82,6 @@ fireEvent.changeText = async (instance: TestInstance, text: string): Promise => - Promise.reject( - new ErrorWithStack( - 'fireEvent.press() is not supported yet in the modern event system. Use userEvent.press() instead.', - fireEvent.press, - ), - ); - /** * 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. diff --git a/src/test-utils/event-system.ts b/src/test-utils/event-system.ts new file mode 100644 index 000000000..ca41e21f7 --- /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({ eventSystem: 'legacy' }); + }); +} From d6580c8b9bb5eefe72d7ccc442674b8aea5a9425 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Fri, 9 Oct 2026 14:56:07 +0200 Subject: [PATCH 26/28] fix ci for RN nightly --- src/events/modern/__tests__/event-types.test.ts | 7 +++++++ src/events/modern/event-types.ts | 14 +++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/events/modern/__tests__/event-types.test.ts b/src/events/modern/__tests__/event-types.test.ts index 2a4309cb6..571786cab 100644 --- a/src/events/modern/__tests__/event-types.test.ts +++ b/src/events/modern/__tests__/event-types.test.ts @@ -127,6 +127,13 @@ 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(); diff --git a/src/events/modern/event-types.ts b/src/events/modern/event-types.ts index 285a56fd8..60dd241bb 100644 --- a/src/events/modern/event-types.ts +++ b/src/events/modern/event-types.ts @@ -70,6 +70,7 @@ export const DIRECT_EVENT_TYPES: readonly string[] = [ 'progress', 'refresh', 'requestClose', + 'safeAreaInsetsChange', 'scroll', 'scrollBeginDrag', 'scrollEndDrag', @@ -80,6 +81,14 @@ export const DIRECT_EVENT_TYPES: readonly string[] = [ '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'; @@ -117,7 +126,10 @@ export function getEventTypeConfig(eventType: string): EventTypeConfig | null { } if (DIRECT_EVENT_TYPES.includes(eventType)) { - return { kind: 'direct', handlerName: getEventHandlerName(eventType) }; + return { + kind: 'direct', + handlerName: DIRECT_EVENT_HANDLER_NAMES[eventType] ?? getEventHandlerName(eventType), + }; } return null; From f802f011b63b4759850feeb988f71c91ebf33253 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Fri, 9 Oct 2026 15:03:25 +0200 Subject: [PATCH 27/28] unstable_eventSystem --- contributing/event-dispatch.md | 2 +- contributing/testing.md | 4 +-- jest-setup-modern.ts | 2 +- jest.config.js | 2 +- src/__tests__/config.test.ts | 16 ++++----- src/config.ts | 13 +++---- src/events/__tests__/create-event.test.ts | 10 +++--- src/events/__tests__/dispatch-event.test.tsx | 4 +-- src/events/__tests__/fire-event.test.tsx | 8 ++--- src/events/create-event.ts | 18 +++++----- src/events/fire-event.ts | 10 +++--- src/test-utils/event-system.ts | 2 +- src/user-event/__tests__/clear.test.tsx | 22 ++++++------ src/user-event/__tests__/paste.test.tsx | 24 ++++++------- src/user-event/type/__tests__/type.test.tsx | 36 ++++++++++---------- 15 files changed, 87 insertions(+), 86 deletions(-) diff --git a/contributing/event-dispatch.md b/contributing/event-dispatch.md index cfa799e35..17a06fba7 100644 --- a/contributing/event-dispatch.md +++ b/contributing/event-dispatch.md @@ -2,7 +2,7 @@ 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/legacy/`, which also holds `fireEvent` itself. This is the `'legacy'` event system, the default for the `eventSystem` config option. A `'modern'` event system that follows React Native's event dispatch is in progress. +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. The files in `src/events/legacy/`: diff --git a/contributing/testing.md b/contributing/testing.md index 5d7df8d4e..ac38c4e2d 100644 --- a/contributing/testing.md +++ b/contributing/testing.md @@ -16,13 +16,13 @@ Every change in `src/` should come with tests. Tests use Jest and live next to t ## Event systems -`fireEvent` and `userEvent` work with both event systems (`configure({ eventSystem })`), so `jest.config.js` has two projects: +`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().eventSystem` inside the test instead of skipping it. +When a test expects different behavior in the two systems, e.g. events bubbling to a parent, branch on `getConfig().unstable_eventSystem` inside the test instead of skipping it. 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 index 5526da7f2..6274d014d 100644 --- a/jest-setup-modern.ts +++ b/jest-setup-modern.ts @@ -2,5 +2,5 @@ import { configure } from './src/pure'; // Runs after `resetToDefaults()` in `jest-setup.ts`. beforeEach(() => { - configure({ eventSystem: 'modern' }); + configure({ unstable_eventSystem: 'modern' }); }); diff --git a/jest.config.js b/jest.config.js index a318a8680..4e552139e 100644 --- a/jest.config.js +++ b/jest.config.js @@ -22,7 +22,7 @@ module.exports = { ], projects: [ { ...baseProject, displayName: 'legacy' }, - // All tests again with `configure({ eventSystem: 'modern' })`. They share snapshots with the + // 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, diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index ff4517054..5b88a76f8 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -13,7 +13,7 @@ afterEach(() => { test('getConfig() returns existing configuration', () => { expect(getConfig().asyncUtilTimeout).toEqual(1000); expect(getConfig().defaultIncludeHiddenElements).toEqual(false); - expect(getConfig().eventSystem).toEqual('legacy'); + expect(getConfig().unstable_eventSystem).toEqual('legacy'); }); test('configure() overrides existing config values', () => { @@ -24,19 +24,19 @@ test('configure() overrides existing config values', () => { defaultDebugOptions: { message: 'debug message' }, defaultIncludeHiddenElements: false, eventDiagnostics: false, - eventSystem: 'legacy', + unstable_eventSystem: 'legacy', }); }); -test('configure() sets eventSystem', () => { - configure({ eventSystem: 'modern' }); - expect(getConfig().eventSystem).toEqual('modern'); +test('configure() sets unstable_eventSystem', () => { + configure({ unstable_eventSystem: 'modern' }); + expect(getConfig().unstable_eventSystem).toEqual('modern'); configure({ asyncUtilTimeout: 2000 }); - expect(getConfig().eventSystem).toEqual('modern'); + expect(getConfig().unstable_eventSystem).toEqual('modern'); resetToDefaults(); - expect(getConfig().eventSystem).toEqual('legacy'); + expect(getConfig().unstable_eventSystem).toEqual('legacy'); }); test('resetToDefaults() resets config to defaults', () => { @@ -79,7 +79,7 @@ test('does not warn when only valid options are passed', () => { defaultIncludeHiddenElements: true, defaultDebugOptions: { message: 'test' }, defaultHidden: false, - eventSystem: 'modern', + unstable_eventSystem: 'modern', }); expect(_console.warn).not.toHaveBeenCalled(); diff --git a/src/config.ts b/src/config.ts index 5b099c0cd..895b06829 100644 --- a/src/config.ts +++ b/src/config.ts @@ -21,10 +21,11 @@ export type Config = { eventDiagnostics: boolean; /** - * Event system used by `fireEvent` and `userEvent`. `'legacy'` is the current simplified - * implementation. `'modern'` follows React Native's event dispatch. Defaults to `'legacy'`. + * 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'`. */ - eventSystem: EventSystem; + unstable_eventSystem: EventSystem; /** Default options for `debug` helper. */ defaultDebugOptions?: Partial; @@ -39,7 +40,7 @@ const defaultConfig: Config = { asyncUtilTimeout: 1000, defaultIncludeHiddenElements: false, eventDiagnostics: false, - eventSystem: 'legacy', + unstable_eventSystem: 'legacy', }; let config = { ...defaultConfig }; @@ -54,7 +55,7 @@ export function configure(options: Partial) { defaultHidden, defaultIncludeHiddenElements, eventDiagnostics, - eventSystem, + unstable_eventSystem, ...rest } = options; @@ -69,7 +70,7 @@ export function configure(options: Partial) { defaultDebugOptions, defaultIncludeHiddenElements: resolvedDefaultIncludeHiddenElements, eventDiagnostics: eventDiagnostics ?? config.eventDiagnostics, - eventSystem: eventSystem ?? config.eventSystem, + 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 index f26d47ca1..d837c4458 100644 --- a/src/events/__tests__/create-event.test.ts +++ b/src/events/__tests__/create-event.test.ts @@ -20,7 +20,7 @@ afterEach(() => { }); test('builds a legacy event object in the legacy event system', () => { - configure({ eventSystem: 'legacy' }); + configure({ unstable_eventSystem: 'legacy' }); const nativeEvent = { target: 0 }; const event = createEvent('focus', nativeEvent); @@ -30,7 +30,7 @@ test('builds a legacy event object in the legacy event system', () => { }); test('builds a SyntheticEvent in the modern event system', () => { - configure({ eventSystem: 'modern' }); + configure({ unstable_eventSystem: 'modern' }); const nativeEvent = { target: 0 }; const event = createEvent('focus', nativeEvent); @@ -43,7 +43,7 @@ test('builds a SyntheticEvent in the modern event system', () => { test.each(['legacy', 'modern'] as const)( 'throws for event types that are not native in the %s event system', (eventSystem) => { - configure({ eventSystem }); + configure({ unstable_eventSystem: eventSystem }); expect(() => createEvent('changeText', {})).toThrow( '"changeText" is not a native event type. Use invokeEventHandler() instead.', @@ -66,9 +66,9 @@ test.each([ ])('builds %s event for the configured event system', (type, build) => { // The touch payload has a `Date.now()` timestamp. jest.spyOn(Date, 'now').mockReturnValue(1); - configure({ eventSystem: 'legacy' }); + configure({ unstable_eventSystem: 'legacy' }); const legacyEvent = build(); - configure({ eventSystem: 'modern' }); + configure({ unstable_eventSystem: 'modern' }); const modernEvent = build(); expect(legacyEvent).not.toBeInstanceOf(SyntheticEvent); diff --git a/src/events/__tests__/dispatch-event.test.tsx b/src/events/__tests__/dispatch-event.test.tsx index b56c1cc12..6b811c6ce 100644 --- a/src/events/__tests__/dispatch-event.test.tsx +++ b/src/events/__tests__/dispatch-event.test.tsx @@ -9,7 +9,7 @@ import { buildFocusNativeEvent } from '../shared/payloads'; describe('legacy event system', () => { beforeEach(() => { - configure({ eventSystem: 'legacy' }); + configure({ unstable_eventSystem: 'legacy' }); }); test('calls only the target prop with the legacy event object', async () => { @@ -37,7 +37,7 @@ describe('legacy event system', () => { describe('modern event system', () => { beforeEach(() => { - configure({ eventSystem: 'modern' }); + configure({ unstable_eventSystem: 'modern' }); }); test('dispatches SyntheticEvent through capture and bubble phases', async () => { diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index 7710b9217..cceef0eb5 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -6,7 +6,7 @@ import { SyntheticEvent } from '../modern/event'; describe('legacy event system', () => { beforeEach(() => { - configure({ eventSystem: 'legacy' }); + configure({ unstable_eventSystem: 'legacy' }); }); test('fireEvent calls only the target prop with the passed arguments', async () => { @@ -55,7 +55,7 @@ describe('legacy event system', () => { describe('modern event system', () => { beforeEach(() => { - configure({ eventSystem: 'modern' }); + configure({ unstable_eventSystem: 'modern' }); }); test('fireEvent dispatches a SyntheticEvent through capture and bubble phases', async () => { @@ -130,9 +130,9 @@ test('reads the event system on each call', async () => { const onLayout = jest.fn(); await render(); - configure({ eventSystem: 'legacy' }); + configure({ unstable_eventSystem: 'legacy' }); await fireEvent.layout(screen.getByTestId('view')); - configure({ eventSystem: 'modern' }); + 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/create-event.ts b/src/events/create-event.ts index 122b64735..ffb69a0ff 100644 --- a/src/events/create-event.ts +++ b/src/events/create-event.ts @@ -23,7 +23,7 @@ export type LegacyEvent = ReturnType> /** * Creates the event object for a native event (`focus`, `change`, `scroll`, ...) in the event - * system selected by `configure({ eventSystem })`: a legacy event object with stubs, or a + * 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. * @@ -40,7 +40,7 @@ export function createEvent( throw new Error(`"${eventType}" is not a native event type. Use invokeEventHandler() instead.`); } - if (getConfig().eventSystem === 'legacy') { + if (getConfig().unstable_eventSystem === 'legacy') { return wrapNativeEvent(nativeEvent); } @@ -48,13 +48,6 @@ export function createEvent( return createModernEvent(eventType, { nativeEvent }) as SyntheticEvent; } -type ScrollEventType = - | 'scroll' - | 'scrollBeginDrag' - | 'scrollEndDrag' - | 'momentumScrollBegin' - | 'momentumScrollEnd'; - export function buildPressEvent() { return createEvent('press', buildTouchNativeEvent()); } @@ -102,3 +95,10 @@ export function buildTextSelectionChangeEvent(range: TextRange) { export function buildContentSizeChangeEvent(size: Size) { return createEvent('contentSizeChange', buildContentSizeChangeNativeEvent(size)); } + +type ScrollEventType = + | 'scroll' + | 'scrollBeginDrag' + | 'scrollEndDrag' + | 'momentumScrollBegin' + | 'momentumScrollEnd'; diff --git a/src/events/fire-event.ts b/src/events/fire-event.ts index 6b6931534..4b2fc5fe5 100644 --- a/src/events/fire-event.ts +++ b/src/events/fire-event.ts @@ -6,13 +6,9 @@ import type { FireEventInit } from './modern/fire-event'; import { fireEvent as modernFireEvent } from './modern/fire-event'; import type { EventProps, EventType, LayoutRectangle } from './shared/types'; -function isModern() { - return getConfig().eventSystem === 'modern'; -} - /** * Public `fireEvent`. Calls the implementation of the event system selected by - * `configure({ eventSystem })`, read on each call, so `configure()` inside a test applies. + * `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. */ @@ -66,4 +62,8 @@ fireEvent.layout = async ( return undefined; }; +function isModern() { + return getConfig().unstable_eventSystem === 'modern'; +} + export { fireEvent }; diff --git a/src/test-utils/event-system.ts b/src/test-utils/event-system.ts index ca41e21f7..f3043be1c 100644 --- a/src/test-utils/event-system.ts +++ b/src/test-utils/event-system.ts @@ -6,6 +6,6 @@ import { configure } from '../config'; */ export function runInLegacyEventSystem() { beforeEach(() => { - configure({ eventSystem: 'legacy' }); + configure({ unstable_eventSystem: 'legacy' }); }); } diff --git a/src/user-event/__tests__/clear.test.tsx b/src/user-event/__tests__/clear.test.tsx index 3b63a0816..16e581295 100644 --- a/src/user-event/__tests__/clear.test.tsx +++ b/src/user-event/__tests__/clear.test.tsx @@ -40,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!', @@ -63,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!', @@ -84,7 +84,7 @@ describe('clear()', () => { ]); }); - it('supports defaultValue prop', async () => { + test('supports defaultValue prop', async () => { const { textInput, events } = await renderTextInputWithToolkit({ defaultValue: 'Hello Default!', }); @@ -106,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, @@ -118,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', @@ -130,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, @@ -154,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(); @@ -186,7 +186,7 @@ describe('clear()', () => { // View that ignores props type checking const AnyView = View as React.ComponentType; - it('bubbles up only native bubbling events, in the modern event system', async () => { + test('bubbles up only native bubbling events, in the modern event system', async () => { const { events, logEvent } = createEventLogger(); await render( { // `focus`, `keyPress`, `change`, `endEditing` and `blur` are bubbling events in React Native. const modernEvents = ['focus', 'keyPress', 'change', 'endEditing', 'blur']; expect(getEventsNames(events)).toEqual( - getConfig().eventSystem === 'modern' ? modernEvents : [], + 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 3362c1560..289919dea 100644 --- a/src/user-event/__tests__/paste.test.tsx +++ b/src/user-event/__tests__/paste.test.tsx @@ -40,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(); @@ -60,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!', @@ -82,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!', @@ -102,7 +102,7 @@ describe('paste()', () => { ]); }); - it('supports defaultValue prop', async () => { + test('supports defaultValue prop', async () => { const { textInput, events } = await renderTextInputWithToolkit({ defaultValue: 'Hello Default!', }); @@ -123,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, @@ -135,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', @@ -147,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, @@ -170,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(); @@ -202,7 +202,7 @@ describe('paste()', () => { // View that ignores props type checking const AnyView = View as React.ComponentType; - it('bubbles up only native bubbling events, in the modern event system', async () => { + test('bubbles up only native bubbling events, in the modern event system', async () => { const { events, logEvent } = createEventLogger(); await render( { // `focus`, `keyPress`, `change`, `endEditing` and `blur` are bubbling events in React Native. const modernEvents = ['focus', 'change', 'endEditing', 'blur']; expect(getEventsNames(events)).toEqual( - getConfig().eventSystem === 'modern' ? modernEvents : [], + 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/type/__tests__/type.test.tsx b/src/user-event/type/__tests__/type.test.tsx index 993d79b03..88f09a4ba 100644 --- a/src/user-event/type/__tests__/type.test.tsx +++ b/src/user-event/type/__tests__/type.test.tsx @@ -38,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(); @@ -68,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(); @@ -85,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() { @@ -118,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(); @@ -146,7 +146,7 @@ describe('type()', () => { ]); }); - it('supports defaultValue prop', async () => { + test('supports defaultValue prop', async () => { const { events } = await renderTextInputWithToolkit({ defaultValue: 'xxx', }); @@ -173,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, }); @@ -184,7 +184,7 @@ describe('type()', () => { expect(getEventsNames(events)).toEqual([]); }); - it('supports backspace', async () => { + test('supports backspace', async () => { const { events } = await renderTextInputWithToolkit({ defaultValue: 'xxx', }); @@ -211,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, }); @@ -266,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(); @@ -294,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(); @@ -331,7 +331,7 @@ describe('type()', () => { // View that ignores props type checking const AnyView = View as React.ComponentType; - it('bubbles up only native bubbling events, in the modern event system', async () => { + test('bubbles up only native bubbling events, in the modern event system', async () => { const { events, logEvent } = createEventLogger(); await render( { 'blur', ]; expect(getEventsNames(events)).toEqual( - getConfig().eventSystem === 'modern' ? modernEvents : [], + 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(); @@ -435,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(); @@ -449,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(); From ecccf77331fdfb020927fd7e5e6db9d6b34eabed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Fri, 9 Oct 2026 15:54:12 +0200 Subject: [PATCH 28/28] test tweaks --- src/events/__tests__/dispatch-event.test.tsx | 136 +++++-------- src/events/__tests__/fire-event.test.tsx | 191 ++++++++----------- 2 files changed, 128 insertions(+), 199 deletions(-) diff --git a/src/events/__tests__/dispatch-event.test.tsx b/src/events/__tests__/dispatch-event.test.tsx index 6b811c6ce..d25aee720 100644 --- a/src/events/__tests__/dispatch-event.test.tsx +++ b/src/events/__tests__/dispatch-event.test.tsx @@ -1,101 +1,69 @@ import * as React from 'react'; import { TextInput, View } from 'react-native'; -import { configure, render, screen } from '../..'; +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'; -describe('legacy event system', () => { - beforeEach(() => { - configure({ unstable_eventSystem: 'legacy' }); - }); +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')} + > + + , + ); - test('calls only the target prop with the legacy event object', async () => { - const onPointerDown = jest.fn(); - const onParentPointerDown = jest.fn(); - await render( - - - , - ); - - const event = createEvent('pointerDown', { pointerId: 1 }); - expect(await dispatchEvent(screen.getByTestId('target'), 'pointerDown', event)).toBe(true); - expect(onPointerDown).toHaveBeenCalledWith(event); - expect(onParentPointerDown).not.toHaveBeenCalled(); - }); - - test('returns false without a handler', async () => { - await render(); - - const event = createEvent('focus', buildFocusNativeEvent()); - expect(await dispatchEvent(screen.getByTestId('input'), 'focus', event)).toBe(false); - }); + 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'], + ); }); -describe('modern event system', () => { - beforeEach(() => { - configure({ unstable_eventSystem: 'modern' }); - }); - - test('dispatches SyntheticEvent through capture and bubble phases', async () => { - const calls: string[] = []; - await render( - calls.push('parent.onPointerDownCapture')} - onPointerDown={(event) => { - calls.push('parent.onPointerDown'); - expect(event).toBeInstanceOf(SyntheticEvent); - expect(event.nativeEvent).toEqual({ pointerId: 1 }); - expect(event.target).toBe(screen.getByTestId('target')); - }} - > - calls.push('target.onPointerDown')} /> - , - ); +test('returns whether an ancestor handled the event', async () => { + const onParentPointerDown = jest.fn(); + await render( + + + , + ); - const event = createEvent('pointerDown', { pointerId: 1 }); - expect(await dispatchEvent(screen.getByTestId('target'), 'pointerDown', event)).toBe(true); - expect(calls).toEqual([ - 'parent.onPointerDownCapture', - 'target.onPointerDown', - 'parent.onPointerDown', - ]); - }); - - test('returns true when only an ancestor handles the event', async () => { - const onParentPointerDown = jest.fn(); - await render( - - - , - ); - - const event = createEvent('pointerDown', { pointerId: 1 }); - expect(await dispatchEvent(screen.getByTestId('target'), 'pointerDown', event)).toBe(true); - expect(onParentPointerDown).toHaveBeenCalledTimes(1); - }); + 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( - - - , - ); +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); - }); + 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(); +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); - }); + 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 cceef0eb5..07c89479b 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -2,128 +2,89 @@ import * as React from 'react'; import { Pressable, ScrollView, Text, TextInput, View } from 'react-native'; import { configure, fireEvent, render, screen } from '../..'; +import { getConfig } from '../../config'; import { SyntheticEvent } from '../modern/event'; -describe('legacy event system', () => { - beforeEach(() => { - configure({ unstable_eventSystem: 'legacy' }); - }); - - test('fireEvent calls only the target prop with the passed arguments', async () => { - const onPointerDown = jest.fn(); - const onParentPointerDownCapture = jest.fn(); - await render( - - - , - ); - - await fireEvent(screen.getByTestId('target'), 'pointerDown', 'a', 'b'); - expect(onPointerDown).toHaveBeenCalledWith('a', 'b'); - expect(onParentPointerDownCapture).not.toHaveBeenCalled(); - }); - - test('fireEvent.changeText calls onChangeText without onChange', async () => { - const onChange = jest.fn(); - const onChangeText = jest.fn(); - await render(); - - await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); - expect(onChangeText).toHaveBeenCalledWith('Hello'); - expect(onChange).not.toHaveBeenCalled(); - }); - - test('fireEvent.press calls onPress', async () => { - const onPress = jest.fn(); - await render(); - - expect(await fireEvent.press(screen.getByTestId('text'))).toBeUndefined(); - expect(onPress).toHaveBeenCalledTimes(1); - }); - - test('fireEvent.scroll and fireEvent.layout pass legacy event objects', async () => { - const onScroll = jest.fn(); - const onLayout = jest.fn(); - await render(); - - expect(await fireEvent.scroll(screen.getByTestId('scrollView'))).toBeUndefined(); - expect(await fireEvent.layout(screen.getByTestId('scrollView'))).toBeUndefined(); - expect(onScroll.mock.calls[0][0]).not.toBeInstanceOf(SyntheticEvent); - expect(onLayout.mock.calls[0][0]).not.toBeInstanceOf(SyntheticEvent); - }); +test('fireEvent calls handlers of the selected event system', async () => { + const onPointerDown = jest.fn(); + const onParentPointerDownCapture = jest.fn(); + await render( + + + , + ); + + 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('modern event system', () => { - beforeEach(() => { - configure({ unstable_eventSystem: 'modern' }); - }); - - test('fireEvent dispatches a SyntheticEvent through capture and bubble phases', async () => { - const onPointerDown = jest.fn(); - const onParentPointerDownCapture = jest.fn(); - await render( - - - , - ); - - expect( - await fireEvent(screen.getByTestId('target'), 'pointerDown', { - nativeEvent: { pointerId: 1 }, - }), - ).toBe(true); - expect(onParentPointerDownCapture).toHaveBeenCalledTimes(1); - expect(onPointerDown.mock.calls[0][0]).toBeInstanceOf(SyntheticEvent); - expect(onPointerDown.mock.calls[0][0].nativeEvent).toEqual({ pointerId: 1 }); - }); - - test('fireEvent throws for event types React Native does not dispatch natively', async () => { - const onChangeText = jest.fn(); - await render(); - - await expect(fireEvent(screen.getByTestId('input'), 'changeText', 'Hello')).rejects.toThrow( - `React Native doesn't dispatch "changeText" natively`, - ); - expect(onChangeText).not.toHaveBeenCalled(); - }); - - test('fireEvent.changeText fires onChange, then onChangeText', async () => { - const calls: string[] = []; - await render( - calls.push(`onChange: ${event.nativeEvent.text}`)} - onChangeText={(text) => calls.push(`onChangeText: ${text}`)} - />, - ); - - expect(await fireEvent.changeText(screen.getByTestId('input'), 'Hello')).toBe(true); - expect(calls).toEqual(['onChange: Hello', 'onChangeText: Hello']); - }); - - test('fireEvent.press uses the legacy implementation', async () => { - const onPress = jest.fn(); - await render( - - Press me - , - ); +test('fireEvent with an event type React Native does not dispatch natively', async () => { + const onChangeText = jest.fn(); + await render(); + + const error = await fireEvent(screen.getByTestId('input'), 'changeText', 'Hello').then( + () => undefined, + (e: Error) => e.message, + ); + 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, + ); + expect(onChangeText.mock.calls).toEqual(isModern ? [] : [['Hello']]); +}); - expect(await fireEvent.press(screen.getByText('Press me'))).toBeUndefined(); - expect(onPress).toHaveBeenCalledTimes(1); - expect(onPress.mock.calls[0][0]).not.toBeInstanceOf(SyntheticEvent); - }); +test('fireEvent.changeText', async () => { + const calls: string[] = []; + await render( + calls.push(`onChange: ${event.nativeEvent.text}`)} + onChangeText={(text) => calls.push(`onChangeText: ${text}`)} + />, + ); + + await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); + expect(calls).toEqual( + getConfig().unstable_eventSystem === 'modern' + ? ['onChange: Hello', 'onChangeText: Hello'] + : ['onChangeText: Hello'], + ); +}); - test('fireEvent.scroll and fireEvent.layout pass SyntheticEvents', async () => { - const onScroll = jest.fn(); - const onLayout = jest.fn(); - await render(); +test('fireEvent.press uses the legacy implementation', async () => { + const onPress = jest.fn(); + await render( + + Press me + , + ); + + expect(await fireEvent.press(screen.getByText('Press me'))).toBeUndefined(); + expect(onPress).toHaveBeenCalledTimes(1); + expect(onPress.mock.calls[0][0]).not.toBeInstanceOf(SyntheticEvent); +}); - expect(await fireEvent.scroll(screen.getByTestId('scrollView'))).toBe(true); - expect(await fireEvent.layout(screen.getByTestId('scrollView'))).toBe(true); - expect(onScroll.mock.calls[0][0]).toBeInstanceOf(SyntheticEvent); - expect(onLayout.mock.calls[0][0]).toBeInstanceOf(SyntheticEvent); - }); +test('fireEvent.scroll and fireEvent.layout pass events of the selected event system', async () => { + const onScroll = jest.fn(); + const onLayout = jest.fn(); + await render(); + + const scrollResult = await fireEvent.scroll(screen.getByTestId('scrollView')); + const layoutResult = await fireEvent.layout(screen.getByTestId('scrollView')); + const isModern = getConfig().unstable_eventSystem === 'modern'; + expect(scrollResult).toBe(isModern ? true : undefined); + expect(layoutResult).toBe(isModern ? true : undefined); + expect(onScroll.mock.calls[0][0] instanceof SyntheticEvent).toBe(isModern); + expect(onLayout.mock.calls[0][0] instanceof SyntheticEvent).toBe(isModern); }); test('reads the event system on each call', async () => {