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