diff --git a/contributing/event-dispatch.md b/contributing/event-dispatch.md
index 9bb951c1c..05eee8511 100644
--- a/contributing/event-dispatch.md
+++ b/contributing/event-dispatch.md
@@ -2,7 +2,11 @@
RNTL has two ways to trigger events. Neither goes through React Native's native event system. Both find `on*` props in the rendered tree and call them inside `act()`.
-Both are built on the shared event subsystem in `src/events/legacy/`, which also holds `fireEvent` itself. This is the `'legacy'` event system, the default for the `unstable_eventSystem` config option. A `'modern'` event system that follows React Native's event dispatch is in progress.
+Both are built on the shared event subsystem in `src/events/legacy/`, which also holds `fireEvent` itself. This is the `'legacy'` event system, the default for the `unstable_eventSystem` config option.
+
+The `'modern'` event system (`src/events/modern/`), still in progress, follows the W3C [`EventTarget`](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget) dispatch that React Native enables by default from 0.88 (the `enableNativeEventTargetEventDispatching` feature flag; `src/private/renderer/events/dispatchNativeEvent.js` and `src/private/webapis/dom/events/EventTarget.js` in `node_modules/react-native`). The path holds only host elements. Bubbling events run a capture phase from the root to the target (`on*Capture` props), then a bubble phase back up (`on*` props), and direct events reach only the target. Event objects have the W3C `Event` fields (`eventPhase`, `currentTarget`, `composedPath()`, `stopPropagation()`, `preventDefault()`) next to the legacy synthetic event API (`nativeEvent`, `persist()`, `isDefaultPrevented()`). Check changes to modern dispatch against these upstream files, not React's legacy plugin system.
+
+RNTL uses this dispatch with every supported React Native version (`>=0.78`), including those before 0.88, which still dispatch through React's legacy plugin system. Prop names, handler order and the synthetic event API are the same on both paths, so tests on older versions get the 0.88 behavior and the W3C fields on top. The modern event system doesn't check the React Native version or its feature flag. See [the 0.88 alignment research](research/rn-0.88-w3c-event-dispatch-alignment.md) for where RNTL matches React Native and where it still differs, e.g. responder negotiation for fired touch events and `addEventListener()` listeners.
The files in `src/events/legacy/`:
@@ -20,8 +24,12 @@ In the modern event system, `src/events/modern/pressability.ts` calls the `onPre
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` drops or moves events a device wouldn't emit from the element (`getEmittingTarget()` in `src/events/modern/emitting-target.ts`), before updating native state, so a dropped `change` leaves it alone. Touch and pointer events (`touchStart`, `pointerDown`, `click`, ...) go to the hit target, found with the same `getTouchPath()` as `press`, so `pointerEvents` moves them to an ancestor or drops them, and `event.target` is the hit target. `display: none` on the element or an ancestor drops them instead of moving them, as a device lays out a hidden view with no frame (iOS doesn't mount it, Android makes it `INVISIBLE`), so no touch can land in its subtree. Text editing events (`focus`, `blur`, `change`, `submitEditing`, `keyPress`, ...) go to the nearest host `TextInput`, as a nested `Text` isn't a native view, and are dropped when it isn't editable. `selectionChange` isn't one of them, as iOS emits it for a non-editable multiline `TextInput`. `change` of a disabled `Switch` is dropped. `disabled` doesn't block touch and pointer events, as on iOS, where only Pressability checks it; on Android, a disabled view is never the hit target, which RNTL doesn't model. Events with `dispatch` keep their target, as the native component declaring them decides when to emit them. The checks aren't in `dispatchEvent()`, which `userEvent` also uses and which stays simple; `userEvent` actions do their own checks. Events are dropped silently, as modern `fireEvent` has no `eventDiagnostics` warnings yet.
+
Modern `fireEvent` takes event types without the `on*` prefix (`press`, not `onPress`), as React Native names them. Like legacy `fireEvent`, it still strips the prefix, but logs a deprecation warning (`warnOnPrefix()` in `src/events/modern/fire-event-utils.ts`), after its checks, so a call that throws doesn't also warn. Support for the prefix will be removed in the next major release. Legacy `fireEvent` accepts both without a warning.
+Modern `fireEvent`'s event object is optional and defaults to `{}`, as the handler gets a `SyntheticEvent` either way. `ensureAtMostOneEventArg()` rejects only extra arguments, which catch legacy calls passing several handler arguments. Event types React Native doesn't know still throw without one, as they need `dispatch`. Touch events (`touchStart`, `touchMove`, `touchEnd`, `touchCancel`) get a default touch payload from `buildTouchPayload()` (`src/events/modern/touch-payload.ts`), the one Pressability callbacks get, with the passed `nativeEvent` deep merged onto it. Other event types, and events with `dispatch`, get the passed `nativeEvent` as is.
+
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/contributing/native-events.md b/contributing/native-events.md
index f1ccee9f6..3291319d2 100644
--- a/contributing/native-events.md
+++ b/contributing/native-events.md
@@ -2,7 +2,9 @@
In React Native, some events **bubble** up to parent elements and others are **direct**, meaning only the element that emitted them receives them. `fireEvent` should behave the same way.
-Today, `fireEvent` still bubbles direct events, with a warning (see [Known gaps](#known-gaps)). Only `fireEvent.layout()` does not bubble. The rules live in `isDirectEvent()` in `src/events/legacy/propagation.ts`.
+In the `'legacy'` event system, the default, `fireEvent` still bubbles direct events, with a warning (see [Known gaps](#known-gaps)). Only `fireEvent.layout()` does not bubble. The rules live in `isDirectEvent()` in `src/events/legacy/propagation.ts`.
+
+The `'modern'` event system dispatches as React Native 0.88 does, through its W3C `EventTarget` dispatch: direct events reach only the target, and bubbling events run capture and bubble phases. It does so on every supported React Native version, including those before 0.88. Bubbling and direct event types come from `src/events/modern/event-types.ts`. See [Event dispatch](event-dispatch.md).
## Which events are which
diff --git a/refs/react-native b/refs/react-native
index db42fda57..e04aaf62f 160000
--- a/refs/react-native
+++ b/refs/react-native
@@ -1 +1 @@
-Subproject commit db42fda57495eecb35fa2fa8f09a0f6c42ec2434
+Subproject commit e04aaf62f70491c803f0e0653075ec088c7c88f4
diff --git a/src/__tests__/modern-types/fire-event.test.tsx b/src/__tests__/modern-types/fire-event.test.tsx
index dfe56b248..bf2c405a6 100644
--- a/src/__tests__/modern-types/fire-event.test.tsx
+++ b/src/__tests__/modern-types/fire-event.test.tsx
@@ -25,10 +25,13 @@ test('accepts known event types, with or without the deprecated "on" prefix', as
await fireEvent(input, 'focus', emptyEvent);
await fireEvent(input, 'onLayout', { nativeEvent: { layout: { width: 100 } }, timeStamp: 1 });
+ // The event object is optional.
+ await fireEvent(input, 'focus');
+ await fireEvent(input, 'onLayout');
- expect(onFocus).toHaveBeenCalledTimes(1);
- expect(onLayout).toHaveBeenCalledTimes(1);
- expect(warnSpy).toHaveBeenCalledTimes(1);
+ expect(onFocus).toHaveBeenCalledTimes(2);
+ expect(onLayout).toHaveBeenCalledTimes(2);
+ expect(warnSpy).toHaveBeenCalledTimes(2);
warnSpy.mockRestore();
});
@@ -78,6 +81,8 @@ test('rejects custom event types without dispatch', async () => {
// @ts-expect-error `dispatch` is required for event types React Native doesn't know
await expect(fireEvent(pager, 'pageSelected', emptyEvent)).rejects.toThrow();
+ // @ts-expect-error the event object with `dispatch` is required for these event types
+ await expect(fireEvent(pager, 'pageSelected')).rejects.toThrow();
// @ts-expect-error `changeText` is not a native event type, the type error suggests alternatives
await expect(fireEvent(pager, 'changeText', 'Hello')).rejects.toThrow();
// @ts-expect-error `changeText` is not a native event type, the type error suggests alternatives
@@ -86,12 +91,10 @@ test('rejects custom event types without dispatch', async () => {
await expect(fireEvent(pager, 'pageSelected', { dispatch: 'capture' })).rejects.toThrow();
});
-test('rejects anything but a single event object', async () => {
+test('rejects anything but at most one event object', async () => {
await render();
const view = screen.getByTestId('view');
- // @ts-expect-error missing event object
- await expect(fireEvent(view, 'focus')).rejects.toThrow();
// @ts-expect-error more than one argument
await expect(fireEvent(view, 'focus', emptyEvent, emptyEvent)).rejects.toThrow();
// @ts-expect-error not an event object
diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx
index f736dfb58..3bdb88d09 100644
--- a/src/events/__tests__/fire-event.test.tsx
+++ b/src/events/__tests__/fire-event.test.tsx
@@ -8,6 +8,7 @@ import {
PanResponder,
Pressable,
ScrollView,
+ Switch,
Text,
TextInput,
TouchableHighlight,
@@ -378,40 +379,51 @@ test('fireEvent does not call props of composite components (legacy: calls them)
});
describe('event object', () => {
- test('throws without an event object (legacy: calls the handler without arguments)', async () => {
+ test.each([
+ ['without an event object', []],
+ ['with an undefined event object', [undefined]],
+ ])('fires %s (legacy: calls the handler without arguments)', async (_, args) => {
const onFocus = jest.fn();
await render();
- const error = await getErrorMessage(fireEvent(screen.getByTestId('input'), 'focus'));
+ await fireEvent(screen.getByTestId('input'), 'focus', ...args);
// Modern matches React Native, where handlers always receive an event.
- expect(error).toBe(
- isModern()
- ? `Unable to fire a "focus" event. Please provide an event object, e.g. fireEvent(element, 'focus', { nativeEvent: {} }).`
- : undefined,
- );
- expect(onFocus.mock.calls).toEqual(isModern() ? [] : [[]]);
+ expect(onFocus.mock.calls).toEqual(isModern() ? [[anySyntheticEvent]] : [args]);
+ expect(onFocus.mock.calls[0][0]?.nativeEvent).toEqual(isModern() ? {} : undefined);
});
- test('throws without an event object for Pressability events (legacy: calls the callback without arguments)', async () => {
+ test('fires Pressability events without an event object (legacy: calls the callback without arguments)', async () => {
const onPressIn = jest.fn();
await render();
- const error = await getErrorMessage(fireEvent(screen.getByTestId('pressable'), 'pressIn'));
+ await fireEvent(screen.getByTestId('pressable'), 'pressIn');
+
+ expect(onPressIn.mock.calls).toEqual(isModern() ? [[anySyntheticEvent]] : [[]]);
+ });
+
+ test('throws without an event object for unknown event types (legacy: calls the handler without arguments)', async () => {
+ const onPageSelected = jest.fn();
+ await render();
+
+ const error = await getErrorMessage(fireEvent(screen.getByTestId('pager'), 'pageSelected'));
+ // Modern needs `dispatch` in the event object to know how to dispatch the event.
expect(error).toBe(
isModern()
- ? `Unable to fire a "pressIn" event. Please provide an event object, e.g. fireEvent(element, 'pressIn', { nativeEvent: {} }).`
+ ? `Unable to fire a "pageSelected" event. React Native doesn't know "pageSelected". ` +
+ '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(onPressIn.mock.calls).toEqual(isModern() ? [] : [[]]);
+ expect(onPageSelected.mock.calls).toEqual(isModern() ? [] : [[]]);
});
test.each([
['text', 'string "text"'],
[42, 'number 42'],
[null, 'null'],
- [undefined, 'undefined'],
[[{ nativeEvent: {} }], 'an array'],
[new Date(0), 'an instance of Date'],
[() => {}, 'a function'],
@@ -3241,6 +3253,342 @@ describe('pointerEvents prop', () => {
});
});
+describe('touch events', () => {
+ test.each([
+ ['touchStart', true],
+ ['touchMove', true],
+ ['touchEnd', false],
+ ['touchCancel', false],
+ ] as const)(
+ '"%s" gets a default touch payload (legacy: passes the event object as given)',
+ async (eventType, isActive) => {
+ const handler = jest.fn();
+ await render();
+
+ await fireEvent(screen.getByTestId('view'), eventType);
+
+ // Modern matches React Native, where `touches` has the touch until it ends.
+ expect(handler.mock.calls).toEqual(isModern() ? [[anySyntheticEvent]] : [[]]);
+ expect(handler.mock.calls[0][0]?.nativeEvent).toEqual(
+ isModern() ? getTouchPayload(isActive) : undefined,
+ );
+ },
+ );
+
+ test('deep merges the passed nativeEvent onto the default touch payload (legacy: passes it as given)', async () => {
+ const onTouchMove = jest.fn();
+ await render();
+
+ await fireEvent(screen.getByTestId('view'), 'touchMove', {
+ nativeEvent: { pageX: 110, pageY: 120 },
+ });
+
+ expect(onTouchMove.mock.calls[0][0].nativeEvent).toEqual(
+ isModern() ? getTouchPayload(true, { pageX: 110, pageY: 120 }) : { pageX: 110, pageY: 120 },
+ );
+ });
+
+ test('keeps touch arrays of the passed nativeEvent', async () => {
+ const onTouchEnd = jest.fn();
+ await render();
+ const touches = [{ identifier: 1, pageX: 5, pageY: 5 }];
+
+ await fireEvent(screen.getByTestId('view'), 'touchEnd', { nativeEvent: { touches } });
+
+ expect(onTouchEnd.mock.calls[0][0].nativeEvent.touches).toEqual(touches);
+ expect(onTouchEnd.mock.calls[0][0].nativeEvent.changedTouches).toEqual(
+ isModern() ? [defaultTouch] : undefined,
+ );
+ });
+
+ test('takes timeStamp from the touch timestamp', async () => {
+ const onTouchStart = jest.fn();
+ await render();
+
+ const nowSpy = jest.spyOn(performance, 'now').mockReturnValue(456);
+ await fireEvent(screen.getByTestId('view'), 'touchStart', {});
+ nowSpy.mockRestore();
+
+ // Legacy passes the event object as given.
+ expect(onTouchStart.mock.calls[0][0].nativeEvent?.timestamp).toBe(isModern() ? 456 : undefined);
+ expect(onTouchStart.mock.calls[0][0].timeStamp).toBe(isModern() ? 456 : undefined);
+ });
+
+ test('passes the nativeEvent as given with dispatch', async () => {
+ const onTouchStart = jest.fn();
+ await render();
+
+ await fireEvent(screen.getByTestId('view'), 'touchStart', {
+ nativeEvent: { pageX: 1 },
+ dispatch: 'direct',
+ });
+
+ expect(onTouchStart.mock.calls[0][0].nativeEvent).toEqual({ pageX: 1 });
+ });
+
+ // Drag detection from the touch handlers of a `Pressable`, as in Expensify's
+ // `ToggleDistanceUnitButton`. Its tests fire `touchEnd` without an event object.
+ function DragAwareButton({ onPress }: { onPress: () => void }) {
+ const start = React.useRef<{ x: number; y: number } | null>(null);
+ return (
+ {
+ start.current = { x: event.nativeEvent.pageX, y: event.nativeEvent.pageY };
+ }}
+ onTouchMove={(event) => {
+ if (
+ start.current != null &&
+ (Math.abs(event.nativeEvent.pageX - start.current.x) > 1 ||
+ Math.abs(event.nativeEvent.pageY - start.current.y) > 1)
+ ) {
+ start.current = null;
+ }
+ }}
+ onTouchEnd={() => {
+ if (start.current != null) {
+ onPress();
+ }
+ start.current = null;
+ }}
+ />
+ );
+ }
+
+ test('runs a tap from touch handlers', async () => {
+ const onPress = jest.fn();
+ await render();
+ const button = screen.getByTestId('button');
+
+ await fireEvent(button, 'touchStart', { nativeEvent: { pageX: 100, pageY: 100 } });
+ await fireEvent(button, 'touchEnd');
+
+ expect(onPress).toHaveBeenCalledTimes(1);
+ });
+
+ test('runs a drag from touch handlers', async () => {
+ const onPress = jest.fn();
+ await render();
+ const button = screen.getByTestId('button');
+
+ await fireEvent(button, 'touchStart', { nativeEvent: { pageX: 100, pageY: 100 } });
+ await fireEvent(button, 'touchMove', { nativeEvent: { pageX: 110, pageY: 110 } });
+ await fireEvent(button, 'touchEnd');
+
+ expect(onPress).not.toHaveBeenCalled();
+ });
+});
+
+describe('touch event hit testing', () => {
+ test('moves touchStart from View with pointerEvents="none" to its parent (legacy: fires on the View)', async () => {
+ const onParentTouchStart = jest.fn();
+ const onTouchStart = jest.fn();
+ await render(
+
+
+ ,
+ );
+
+ await fireEvent(screen.getByTestId('view'), 'touchStart', emptyEvent);
+
+ // Modern matches React Native, where hit testing skips the View, so the touch targets its
+ // parent. Legacy blocks only `press`.
+ expect(onTouchStart).toHaveBeenCalledTimes(isModern() ? 0 : 1);
+ expect(onParentTouchStart).toHaveBeenCalledTimes(isModern() ? 1 : 0);
+ expect(onParentTouchStart.mock.calls[0]?.[0].target).toBe(
+ isModern() ? screen.getByTestId('parent') : undefined,
+ );
+ });
+
+ test('drops touchStart when pointerEvents="none" blocks every element (legacy: fires on the View)', async () => {
+ const onTouchStart = jest.fn();
+ await render();
+
+ await fireEvent(screen.getByTestId('view'), 'touchStart', emptyEvent);
+
+ expect(onTouchStart).toHaveBeenCalledTimes(isModern() ? 0 : 1);
+ });
+
+ test('drops touchStart on View with pointerEvents="box-none" (legacy: fires on the View)', async () => {
+ const onTouchStart = jest.fn();
+ await render();
+
+ await fireEvent(screen.getByTestId('view'), 'touchStart', emptyEvent);
+
+ // Modern matches React Native: the View is never the touch target, and has no ancestor.
+ expect(onTouchStart).toHaveBeenCalledTimes(isModern() ? 0 : 1);
+ });
+
+ test('fires touchStart on child of View with pointerEvents="box-none" (legacy: calls only the child)', async () => {
+ const onParentTouchStart = jest.fn();
+ const onTouchStart = jest.fn();
+ await render(
+
+
+ ,
+ );
+
+ await fireEvent(screen.getByTestId('child'), 'touchStart', emptyEvent);
+
+ // Modern matches React Native: the child is the target, and the event bubbles to the parent.
+ expect(onTouchStart).toHaveBeenCalledTimes(1);
+ expect(onParentTouchStart).toHaveBeenCalledTimes(isModern() ? 1 : 0);
+ expect(onTouchStart.mock.calls[0]?.[0].target).toBe(
+ isModern() ? screen.getByTestId('child') : undefined,
+ );
+ });
+
+ test('moves touchStart from child to View with pointerEvents="box-only" (legacy: fires on the child)', async () => {
+ const onParentTouchStart = jest.fn();
+ const onTouchStart = jest.fn();
+ await render(
+
+
+ ,
+ );
+
+ await fireEvent(screen.getByTestId('child'), 'touchStart', emptyEvent);
+
+ expect(onTouchStart).toHaveBeenCalledTimes(isModern() ? 0 : 1);
+ expect(onParentTouchStart).toHaveBeenCalledTimes(isModern() ? 1 : 0);
+ expect(onParentTouchStart.mock.calls[0]?.[0].target).toBe(
+ isModern() ? screen.getByTestId('parent') : undefined,
+ );
+ });
+
+ test('moves pointerDown from View with pointerEvents="none" to its parent (legacy: fires on the View)', async () => {
+ const onParentPointerDown = jest.fn();
+ const onPointerDown = jest.fn();
+ await render(
+
+
+ ,
+ );
+
+ await fireEvent(screen.getByTestId('view'), 'pointerDown', emptyEvent);
+
+ expect(onPointerDown).toHaveBeenCalledTimes(isModern() ? 0 : 1);
+ expect(onParentPointerDown).toHaveBeenCalledTimes(isModern() ? 1 : 0);
+ });
+
+ test('fires touch event with dispatch on View with pointerEvents="none"', async () => {
+ const onTouchStart = jest.fn();
+ await render();
+
+ // A native component declaring its own event decides when to emit it.
+ await fireEvent(screen.getByTestId('view'), 'touchStart', {
+ nativeEvent: {},
+ dispatch: 'bubbling',
+ });
+
+ expect(onTouchStart).toHaveBeenCalledTimes(1);
+ });
+
+ test('fires focus inside View with pointerEvents="none"', async () => {
+ const onFocus = jest.fn();
+ await render(
+
+
+ ,
+ );
+
+ await fireEvent(screen.getByTestId('input'), 'focus', emptyEvent);
+
+ // `pointerEvents` only affects touches. The input can still be focused, e.g. from a keyboard.
+ expect(onFocus).toHaveBeenCalledTimes(1);
+ });
+
+ test('fires touchStart on disabled Pressable (legacy: blocks it)', async () => {
+ const onPress = jest.fn();
+ const onTouchStart = jest.fn();
+ await render(
+ ,
+ );
+
+ await fireEvent(screen.getByTestId('pressable'), 'touchStart', emptyEvent);
+
+ // Modern matches iOS, where only Pressability checks `disabled`. Legacy blocks events whose
+ // touch responder declines the touch.
+ expect(onTouchStart).toHaveBeenCalledTimes(isModern() ? 1 : 0);
+ expect(onPress).not.toHaveBeenCalled();
+ });
+});
+
+describe('display: none', () => {
+ test('does not press inside View with display: none (legacy: presses)', async () => {
+ const onPress = jest.fn();
+ await render(
+
+
+ ,
+ );
+
+ await fireEvent.press(screen.getByTestId('pressable', { includeHiddenElements: true }));
+
+ // Modern matches React Native, where a hidden view has no frame, so nothing in it can be
+ // touched. Legacy doesn't check `display`.
+ expect(onPress).toHaveBeenCalledTimes(isModern() ? 0 : 1);
+ });
+
+ test('drops touchStart on View with display: none instead of moving it to the parent (legacy: fires on the View)', async () => {
+ const onParentTouchStart = jest.fn();
+ const onTouchStart = jest.fn();
+ await render(
+
+
+ ,
+ );
+
+ await fireEvent(
+ screen.getByTestId('view', { includeHiddenElements: true }),
+ 'touchStart',
+ emptyEvent,
+ );
+
+ // Unlike `pointerEvents="none"`, the hidden View covers no area of its parent to touch.
+ expect(onTouchStart).toHaveBeenCalledTimes(isModern() ? 0 : 1);
+ expect(onParentTouchStart).not.toHaveBeenCalled();
+ });
+
+ test('does not press an element that became hidden after it was queried (legacy: presses)', async () => {
+ const onPress = jest.fn();
+ function Screen({ hidden }: { hidden: boolean }) {
+ return (
+
+
+
+ );
+ }
+
+ await render();
+ const pressable = screen.getByTestId('pressable');
+ await screen.rerender();
+
+ await fireEvent.press(pressable);
+
+ expect(onPress).toHaveBeenCalledTimes(isModern() ? 0 : 1);
+ });
+});
+
+describe('disabled Switch', () => {
+ test('does not dispatch change (legacy: dispatches change)', async () => {
+ // Legacy calls `Switch`'s `onChange`, whose native value command logs an error in Jest.
+ const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
+ const onChange = jest.fn();
+ const onValueChange = jest.fn();
+ await render(
+ ,
+ );
+
+ await fireEvent(screen.getByTestId('switch'), 'change', { nativeEvent: { value: true } });
+
+ // Modern matches a device, where a disabled Switch can't be toggled.
+ expect(onChange).toHaveBeenCalledTimes(isModern() ? 0 : 1);
+ expect(onValueChange).toHaveBeenCalledTimes(isModern() ? 0 : 1);
+ errorSpy.mockRestore();
+ });
+});
+
describe('non-editable TextInput', () => {
function WrappedTextInput(props: TextInputProps) {
return ;
@@ -3250,7 +3598,7 @@ describe('non-editable TextInput', () => {
return ;
}
- test('dispatches focus and submitEditing, but not changeText (legacy: blocks touch-related events)', async () => {
+ test('does not dispatch focus, changeText or submitEditing, but dispatches layout', async () => {
const onFocus = jest.fn();
const onChangeText = jest.fn();
const onSubmitEditing = jest.fn();
@@ -3273,18 +3621,17 @@ describe('non-editable TextInput', () => {
await fireEvent(input, 'submitEditing', { nativeEvent: { text: 'Text' } });
await fireEvent(input, 'layout', layoutEvent);
- // Legacy matches a device, where a non-editable TextInput can't be focused or submitted.
- // Modern `fireEvent()` dispatches the event as given, without checking `editable`.
- expect(onFocus).toHaveBeenCalledTimes(isModern() ? 1 : 0);
+ // As on a device, where a non-editable TextInput can't be focused or submitted.
+ expect(onFocus).not.toHaveBeenCalled();
expect(onChangeText).not.toHaveBeenCalled();
- expect(onSubmitEditing).toHaveBeenCalledTimes(isModern() ? 1 : 0);
+ expect(onSubmitEditing).not.toHaveBeenCalled();
// Modern matches React Native, which passes a `SyntheticEvent`. Legacy passes the event object
// as given.
expect(onLayout.mock.calls).toEqual([[isModern() ? anySyntheticEvent : layoutEvent]]);
expect(onLayout.mock.calls[0][0]).toMatchObject(layoutEvent);
});
- test('bubbles focus and submitEditing from nested Text child, but not layout (legacy: blocks touch-related events)', async () => {
+ test('does not dispatch focus or submitEditing from nested Text child, nor bubble layout (legacy: bubbles layout)', async () => {
const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {});
const onFocus = jest.fn();
const onChangeText = jest.fn();
@@ -3312,11 +3659,10 @@ describe('non-editable TextInput', () => {
});
await fireEvent(subject, 'layout', layoutEvent);
- // Legacy matches a device, where a non-editable TextInput can't be focused or submitted.
- // Modern `fireEvent()` dispatches the events as given, and they bubble from the Text to the
- // TextInput.
- expect(onFocus).toHaveBeenCalledTimes(isModern() ? 1 : 0);
- expect(onSubmitEditing).toHaveBeenCalledTimes(isModern() ? 1 : 0);
+ // As on a device, where a non-editable TextInput can't be focused or submitted. Modern emits
+ // them from the TextInput, as the nested Text isn't a native view.
+ expect(onFocus).not.toHaveBeenCalled();
+ expect(onSubmitEditing).not.toHaveBeenCalled();
// Modern `changeText` accepts only a host TextInput. Legacy walks up to it, and skips it.
expect(changeTextError).toBe(
isModern()
@@ -3334,42 +3680,38 @@ describe('non-editable TextInput', () => {
test.each([
['WrappedTextInput', WrappedTextInput],
['DoubleWrappedTextInput', DoubleWrappedTextInput],
- ])(
- 'dispatches focus and submitEditing on %s, but not changeText (legacy: blocks touch-related events)',
- async (_, Component) => {
- const onFocus = jest.fn();
- const onChangeText = jest.fn();
- const onSubmitEditing = jest.fn();
- const onLayout = jest.fn();
+ ])('does not dispatch focus, changeText or submitEditing on %s', async (_, Component) => {
+ const onFocus = jest.fn();
+ const onChangeText = jest.fn();
+ const onSubmitEditing = jest.fn();
+ const onLayout = jest.fn();
- await render(
- ,
- );
+ await render(
+ ,
+ );
- const input = screen.getByTestId('input');
- await fireEvent(input, 'focus', emptyEvent);
- await fireEvent.changeText(input, 'Text');
- await fireEvent(input, 'submitEditing', { nativeEvent: { text: 'Text' } });
- await fireEvent(input, 'layout', layoutEvent);
-
- // Legacy matches a device, where a non-editable TextInput can't be focused or submitted.
- // Modern `fireEvent()` dispatches the event as given, without checking `editable`.
- expect(onFocus).toHaveBeenCalledTimes(isModern() ? 1 : 0);
- expect(onChangeText).not.toHaveBeenCalled();
- expect(onSubmitEditing).toHaveBeenCalledTimes(isModern() ? 1 : 0);
- // Modern matches React Native, which passes a `SyntheticEvent`. Legacy passes the event
- // object as given.
- expect(onLayout.mock.calls).toEqual([[isModern() ? anySyntheticEvent : layoutEvent]]);
- expect(onLayout.mock.calls[0][0]).toMatchObject(layoutEvent);
- },
- );
+ const input = screen.getByTestId('input');
+ await fireEvent(input, 'focus', emptyEvent);
+ await fireEvent.changeText(input, 'Text');
+ await fireEvent(input, 'submitEditing', { nativeEvent: { text: 'Text' } });
+ await fireEvent(input, 'layout', layoutEvent);
+
+ // As on a device, where a non-editable TextInput can't be focused or submitted.
+ expect(onFocus).not.toHaveBeenCalled();
+ expect(onChangeText).not.toHaveBeenCalled();
+ expect(onSubmitEditing).not.toHaveBeenCalled();
+ // Modern matches React Native, which passes a `SyntheticEvent`. Legacy passes the event
+ // object as given.
+ expect(onLayout.mock.calls).toEqual([[isModern() ? anySyntheticEvent : layoutEvent]]);
+ expect(onLayout.mock.calls[0][0]).toMatchObject(layoutEvent);
+ });
test('fires layout event', async () => {
const onLayout = jest.fn();
@@ -3384,6 +3726,39 @@ describe('non-editable TextInput', () => {
await fireEvent(screen.getByTestId('input'), 'scroll', verticalScrollEvent);
expect(onScroll).toHaveBeenCalled();
});
+
+ test('fires selectionChange (legacy: blocks it)', async () => {
+ const onSelectionChange = jest.fn();
+ await render(
+ ,
+ );
+
+ await fireEvent(screen.getByTestId('input'), 'selectionChange', {
+ nativeEvent: { selection: { start: 0, end: 4 } },
+ });
+
+ // Modern matches iOS, where a non-editable multiline TextInput emits selection changes.
+ expect(onSelectionChange).toHaveBeenCalledTimes(isModern() ? 1 : 0);
+ });
+});
+
+describe('editable TextInput with nested Text', () => {
+ test('emits focus from the TextInput (legacy: passes the event object as given)', async () => {
+ const onFocus = jest.fn();
+ await render(
+
+ Nested Text
+ ,
+ );
+
+ await fireEvent(screen.getByText('Nested Text'), 'focus', emptyEvent);
+
+ expect(onFocus).toHaveBeenCalledTimes(1);
+ // Modern matches React Native, where the nested Text isn't a native view.
+ expect(onFocus.mock.calls[0][0].target).toEqual(
+ isModern() ? screen.getByTestId('input') : undefined,
+ );
+ });
});
describe('responder system', () => {
diff --git a/src/events/fire-event.ts b/src/events/fire-event.ts
index 2d0dc1b81..55b9f4ed6 100644
--- a/src/events/fire-event.ts
+++ b/src/events/fire-event.ts
@@ -35,20 +35,21 @@ type CustomFireEventInit = FireEventInit & { dispatch: CustomEventDispatch };
type ChangeTextHint = 'Use fireEvent.changeText() or userEvent.type() instead.';
/**
- * Event object that modern `fireEvent` accepts at runtime: `dispatch` is required for event types
- * React Native doesn't know. Not distributive, so a union with an unknown event type requires it.
+ * Event object that modern `fireEvent` accepts at runtime: optional for event types React Native
+ * knows, and required with `dispatch` for the others. Not distributive, so a union with an unknown
+ * event type requires it.
*/
-type ModernFireEventInit = [T] extends [KnownEventName]
- ? FireEventInit
+type ModernFireEventArgs = [T] extends [KnownEventName]
+ ? [event?: FireEventInit]
: [T] extends ['changeText' | 'onChangeText']
- ? ChangeTextHint | CustomFireEventInit
- : CustomFireEventInit;
+ ? [event: ChangeTextHint | CustomFireEventInit]
+ : [event: CustomFireEventInit];
type ModernFireEvent = {
(
instance: TestInstance,
eventType: T,
- event: ModernFireEventInit,
+ ...args: ModernFireEventArgs
): Promise;
changeText: (instance: TestInstance, text: string) => Promise;
press: (instance: TestInstance, event?: FireEventInit) => Promise;
diff --git a/src/events/modern/emitting-target.ts b/src/events/modern/emitting-target.ts
new file mode 100644
index 000000000..8a9c9cf28
--- /dev/null
+++ b/src/events/modern/emitting-target.ts
@@ -0,0 +1,96 @@
+import type { TestInstance } from 'test-renderer';
+
+import { isHostSwitch, isHostTextInput } from '../../helpers/host-component-names';
+import { isEditableTextInput } from '../../helpers/text-input';
+import type { CreateEventInit } from './event';
+import { getTouchPath } from './pressability';
+
+/**
+ * Native touch and pointer events, which a device emits from the view its hit testing finds, as
+ * `pointerEvents` decides. Pointer capture events go to the capturing view instead.
+ */
+const HIT_TESTED_EVENT_TYPES = new Set([
+ 'click',
+ 'pointerCancel',
+ 'pointerDown',
+ 'pointerEnter',
+ 'pointerLeave',
+ 'pointerMove',
+ 'pointerOut',
+ 'pointerOver',
+ 'pointerUp',
+ 'touchCancel',
+ 'touchEnd',
+ 'touchMove',
+ 'touchStart',
+]);
+
+/**
+ * `TextInput` events a non-editable `TextInput` doesn't emit, as it can't be focused. Its
+ * `selectionChange` stays, as iOS emits it for a non-editable multiline `TextInput`, and so do
+ * events that don't need focus, like `layout`, `scroll` and `contentSizeChange`.
+ */
+const TEXT_EDITING_EVENT_TYPES = new Set([
+ 'blur',
+ 'change',
+ 'changeSync',
+ 'endEditing',
+ 'focus',
+ 'keyPress',
+ 'keyPressSync',
+ 'submitEditing',
+]);
+
+/**
+ * The element a device would emit the event from, or `null` when no element would emit it:
+ *
+ * - Touch and pointer events: the hit target, as for `press` (`getTouchPath()`). `pointerEvents`
+ * moves them to the nearest ancestor it doesn't block, and drops them when it blocks every
+ * element. Dropped when `display: none` hides the element.
+ * - Text editing events: the nearest host `TextInput`, as a `Text` nested in a `TextInput` isn't a
+ * native view on a device. Dropped when it isn't editable.
+ * - `change` of a disabled `Switch`: dropped, as it can't be toggled.
+ *
+ * `disabled` doesn't block touch and pointer events, as on iOS, where only Pressability checks it.
+ * Known difference from Android: there, a disabled view is never the hit target, so the touch goes
+ * to its ancestors.
+ *
+ * Events with `dispatch` are declared by a native component, which decides when to emit them, so
+ * they keep the target.
+ */
+export function getEmittingTarget(
+ target: TestInstance,
+ eventType: string,
+ init: CreateEventInit,
+): TestInstance | null {
+ if (init.dispatch != null) {
+ return target;
+ }
+
+ if (HIT_TESTED_EVENT_TYPES.has(eventType)) {
+ return getTouchPath(target)[0] ?? null;
+ }
+
+ if (TEXT_EDITING_EVENT_TYPES.has(eventType)) {
+ const textInput = findHostTextInput(target);
+ if (textInput != null) {
+ return isEditableTextInput(textInput) ? textInput : null;
+ }
+ }
+
+ if (eventType === 'change' && isHostSwitch(target) && target.props.disabled === true) {
+ return null;
+ }
+
+ return target;
+}
+
+/** The element or its nearest host `TextInput` ancestor, or `null` when there is none. */
+function findHostTextInput(instance: TestInstance): TestInstance | null {
+ let current: TestInstance | null = instance;
+ while (current != null && !isHostTextInput(current)) {
+ current = current.parent;
+ }
+
+ return current;
+}
diff --git a/src/events/modern/fire-event-utils.ts b/src/events/modern/fire-event-utils.ts
index c23e9e368..d5fe74b52 100644
--- a/src/events/modern/fire-event-utils.ts
+++ b/src/events/modern/fire-event-utils.ts
@@ -87,25 +87,18 @@ export function ensureEventType(
}
/**
- * Throws unless exactly one argument follows the event type in modern `fireEvent`, as React Native
- * handlers receive a single event. Catches legacy calls passing several handler arguments.
+ * Throws when more than one argument follows the event type in modern `fireEvent`, as React Native
+ * handlers receive a single event. Catches legacy calls passing several handler arguments. No
+ * argument is fine: the event object is optional.
*
* @param callsite the function to remove from the error stack, e.g. `fireEvent`
*/
-export function ensureSingleEventArg(
+export function ensureAtMostOneEventArg(
eventType: string,
args: unknown[],
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
callsite: Function,
): void {
- if (args.length === 0) {
- throw new ErrorWithStack(
- `Unable to fire a "${eventType}" event. Please provide an event object, ` +
- `e.g. fireEvent(element, '${eventType}', { nativeEvent: {} }).`,
- callsite,
- );
- }
-
if (args.length > 1) {
throw new ErrorWithStack(
`Unable to fire a "${eventType}" event. Expected a single event object, ` +
diff --git a/src/events/modern/fire-event.ts b/src/events/modern/fire-event.ts
index 108983ef9..42599fc12 100644
--- a/src/events/modern/fire-event.ts
+++ b/src/events/modern/fire-event.ts
@@ -2,7 +2,6 @@ import type { TestInstance } from 'test-renderer';
import { isInstanceMounted } from '../../helpers/component-tree';
import { ErrorWithStack } from '../../helpers/errors';
-import { isEditableTextInput } from '../../helpers/text-input';
import { getEventHandlerByEventType, normalizeEventType } from '../shared/handler';
import { mergeEventProps } from '../shared/merge';
import {
@@ -17,19 +16,21 @@ import {
} from '../shared/update-native-state';
import type { DispatchOptions } from './dispatch';
import { dispatchEvent } from './dispatch';
+import { getEmittingTarget } from './emitting-target';
import type { CreateEventInit, NativeEventPayload } from './event';
import { createEvent } from './event';
import type { PressabilityEventType } from './event-types';
import { isPressabilityEventType } from './event-types';
import {
+ ensureAtMostOneEventArg,
ensureEventType,
- ensureSingleEventArg,
validateChangeTextArgs,
validateEventInit,
warnOnPrefix,
} from './fire-event-utils';
import { dispatchPressabilityEvent, findResponder, getTouchPath } from './pressability';
import { createPressabilityEvent } from './responder-event';
+import { buildTouchPayload, isActiveTouchEventType, isTouchEventType } from './touch-payload';
/**
* Event object passed to modern `fireEvent`. Handlers receive a `SyntheticEvent` from
@@ -45,15 +46,23 @@ 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. 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.
+ * The event object is optional and defaults to `{}`. At most one is accepted, 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.
+ *
+ * Touch events (`touchStart`, `touchMove`, `touchEnd`, `touchCancel`) get a default touch payload,
+ * with the passed `nativeEvent` deep merged onto it, as `fireEvent.press()` does. Other event types
+ * get the passed `nativeEvent` as is.
*
* `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.
*
+ * Like a device, drops events the element can't emit, and moves touch and pointer events to the
+ * hit target. See `getEmittingTarget()`.
+ *
* Resolves to nothing, unlike DOM `fireEvent`: React Native has no default actions that
* `preventDefault()` could cancel. Handlers can read `event.defaultPrevented` themselves.
*
@@ -63,14 +72,16 @@ export type FireEventInit = CreateEventInit;
export async function fireEvent(
instance: TestInstance,
eventType: string,
- ...args: [event: FireEventInit]
+ ...args: [event?: FireEventInit]
): Promise {
const normalizedType = normalizeEventType(eventType);
ensureInstance(instance, normalizedType, fireEvent);
// Before the event object, as `fireEvent(input, 'changeText', 'Hello')` needs the hint more.
ensureEventType(normalizedType, args[0], fireEvent);
- ensureSingleEventArg(normalizedType, args, fireEvent);
- const init = validateEventInit(normalizedType, args[0], fireEvent);
+ ensureAtMostOneEventArg(normalizedType, args, fireEvent);
+ // Explicit `undefined` too, as for an omitted optional parameter. `null` still throws.
+ const event = args[0] === undefined ? {} : args[0];
+ const init = validateEventInit(normalizedType, event, fireEvent);
// After the checks, so a call that throws doesn't also warn.
warnOnPrefix(eventType);
if (isPressabilityEventType(normalizedType) && init.dispatch == null) {
@@ -78,6 +89,12 @@ export async function fireEvent(
return;
}
+ if (isTouchEventType(normalizedType) && init.dispatch == null) {
+ const nativeEvent = buildTouchPayload(isActiveTouchEventType(normalizedType), init.nativeEvent);
+ await fireEventInternal(instance, normalizedType, { ...init, nativeEvent });
+ return;
+ }
+
await fireEventInternal(instance, normalizedType, init);
}
@@ -85,15 +102,11 @@ export async function fireEvent(
* Changes the text of a `TextInput` as a device does: fires a `change` event with the new text,
* and calls `onChangeText(text)` right after the input's own `onChange`, as `TextInput` does from
* its `onChange` wrapper. Ancestors receive the bubbling `onChange`, but never `onChangeText`.
- * A non-editable `TextInput` receives no events, as on a device.
+ * A non-editable `TextInput` receives no events, as on a device (see `getEmittingTarget()`).
*/
fireEvent.changeText = async (instance: TestInstance, text: string): Promise => {
ensureInstance(instance, 'changeText', fireEvent.changeText);
validateChangeTextArgs(instance, text, fireEvent.changeText);
- if (!isEditableTextInput(instance)) {
- return;
- }
-
const selection = { start: text.length, end: text.length };
const payload = buildTextChangeNativeEvent(text, selection);
const dispatchOptions: DispatchOptions = {
@@ -111,7 +124,7 @@ fireEvent.changeText = async (instance: TestInstance, text: string): Promise {
+ if (!isInstanceMounted(instance)) {
+ return;
+ }
+
+ // Before native state, so a dropped `change` leaves it alone.
+ const target = getEmittingTarget(instance, eventType, init);
+ if (target == null) {
+ return;
+ }
+
const nativeEvent = basePayload
- ? mergeEventProps(basePayload, getNativeStateEventProps(instance, eventType), init.nativeEvent)
+ ? mergeEventProps(basePayload, getNativeStateEventProps(target, eventType), init.nativeEvent)
: init.nativeEvent;
const event = createEvent(eventType, { ...init, nativeEvent });
- if (event == null || !isInstanceMounted(instance)) {
+ if (event == null) {
return;
}
// Before the dispatch, as a device updates its native views before emitting the event.
- updateNativeStateFromEvent(instance, eventType, event);
- await dispatchEvent(instance, event, dispatchOptions);
+ updateNativeStateFromEvent(target, eventType, event);
+ await dispatchEvent(target, event, dispatchOptions);
}
/** Touches the element and calls one Pressability callback of the touch responder. */
diff --git a/src/events/modern/pressability.ts b/src/events/modern/pressability.ts
index a5167beee..c9fd3062d 100644
--- a/src/events/modern/pressability.ts
+++ b/src/events/modern/pressability.ts
@@ -1,3 +1,4 @@
+import { StyleSheet } from 'react-native';
import type { TestInstance } from 'test-renderer';
import { act } from '../../act';
@@ -103,9 +104,13 @@ export async function dispatchPressabilityEvent(
/**
* Hit testing: the elements a touch on the element goes through, from the hit target to the root.
* The hit target is the element, or its nearest ancestor when `pointerEvents` blocks it. Empty
- * when `pointerEvents` blocks every element.
+ * when `pointerEvents` blocks every element, or when `display: none` hides the element.
*/
export function getTouchPath(target: TestInstance): TestInstance[] {
+ if (isHiddenByDisplayNone(target)) {
+ return [];
+ }
+
let hitTarget: TestInstance | null = target;
while (hitTarget?.parent != null && getPointerEventsBlocker(hitTarget) != null) {
hitTarget = hitTarget.parent;
@@ -121,6 +126,20 @@ export function getTouchPath(target: TestInstance): TestInstance[] {
return path;
}
+/**
+ * Whether `display: none` on the element or an ancestor hides it. A device lays out a hidden view
+ * with no frame (iOS doesn't even mount it), so no touch can land on it or its subtree.
+ */
+function isHiddenByDisplayNone(instance: TestInstance): boolean {
+ for (let current: TestInstance | null = instance; current != null; current = current.parent) {
+ if (StyleSheet.flatten(current.props.style)?.display === 'none') {
+ return true;
+ }
+ }
+
+ return false;
+}
+
/**
* Responder negotiation, as React Native's `ResponderEventPlugin` does on touch start:
* `onStartShouldSetResponder` from the hit target to the root. The first one returning `true` wins.
diff --git a/src/events/modern/responder-event.ts b/src/events/modern/responder-event.ts
index e2e66931f..9c7971ea6 100644
--- a/src/events/modern/responder-event.ts
+++ b/src/events/modern/responder-event.ts
@@ -1,8 +1,7 @@
-import { mergeEventProps } from '../shared/merge';
-import { buildTouchNativeEvent } from '../shared/payloads';
import type { CreateEventInit, NativeEventPayload } from './event';
import { SyntheticEvent } from './event';
import type { PressabilityEventType } from './event-types';
+import { buildTouchPayload } from './touch-payload';
/** The dispatch configs React Native's responder system passes to `ResponderEvent`. */
const RESPONDER_DISPATCH_CONFIGS = {
@@ -53,38 +52,10 @@ export function createPressabilityEvent(
init: CreateEventInit = {},
): ResponderEvent {
const isTouchStart = eventType === 'pressIn' || eventType === 'longPress';
- const nativeEvent = buildResponderNativeEvent(isTouchStart, init.nativeEvent);
+ const nativeEvent = buildTouchPayload(isTouchStart, init.nativeEvent);
return new ResponderEvent(
isTouchStart ? 'responderGrant' : 'responderRelease',
{ timeStamp: init.timeStamp },
nativeEvent,
);
}
-
-/**
- * React Native's touch payload (`touchEventPayload()` in `TouchEventEmitter.cpp`): the changed
- * touch's fields, plus the touch arrays. On the New Architecture, `timestamp` uses the clock of
- * `performance.now()`.
- */
-function buildResponderNativeEvent(
- isTouchStart: boolean,
- init: NativeEventPayload | undefined,
-): NativeEventPayload {
- const nativeEvent: NativeEventPayload = mergeEventProps(
- buildTouchNativeEvent(),
- { timestamp: performance.now() },
- init,
- );
-
- const touch = { ...nativeEvent };
- delete touch.changedTouches;
- delete touch.touches;
- if (init == null || !Object.hasOwn(init, 'changedTouches')) {
- nativeEvent.changedTouches = [{ ...touch }];
- }
- if (init == null || !Object.hasOwn(init, 'touches')) {
- nativeEvent.touches = isTouchStart ? [{ ...touch }] : [];
- }
-
- return nativeEvent;
-}
diff --git a/src/events/modern/touch-payload.ts b/src/events/modern/touch-payload.ts
new file mode 100644
index 000000000..07ada4671
--- /dev/null
+++ b/src/events/modern/touch-payload.ts
@@ -0,0 +1,56 @@
+import { mergeEventProps } from '../shared/merge';
+import { buildTouchNativeEvent } from '../shared/payloads';
+import type { NativeEventPayload } from './event';
+
+/**
+ * React Native's touch payload (`touchEventPayload()` in `TouchEventEmitter.cpp`): the changed
+ * touch's fields, plus the touch arrays. On the New Architecture, `timestamp` uses the clock of
+ * `performance.now()`.
+ *
+ * `init` is deep merged onto a default touch. As on a device, `changedTouches` has the touch, and
+ * `touches` has it while the touch is active (touch start and move) and is empty once it ends,
+ * unless `init` sets them.
+ *
+ * @param isActive whether the touch is still on the screen: `true` on touch start and move, `false`
+ * on touch end and cancel.
+ */
+export function buildTouchPayload(
+ isActive: boolean,
+ init: NativeEventPayload | undefined,
+): NativeEventPayload {
+ const nativeEvent: NativeEventPayload = mergeEventProps(
+ buildTouchNativeEvent(),
+ { timestamp: performance.now() },
+ init,
+ );
+
+ const touch = { ...nativeEvent };
+ delete touch.changedTouches;
+ delete touch.touches;
+ if (init == null || !Object.hasOwn(init, 'changedTouches')) {
+ nativeEvent.changedTouches = [{ ...touch }];
+ }
+ if (init == null || !Object.hasOwn(init, 'touches')) {
+ nativeEvent.touches = isActive ? [{ ...touch }] : [];
+ }
+
+ return nativeEvent;
+}
+
+/** Touch event types, with whether the touch is still active after each. */
+const TOUCH_EVENT_TYPES: Readonly> = {
+ touchStart: true,
+ touchMove: true,
+ touchEnd: false,
+ touchCancel: false,
+};
+
+/** Whether the event type is a native touch event (`touchStart`, `touchMove`, ...). */
+export function isTouchEventType(eventType: string): boolean {
+ return Object.hasOwn(TOUCH_EVENT_TYPES, eventType);
+}
+
+/** Whether the touch is still active after a touch event of this type. */
+export function isActiveTouchEventType(eventType: string): boolean {
+ return TOUCH_EVENT_TYPES[eventType] === true;
+}
diff --git a/src/user-event/press/__tests__/press.test.tsx b/src/user-event/press/__tests__/press.test.tsx
index 990afe05f..2877a46a0 100644
--- a/src/user-event/press/__tests__/press.test.tsx
+++ b/src/user-event/press/__tests__/press.test.tsx
@@ -350,6 +350,28 @@ describe('userEvent.press with fake timers', () => {
expect(events).toEqual([]);
});
+ test('does not call press when parent has display: none (legacy: calls it)', async () => {
+ const { events, logEvent } = createEventLogger();
+ const user = userEvent.setup();
+
+ await render(
+
+
+ ,
+ );
+ await user.press(screen.getByTestId('pressable', { includeHiddenElements: true }));
+
+ // Modern matches React Native, where nothing inside a hidden view can be touched.
+ expect(getEventsNames(events)).toEqual(
+ isModernEventSystem() ? [] : ['pressIn', 'pressOut', 'press'],
+ );
+ });
+
test('calls press when pressable has pointer events box-only', async () => {
const { events, logEvent } = createEventLogger();
const user = userEvent.setup();
diff --git a/website/docs/14.x/docs/api/events/modern-event-system.mdx b/website/docs/14.x/docs/api/events/modern-event-system.mdx
index c9d6070e4..58965ff70 100644
--- a/website/docs/14.x/docs/api/events/modern-event-system.mdx
+++ b/website/docs/14.x/docs/api/events/modern-event-system.mdx
@@ -1,5 +1,12 @@
# Modern Event System
+RNTL has two event systems:
+
+- **Legacy** (default): the simplified system RNTL has used from the beginning, tweaked and improved over time.
+- **Modern** (recommended): a realistic simulation of the React Native event system, with direct and bubbling events and event objects shaped like the ones React Native creates. We plan to make it the default in the next major release.
+
+The event system controls how [`fireEvent`](/docs/api/events/fire-event), [`userEvent`](/docs/api/events/user-event) and [`UNSAFE_invokeHandler`](/docs/api/events/fire-event#unsafe-invoke-handler) dispatch events.
+
## How to enable the modern event system
Add these two lines to your Jest setup file:
@@ -15,6 +22,129 @@ configure({ unstable_eventSystem: 'modern' });
Make sure your `tsconfig.json` includes the setup file. If it doesn't, TypeScript won't see the import, and you won't get the stricter checks.
-## Event types
+## Key differences
+
+| | Legacy | Modern |
+| --------------- | --------------------------------------------------------- | --------------------------------------------------------------------------- |
+| Event dispatch | Calls the first handler found, walking up from the target | Bubbling and direct events, as in React Native |
+| Event object | What you pass | `SyntheticEvent`, as in React Native |
+| Handlers called | Props of host and composite components | Props of host elements, and press callbacks of `Pressable` and `Touchable*` |
+
+## API differences
+
+### `fireEvent`
+
+```ts
+// Legacy
+function fireEvent(instance: TestInstance, eventType: string, ...data: unknown[]): Promise;
+
+// Modern
+function fireEvent(instance: TestInstance, eventType: string, event?: FireEventInit): Promise;
+
+type FireEventInit = {
+ nativeEvent?: Record;
+ timeStamp?: number;
+ dispatch?: 'direct' | 'bubbling';
+};
+```
+
+#### Event type
Pass event types without the `on` prefix: `'focus'`, not `'onFocus'`. The prefix still works but logs a deprecation warning, and will stop working in the next major release.
+
+The event type must be one React Native dispatches natively. `changeText` isn't one, as `TextInput` calls `onChangeText` from JavaScript. Use [`fireEvent.changeText()`](/docs/api/events/fire-event#change-text) or [`userEvent.type()`](/docs/api/events/user-event#type) instead.
+
+#### Event object
+
+Legacy `fireEvent` passes all its arguments to the handler as they are. Modern `fireEvent` takes at most one event object, creates a `SyntheticEvent` from it, and passes that to the handler. Without an event object, the handler still gets a `SyntheticEvent`, with an empty `nativeEvent`. The event object can only have `nativeEvent`, `timeStamp` and `dispatch`. Other keys throw: `target`, `currentTarget` and the event methods come from the `SyntheticEvent`, and payload fields belong in `nativeEvent`.
+
+```ts
+// Legacy
+await fireEvent(input, 'focus');
+await fireEvent(view, 'layout', { nativeEvent: { layout: { width: 100 } } });
+
+// Modern
+await fireEvent(input, 'focus');
+await fireEvent(view, 'layout', { nativeEvent: { layout: { width: 100 } } });
+```
+
+Touch events (`touchStart`, `touchMove`, `touchEnd` and `touchCancel`) get a default touch payload, as on a device, with the `nativeEvent` you pass deep merged onto it. `changedTouches` has the touch, and `touches` has it until the touch ends, unless you pass them. Other event types, and events fired with `dispatch`, get `nativeEvent` as you pass it.
+
+```ts
+await fireEvent(button, 'touchStart', { nativeEvent: { pageX: 100, pageY: 100 } });
+await fireEvent(button, 'touchMove', { nativeEvent: { pageX: 110, pageY: 110 } });
+await fireEvent(button, 'touchEnd');
+```
+
+#### Dispatch
+
+Modern `fireEvent` dispatches the event as React Native does, based on the event type:
+
+- Bubbling events (`press`, `focus`, `change`, ...) call `on*Capture` handlers from the root down to the target, then `on*` handlers from the target up to the root, until one calls `stopPropagation()`.
+- Direct events (`scroll`, `layout`, ...) call only the target's handler.
+
+Legacy `fireEvent` calls only the first handler it finds, walking up from the target, for both kinds of events.
+
+For event types React Native doesn't know, like native events of third-party components, pass `dispatch`. Match the component's prop type: `'direct'` for `DirectEventHandler`, `'bubbling'` for `BubblingEventHandler`. Without `dispatch`, such event types throw.
+
+```ts
+await fireEvent(pager, 'pageSelected', { nativeEvent: { position: 1 }, dispatch: 'direct' });
+```
+
+#### Device checks
+
+Modern `fireEvent` doesn't call handlers for events a device wouldn't emit from the element:
+
+- Touch and pointer events (`touchStart`, `pointerDown`, ...) go to the element a touch would hit. `pointerEvents` moves them to an ancestor, or drops them when it blocks every element, as it does for `press`. `disabled` doesn't block them, as on iOS.
+- Nothing inside a `display: 'none'` subtree can be touched, so touch and pointer events and `press` are dropped there, also for an element that became hidden after you queried it.
+- A non-editable `TextInput` doesn't emit `focus`, `blur`, `change`, `submitEditing`, `endEditing` or `keyPress`. It still emits `selectionChange`, `layout`, `scroll` and `contentSizeChange`. A `Text` nested in a `TextInput` emits these events from the `TextInput`.
+- A disabled `Switch` doesn't emit `change`.
+
+Events fired with `dispatch` skip these checks.
+
+#### Composite components
+
+Modern `fireEvent` calls only props of host elements, as React Native does. It doesn't call props of composite components, like your own `onInputChange`, or JavaScript callbacks with custom arguments, like `onContentSizeChange(width, height)` of `FlatList`. This is the change most likely to break existing tests. Use [`UNSAFE_invokeHandler`](/docs/api/events/fire-event#unsafe-invoke-handler) for these:
+
+```ts
+// Legacy
+await fireEvent(list, 'contentSizeChange', 320, 1200);
+
+// Modern
+await UNSAFE_invokeHandler(list, 'onContentSizeChange', 320, 1200);
+```
+
+The exceptions are the press callbacks of `Pressable` and `Touchable*` components: `onPress`, `onPressIn`, `onPressOut` and `onLongPress`. React Native calls them from its Pressability logic, not from a native event, and modern `fireEvent` does the same. Firing `press`, `pressIn`, `pressOut` or `longPress` calls the callback of the element that becomes the touch responder, read from its `testOnly_pressabilityConfig()`.
+
+```ts
+await fireEvent(screen.getByText('Submit'), 'pressIn', {});
+```
+
+#### Return value
+
+Legacy `fireEvent` resolves to the handler's return value. Modern `fireEvent` resolves to `undefined`.
+
+### `userEvent`
+
+The `userEvent` API stays the same. The difference is in how each event of an interaction is delivered:
+
+- Native events, like `focus`, `change` or `scroll`, get a `SyntheticEvent` and are dispatched as in React Native, so bubbling events also reach handlers of ancestors. Legacy `userEvent` calls only the target's own handler, with a stub event.
+- `press()` and `longPress()` no longer wait 130 ms and 500 ms. Legacy `userEvent` runs Pressability through the responder's touch handlers, with its timers. Modern `userEvent` reads the press callbacks from `testOnly_pressabilityConfig()` and calls `onPressIn`, `onLongPress`, `onPressOut` and `onPress` directly. So `longPress()` always calls `onLongPress`, Pressability timing props like `delayLongPress` are ignored, and `duration` only sets how long the touch lasts. Both press the same element as modern `fireEvent.press()`: the touch responder.
+
+### `UNSAFE_invokeHandler`
+
+```ts
+function UNSAFE_invokeHandler(
+ instance: TestInstance,
+ handlerName: string,
+ ...args: unknown[]
+): Promise;
+```
+
+An escape hatch, mostly for legacy `fireEvent` calls that modern `fireEvent` no longer supports. Calls the handler prop directly, without simulating any event, so the test no longer follows how a user interacts with the app. Prefer `fireEvent` or `userEvent` where they work.
+
+- `instance`: the element whose handler to call. The prop is looked up on the element, then on the composite components that render it.
+- `handlerName`: the exact prop name, e.g. `onContentSizeChange`.
+- `args`: passed to the handler as they are.
+
+Resolves to the handler's return value. Works the same in both event systems. See [`UNSAFE_invokeHandler`](/docs/api/events/fire-event#unsafe-invoke-handler) for details.