diff --git a/contributing/event-dispatch.md b/contributing/event-dispatch.md
index 9bb951c1c..7d84ca1bf 100644
--- a/contributing/event-dispatch.md
+++ b/contributing/event-dispatch.md
@@ -20,6 +20,8 @@ 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` doesn't log `eventDiagnostics` warnings yet. Only legacy `fireEvent` and `userEvent` do.
diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx
index f736dfb58..781adfcff 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,
@@ -3241,6 +3242,216 @@ describe('pointerEvents prop', () => {
});
});
+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 +3461,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 +3484,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 +3522,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 +3543,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 +3589,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/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.ts b/src/events/modern/fire-event.ts
index 108983ef9..063801e6a 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,6 +16,7 @@ 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';
@@ -54,6 +54,9 @@ export type FireEventInit = CreateEventInit;
* 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.
*
@@ -85,15 +88,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 +110,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/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..fc5a39615 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,121 @@ 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 exactly one event object, creates a `SyntheticEvent` from it, and passes that to the handler. 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 } } });
+```
+
+#### 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.