From 40e17e89de1741da7d688b91e16838e1fef8db6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Wed, 14 Oct 2026 13:09:25 +0200 Subject: [PATCH 1/5] wip --- contributing/event-dispatch.md | 2 + src/events/__tests__/fire-event.test.tsx | 189 +++++++++++++++++- .../modern/__tests__/event-types.test.ts | 17 ++ src/events/modern/__tests__/event.test.ts | 29 +++ src/events/modern/event-types.ts | 43 +++- src/events/modern/event.ts | 16 +- src/events/modern/fire-event-utils.ts | 75 ++++++- src/events/modern/fire-event.ts | 11 +- 8 files changed, 352 insertions(+), 30 deletions(-) diff --git a/contributing/event-dispatch.md b/contributing/event-dispatch.md index d4003ac36..3e650bd83 100644 --- a/contributing/event-dispatch.md +++ b/contributing/event-dispatch.md @@ -18,6 +18,8 @@ Code used by both event systems lives in `src/events/shared/`: `handler.ts` (fin In the modern event system, `src/events/modern/pressability.ts` calls the `onPress`, `onPressIn`, `onPressOut` or `onLongPress` callback of the element that becomes the touch responder, reading `Pressable` and `Touchable*` callbacks from Pressability's `testOnly_pressabilityConfig()`. These callbacks aren't native events, so modern `fireEvent` sends the event types in `PRESSABILITY_EVENT_TYPES` (`src/events/modern/event-types.ts`) there instead of to `dispatchEvent()`: `fireEvent.press()`, and `fireEvent()` with `press`, `pressIn`, `pressOut` or `longPress`. `dispatchEvent()` still dispatches the native `press` event, which reaches only host `onPress` props. The callbacks get a `ResponderEvent` from `src/events/modern/responder-event.ts`, like React Native's responder system creates in JavaScript: `responderGrant` or `responderRelease`, with a touch payload, but no `touchHistory`, which only `PanResponder` reads. It isn't trusted, has no event phase or composed path, and has a `null` `currentTarget` in `onLongPress`, which Pressability calls from a timer. +Modern `fireEvent` throws for event types React Native doesn't know (`ensureEventType()` in `src/events/modern/fire-event-utils.ts`), unless the event object has `dispatch: 'direct' | 'bubbling'`. This is for native events of third-party components, which render as host elements with all their props in Jest, e.g. `fireEvent(pager, 'pageSelected', { nativeEvent: { position: 1 }, dispatch: 'direct' })`. `getCustomEventTypeConfig()` in `src/events/modern/event-types.ts` builds the config codegen would: handler props named after the event type, and no `skipBubbling`, which codegen can't declare. `dispatch` is required, not defaulted, so a legacy call for a JavaScript callback that the Jest mocks put on a host, like `fireEvent(list, 'endReached', ...)`, still throws and points to `UNSAFE_invokeHandler()`. Known event types reject `dispatch`. + Modern `fireEvent` doesn't log `eventDiagnostics` warnings yet. Only legacy `fireEvent` and `userEvent` do. `src/user-event/` is a separate module on top of the event subsystem. It creates and dispatches native events through the facades `src/events/create-event.ts` and `src/events/dispatch-event.ts`. For a legacy event object, `dispatchEvent()` calls the target's own handler with `invokeEventHandler()` from `src/events/shared/invoke-event-handler.ts`. Other modules are imported directly: legacy touch and responder event builders from `src/events/legacy/builders/common.ts`, and `src/events/modern/` and `src/events/shared/` modules, e.g. `src/user-event/modern/press.ts` (hit testing and responder negotiation from `pressability.ts`, handler lookup from `handler.ts`) and `src/user-event/utils/` (`invokeEventHandler()`, the `SyntheticEvent` type and the warning helpers from `warnings.ts`). diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index d22326903..d5d25585a 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -1830,6 +1830,187 @@ describe('direct events', () => { }); }); +describe('custom event types', () => { + // Third-party native components render as host elements with all their props in Jest, as + // `@react-native/jest-preset` mocks `requireNativeComponent()` and `NativeComponentRegistry`. + test('dispatches a direct custom event to the target only (legacy: passes the event object as given)', async () => { + const onPageSelected = jest.fn(); + const onParentPageSelected = jest.fn(); + await render( + React.createElement( + 'RNCViewPager', + { onPageSelected: onParentPageSelected }, + React.createElement('RNCViewPager', { testID: 'pager', onPageSelected }), + ), + ); + const pager = screen.getByTestId('pager'); + + const event = { nativeEvent: { position: 1 }, dispatch: 'direct' }; + await fireEvent(pager, 'pageSelected', event); + + // Modern matches React Native, which calls only the target's prop for direct events. + expect(onParentPageSelected).not.toHaveBeenCalled(); + expect(onPageSelected).toHaveBeenCalledTimes(1); + const received = (onPageSelected.mock.calls[0] as unknown[])[0] as SyntheticEvent; + expect(received).toEqual(isModern() ? anySyntheticEvent : event); + expect(received.nativeEvent).toEqual({ position: 1 }); + expect(received.type).toBe(isModern() ? 'pageselected' : undefined); + expect(received.rnIsDirect).toBe(isModern() ? true : undefined); + expect(received.target).toBe(isModern() ? pager : undefined); + }); + + test('dispatches a bubbling custom event through capture and bubble phases (legacy: calls only the nearest handler)', async () => { + const calls: string[] = []; + const getHandlers = (id: string) => ({ + onCustomGestureCapture: () => calls.push(`${id}.onCustomGestureCapture`), + onCustomGesture: () => calls.push(`${id}.onCustomGesture`), + }); + await render( + + {React.createElement('RNCustomView', { testID: 'target', ...getHandlers('target') })} + , + ); + + await fireEvent(screen.getByTestId('target'), 'customGesture', { + nativeEvent: {}, + dispatch: 'bubbling', + }); + + expect(calls).toEqual( + isModern() + ? [ + 'parent.onCustomGestureCapture', + 'target.onCustomGestureCapture', + 'target.onCustomGesture', + 'parent.onCustomGesture', + ] + : ['target.onCustomGesture'], + ); + }); + + test('accepts a custom event type with the "on" prefix', async () => { + const onPageSelected = jest.fn(); + await render(React.createElement('RNCViewPager', { testID: 'pager', onPageSelected })); + + await fireEvent(screen.getByTestId('pager'), 'onPageSelected', { + nativeEvent: { position: 2 }, + dispatch: 'direct', + }); + + expect(onPageSelected).toHaveBeenCalledTimes(1); + expect(onPageSelected.mock.calls[0][0].nativeEvent).toEqual({ position: 2 }); + }); + + test('throws for a custom event type without dispatch (legacy: calls the handler)', async () => { + const onEndReached = jest.fn(); + // The Jest mock of `ScrollView` puts `FlatList`'s JavaScript callbacks, like `onEndReached`, + // on the host element. + await render( + {item}} + onEndReached={onEndReached} + />, + ); + + const error = await getErrorMessage( + fireEvent(screen.getByTestId('list'), 'endReached', { + nativeEvent: { distanceFromEnd: 0 }, + }), + ); + + expect(error).toBe( + isModern() + ? 'Unable to fire a "endReached" event. React Native doesn\'t know "endReached". For a native event of a third-party component, pass dispatch: "direct" or "bubbling" in the event object, matching its DirectEventHandler or BubblingEventHandler prop type. To call a handler prop directly, use UNSAFE_invokeHandler().' + : undefined, + ); + expect(onEndReached).toHaveBeenCalledTimes(isModern() ? 0 : 1); + }); + + test.each([ + [ + 'fireEvent() with focus', + 'focus', + () => , + (instance: TestInstance) => + fireEvent(instance, 'focus', { nativeEvent: {}, dispatch: 'direct' }), + ], + [ + 'fireEvent() with press', + 'press', + () => , + (instance: TestInstance) => + fireEvent(instance, 'press', { nativeEvent: {}, dispatch: 'bubbling' }), + ], + [ + 'fireEvent.press()', + 'press', + () => , + (instance: TestInstance) => fireEvent.press(instance, { dispatch: 'bubbling' }), + ], + [ + 'fireEvent.scroll()', + 'scroll', + () => , + (instance: TestInstance) => fireEvent.scroll(instance, { dispatch: 'direct' }), + ], + ])( + '%s throws with dispatch for a known event type (legacy: passes it to the handler)', + async (_, eventType, renderElement, fire) => { + await render(renderElement()); + + const error = await getErrorMessage(fire(screen.getByTestId('target'))); + + expect(error).toBe( + isModern() + ? `Unable to fire a "${eventType}" event. Remove "dispatch": it's only for event types React Native doesn't know.` + : undefined, + ); + }, + ); + + test.each([ + ['capture', 'string "capture"'], + [null, 'null'], + [true, 'boolean true'], + ])( + 'throws when dispatch is %p (legacy: passes it to the handler)', + async (dispatch, description) => { + const onPageSelected = jest.fn(); + await render(React.createElement('RNCViewPager', { testID: 'pager', onPageSelected })); + + const error = await getErrorMessage( + fireEvent(screen.getByTestId('pager'), 'pageSelected', { nativeEvent: {}, dispatch }), + ); + + expect(error).toBe( + isModern() + ? `Unable to fire a "pageSelected" event. Expected "dispatch" to be "direct" or "bubbling", received ${description}.` + : undefined, + ); + expect(onPageSelected).toHaveBeenCalledTimes(isModern() ? 0 : 1); + }, + ); + + test('throws for changeText with dispatch (legacy: calls the handler)', async () => { + const onChangeText = jest.fn(); + await render(); + + const error = await getErrorMessage( + fireEvent(screen.getByTestId('input'), 'changeText', { nativeEvent: {}, dispatch: 'direct' }), + ); + + // `onChangeText` isn't a native event: `TextInput` calls it from its `onChange` handler. + 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).toHaveBeenCalledTimes(isModern() ? 0 : 1); + }); +}); + describe('Pressability event types', () => { function logCallbacks(calls: string[], id: string) { const log = (name: string) => () => { @@ -1934,7 +2115,7 @@ test('fireEvent throws for custom event (onCustomEvent) on composite component ( // calls any matching prop, also on composite components. expect(error).toBe( isModern() - ? `Unable to fire a "customEvent" event. React Native doesn't dispatch "customEvent" natively, so no handler would be called. To call a handler prop directly, use UNSAFE_invokeHandler().` + ? `Unable to fire a "customEvent" event. React Native doesn't know "customEvent". For a native event of a third-party component, pass dispatch: "direct" or "bubbling" in the event object, matching its DirectEventHandler or BubblingEventHandler prop type. To call a handler prop directly, use UNSAFE_invokeHandler().` : undefined, ); expect(handler.mock.calls).toEqual(isModern() ? [] : [['event data']]); @@ -1953,7 +2134,7 @@ test('fireEvent throws for custom prop name (handlePress) on composite component // calls any matching prop, also on composite components. expect(error).toBe( isModern() - ? `Unable to fire a "handlePress" event. React Native doesn't dispatch "handlePress" natively, so no handler would be called. To call a handler prop directly, use UNSAFE_invokeHandler().` + ? `Unable to fire a "handlePress" event. React Native doesn't know "handlePress". For a native event of a third-party component, pass dispatch: "direct" or "bubbling" in the event object, matching its DirectEventHandler or BubblingEventHandler prop type. To call a handler prop directly, use UNSAFE_invokeHandler().` : undefined, ); expect(handler).toHaveBeenCalledTimes(isModern() ? 0 : 1); @@ -2032,7 +2213,7 @@ test('fireEvent throws with non-existent event name (legacy: does nothing)', asy expect(outcome).toStrictEqual( isModern() ? { - error: `Unable to fire a "nonExistentEvent" event. React Native doesn't dispatch "nonExistentEvent" natively, so no handler would be called. To call a handler prop directly, use UNSAFE_invokeHandler().`, + error: `Unable to fire a "nonExistentEvent" event. React Native doesn't know "nonExistentEvent". For a native event of a third-party component, pass dispatch: "direct" or "bubbling" in the event object, matching its DirectEventHandler or BubblingEventHandler prop type. To call a handler prop directly, use UNSAFE_invokeHandler().`, } : { result: undefined }, ); @@ -3200,7 +3381,7 @@ describe('responder system', () => { // responder system's touch history, which modern doesn't simulate, so modern throws. expect(error).toBe( isModern() - ? `Unable to fire a "responderMove" event. React Native doesn't dispatch "responderMove" natively, so no handler would be called. To call a handler prop directly, use UNSAFE_invokeHandler().` + ? `Unable to fire a "responderMove" event. React Native doesn't know "responderMove". For a native event of a third-party component, pass dispatch: "direct" or "bubbling" in the event object, matching its DirectEventHandler or BubblingEventHandler prop type. To call a handler prop directly, use UNSAFE_invokeHandler().` : undefined, ); expect(onDrag).toHaveBeenCalledTimes(isModern() ? 0 : 1); diff --git a/src/events/modern/__tests__/event-types.test.ts b/src/events/modern/__tests__/event-types.test.ts index 571786cab..17b5f089a 100644 --- a/src/events/modern/__tests__/event-types.test.ts +++ b/src/events/modern/__tests__/event-types.test.ts @@ -2,6 +2,7 @@ import { createEvent } from '../event'; import { BUBBLING_EVENT_TYPES, DIRECT_EVENT_TYPES, + getCustomEventTypeConfig, getEventTypeConfig, SKIP_BUBBLING_EVENT_TYPES, } from '../event-types'; @@ -140,3 +141,19 @@ test('getEventTypeConfig() returns null for events unknown to React Native', () expect(getEventTypeConfig('onFocus')).toBeNull(); expect(getEventTypeConfig('toString')).toBeNull(); }); + +test('getCustomEventTypeConfig() returns config of direct custom event', () => { + expect(getCustomEventTypeConfig('pageSelected', 'direct')).toEqual({ + kind: 'direct', + handlerName: 'onPageSelected', + }); +}); + +test('getCustomEventTypeConfig() returns config of bubbling custom event', () => { + expect(getCustomEventTypeConfig('customGesture', 'bubbling')).toEqual({ + kind: 'bubbling', + handlerName: 'onCustomGesture', + captureHandlerName: 'onCustomGestureCapture', + skipBubbling: false, + }); +}); diff --git a/src/events/modern/__tests__/event.test.ts b/src/events/modern/__tests__/event.test.ts index 7595ac770..2a4443473 100644 --- a/src/events/modern/__tests__/event.test.ts +++ b/src/events/modern/__tests__/event.test.ts @@ -194,4 +194,33 @@ describe('createEvent()', () => { test('returns null for events unknown to React Native', () => { expect(createEvent('changeText', { nativeEvent: { text: 'Hello' } })).toBeNull(); }); + + test('creates direct custom event with dispatch', () => { + const nativeEvent = { position: 1 }; + + const event = createEvent('pageSelected', { nativeEvent, dispatch: 'direct' }); + + expect(event?.type).toBe('pageselected'); + expect(event?.bubbles).toBe(false); + expect(event?.rnIsDirect).toBe(true); + expect(event?.nativeEvent).toBe(nativeEvent); + expect(event?.dispatchConfig).toEqual({ registrationName: 'onPageSelected' }); + }); + + test('creates bubbling custom event with dispatch', () => { + const event = createEvent('customGesture', { dispatch: 'bubbling' }); + + expect(event?.bubbles).toBe(true); + expect(event?.rnIsDirect).toBe(false); + expect(event?.dispatchConfig).toEqual({ + phasedRegistrationNames: { bubbled: 'onCustomGesture', captured: 'onCustomGestureCapture' }, + }); + }); + + test('ignores dispatch for events known to React Native', () => { + const event = createEvent('focus', { dispatch: 'direct' }); + + expect(event?.bubbles).toBe(true); + expect(event?.rnIsDirect).toBe(false); + }); }); diff --git a/src/events/modern/event-types.ts b/src/events/modern/event-types.ts index 1021ab123..3bc8ba8b4 100644 --- a/src/events/modern/event-types.ts +++ b/src/events/modern/event-types.ts @@ -121,19 +121,21 @@ type DirectEventTypeConfig = { /** How React Native dispatches an event type, and which handler props it calls. */ export type EventTypeConfig = BubblingEventTypeConfig | DirectEventTypeConfig; +/** + * How a native component dispatches an event type React Native doesn't know, as its codegen spec + * declares the handler prop: `DirectEventHandler` or `BubblingEventHandler`. + */ +export type CustomEventDispatch = 'direct' | 'bubbling'; + +export const CUSTOM_EVENT_DISPATCHES: readonly CustomEventDispatch[] = ['direct', 'bubbling']; + /** * Returns how React Native dispatches the event type (e.g. `focus`), or `null` if React Native * doesn't know it. */ export function getEventTypeConfig(eventType: string): EventTypeConfig | null { if (BUBBLING_EVENT_TYPES.includes(eventType)) { - const handlerName = getEventHandlerName(eventType); - return { - kind: 'bubbling', - handlerName, - captureHandlerName: `${handlerName}Capture`, - skipBubbling: SKIP_BUBBLING_EVENT_TYPES.includes(eventType), - }; + return getBubblingEventTypeConfig(eventType, SKIP_BUBBLING_EVENT_TYPES.includes(eventType)); } if (DIRECT_EVENT_TYPES.includes(eventType)) { @@ -145,3 +147,30 @@ export function getEventTypeConfig(eventType: string): EventTypeConfig | null { return null; } + +/** + * Returns the config React Native builds for an event type a native component declares, e.g. a + * third-party component's `pageSelected`. As codegen generates it (`GenerateViewConfigJs.js`), the + * handler props follow the event type (`onPageSelected`, `onPageSelectedCapture`), and bubbling + * events always bubble. + */ +export function getCustomEventTypeConfig( + eventType: string, + dispatch: CustomEventDispatch, +): EventTypeConfig { + if (dispatch === 'bubbling') { + return getBubblingEventTypeConfig(eventType, false); + } + + return { kind: 'direct', handlerName: getEventHandlerName(eventType) }; +} + +function getBubblingEventTypeConfig(eventType: string, skipBubbling: boolean): EventTypeConfig { + const handlerName = getEventHandlerName(eventType); + return { + kind: 'bubbling', + handlerName, + captureHandlerName: `${handlerName}Capture`, + skipBubbling, + }; +} diff --git a/src/events/modern/event.ts b/src/events/modern/event.ts index b10b5f568..c681a4a6f 100644 --- a/src/events/modern/event.ts +++ b/src/events/modern/event.ts @@ -1,7 +1,7 @@ import type { TestInstance } from 'test-renderer'; -import type { EventTypeConfig } from './event-types'; -import { getEventTypeConfig } from './event-types'; +import type { CustomEventDispatch, EventTypeConfig } from './event-types'; +import { getCustomEventTypeConfig, getEventTypeConfig } from './event-types'; export type NativeEventPayload = Record; @@ -21,6 +21,11 @@ export type CreateEventInit = { * (`nativeEvent.timeStamp ?? nativeEvent.timestamp`), then to `performance.now()`. */ timeStamp?: number; + /** + * How to dispatch an event type React Native doesn't know, e.g. a third-party component's + * `pageSelected`. Ignored for event types React Native knows. + */ + dispatch?: CustomEventDispatch; }; /** `bubbles` and `rnIsDirect` default to what the type config says. */ @@ -226,10 +231,13 @@ export class SyntheticEvent { * `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. + * @returns `null` for events unknown to React Native without `init.dispatch`, as React Native drops + * them. */ export function createEvent(eventType: string, init: CreateEventInit = {}): SyntheticEvent | null { - const config = getEventTypeConfig(eventType); + const config = + getEventTypeConfig(eventType) ?? + (init.dispatch != null ? getCustomEventTypeConfig(eventType, init.dispatch) : null); if (config == null) { return null; } diff --git a/src/events/modern/fire-event-utils.ts b/src/events/modern/fire-event-utils.ts index 7d5b08a4e..f51a16653 100644 --- a/src/events/modern/fire-event-utils.ts +++ b/src/events/modern/fire-event-utils.ts @@ -4,9 +4,13 @@ import { ErrorWithStack } from '../../helpers/errors'; import { isHostTextInput } from '../../helpers/host-component-names'; import type { CreateEventInit } from './event'; import { SyntheticEvent } from './event'; -import { getEventTypeConfig, isPressabilityEventType } from './event-types'; +import { + CUSTOM_EVENT_DISPATCHES, + getEventTypeConfig, + isPressabilityEventType, +} from './event-types'; -const ALLOWED_INIT_KEYS = ['nativeEvent', 'timeStamp']; +const ALLOWED_INIT_KEYS = ['nativeEvent', 'timeStamp', 'dispatch']; const EVENT_KEYS = new Set( Object.getOwnPropertyNames(SyntheticEvent.prototype).filter((key) => key !== 'constructor'), @@ -18,28 +22,46 @@ const UNKNOWN_EVENT_TYPE_ALTERNATIVES: Record = { }; /** - * Throws for event types React Native doesn't dispatch natively, such as `changeText` or custom - * prop names, except Pressability event types (`pressIn`, ...). React Native drops them, so - * dropping them here would call no handler, and a test asserting that a handler wasn't called - * would pass for the wrong reason. + * Throws for event types React Native doesn't know, such as `changeText` or custom prop names, + * except Pressability event types (`pressIn`, ...), and custom event types passed with `dispatch`. + * React Native drops unknown events, so dropping them here would call no handler, and a test + * asserting that a handler wasn't called would pass for the wrong reason. * + * `dispatch` is required, not defaulted to `direct`: a legacy call such as + * `fireEvent(list, 'endReached', ...)` would otherwise pass a `SyntheticEvent` to `onEndReached`, + * which `FlatList` calls from JavaScript, but the Jest mock of `ScrollView` puts on the host. + * + * @param init the event object, checked by `validateEventInit()` afterwards * @param callsite the function to remove from the error stack, e.g. `fireEvent` */ export function ensureEventType( eventType: string, + init: unknown, // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type callsite: Function, ): void { - if (getEventTypeConfig(eventType) != null || isPressabilityEventType(eventType)) { + if (isKnownEventType(eventType)) { return; } const alternative = UNKNOWN_EVENT_TYPE_ALTERNATIVES[eventType]; + if (alternative != null) { + throw new ErrorWithStack( + `Unable to fire a "${eventType}" event. React Native doesn't dispatch "${eventType}" ` + + `natively. Use ${alternative} instead.`, + callsite, + ); + } + + if (isPlainObject(init) && init.dispatch !== undefined) { + return; + } + throw new ErrorWithStack( - `Unable to fire a "${eventType}" event. React Native doesn't dispatch "${eventType}" natively` + - (alternative != null - ? `. Use ${alternative} instead.` - : ', so no handler would be called. To call a handler prop directly, use UNSAFE_invokeHandler().'), + `Unable to fire a "${eventType}" event. React Native doesn't know "${eventType}". ` + + `For a native event of a third-party component, pass dispatch: "direct" or "bubbling" ` + + 'in the event object, matching its DirectEventHandler or BubblingEventHandler prop type. ' + + 'To call a handler prop directly, use UNSAFE_invokeHandler().', callsite, ); } @@ -119,9 +141,36 @@ export function validateEventInit( ); } + if (init.dispatch !== undefined) { + validateDispatch(eventType, init.dispatch, callsite); + } + return init as CreateEventInit; } +function validateDispatch( + eventType: string, + dispatch: unknown, + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + callsite: Function, +) { + if (isKnownEventType(eventType)) { + throw new ErrorWithStack( + `Unable to fire a "${eventType}" event. Remove "dispatch": it's only for event types ` + + "React Native doesn't know.", + callsite, + ); + } + + if (!(CUSTOM_EVENT_DISPATCHES as readonly unknown[]).includes(dispatch)) { + throw new ErrorWithStack( + `Unable to fire a "${eventType}" event. Expected "dispatch" to be "direct" or "bubbling", ` + + `received ${describeValue(dispatch)}.`, + callsite, + ); + } +} + /** * Validates the arguments of `fireEvent.changeText`: a host `TextInput`, as only `TextInput` calls * `onChangeText`, and a string. @@ -151,6 +200,10 @@ export function validateChangeTextArgs( } } +function isKnownEventType(eventType: string): boolean { + return getEventTypeConfig(eventType) != null || isPressabilityEventType(eventType); +} + /** * Explains why each key can't be passed. Dropping keys silently, as DOM event constructors do, * would leave handlers reading `undefined` with no explanation. diff --git a/src/events/modern/fire-event.ts b/src/events/modern/fire-event.ts index 7414e58fd..8c9d4c21c 100644 --- a/src/events/modern/fire-event.ts +++ b/src/events/modern/fire-event.ts @@ -32,7 +32,8 @@ import { createPressabilityEvent } from './responder-event'; /** * Event object passed to modern `fireEvent`. Handlers receive a `SyntheticEvent` from - * `createEvent()`, with `nativeEvent` as its `nativeEvent`. + * `createEvent()`, with `nativeEvent` as its `nativeEvent`. `dispatch` is required for event types + * React Native doesn't know, and not allowed for the others. */ export type FireEventInit = CreateEventInit; @@ -43,8 +44,10 @@ export type FireEventInit = CreateEventInit; * Pressability event types (`press`, `pressIn`, `pressOut`, `longPress`) call the callback of the * element that becomes the touch responder instead, as `fireEvent.press()` does. * - * Exactly one event object is required, as React Native handlers receive a single event. Other - * event types React Native doesn't dispatch natively (`changeText`, custom prop names) throw. + * Exactly one event object is required, as React Native handlers receive a single event. Event + * types React Native doesn't know need `dispatch: 'direct' | 'bubbling'` in the event object, as + * declared by the native component, e.g. a third-party component's `pageSelected`. Without it they + * throw, as do `changeText` and custom prop names. * * Resolves to nothing, unlike DOM `fireEvent`: React Native has no default actions that * `preventDefault()` could cancel. Handlers can read `event.defaultPrevented` themselves. @@ -59,7 +62,7 @@ export async function fireEvent( const normalizedType = normalizeEventType(eventType); ensureInstance(instance, normalizedType, fireEvent); // Before the event object, as `fireEvent(input, 'changeText', 'Hello')` needs the hint more. - ensureEventType(normalizedType, fireEvent); + ensureEventType(normalizedType, args[0], fireEvent); ensureSingleEventArg(normalizedType, args, fireEvent); const init = validateEventInit(normalizedType, args[0], fireEvent); if (isPressabilityEventType(normalizedType)) { From 398863e058551337b51fd3800e9a88cd8eb3137c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Wed, 14 Oct 2026 14:44:01 +0200 Subject: [PATCH 2/5] pager like test --- src/events/__tests__/fire-event.test.tsx | 32 ++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index d5d25585a..a3e43a0c1 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -1901,6 +1901,38 @@ describe('custom event types', () => { expect(onPageSelected.mock.calls[0][0].nativeEvent).toEqual({ position: 2 }); }); + test('reaches the user callback through a wrapper that adapts the host prop', async () => { + // Shaped like `react-native-pager-view`: the composite passes its own handler to the host, + // which then calls the user's prop. + type PageSelectedEvent = { nativeEvent: { position: number } }; + class PagerView extends React.Component<{ + testID?: string; + onPageSelected?: (event: PageSelectedEvent) => void; + }> { + _onPageSelected = (event: PageSelectedEvent) => { + this.props.onPageSelected?.(event); + }; + + render() { + return React.createElement('RNCViewPager', { + testID: this.props.testID, + onPageSelected: this._onPageSelected, + }); + } + } + + const onPageSelected = jest.fn(); + await render(); + + await fireEvent(screen.getByTestId('pager'), 'pageSelected', { + nativeEvent: { position: 3 }, + dispatch: 'direct', + }); + + expect(onPageSelected).toHaveBeenCalledTimes(1); + expect(onPageSelected.mock.calls[0][0].nativeEvent).toEqual({ position: 3 }); + }); + test('throws for a custom event type without dispatch (legacy: calls the handler)', async () => { const onEndReached = jest.fn(); // The Jest mock of `ScrollView` puts `FlatList`'s JavaScript callbacks, like `onEndReached`, From 0c04500eddb3c6020c97b2dad52724dbaed7a9fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 15 Oct 2026 09:46:18 +0200 Subject: [PATCH 3/5] allow overriding dispatch for all events --- contributing/event-dispatch.md | 2 +- src/events/__tests__/fire-event.test.tsx | 174 +++++++++++++++------- src/events/modern/__tests__/event.test.ts | 7 +- src/events/modern/event.ts | 10 +- src/events/modern/fire-event-utils.ts | 20 +-- src/events/modern/fire-event.ts | 29 +++- 6 files changed, 166 insertions(+), 76 deletions(-) diff --git a/contributing/event-dispatch.md b/contributing/event-dispatch.md index 3e650bd83..ec7405e09 100644 --- a/contributing/event-dispatch.md +++ b/contributing/event-dispatch.md @@ -18,7 +18,7 @@ Code used by both event systems lives in `src/events/shared/`: `handler.ts` (fin In the modern event system, `src/events/modern/pressability.ts` calls the `onPress`, `onPressIn`, `onPressOut` or `onLongPress` callback of the element that becomes the touch responder, reading `Pressable` and `Touchable*` callbacks from Pressability's `testOnly_pressabilityConfig()`. These callbacks aren't native events, so modern `fireEvent` sends the event types in `PRESSABILITY_EVENT_TYPES` (`src/events/modern/event-types.ts`) there instead of to `dispatchEvent()`: `fireEvent.press()`, and `fireEvent()` with `press`, `pressIn`, `pressOut` or `longPress`. `dispatchEvent()` still dispatches the native `press` event, which reaches only host `onPress` props. The callbacks get a `ResponderEvent` from `src/events/modern/responder-event.ts`, like React Native's responder system creates in JavaScript: `responderGrant` or `responderRelease`, with a touch payload, but no `touchHistory`, which only `PanResponder` reads. It isn't trusted, has no event phase or composed path, and has a `null` `currentTarget` in `onLongPress`, which Pressability calls from a timer. -Modern `fireEvent` throws for event types React Native doesn't know (`ensureEventType()` in `src/events/modern/fire-event-utils.ts`), unless the event object has `dispatch: 'direct' | 'bubbling'`. This is for native events of third-party components, which render as host elements with all their props in Jest, e.g. `fireEvent(pager, 'pageSelected', { nativeEvent: { position: 1 }, dispatch: 'direct' })`. `getCustomEventTypeConfig()` in `src/events/modern/event-types.ts` builds the config codegen would: handler props named after the event type, and no `skipBubbling`, which codegen can't declare. `dispatch` is required, not defaulted, so a legacy call for a JavaScript callback that the Jest mocks put on a host, like `fireEvent(list, 'endReached', ...)`, still throws and points to `UNSAFE_invokeHandler()`. Known event types reject `dispatch`. +Modern `fireEvent` throws for event types React Native doesn't know (`ensureEventType()` in `src/events/modern/fire-event-utils.ts`), unless the event object has `dispatch: 'direct' | 'bubbling'`. This is for native events of third-party components, which render as host elements with all their props in Jest, e.g. `fireEvent(pager, 'pageSelected', { nativeEvent: { position: 1 }, dispatch: 'direct' })`. `getCustomEventTypeConfig()` in `src/events/modern/event-types.ts` builds the config codegen would: handler props named after the event type, and no `skipBubbling`, which codegen can't declare. `dispatch` is required, not defaulted, so a legacy call for a JavaScript callback that the Jest mocks put on a host, like `fireEvent(list, 'endReached', ...)`, still throws and points to `UNSAFE_invokeHandler()`. For known event types, `dispatch` overrides React Native's config, as a native component can declare its own event under a built-in name with different semantics, e.g. `react-native-maps` declares `onPress` as a `BubblingEventHandler`. `createEvent()` then uses `getCustomEventTypeConfig()`, Pressability event types skip Pressability and are dispatched with `dispatchEvent()`, and `fireEvent()` neither reads nor updates native state. `fireEvent.press()` with `dispatch` also skips the default touch payload. `dispatch` on a `Pressable` therefore calls no callback, as its host has no `onPress` prop. Modern `fireEvent` doesn't log `eventDiagnostics` warnings yet. Only legacy `fireEvent` and `userEvent` do. diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index a3e43a0c1..6c6655135 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -1960,47 +1960,123 @@ describe('custom event types', () => { expect(onEndReached).toHaveBeenCalledTimes(isModern() ? 0 : 1); }); - test.each([ - [ - 'fireEvent() with focus', - 'focus', - () => , - (instance: TestInstance) => - fireEvent(instance, 'focus', { nativeEvent: {}, dispatch: 'direct' }), - ], - [ - 'fireEvent() with press', - 'press', - () => , - (instance: TestInstance) => - fireEvent(instance, 'press', { nativeEvent: {}, dispatch: 'bubbling' }), - ], - [ - 'fireEvent.press()', - 'press', - () => , - (instance: TestInstance) => fireEvent.press(instance, { dispatch: 'bubbling' }), - ], - [ - 'fireEvent.scroll()', - 'scroll', - () => , - (instance: TestInstance) => fireEvent.scroll(instance, { dispatch: 'direct' }), - ], - ])( - '%s throws with dispatch for a known event type (legacy: passes it to the handler)', - async (_, eventType, renderElement, fire) => { - await render(renderElement()); + test('dispatch overrides a bubbling built-in event type, skipping Pressability (legacy: calls only the nearest handler)', async () => { + // Shaped like `react-native-maps`: the native map declares `onPress` as a + // `BubblingEventHandler`, so a tap reaches it as a native `press` event, not from Pressability. + const calls: string[] = []; + const getHandlers = (id: string) => ({ + onPressCapture: () => calls.push(`${id}.onPressCapture`), + onPress: () => calls.push(`${id}.onPress`), + }); + await render( + calls.push('pressable.onPress')}> + {React.createElement( + 'AIRMapContainer', + getHandlers('parent'), + React.createElement('AIRMap', { testID: 'map', ...getHandlers('map') }), + )} + , + ); - const error = await getErrorMessage(fire(screen.getByTestId('target'))); + await fireEvent(screen.getByTestId('map'), 'press', { + nativeEvent: { coordinate: { latitude: 1, longitude: 2 } }, + dispatch: 'bubbling', + }); - expect(error).toBe( - isModern() - ? `Unable to fire a "${eventType}" event. Remove "dispatch": it's only for event types React Native doesn't know.` - : undefined, - ); - }, - ); + expect(calls).toEqual( + isModern() + ? ['parent.onPressCapture', 'map.onPressCapture', 'map.onPress', 'parent.onPress'] + : ['map.onPress'], + ); + }); + + test('dispatch overrides a Pressability event type with a direct native event', async () => { + const onLongPress = jest.fn(); + const onParentLongPress = jest.fn(); + await render( + React.createElement( + 'AIRMapContainer', + { onLongPress: onParentLongPress }, + React.createElement('AIRMap', { testID: 'map', onLongPress }), + ), + ); + const map = screen.getByTestId('map'); + + await fireEvent(map, 'longPress', { nativeEvent: { x: 1 }, dispatch: 'direct' }); + + expect(onParentLongPress).not.toHaveBeenCalled(); + expect(onLongPress).toHaveBeenCalledTimes(1); + const received = onLongPress.mock.calls[0][0] as SyntheticEvent; + expect(received.nativeEvent).toEqual({ x: 1 }); + expect(received.rnIsDirect).toBe(isModern() ? true : undefined); + expect(received.target).toBe(isModern() ? map : undefined); + }); + + test('dispatch overrides a bubbling built-in event type with a direct one', async () => { + const onFocus = jest.fn(); + const onParentFocus = jest.fn(); + await render( + React.createElement( + 'RNCustomView', + { onFocus: onParentFocus }, + React.createElement('RNCustomView', { testID: 'target', onFocus }), + ), + ); + + await fireEvent(screen.getByTestId('target'), 'focus', { nativeEvent: {}, dispatch: 'direct' }); + + expect(onFocus).toHaveBeenCalledTimes(1); + expect(onParentFocus).not.toHaveBeenCalled(); + }); + + test('dispatch overrides a direct built-in event type with a bubbling one (legacy: calls only the nearest handler)', async () => { + const onLayout = jest.fn(); + const onParentLayout = jest.fn(); + await render( + React.createElement( + 'RNCustomView', + { onLayout: onParentLayout }, + React.createElement('RNCustomView', { testID: 'target', onLayout }), + ), + ); + + await fireEvent(screen.getByTestId('target'), 'layout', { + nativeEvent: {}, + dispatch: 'bubbling', + }); + + expect(onLayout).toHaveBeenCalledTimes(1); + expect(onParentLayout).toHaveBeenCalledTimes(isModern() ? 1 : 0); + }); + + test('fireEvent.press() with dispatch fires a native press event with the passed payload', async () => { + const onPress = jest.fn(); + await render(React.createElement('AIRMap', { testID: 'map', onPress })); + + await fireEvent.press(screen.getByTestId('map'), { + nativeEvent: { coordinate: { latitude: 1, longitude: 2 } }, + dispatch: 'bubbling', + }); + + expect(onPress).toHaveBeenCalledTimes(1); + // Modern skips the default touch payload, as the event is the component's own. + const { nativeEvent } = onPress.mock.calls[0][0]; + expect(nativeEvent).toMatchObject({ coordinate: { latitude: 1, longitude: 2 } }); + expect('pageX' in nativeEvent).toBe(!isModern()); + }); + + test('dispatch on a Pressable calls no Pressability callback (legacy: calls onPress)', async () => { + const onPress = jest.fn(); + await render(); + + await fireEvent(screen.getByTestId('target'), 'press', { + nativeEvent: {}, + dispatch: 'bubbling', + }); + + // Modern dispatches a native `press` event, which `Pressable`'s host has no prop for. + expect(onPress).toHaveBeenCalledTimes(isModern() ? 0 : 1); + }); test.each([ ['capture', 'string "capture"'], @@ -2025,21 +2101,17 @@ describe('custom event types', () => { }, ); - test('throws for changeText with dispatch (legacy: calls the handler)', async () => { + test('dispatches changeText with dispatch as a native event of the component', async () => { const onChangeText = jest.fn(); - await render(); + await render(React.createElement('RNCustomInput', { testID: 'input', onChangeText })); - const error = await getErrorMessage( - fireEvent(screen.getByTestId('input'), 'changeText', { nativeEvent: {}, dispatch: 'direct' }), - ); + await fireEvent(screen.getByTestId('input'), 'changeText', { + nativeEvent: { text: 'Hello' }, + dispatch: 'direct', + }); - // `onChangeText` isn't a native event: `TextInput` calls it from its `onChange` handler. - 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).toHaveBeenCalledTimes(isModern() ? 0 : 1); + expect(onChangeText).toHaveBeenCalledTimes(1); + expect(onChangeText.mock.calls[0][0].nativeEvent).toEqual({ text: 'Hello' }); }); }); diff --git a/src/events/modern/__tests__/event.test.ts b/src/events/modern/__tests__/event.test.ts index 2a4443473..5a0eb0acd 100644 --- a/src/events/modern/__tests__/event.test.ts +++ b/src/events/modern/__tests__/event.test.ts @@ -217,10 +217,11 @@ describe('createEvent()', () => { }); }); - test('ignores dispatch for events known to React Native', () => { + test('overrides the dispatch of events known to React Native with dispatch', () => { const event = createEvent('focus', { dispatch: 'direct' }); - expect(event?.bubbles).toBe(true); - expect(event?.rnIsDirect).toBe(false); + expect(event?.bubbles).toBe(false); + expect(event?.rnIsDirect).toBe(true); + expect(event?.dispatchConfig).toEqual({ registrationName: 'onFocus' }); }); }); diff --git a/src/events/modern/event.ts b/src/events/modern/event.ts index c681a4a6f..c22c6dac9 100644 --- a/src/events/modern/event.ts +++ b/src/events/modern/event.ts @@ -23,7 +23,7 @@ export type CreateEventInit = { timeStamp?: number; /** * How to dispatch an event type React Native doesn't know, e.g. a third-party component's - * `pageSelected`. Ignored for event types React Native knows. + * `pageSelected`. Overrides React Native's dispatch for event types it knows. */ dispatch?: CustomEventDispatch; }; @@ -231,13 +231,17 @@ export class SyntheticEvent { * `dispatchEvent()`. Like Testing Library's DOM `createEvent()`. * * @param eventType e.g. `focus` or `pointerUp` + * `init.dispatch` overrides React Native's config for the event type, as a native component can + * declare its own event with a built-in name, e.g. a direct `press`. + * * @returns `null` for events unknown to React Native without `init.dispatch`, as React Native drops * them. */ export function createEvent(eventType: string, init: CreateEventInit = {}): SyntheticEvent | null { const config = - getEventTypeConfig(eventType) ?? - (init.dispatch != null ? getCustomEventTypeConfig(eventType, init.dispatch) : null); + init.dispatch != null + ? getCustomEventTypeConfig(eventType, init.dispatch) + : getEventTypeConfig(eventType); if (config == null) { return null; } diff --git a/src/events/modern/fire-event-utils.ts b/src/events/modern/fire-event-utils.ts index f51a16653..a6b8c6bc5 100644 --- a/src/events/modern/fire-event-utils.ts +++ b/src/events/modern/fire-event-utils.ts @@ -23,7 +23,7 @@ const UNKNOWN_EVENT_TYPE_ALTERNATIVES: Record = { /** * Throws for event types React Native doesn't know, such as `changeText` or custom prop names, - * except Pressability event types (`pressIn`, ...), and custom event types passed with `dispatch`. + * except Pressability event types (`pressIn`, ...), and event types passed with `dispatch`. * React Native drops unknown events, so dropping them here would call no handler, and a test * asserting that a handler wasn't called would pass for the wrong reason. * @@ -44,6 +44,12 @@ export function ensureEventType( return; } + // Custom event type: the caller declares how it dispatches. + const hasDispatch = isPlainObject(init) && init.dispatch !== undefined; + if (hasDispatch) { + return; + } + const alternative = UNKNOWN_EVENT_TYPE_ALTERNATIVES[eventType]; if (alternative != null) { throw new ErrorWithStack( @@ -53,10 +59,6 @@ export function ensureEventType( ); } - if (isPlainObject(init) && init.dispatch !== undefined) { - return; - } - throw new ErrorWithStack( `Unable to fire a "${eventType}" event. React Native doesn't know "${eventType}". ` + `For a native event of a third-party component, pass dispatch: "direct" or "bubbling" ` + @@ -154,14 +156,6 @@ function validateDispatch( // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type callsite: Function, ) { - if (isKnownEventType(eventType)) { - throw new ErrorWithStack( - `Unable to fire a "${eventType}" event. Remove "dispatch": it's only for event types ` + - "React Native doesn't know.", - callsite, - ); - } - if (!(CUSTOM_EVENT_DISPATCHES as readonly unknown[]).includes(dispatch)) { throw new ErrorWithStack( `Unable to fire a "${eventType}" event. Expected "dispatch" to be "direct" or "bubbling", ` + diff --git a/src/events/modern/fire-event.ts b/src/events/modern/fire-event.ts index 8c9d4c21c..f06814eea 100644 --- a/src/events/modern/fire-event.ts +++ b/src/events/modern/fire-event.ts @@ -33,7 +33,7 @@ import { createPressabilityEvent } from './responder-event'; /** * Event object passed to modern `fireEvent`. Handlers receive a `SyntheticEvent` from * `createEvent()`, with `nativeEvent` as its `nativeEvent`. `dispatch` is required for event types - * React Native doesn't know, and not allowed for the others. + * React Native doesn't know, and overrides React Native's dispatch for the others. */ export type FireEventInit = CreateEventInit; @@ -49,6 +49,11 @@ export type FireEventInit = CreateEventInit; * declared by the native component, e.g. a third-party component's `pageSelected`. Without it they * throw, as do `changeText` and custom prop names. * + * `dispatch` also overrides React Native's dispatch for known event types, for a native component + * that declares its own event with a built-in name, e.g. a map's `BubblingEventHandler` `onPress`. + * The event is then dispatched as a native event: Pressability event types skip Pressability, and + * no native state (e.g. a `ScrollView`'s offset) is updated. + * * Resolves to nothing, unlike DOM `fireEvent`: React Native has no default actions that * `preventDefault()` could cancel. Handlers can read `event.defaultPrevented` themselves. * @@ -65,7 +70,7 @@ export async function fireEvent( ensureEventType(normalizedType, args[0], fireEvent); ensureSingleEventArg(normalizedType, args, fireEvent); const init = validateEventInit(normalizedType, args[0], fireEvent); - if (isPressabilityEventType(normalizedType)) { + if (isPressabilityEventType(normalizedType) && init.dispatch == null) { await firePressabilityEvent(instance, normalizedType, init); return; } @@ -107,11 +112,17 @@ fireEvent.changeText = async (instance: TestInstance, text: string): Promise => { ensureInstance(instance, 'press', fireEvent.press); const init = validateEventInit('press', event, fireEvent.press); + if (init.dispatch != null) { + await fireEventInternal(instance, 'press', init); + return; + } + await firePressabilityEvent(instance, 'press', init); }; @@ -148,8 +159,14 @@ async function fireEventInternal( basePayload?: NativeEventPayload, dispatchOptions?: DispatchOptions, ): Promise { + // With `dispatch`, the event is the component's own, so React Native's native state doesn't apply. + const hasExplicitDispatch = init.dispatch != null; const nativeEvent = basePayload - ? mergeEventProps(basePayload, getNativeStateEventProps(instance, eventType), init.nativeEvent) + ? mergeEventProps( + basePayload, + hasExplicitDispatch ? undefined : getNativeStateEventProps(instance, eventType), + init.nativeEvent, + ) : init.nativeEvent; const event = createEvent(eventType, { ...init, nativeEvent }); if (event == null || !isInstanceMounted(instance)) { @@ -157,7 +174,9 @@ async function fireEventInternal( } // Before the dispatch, as a device updates its native views before emitting the event. - updateNativeStateFromEvent(instance, eventType, event); + if (!hasExplicitDispatch) { + updateNativeStateFromEvent(instance, eventType, event); + } await dispatchEvent(instance, event, dispatchOptions); } From 3b2db5d31431e67013cdfe426864a8cc6637ca58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 15 Oct 2026 09:50:25 +0200 Subject: [PATCH 4/5] refactor separate direct and bubbling dispatch --- src/events/modern/dispatch.ts | 106 ++++++++++++++++++------------- src/events/modern/event-types.ts | 4 +- 2 files changed, 65 insertions(+), 45 deletions(-) diff --git a/src/events/modern/dispatch.ts b/src/events/modern/dispatch.ts index 2c2c96515..898a802a4 100644 --- a/src/events/modern/dispatch.ts +++ b/src/events/modern/dispatch.ts @@ -5,15 +5,16 @@ import { isInstanceMounted } from '../../helpers/component-tree'; import { getEventHandlerByName } from '../shared/handler'; import type { EventPhase } from './event'; import { getEventInternals, SyntheticEvent } from './event'; +import type { BubblingEventTypeConfig, DirectEventTypeConfig } from './event-types'; /** * Dispatches an event from `createEvent()` as React Native does for native events * (`src/private/renderer/events/dispatchNativeEvent.js`, then `dispatch()` in * `src/private/webapis/dom/events/EventTarget.js`): * + * - Direct events: only the target's `on*` prop. See `dispatchDirectEvent()`. * - Bubbling events: capture phase from the root to the target (`on*Capture`), then bubble phase - * back to the root (`on*`). `skipBubbling` events call only the target when bubbling. - * - Direct events: only the target's `on*` prop. + * back to the root (`on*`). See `dispatchBubblingEvent()`. * * Only host elements are on the path, so composite props are never called. A `testOnly_` prop * (`testOnly_onFocus`) is used when the element has no regular one. Every handler on the @@ -34,16 +35,18 @@ export async function dispatchEvent( return false; } - const path = getEventPath(target, event); + const config = getEventInternals(event).typeConfig; const state: DispatchState = { hasCalledHandler: false, firstError: null }; await act(() => { getEventInternals(event).isTrusted = true; getEventInternals(event).target = target; - getEventInternals(event).composedPath = path; + if (config.kind === 'direct') { + dispatchDirectEvent(event, target, config, state, options?.afterTargetHandler); + } else { + dispatchBubblingEvent(event, target, config, state, options?.afterTargetHandler); + } - runCapturePhase(event, path, state); - runBubblePhase(event, path, state, options?.afterTargetHandler); resetEvent(event); }); @@ -57,10 +60,10 @@ export async function dispatchEvent( export type DispatchOptions = { /** - * Runs at the target in the bubble phase, right after the target's own handler, like a composite - * component wrapping the host prop: `TextInput` passes `onChange={_onChange}`, which calls - * `props.onChange(event)`, then `props.onChangeText(text)`. Skipped when propagation stopped - * before the target, or when the target's handler threw. Runs when the target has no handler. + * Runs at the target, right after the target's own handler, like a composite component wrapping + * the host prop: `TextInput` passes `onChange={_onChange}`, which calls `props.onChange(event)`, + * then `props.onChangeText(text)`. Skipped when propagation stopped before the target, or when + * the target's handler threw. Runs when the target has no handler. */ afterTargetHandler?: (target: TestInstance, event: SyntheticEvent) => void; }; @@ -79,56 +82,73 @@ type DispatchState = { firstError: { error: unknown } | null; }; -/** Target first, then host ancestors. The root container is not an element, so it's excluded. */ -function getEventPath(target: TestInstance, event: SyntheticEvent): TestInstance[] { - if (event.rnIsDirect) { - return [target]; - } - - const path: TestInstance[] = []; - let current: TestInstance | null = target; - while (current?.parent != null) { - path.push(current); - current = current.parent; - } - - return path; +/** Calls only the target's handler: no capture phase, no ancestors. */ +function dispatchDirectEvent( + event: SyntheticEvent, + target: TestInstance, + config: DirectEventTypeConfig, + state: DispatchState, + afterTargetHandler: DispatchOptions['afterTargetHandler'], +) { + getEventInternals(event).composedPath = [target]; + callHandler( + event, + target, + config.handlerName, + SyntheticEvent.AT_TARGET, + state, + afterTargetHandler, + ); } -function runCapturePhase(event: SyntheticEvent, path: TestInstance[], state: DispatchState) { - const config = getEventInternals(event).typeConfig; - if (config.kind === 'direct') { - return; - } +/** + * Capture phase from the root down to the target, then bubble phase from the target back up to the + * root. `skipBubbling` events (`pointerEnter`, `pointerLeave`) run the full capture phase, but + * stop the bubble phase at the target. + */ +function dispatchBubblingEvent( + event: SyntheticEvent, + target: TestInstance, + config: BubblingEventTypeConfig, + state: DispatchState, + afterTargetHandler: DispatchOptions['afterTargetHandler'], +) { + const path = getAncestorPath(target); + getEventInternals(event).composedPath = path; + // Capture: root first, target last. for (let i = path.length - 1; i >= 0 && !event.isPropagationStopped(); i -= 1) { const phase = i === 0 ? SyntheticEvent.AT_TARGET : SyntheticEvent.CAPTURING_PHASE; callHandler(event, path[i], config.captureHandlerName, phase, state); } -} -function runBubblePhase( - event: SyntheticEvent, - path: TestInstance[], - state: DispatchState, - afterTargetHandler: DispatchOptions['afterTargetHandler'], -) { - const handlerName = getEventInternals(event).typeConfig.handlerName; - const nodes = event.bubbles ? path : path.slice(0, 1); - for (let i = 0; i < nodes.length && !event.isPropagationStopped(); i += 1) { + // Bubble: target first, root last. + const bubblePath = config.skipBubbling ? [target] : path; + for (let i = 0; i < bubblePath.length && !event.isPropagationStopped(); i += 1) { const isTarget = i === 0; - const phase = isTarget ? SyntheticEvent.AT_TARGET : SyntheticEvent.BUBBLING_PHASE; callHandler( event, - nodes[i], - handlerName, - phase, + bubblePath[i], + config.handlerName, + isTarget ? SyntheticEvent.AT_TARGET : SyntheticEvent.BUBBLING_PHASE, state, isTarget ? afterTargetHandler : undefined, ); } } +/** Target first, then host ancestors. The root container is not an element, so it's excluded. */ +function getAncestorPath(target: TestInstance): TestInstance[] { + const path: TestInstance[] = []; + let current: TestInstance | null = target; + while (current?.parent != null) { + path.push(current); + current = current.parent; + } + + return path; +} + function callHandler( event: SyntheticEvent, node: TestInstance, diff --git a/src/events/modern/event-types.ts b/src/events/modern/event-types.ts index 3bc8ba8b4..d5a68089f 100644 --- a/src/events/modern/event-types.ts +++ b/src/events/modern/event-types.ts @@ -102,7 +102,7 @@ const DIRECT_EVENT_HANDLER_NAMES: Readonly> = { }; /** Capture phase from the root to the target, then bubble phase back to the root. */ -type BubblingEventTypeConfig = { +export type BubblingEventTypeConfig = { kind: 'bubbling'; /** Bubble phase prop, e.g. `onFocus`. */ handlerName: string; @@ -112,7 +112,7 @@ type BubblingEventTypeConfig = { }; /** Calls only the target's prop, without the capture phase. */ -type DirectEventTypeConfig = { +export type DirectEventTypeConfig = { kind: 'direct'; /** e.g. `onLayout` */ handlerName: string; From ea9d9c1117b73b24fb1bb3e2d99ca17eaae211e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 15 Oct 2026 09:56:55 +0200 Subject: [PATCH 5/5] cleanup pass --- contributing/event-dispatch.md | 2 +- src/events/modern/event-types.ts | 9 ++++++--- src/events/modern/event.ts | 2 +- src/events/modern/fire-event-utils.ts | 25 ++++++++----------------- src/events/modern/fire-event.ts | 17 ++++------------- 5 files changed, 20 insertions(+), 35 deletions(-) diff --git a/contributing/event-dispatch.md b/contributing/event-dispatch.md index ec7405e09..eb5e2842e 100644 --- a/contributing/event-dispatch.md +++ b/contributing/event-dispatch.md @@ -18,7 +18,7 @@ Code used by both event systems lives in `src/events/shared/`: `handler.ts` (fin In the modern event system, `src/events/modern/pressability.ts` calls the `onPress`, `onPressIn`, `onPressOut` or `onLongPress` callback of the element that becomes the touch responder, reading `Pressable` and `Touchable*` callbacks from Pressability's `testOnly_pressabilityConfig()`. These callbacks aren't native events, so modern `fireEvent` sends the event types in `PRESSABILITY_EVENT_TYPES` (`src/events/modern/event-types.ts`) there instead of to `dispatchEvent()`: `fireEvent.press()`, and `fireEvent()` with `press`, `pressIn`, `pressOut` or `longPress`. `dispatchEvent()` still dispatches the native `press` event, which reaches only host `onPress` props. The callbacks get a `ResponderEvent` from `src/events/modern/responder-event.ts`, like React Native's responder system creates in JavaScript: `responderGrant` or `responderRelease`, with a touch payload, but no `touchHistory`, which only `PanResponder` reads. It isn't trusted, has no event phase or composed path, and has a `null` `currentTarget` in `onLongPress`, which Pressability calls from a timer. -Modern `fireEvent` throws for event types React Native doesn't know (`ensureEventType()` in `src/events/modern/fire-event-utils.ts`), unless the event object has `dispatch: 'direct' | 'bubbling'`. This is for native events of third-party components, which render as host elements with all their props in Jest, e.g. `fireEvent(pager, 'pageSelected', { nativeEvent: { position: 1 }, dispatch: 'direct' })`. `getCustomEventTypeConfig()` in `src/events/modern/event-types.ts` builds the config codegen would: handler props named after the event type, and no `skipBubbling`, which codegen can't declare. `dispatch` is required, not defaulted, so a legacy call for a JavaScript callback that the Jest mocks put on a host, like `fireEvent(list, 'endReached', ...)`, still throws and points to `UNSAFE_invokeHandler()`. For known event types, `dispatch` overrides React Native's config, as a native component can declare its own event under a built-in name with different semantics, e.g. `react-native-maps` declares `onPress` as a `BubblingEventHandler`. `createEvent()` then uses `getCustomEventTypeConfig()`, Pressability event types skip Pressability and are dispatched with `dispatchEvent()`, and `fireEvent()` neither reads nor updates native state. `fireEvent.press()` with `dispatch` also skips the default touch payload. `dispatch` on a `Pressable` therefore calls no callback, as its host has no `onPress` prop. +Modern `fireEvent` throws for event types React Native doesn't know (`ensureEventType()` in `src/events/modern/fire-event-utils.ts`), unless the event object has `dispatch: 'direct' | 'bubbling'`. This is for native events of third-party components, which render as host elements with all their props in Jest, e.g. `fireEvent(pager, 'pageSelected', { nativeEvent: { position: 1 }, dispatch: 'direct' })`. `getCustomEventTypeConfig()` in `src/events/modern/event-types.ts` builds the config codegen would: handler props named after the event type, and no `skipBubbling`, which codegen can't declare. `dispatch` is required, not defaulted, so a legacy call for a JavaScript callback that the Jest mocks put on a host, like `fireEvent(list, 'endReached', ...)`, still throws and points to `UNSAFE_invokeHandler()`. For known event types, `dispatch` overrides React Native's config, as a native component can declare its own event under a built-in name with different semantics, e.g. `react-native-maps` declares `onPress` as a `BubblingEventHandler`. `createEvent()` then uses `getCustomEventTypeConfig()`, and Pressability event types skip Pressability and are dispatched with `dispatchEvent()`. `fireEvent.press()` with `dispatch` also skips the default touch payload. `dispatch` on a `Pressable` therefore calls no callback, as its host has no `onPress` prop. Modern `fireEvent` doesn't log `eventDiagnostics` warnings yet. Only legacy `fireEvent` and `userEvent` do. diff --git a/src/events/modern/event-types.ts b/src/events/modern/event-types.ts index d5a68089f..ca04bb4d6 100644 --- a/src/events/modern/event-types.ts +++ b/src/events/modern/event-types.ts @@ -125,9 +125,9 @@ export type EventTypeConfig = BubblingEventTypeConfig | DirectEventTypeConfig; * How a native component dispatches an event type React Native doesn't know, as its codegen spec * declares the handler prop: `DirectEventHandler` or `BubblingEventHandler`. */ -export type CustomEventDispatch = 'direct' | 'bubbling'; +export const CUSTOM_EVENT_DISPATCHES = ['direct', 'bubbling'] as const; -export const CUSTOM_EVENT_DISPATCHES: readonly CustomEventDispatch[] = ['direct', 'bubbling']; +export type CustomEventDispatch = (typeof CUSTOM_EVENT_DISPATCHES)[number]; /** * Returns how React Native dispatches the event type (e.g. `focus`), or `null` if React Native @@ -165,7 +165,10 @@ export function getCustomEventTypeConfig( return { kind: 'direct', handlerName: getEventHandlerName(eventType) }; } -function getBubblingEventTypeConfig(eventType: string, skipBubbling: boolean): EventTypeConfig { +function getBubblingEventTypeConfig( + eventType: string, + skipBubbling: boolean, +): BubblingEventTypeConfig { const handlerName = getEventHandlerName(eventType); return { kind: 'bubbling', diff --git a/src/events/modern/event.ts b/src/events/modern/event.ts index c22c6dac9..cf0983d9e 100644 --- a/src/events/modern/event.ts +++ b/src/events/modern/event.ts @@ -230,10 +230,10 @@ export class SyntheticEvent { * Creates the event React Native would create for a native event of this type, ready for * `dispatchEvent()`. Like Testing Library's DOM `createEvent()`. * - * @param eventType e.g. `focus` or `pointerUp` * `init.dispatch` overrides React Native's config for the event type, as a native component can * declare its own event with a built-in name, e.g. a direct `press`. * + * @param eventType e.g. `focus` or `pointerUp` * @returns `null` for events unknown to React Native without `init.dispatch`, as React Native drops * them. */ diff --git a/src/events/modern/fire-event-utils.ts b/src/events/modern/fire-event-utils.ts index a6b8c6bc5..7be96df36 100644 --- a/src/events/modern/fire-event-utils.ts +++ b/src/events/modern/fire-event-utils.ts @@ -45,8 +45,7 @@ export function ensureEventType( } // Custom event type: the caller declares how it dispatches. - const hasDispatch = isPlainObject(init) && init.dispatch !== undefined; - if (hasDispatch) { + if (isPlainObject(init) && init.dispatch !== undefined) { return; } @@ -143,26 +142,18 @@ export function validateEventInit( ); } - if (init.dispatch !== undefined) { - validateDispatch(eventType, init.dispatch, callsite); - } - - return init as CreateEventInit; -} - -function validateDispatch( - eventType: string, - dispatch: unknown, - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - callsite: Function, -) { - if (!(CUSTOM_EVENT_DISPATCHES as readonly unknown[]).includes(dispatch)) { + if ( + init.dispatch !== undefined && + !(CUSTOM_EVENT_DISPATCHES as readonly unknown[]).includes(init.dispatch) + ) { throw new ErrorWithStack( `Unable to fire a "${eventType}" event. Expected "dispatch" to be "direct" or "bubbling", ` + - `received ${describeValue(dispatch)}.`, + `received ${describeValue(init.dispatch)}.`, callsite, ); } + + return init as CreateEventInit; } /** diff --git a/src/events/modern/fire-event.ts b/src/events/modern/fire-event.ts index f06814eea..161672a0f 100644 --- a/src/events/modern/fire-event.ts +++ b/src/events/modern/fire-event.ts @@ -51,8 +51,7 @@ export type FireEventInit = CreateEventInit; * * `dispatch` also overrides React Native's dispatch for known event types, for a native component * that declares its own event with a built-in name, e.g. a map's `BubblingEventHandler` `onPress`. - * The event is then dispatched as a native event: Pressability event types skip Pressability, and - * no native state (e.g. a `ScrollView`'s offset) is updated. + * Pressability event types are then dispatched as native events, skipping Pressability. * * Resolves to nothing, unlike DOM `fireEvent`: React Native has no default actions that * `preventDefault()` could cancel. Handlers can read `event.defaultPrevented` themselves. @@ -146,7 +145,7 @@ fireEvent.layout = async ( }; /** - * Expects an event type React Native dispatches natively, without the `on*` prefix. + * Expects an event type without the `on*` prefix, that React Native knows or that has `dispatch`. * * @param basePayload the payload a device would send. Native state, like a `ScrollView`'s * size, is merged onto it, then the passed `nativeEvent` is deep merged on top. Without it, the @@ -159,14 +158,8 @@ async function fireEventInternal( basePayload?: NativeEventPayload, dispatchOptions?: DispatchOptions, ): Promise { - // With `dispatch`, the event is the component's own, so React Native's native state doesn't apply. - const hasExplicitDispatch = init.dispatch != null; const nativeEvent = basePayload - ? mergeEventProps( - basePayload, - hasExplicitDispatch ? undefined : getNativeStateEventProps(instance, eventType), - init.nativeEvent, - ) + ? mergeEventProps(basePayload, getNativeStateEventProps(instance, eventType), init.nativeEvent) : init.nativeEvent; const event = createEvent(eventType, { ...init, nativeEvent }); if (event == null || !isInstanceMounted(instance)) { @@ -174,9 +167,7 @@ async function fireEventInternal( } // Before the dispatch, as a device updates its native views before emitting the event. - if (!hasExplicitDispatch) { - updateNativeStateFromEvent(instance, eventType, event); - } + updateNativeStateFromEvent(instance, eventType, event); await dispatchEvent(instance, event, dispatchOptions); }