From 614449d77dee3a0107512fb4d180ebc4cd3c7ac9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 15 Oct 2026 12:23:12 +0200 Subject: [PATCH 1/6] intro --- website/docs/14.x/docs/api/events/modern-event-system.mdx | 7 +++++++ 1 file changed, 7 insertions(+) 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..8fceee44e 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: From e3938e49b139047297bcab6fca795334529fd3bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 15 Oct 2026 12:33:39 +0200 Subject: [PATCH 2/6] fire event API docs --- .../docs/api/events/modern-event-system.mdx | 81 ++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) 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 8fceee44e..24097948a 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 @@ -22,6 +22,85 @@ 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' }); +``` + +#### 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`. From 05838694bbce86aa7c66b4fc27fd4ab4dfa1380d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 15 Oct 2026 12:58:11 +0200 Subject: [PATCH 3/6] userEvent docs --- website/docs/14.x/docs/api/events/modern-event-system.mdx | 7 +++++++ 1 file changed, 7 insertions(+) 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 24097948a..d4325bae0 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 @@ -104,3 +104,10 @@ 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. From 0c1052577056348bfe0094cbc95974ef05f471b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 15 Oct 2026 13:00:38 +0200 Subject: [PATCH 4/6] invoke handler --- .../docs/api/events/modern-event-system.mdx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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 d4325bae0..f0224d266 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 @@ -111,3 +111,21 @@ The `userEvent` API stays the same. The difference is in how each event of an in - 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. From c88c6eaffee24a15f7f23f8f2f33fc8765bb3a38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 15 Oct 2026 16:02:39 +0200 Subject: [PATCH 5/6] fireEvent: hit testing/pointer events check v0 --- contributing/event-dispatch.md | 2 + src/events/__tests__/fire-event.test.tsx | 272 +++++++++++++++--- src/events/modern/emitting-target.ts | 96 +++++++ src/events/modern/fire-event.ts | 29 +- .../docs/api/events/modern-event-system.mdx | 10 + 5 files changed, 354 insertions(+), 55 deletions(-) create mode 100644 src/events/modern/emitting-target.ts diff --git a/contributing/event-dispatch.md b/contributing/event-dispatch.md index 9bb951c1c..c82dffcd6 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. 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..b831ad62f 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,160 @@ 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('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 +3405,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 +3428,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 +3466,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 +3487,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 +3533,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..9b0af6a31 --- /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. + * - 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..202afad05 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 = { @@ -162,17 +161,27 @@ async function fireEventInternal( basePayload?: NativeEventPayload, dispatchOptions?: DispatchOptions, ): 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/website/docs/14.x/docs/api/events/modern-event-system.mdx b/website/docs/14.x/docs/api/events/modern-event-system.mdx index f0224d266..12f74e8f9 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 @@ -83,6 +83,16 @@ For event types React Native doesn't know, like native events of third-party com 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. +- 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: From 3edcb7abb8c7a230ad490ff81e71e260a1239248 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Fri, 16 Oct 2026 09:06:40 +0200 Subject: [PATCH 6/6] handle display: none --- contributing/event-dispatch.md | 2 +- src/events/__tests__/fire-event.test.tsx | 56 +++++++++++++++++++ src/events/modern/emitting-target.ts | 2 +- src/events/modern/fire-event.ts | 2 +- src/events/modern/pressability.ts | 21 ++++++- src/user-event/press/__tests__/press.test.tsx | 22 ++++++++ .../docs/api/events/modern-event-system.mdx | 1 + 7 files changed, 102 insertions(+), 4 deletions(-) diff --git a/contributing/event-dispatch.md b/contributing/event-dispatch.md index c82dffcd6..7d84ca1bf 100644 --- a/contributing/event-dispatch.md +++ b/contributing/event-dispatch.md @@ -20,7 +20,7 @@ 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. 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` 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. diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index b831ad62f..781adfcff 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -3377,6 +3377,62 @@ describe('touch event hit testing', () => { }); }); +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(