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