From c8aa0a3a188473e69fe48fc3e480381e6cd69ea4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Mon, 5 Oct 2026 14:50:08 +0200 Subject: [PATCH 01/16] common direct events ^ Conflicts: ^ src/events/propagation.ts --- CHANGELOG.md | 9 + contributing/native-events.md | 16 +- docs/api/fire-event.md | 12 +- src/events/__tests__/fire-event.test.tsx | 196 ++++++++++++++++++ src/events/propagation.ts | 72 ++++++- src/events/warnings.ts | 2 +- .../docs/14.x/docs/api/events/fire-event.mdx | 12 +- 7 files changed, 309 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2816691b5..2397fa406 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,15 @@ with v14. - Added `userEvent.pullToRefresh()` to simulate the pull-to-refresh gesture on a host `ScrollView` element, invoking the `onRefresh` handler of its `refreshControl` prop. +### Fixes + +- `fireEvent` no longer bubbles events to the host element that emits them directly in React + Native: `ScrollView` `scrollBeginDrag`, `scrollEndDrag`, `momentumScrollBegin`, + `momentumScrollEnd` and `contentSizeChange`; `TextInput` `selectionChange` and + `contentSizeChange`; `Text` `textLayout`; and `Image` `loadStart`, `progress`, `load`, `error` + and `loadEnd`. Fire these events on the emitting element instead. Handlers with the same name on + other components still receive bubbled events. + ## 14.0.0 ### Migration guide diff --git a/contributing/native-events.md b/contributing/native-events.md index 4b6f27570..10e583066 100644 --- a/contributing/native-events.md +++ b/contributing/native-events.md @@ -2,7 +2,7 @@ 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` treats every event as bubbling except `layout`. The list of direct events lives in `isDirectEvent()` in `src/events/propagation.ts`. +Today, `fireEvent` treats only some of the direct events below as direct. The rest still bubble (see [Known gaps](#known-gaps)). The rules live in `isDirectEvent()` in `src/events/propagation.ts`. ## Which events are which @@ -26,7 +26,19 @@ This is simplified. A few events differ between iOS and Android. Check the sourc ## Known gaps -All the direct events above except `layout` still bubble in `fireEvent`. Fixing that is a breaking change: tests that fire these events on a child element would stop reaching the parent's handler. +`fireEvent` treats these events as direct, based on the host element type (see `isDirectEvent()`): + +- `layout` on all elements +- `ScrollView`: `scrollBeginDrag`, `scrollEndDrag`, `momentumScrollBegin`, `momentumScrollEnd`, `contentSizeChange` +- `TextInput`: `selectionChange`, `contentSizeChange` +- `Text`: `textLayout` +- `Image`: `loadStart`, `progress`, `load`, `error`, `loadEnd` + +A direct event fired on its emitting element only checks that element. Fired on a nested element, it bubbles as usual but stops at the first ancestor that emits it, as React Native never delivers it there from a child. Handlers with the same name elsewhere, like an `onLoad` prop of a custom composite component, still receive bubbled events. + +These were chosen because tests rarely fire them on a nested element: `TextInput` and `Image` have no children, `Text` queries usually match the `Text` that owns the handler, and the drag and momentum events are usually fired on the `ScrollView` itself. + +The other direct events in the table above still bubble in `fireEvent`: `scroll`, accessibility actions, `Modal` events, and `refresh`. Changing them is a breaking change, as tests fire them on nested elements, e.g. `scroll` on `ScrollView` content. Leave these for a major release. ## Sources diff --git a/docs/api/fire-event.md b/docs/api/fire-event.md index 8e550c925..0f6a02d52 100644 --- a/docs/api/fire-event.md +++ b/docs/api/fire-event.md @@ -13,6 +13,16 @@ function fireEvent(instance: TestInstance, eventName: string, ...data: unknown[] The `fireEvent` API triggers event handlers on both host and composite components. It traverses the component tree bottom-up from the passed element to find an enabled event handler named `onXxx` where `xxx` is the event name. +Some events are direct in React Native: they are delivered only to the host element that emitted them. When you fire such an event on its emitting element, `fireEvent` only checks that element. When you fire it on a nested element, `fireEvent` stops looking for handlers at the emitting element. These events are: + +- `layout` on all elements +- `scrollBeginDrag`, `scrollEndDrag`, `momentumScrollBegin`, `momentumScrollEnd` and `contentSizeChange` on `ScrollView` +- `selectionChange` and `contentSizeChange` on `TextInput` +- `textLayout` on `Text` +- `loadStart`, `progress`, `load`, `error` and `loadEnd` on `Image` + +Events with these names still bubble to other handlers, such as an `onLoad` prop of your own composite component. + Unlike User Event, this API does not automatically pass event object to event handler, this is responsibility of the user to construct such object. The base `fireEvent(instance, eventName, ...data)` API can pass multiple custom arguments to the handler. Convenience helpers such as `fireEvent.press` and `fireEvent.scroll` are different: they create a default event object and accept one optional object to merge into it. @@ -180,7 +190,7 @@ fireEvent.layout: ( Builds a layout event carrying the given `layout` rectangle and invokes the `onLayout` handler of the given element. Use it to simulate the layout engine measuring an element, e.g. to test components that adapt to a measured size. -Unlike other `fireEvent` calls, layout events do not bubble: React Native delivers them only to the measured element, so the handler is not looked up on parent elements. +Layout events do not bubble: React Native delivers them only to the measured element, so the handler is not looked up on parent elements. The `layout` values are merged onto a zeroed rectangle (`{ x: 0, y: 0, width: 0, height: 0 }`), so pass only the fields your component reads. diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index 3b9382ae6..cfdfa8185 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -1,6 +1,8 @@ import * as React from 'react'; import type { TextInputProps } from 'react-native'; import { + Image, + ImageBackground, PanResponder, Pressable, ScrollView, @@ -653,6 +655,200 @@ describe('fireEvent.layout', () => { }); }); +describe('direct events', () => { + const directEventCases: Array<{ + eventName: string; + renderTarget: (handler: jest.Mock) => React.ReactElement; + }> = [ + { + eventName: 'scrollBeginDrag', + renderTarget: (handler) => , + }, + { + eventName: 'scrollEndDrag', + renderTarget: (handler) => , + }, + { + eventName: 'momentumScrollBegin', + renderTarget: (handler) => , + }, + { + eventName: 'momentumScrollEnd', + renderTarget: (handler) => , + }, + { + eventName: 'contentSizeChange', + renderTarget: (handler) => , + }, + { + eventName: 'contentSizeChange', + renderTarget: (handler) => , + }, + { + eventName: 'selectionChange', + renderTarget: (handler) => , + }, + { + eventName: 'textLayout', + renderTarget: (handler) => ( + + Text + + ), + }, + ...(['loadStart', 'progress', 'load', 'error', 'loadEnd'] as const).map((eventName) => ({ + eventName, + renderTarget: (handler: jest.Mock) => ( + + ), + })), + ]; + + test.each(directEventCases)( + 'calls "$eventName" handler on the target element', + async ({ eventName, renderTarget }) => { + const handler = jest.fn(); + await render(renderTarget(handler)); + + await fireEvent(screen.getByTestId('target'), eventName); + + expect(handler).toHaveBeenCalledTimes(1); + }, + ); + + test.each(directEventCases)( + 'does not bubble "$eventName" from a nested element to the emitting element', + async ({ eventName, renderTarget }) => { + const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); + const handler = jest.fn(); + await render( + React.cloneElement(renderTarget(handler), {}, Nested), + ); + + await fireEvent(screen.getByTestId('nested'), eventName); + + expect(handler).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }, + ); + + test('does not bubble when fired with "on" prefixed event name', async () => { + const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); + const onMomentumScrollEnd = jest.fn(); + await render( + + + , + ); + + await fireEvent(screen.getByTestId('child'), 'onMomentumScrollEnd'); + + expect(onMomentumScrollEnd).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledTimes(1); + warnSpy.mockRestore(); + }); + + test('warns when direct event would bubble to the emitting element', async () => { + const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); + await render( + {}}> + + , + ); + + await fireEvent(screen.getByTestId('child'), 'momentumScrollEnd'); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + " ▲ fireEvent: "momentumScrollEnd" event does not bubble, fire it on the element that has the handler instead. + " + `); + warnSpy.mockRestore(); + }); + + test('stops bubbling at the emitting element', async () => { + const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); + const onMomentumScrollEnd = jest.fn(); + const Screen = (_props: { onMomentumScrollEnd: () => void }) => ( + + + + + + ); + await render(); + + await fireEvent(screen.getByTestId('child'), 'momentumScrollEnd'); + + expect(onMomentumScrollEnd).not.toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + test('bubbles event with direct event name to composite component handler', async () => { + const onLoad = jest.fn(); + const onError = jest.fn(); + const Card = (_props: { onLoad: () => void; onError: () => void }) => ( + + Card + + ); + await render(); + + await fireEvent(screen.getByText('Card'), 'load'); + await fireEvent(screen.getByText('Card'), 'error'); + + expect(onLoad).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledTimes(1); + }); + + test('bubbles event with direct event name to host element that does not emit it', async () => { + const onLoad = jest.fn(); + await render( + // @ts-expect-error View does not have onLoad prop + + Content + , + ); + + await fireEvent(screen.getByText('Content'), 'load'); + + expect(onLoad).toHaveBeenCalledTimes(1); + }); + + test('bubbles load event from ImageBackground children to ImageBackground handler', async () => { + const onLoad = jest.fn(); + await render( + + Caption + , + ); + + await fireEvent(screen.getByText('Caption'), 'load'); + + expect(onLoad).toHaveBeenCalledTimes(1); + }); + + test('still bubbles scroll event to ancestor ScrollView', async () => { + const onScroll = jest.fn(); + await render( + + + , + ); + + await fireEvent.scroll(screen.getByTestId('child')); + + expect(onScroll).toHaveBeenCalledTimes(1); + }); +}); + test('fireEvent fires custom event (onCustomEvent) on composite component', async () => { const CustomComponent = ({ onCustomEvent }: { onCustomEvent: (data: string) => void }) => ( onCustomEvent('event data')}> diff --git a/src/events/propagation.ts b/src/events/propagation.ts index 29613a563..bbfb44d5b 100644 --- a/src/events/propagation.ts +++ b/src/events/propagation.ts @@ -1,14 +1,63 @@ import type { Fiber, TestInstance } from 'test-renderer'; +import { formatElement } from '../helpers/format-element'; +import { + isHostImage, + isHostScrollView, + isHostText, + isHostTextInput, +} from '../helpers/host-component-names'; +import { logger } from '../helpers/logger'; import { getEventHandlerFromProps, normalizeEventName } from './handler'; import { isEventEnabled, isTouchResponder } from './is-enabled'; import type { EventHandler } from './types'; /** - * Direct events are delivered by React Native only to the emitting element and do not bubble. + * Direct events emitted by specific host components. + * + * Note: these lists are intentionally incomplete. Remaining direct events (e.g. `scroll`, `Modal` + * events, `refresh`, `accessibilityAction`) still bubble, as changing them is a breaking change. + * See `contributing/native-events.md`. */ -export function isDirectEvent(eventName: string) { - return eventName === 'layout'; +const COMMON_DIRECT_EVENTS = ['layout']; +const TEXT_DIRECT_EVENTS = ['textLayout']; +const TEXT_INPUT_DIRECT_EVENTS = ['selectionChange', 'contentSizeChange']; +const IMAGE_DIRECT_EVENTS = ['loadStart', 'progress', 'load', 'error', 'loadEnd']; +const SCROLL_VIEW_DIRECT_EVENTS = [ + 'scrollBeginDrag', + 'scrollEndDrag', + 'momentumScrollBegin', + 'momentumScrollEnd', + 'contentSizeChange', +]; + +/** + * Direct events are delivered by React Native only to the host element that emitted them and do + * not bubble. Whether an event is direct depends on the host element type, e.g. `load` is direct + * for `Image` elements, while custom `onLoad` props of composite components still bubble. + */ +export function isDirectEvent(instance: TestInstance, eventName: string) { + if (COMMON_DIRECT_EVENTS.includes(eventName)) { + return true; + } + + if (isHostText(instance)) { + return TEXT_DIRECT_EVENTS.includes(eventName); + } + + if (isHostTextInput(instance)) { + return TEXT_INPUT_DIRECT_EVENTS.includes(eventName); + } + + if (isHostImage(instance)) { + return IMAGE_DIRECT_EVENTS.includes(eventName); + } + + if (isHostScrollView(instance)) { + return SCROLL_VIEW_DIRECT_EVENTS.includes(eventName); + } + + return false; } type FindEventHandlerResult = { @@ -22,7 +71,8 @@ type FindEventHandlerResult = { /** * Finds the handler that should receive the event, as `fireEvent` does: direct events only - * check the target, other events bubble up the tree until an enabled handler is found. + * check the target, other events bubble up the tree until an enabled handler is found. Bubbling + * stops at an ancestor that emits the event as direct, as such events never come from children. * * Note: handlers are looked up by the event name as passed, while event rules (direct events, * `isEventEnabled`) use the name without the `on*` prefix. @@ -31,7 +81,7 @@ export function findEventHandler( instance: TestInstance, eventName: string, ): FindEventHandlerResult { - if (isDirectEvent(normalizeEventName(eventName))) { + if (isDirectEvent(instance, normalizeEventName(eventName))) { const handler = getEventHandlerFromProps(instance.props, eventName, { loose: true }); return { handler: handler ?? null, skippedTargets: [] }; } @@ -51,6 +101,18 @@ function findBubblingEventHandler( getEventHandlerFromProps(instance.props, eventName, { loose: true }) ?? findEventHandlerFromFiber(instance.unstable_fiber, eventName); + // Direct events emitted by this ancestor never come from its children. + if (isDirectEvent(instance, normalizeEventName(eventName))) { + if (handler) { + logger.warn( + `fireEvent: "${eventName}" event does not bubble, fire it on the element that has the handler instead.`, + formatElement(instance), + ); + } + + return { handler: null, skippedTargets }; + } + if (handler) { if (isEventEnabled(instance, normalizeEventName(eventName), touchResponder)) { return { handler, skippedTargets }; diff --git a/src/events/warnings.ts b/src/events/warnings.ts index ba1832cbe..4dfec464b 100644 --- a/src/events/warnings.ts +++ b/src/events/warnings.ts @@ -103,7 +103,7 @@ function getUnhandledEventWarning( } const handlerName = getEventHandlerName(eventName); - if (isDirectEvent(normalizeEventName(eventName))) { + if (isDirectEvent(instance, normalizeEventName(eventName))) { return { message: `No "${handlerName}" handler found on the element. "${eventName}" events do not bubble to ancestors.`, elements: [instance], diff --git a/website/docs/14.x/docs/api/events/fire-event.mdx b/website/docs/14.x/docs/api/events/fire-event.mdx index b1d51e086..2c2863251 100644 --- a/website/docs/14.x/docs/api/events/fire-event.mdx +++ b/website/docs/14.x/docs/api/events/fire-event.mdx @@ -14,6 +14,16 @@ function fireEvent(instance: TestInstance, eventName: string, ...data: unknown[] The `fireEvent` API triggers event handlers on both host and composite components. It traverses the component tree bottom-up from the passed element to find an enabled event handler named `onXxx` where `xxx` is the event name. +Some events are direct in React Native: they are delivered only to the host element that emitted them. When you fire such an event on its emitting element, `fireEvent` only checks that element. When you fire it on a nested element, `fireEvent` stops looking for handlers at the emitting element. These events are: + +- `layout` on all elements +- `scrollBeginDrag`, `scrollEndDrag`, `momentumScrollBegin`, `momentumScrollEnd` and `contentSizeChange` on `ScrollView` +- `selectionChange` and `contentSizeChange` on `TextInput` +- `textLayout` on `Text` +- `loadStart`, `progress`, `load`, `error` and `loadEnd` on `Image` + +Events with these names still bubble to other handlers, such as an `onLoad` prop of your own composite component. + Unlike User Event, this API does not automatically pass event object to event handler, this is responsibility of the user to construct such object. The base `fireEvent(instance, eventName, ...data)` API can pass multiple custom arguments to the handler. Convenience helpers such as `fireEvent.press` and `fireEvent.scroll` are different: they create a default event object and accept one optional object to merge into it. @@ -186,7 +196,7 @@ fireEvent.layout: ( Builds a layout event carrying the given `layout` rectangle and invokes the `onLayout` handler of the given element. Use it to simulate the layout engine measuring an element, e.g. to test components that adapt to a measured size. -Unlike other `fireEvent` calls, layout events do not bubble: React Native delivers them only to the measured element, so the handler is not looked up on parent elements. +Layout events do not bubble: React Native delivers them only to the measured element, so the handler is not looked up on parent elements. The `layout` values are merged onto a zeroed rectangle (`{ x: 0, y: 0, width: 0, height: 0 }`), so pass only the fields your component reads. From b2de1fc9c4bc8587fa3987d690d7fe3e7a005543 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Mon, 5 Oct 2026 15:36:13 +0200 Subject: [PATCH 02/16] leaky direct events --- CHANGELOG.md | 17 +- contributing/native-events.md | 15 +- docs/api/fire-event.md | 11 +- src/events/__tests__/fire-event.test.tsx | 196 ++++++++++++++++-- src/events/propagation.ts | 84 ++++++-- .../docs/14.x/docs/api/events/fire-event.mdx | 11 +- 6 files changed, 285 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2397fa406..c3ef20b31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,11 +20,18 @@ with v14. ### Fixes - `fireEvent` no longer bubbles events to the host element that emits them directly in React - Native: `ScrollView` `scrollBeginDrag`, `scrollEndDrag`, `momentumScrollBegin`, - `momentumScrollEnd` and `contentSizeChange`; `TextInput` `selectionChange` and - `contentSizeChange`; `Text` `textLayout`; and `Image` `loadStart`, `progress`, `load`, `error` - and `loadEnd`. Fire these events on the emitting element instead. Handlers with the same name on - other components still receive bubbled events. + Native: `Text` `textLayout`; `TextInput` `selectionChange` and `contentSizeChange`; `Image` + `loadStart`, `progress`, `load`, `error` and `loadEnd`; and `ScrollView` `scrollBeginDrag`, + `scrollEndDrag`, `momentumScrollBegin`, `momentumScrollEnd` and `contentSizeChange`. Fire these + events on the emitting element instead. Handlers with the same name on other components still + receive bubbled events. + +### Deprecations + +- `fireEvent` warns when other direct events bubble from a nested element to their emitting + element or past it: `accessibilityAction`; `TextInput` `scroll`; `ScrollView` `scroll` and + `refresh`; and `Modal` `requestClose`, `show`, `dismiss` and `orientationChange`. They will stop + bubbling in the next major version. ## 14.0.0 diff --git a/contributing/native-events.md b/contributing/native-events.md index 10e583066..d6f589e30 100644 --- a/contributing/native-events.md +++ b/contributing/native-events.md @@ -29,16 +29,25 @@ This is simplified. A few events differ between iOS and Android. Check the sourc `fireEvent` treats these events as direct, based on the host element type (see `isDirectEvent()`): - `layout` on all elements -- `ScrollView`: `scrollBeginDrag`, `scrollEndDrag`, `momentumScrollBegin`, `momentumScrollEnd`, `contentSizeChange` -- `TextInput`: `selectionChange`, `contentSizeChange` - `Text`: `textLayout` +- `TextInput`: `selectionChange`, `contentSizeChange` - `Image`: `loadStart`, `progress`, `load`, `error`, `loadEnd` +- `ScrollView`: `scrollBeginDrag`, `scrollEndDrag`, `momentumScrollBegin`, `momentumScrollEnd`, `contentSizeChange` A direct event fired on its emitting element only checks that element. Fired on a nested element, it bubbles as usual but stops at the first ancestor that emits it, as React Native never delivers it there from a child. Handlers with the same name elsewhere, like an `onLoad` prop of a custom composite component, still receive bubbled events. These were chosen because tests rarely fire them on a nested element: `TextInput` and `Image` have no children, `Text` queries usually match the `Text` that owns the handler, and the drag and momentum events are usually fired on the `ScrollView` itself. -The other direct events in the table above still bubble in `fireEvent`: `scroll`, accessibility actions, `Modal` events, and `refresh`. Changing them is a breaking change, as tests fire them on nested elements, e.g. `scroll` on `ScrollView` content. Leave these for a major release. +The other direct events still bubble for backward compatibility (see `isLeakyDirectEvent()`), as tests fire them on nested elements, e.g. `scroll` on `ScrollView` content: + +- `accessibilityAction` on all elements +- `TextInput`: `scroll` +- `ScrollView`: `scroll`, `refresh` +- `Modal`: `requestClose`, `show`, `dismiss`, `orientationChange` + +`fireEvent` logs a warning when one of these events bubbles from a nested element to the handler of an ancestor that emits it, e.g. `scroll` from `ScrollView` content to the `ScrollView`'s `onScroll`. Only the type of the element with the handler is checked, so a handler further up on an element that doesn't emit the event gets no warning, although it will stop receiving the event too. In the next major release, move these events to the direct lists and remove the warning. + +`refresh` is emitted by `RefreshControl`, but the Jest `ScrollView` mock doesn't render the `refreshControl` element. `FlatList` and `SectionList` pass `onRefresh` to the host `ScrollView`, so the rule uses `ScrollView` as the emitting element. ## Sources diff --git a/docs/api/fire-event.md b/docs/api/fire-event.md index 0f6a02d52..1b2770839 100644 --- a/docs/api/fire-event.md +++ b/docs/api/fire-event.md @@ -16,13 +16,20 @@ The `fireEvent` API triggers event handlers on both host and composite component Some events are direct in React Native: they are delivered only to the host element that emitted them. When you fire such an event on its emitting element, `fireEvent` only checks that element. When you fire it on a nested element, `fireEvent` stops looking for handlers at the emitting element. These events are: - `layout` on all elements -- `scrollBeginDrag`, `scrollEndDrag`, `momentumScrollBegin`, `momentumScrollEnd` and `contentSizeChange` on `ScrollView` -- `selectionChange` and `contentSizeChange` on `TextInput` - `textLayout` on `Text` +- `selectionChange` and `contentSizeChange` on `TextInput` - `loadStart`, `progress`, `load`, `error` and `loadEnd` on `Image` +- `scrollBeginDrag`, `scrollEndDrag`, `momentumScrollBegin`, `momentumScrollEnd` and `contentSizeChange` on `ScrollView` Events with these names still bubble to other handlers, such as an `onLoad` prop of your own composite component. +These direct events still bubble for backward compatibility, but `fireEvent` logs a warning when they bubble from a nested element to the handler of an ancestor that emits them, e.g. `scroll` from `ScrollView` content to the `ScrollView`. They will stop bubbling in the next major version, so fire them on the element that has the handler: + +- `accessibilityAction` on all elements +- `scroll` on `TextInput` +- `scroll` and `refresh` on `ScrollView` +- `requestClose`, `show`, `dismiss` and `orientationChange` on `Modal` + Unlike User Event, this API does not automatically pass event object to event handler, this is responsibility of the user to construct such object. The base `fireEvent(instance, eventName, ...data)` API can pass multiple custom arguments to the handler. Convenience helpers such as `fireEvent.press` and `fireEvent.scroll` are different: they create a default event object and accept one optional object to merge into it. diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index cfdfa8185..3abacb1e5 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -1,8 +1,10 @@ import * as React from 'react'; import type { TextInputProps } from 'react-native'; import { + FlatList, Image, ImageBackground, + Modal, PanResponder, Pressable, ScrollView, @@ -494,6 +496,7 @@ describe('fireEvent.scroll', () => { }); test('does not use layout size of non-ScrollView element as layoutMeasurement', async () => { + const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); const onScroll = jest.fn(); await render( @@ -509,6 +512,7 @@ describe('fireEvent.scroll', () => { width: 0, height: 0, }); + warnSpy.mockRestore(); }); }); @@ -658,39 +662,39 @@ describe('fireEvent.layout', () => { describe('direct events', () => { const directEventCases: Array<{ eventName: string; - renderTarget: (handler: jest.Mock) => React.ReactElement; + ui: (handler: jest.Mock) => React.ReactElement; }> = [ { eventName: 'scrollBeginDrag', - renderTarget: (handler) => , + ui: (handler) => , }, { eventName: 'scrollEndDrag', - renderTarget: (handler) => , + ui: (handler) => , }, { eventName: 'momentumScrollBegin', - renderTarget: (handler) => , + ui: (handler) => , }, { eventName: 'momentumScrollEnd', - renderTarget: (handler) => , + ui: (handler) => , }, { eventName: 'contentSizeChange', - renderTarget: (handler) => , + ui: (handler) => , }, { eventName: 'contentSizeChange', - renderTarget: (handler) => , + ui: (handler) => , }, { eventName: 'selectionChange', - renderTarget: (handler) => , + ui: (handler) => , }, { eventName: 'textLayout', - renderTarget: (handler) => ( + ui: (handler) => ( Text @@ -698,7 +702,7 @@ describe('direct events', () => { }, ...(['loadStart', 'progress', 'load', 'error', 'loadEnd'] as const).map((eventName) => ({ eventName, - renderTarget: (handler: jest.Mock) => ( + ui: (handler: jest.Mock) => ( { test.each(directEventCases)( 'calls "$eventName" handler on the target element', - async ({ eventName, renderTarget }) => { + async ({ eventName, ui }) => { const handler = jest.fn(); - await render(renderTarget(handler)); + await render(ui(handler)); await fireEvent(screen.getByTestId('target'), eventName); @@ -722,12 +726,10 @@ describe('direct events', () => { test.each(directEventCases)( 'does not bubble "$eventName" from a nested element to the emitting element', - async ({ eventName, renderTarget }) => { + async ({ eventName, ui }) => { const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); const handler = jest.fn(); - await render( - React.cloneElement(renderTarget(handler), {}, Nested), - ); + await render(React.cloneElement(ui(handler), {}, Nested)); await fireEvent(screen.getByTestId('nested'), eventName); @@ -834,18 +836,174 @@ describe('direct events', () => { expect(onLoad).toHaveBeenCalledTimes(1); }); +}); - test('still bubbles scroll event to ancestor ScrollView', async () => { - const onScroll = jest.fn(); +describe('leaky direct events', () => { + const leakyEventCases: Array<{ + name: string; + eventName: string; + ui: (handler: jest.Mock) => React.ReactElement; + }> = [ + { + name: 'scroll from ScrollView content', + eventName: 'scroll', + ui: (handler) => ( + + + + ), + }, + { + name: 'scroll from TextInput to ancestor ScrollView', + eventName: 'scroll', + ui: (handler) => ( + + + + ), + }, + { + name: 'refresh from FlatList item', + eventName: 'refresh', + ui: (handler) => ( + {item}} + refreshing={false} + onRefresh={handler} + /> + ), + }, + { + name: 'accessibilityAction from Pressable content', + eventName: 'accessibilityAction', + ui: (handler) => ( + + Button + + ), + }, + ...(['requestClose', 'show', 'dismiss', 'orientationChange'] as const).map((eventName) => ({ + name: `${eventName} from Modal content`, + eventName, + ui: (handler: jest.Mock) => ( + + Content + + ), + })), + ]; + + test.each(leakyEventCases)('bubbles $name with a warning', async ({ eventName, ui }) => { + const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); + const handler = jest.fn(); + await render(ui(handler)); + + await fireEvent(screen.getByTestId('target'), eventName); + + expect(handler).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledTimes(1); + warnSpy.mockRestore(); + }); + + test.each(leakyEventCases)( + 'does not warn for $name when fired on the emitting element', + async ({ eventName, ui }) => { + const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); + const handler = jest.fn(); + await render(ui(handler)); + + await fireEvent(screen.getByTestId('emitter'), eventName); + + expect(handler).toHaveBeenCalledTimes(1); + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }, + ); + + test('warns about stopping bubbling in the next major version', async () => { + const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); await render( - + {}}> , ); await fireEvent.scroll(screen.getByTestId('child')); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + " ▲ fireEvent: "scroll" event bubbled to the handler of an ancestor element. React Native does not bubble this event, and fireEvent will stop bubbling it in the next major version. Fire it on the element that has the handler instead. + " + `); + warnSpy.mockRestore(); + }); + + test('warns when handler is on composite component above the emitting element', async () => { + const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); + const onScroll = jest.fn(); + const Screen = (_props: { onScroll: () => void }) => ( + + + + ); + await render(); + + await fireEvent.scroll(screen.getByTestId('child')); + + expect(onScroll).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledTimes(1); + warnSpy.mockRestore(); + }); + + // Known gap: only the type of the element with the handler is checked. + test('does not warn when handler is on an element that does not emit the event', async () => { + const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); + const onScroll = jest.fn(); + const Screen = (_props: { onScroll: () => void }) => ( + + + + + + ); + await render(); + + await fireEvent.scroll(screen.getByTestId('child')); + expect(onScroll).toHaveBeenCalledTimes(1); + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + test('does not warn when bubbling to composite component handler', async () => { + const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); + const onShow = jest.fn(); + const onDismiss = jest.fn(); + const Toast = (_props: { onShow: () => void; onDismiss: () => void }) => ( + + Toast + + ); + await render(); + + await fireEvent(screen.getByText('Toast'), 'show'); + await fireEvent(screen.getByText('Toast'), 'dismiss'); + + expect(onShow).toHaveBeenCalledTimes(1); + expect(onDismiss).toHaveBeenCalledTimes(1); + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); }); }); diff --git a/src/events/propagation.ts b/src/events/propagation.ts index bbfb44d5b..270a58701 100644 --- a/src/events/propagation.ts +++ b/src/events/propagation.ts @@ -3,6 +3,7 @@ import type { Fiber, TestInstance } from 'test-renderer'; import { formatElement } from '../helpers/format-element'; import { isHostImage, + isHostModal, isHostScrollView, isHostText, isHostTextInput, @@ -12,13 +13,7 @@ import { getEventHandlerFromProps, normalizeEventName } from './handler'; import { isEventEnabled, isTouchResponder } from './is-enabled'; import type { EventHandler } from './types'; -/** - * Direct events emitted by specific host components. - * - * Note: these lists are intentionally incomplete. Remaining direct events (e.g. `scroll`, `Modal` - * events, `refresh`, `accessibilityAction`) still bubble, as changing them is a breaking change. - * See `contributing/native-events.md`. - */ +/** Intentionally incomplete, see `*_LEAKY_DIRECT_EVENTS` below. */ const COMMON_DIRECT_EVENTS = ['layout']; const TEXT_DIRECT_EVENTS = ['textLayout']; const TEXT_INPUT_DIRECT_EVENTS = ['selectionChange', 'contentSizeChange']; @@ -60,6 +55,35 @@ export function isDirectEvent(instance: TestInstance, eventName: string) { return false; } +/** + * Direct in React Native, but still bubble (leak) with a warning for backward compatibility. + * Make them direct in the next major version. See `contributing/native-events.md`. + */ +const COMMON_LEAKY_DIRECT_EVENTS = ['accessibilityAction']; +const TEXT_INPUT_LEAKY_DIRECT_EVENTS = ['scroll']; +const SCROLL_VIEW_LEAKY_DIRECT_EVENTS = ['scroll', 'refresh']; +const MODAL_LEAKY_DIRECT_EVENTS = ['requestClose', 'show', 'dismiss', 'orientationChange']; + +function isLeakyDirectEvent(instance: TestInstance, eventName: string) { + if (COMMON_LEAKY_DIRECT_EVENTS.includes(eventName)) { + return true; + } + + if (isHostTextInput(instance)) { + return TEXT_INPUT_LEAKY_DIRECT_EVENTS.includes(eventName); + } + + if (isHostScrollView(instance)) { + return SCROLL_VIEW_LEAKY_DIRECT_EVENTS.includes(eventName); + } + + if (isHostModal(instance)) { + return MODAL_LEAKY_DIRECT_EVENTS.includes(eventName); + } + + return false; +} + type FindEventHandlerResult = { handler: EventHandler | null; /** @@ -81,28 +105,52 @@ export function findEventHandler( instance: TestInstance, eventName: string, ): FindEventHandlerResult { - if (isDirectEvent(instance, normalizeEventName(eventName))) { + const normalizedEventName = normalizeEventName(eventName); + if (isDirectEvent(instance, normalizedEventName)) { const handler = getEventHandlerFromProps(instance.props, eventName, { loose: true }); return { handler: handler ?? null, skippedTargets: [] }; } - return findBubblingEventHandler(instance, eventName, undefined, []); + const { owner, skippedTargets } = findBubblingHandlerOwner(instance, eventName, undefined, []); + if (!owner) { + return { handler: null, skippedTargets }; + } + + // React Native delivers leaky direct events only to elements that emit them, never from children. + if (owner.instance !== instance && isLeakyDirectEvent(owner.instance, normalizedEventName)) { + logger.warn( + `fireEvent: "${eventName}" event bubbled to the handler of an ancestor element. React Native does not bubble this event, and fireEvent will stop bubbling it in the next major version. Fire it on the element that has the handler instead.`, + formatElement(owner.instance), + ); + } + + return { handler: owner.handler, skippedTargets }; } -function findBubblingEventHandler( +type HandlerOwner = { + handler: EventHandler; + instance: TestInstance; +}; + +type FindHandlerOwnerResult = { + owner: HandlerOwner | null; + skippedTargets: TestInstance[]; +}; + +function findBubblingHandlerOwner( instance: TestInstance, eventName: string, nearestTouchResponder: TestInstance | undefined, skippedTargets: TestInstance[], -): FindEventHandlerResult { +): FindHandlerOwnerResult { const touchResponder = isTouchResponder(instance) ? instance : nearestTouchResponder; + const normalizedEventName = normalizeEventName(eventName); const handler = getEventHandlerFromProps(instance.props, eventName, { loose: true }) ?? findEventHandlerFromFiber(instance.unstable_fiber, eventName); - // Direct events emitted by this ancestor never come from its children. - if (isDirectEvent(instance, normalizeEventName(eventName))) { + if (isDirectEvent(instance, normalizedEventName)) { if (handler) { logger.warn( `fireEvent: "${eventName}" event does not bubble, fire it on the element that has the handler instead.`, @@ -110,12 +158,12 @@ function findBubblingEventHandler( ); } - return { handler: null, skippedTargets }; + return { owner: null, skippedTargets }; } if (handler) { - if (isEventEnabled(instance, normalizeEventName(eventName), touchResponder)) { - return { handler, skippedTargets }; + if (isEventEnabled(instance, normalizedEventName, touchResponder)) { + return { owner: { handler, instance }, skippedTargets }; } // Handlers on the same touch responder report it only once. @@ -126,10 +174,10 @@ function findBubblingEventHandler( } if (instance.parent === null) { - return { handler: null, skippedTargets }; + return { owner: null, skippedTargets }; } - return findBubblingEventHandler(instance.parent, eventName, touchResponder, skippedTargets); + return findBubblingHandlerOwner(instance.parent, eventName, touchResponder, skippedTargets); } function findEventHandlerFromFiber(fiber: Fiber | null, eventName: string): EventHandler | null { diff --git a/website/docs/14.x/docs/api/events/fire-event.mdx b/website/docs/14.x/docs/api/events/fire-event.mdx index 2c2863251..1a5063469 100644 --- a/website/docs/14.x/docs/api/events/fire-event.mdx +++ b/website/docs/14.x/docs/api/events/fire-event.mdx @@ -17,13 +17,20 @@ The `fireEvent` API triggers event handlers on both host and composite component Some events are direct in React Native: they are delivered only to the host element that emitted them. When you fire such an event on its emitting element, `fireEvent` only checks that element. When you fire it on a nested element, `fireEvent` stops looking for handlers at the emitting element. These events are: - `layout` on all elements -- `scrollBeginDrag`, `scrollEndDrag`, `momentumScrollBegin`, `momentumScrollEnd` and `contentSizeChange` on `ScrollView` -- `selectionChange` and `contentSizeChange` on `TextInput` - `textLayout` on `Text` +- `selectionChange` and `contentSizeChange` on `TextInput` - `loadStart`, `progress`, `load`, `error` and `loadEnd` on `Image` +- `scrollBeginDrag`, `scrollEndDrag`, `momentumScrollBegin`, `momentumScrollEnd` and `contentSizeChange` on `ScrollView` Events with these names still bubble to other handlers, such as an `onLoad` prop of your own composite component. +These direct events still bubble for backward compatibility, but `fireEvent` logs a warning when they bubble from a nested element to the handler of an ancestor that emits them, e.g. `scroll` from `ScrollView` content to the `ScrollView`. They will stop bubbling in the next major version, so fire them on the element that has the handler: + +- `accessibilityAction` on all elements +- `scroll` on `TextInput` +- `scroll` and `refresh` on `ScrollView` +- `requestClose`, `show`, `dismiss` and `orientationChange` on `Modal` + Unlike User Event, this API does not automatically pass event object to event handler, this is responsibility of the user to construct such object. The base `fireEvent(instance, eventName, ...data)` API can pass multiple custom arguments to the handler. Convenience helpers such as `fireEvent.press` and `fireEvent.scroll` are different: they create a default event object and accept one optional object to merge into it. From b39e22311a2aa1f3465e94c964a1852d32eea407 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Mon, 5 Oct 2026 15:52:26 +0200 Subject: [PATCH 03/16] tweaks --- contributing/native-events.md | 2 +- src/events/__tests__/fire-event.test.tsx | 72 ++++++++++++------------ src/events/propagation.ts | 29 +++++----- 3 files changed, 53 insertions(+), 50 deletions(-) diff --git a/contributing/native-events.md b/contributing/native-events.md index d6f589e30..620a89136 100644 --- a/contributing/native-events.md +++ b/contributing/native-events.md @@ -38,7 +38,7 @@ A direct event fired on its emitting element only checks that element. Fired on These were chosen because tests rarely fire them on a nested element: `TextInput` and `Image` have no children, `Text` queries usually match the `Text` that owns the handler, and the drag and momentum events are usually fired on the `ScrollView` itself. -The other direct events still bubble for backward compatibility (see `isLeakyDirectEvent()`), as tests fire them on nested elements, e.g. `scroll` on `ScrollView` content: +The other direct events still bubble for backward compatibility (see `isDeprecatedBubblingEvent()`), as tests fire them on nested elements, e.g. `scroll` on `ScrollView` content: - `accessibilityAction` on all elements - `TextInput`: `scroll` diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index 3abacb1e5..d41dd61c6 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -20,6 +20,7 @@ import { import { fireEvent, render, screen } from '../..'; import { configure } from '../../config'; import { _console, logger } from '../../helpers/logger'; +import { getEventHandlerName } from '../handler'; import { nativeState } from '../native-state'; const layoutEvent = { nativeEvent: { layout: { width: 100, height: 100 } } }; @@ -660,6 +661,16 @@ describe('fireEvent.layout', () => { }); describe('direct events', () => { + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + const directEventCases: Array<{ eventName: string; ui: (handler: jest.Mock) => React.ReactElement; @@ -706,7 +717,7 @@ describe('direct events', () => { ), })), @@ -727,19 +738,16 @@ describe('direct events', () => { test.each(directEventCases)( 'does not bubble "$eventName" from a nested element to the emitting element', async ({ eventName, ui }) => { - const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); const handler = jest.fn(); await render(React.cloneElement(ui(handler), {}, Nested)); await fireEvent(screen.getByTestId('nested'), eventName); expect(handler).not.toHaveBeenCalled(); - warnSpy.mockRestore(); }, ); test('does not bubble when fired with "on" prefixed event name', async () => { - const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); const onMomentumScrollEnd = jest.fn(); await render( @@ -751,11 +759,9 @@ describe('direct events', () => { expect(onMomentumScrollEnd).not.toHaveBeenCalled(); expect(warnSpy).toHaveBeenCalledTimes(1); - warnSpy.mockRestore(); }); test('warns when direct event would bubble to the emitting element', async () => { - const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); await render( {}}> @@ -771,11 +777,9 @@ describe('direct events', () => { /> " `); - warnSpy.mockRestore(); }); test('stops bubbling at the emitting element', async () => { - const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); const onMomentumScrollEnd = jest.fn(); const Screen = (_props: { onMomentumScrollEnd: () => void }) => ( @@ -790,7 +794,6 @@ describe('direct events', () => { expect(onMomentumScrollEnd).not.toHaveBeenCalled(); expect(warnSpy).not.toHaveBeenCalled(); - warnSpy.mockRestore(); }); test('bubbles event with direct event name to composite component handler', async () => { @@ -838,8 +841,18 @@ describe('direct events', () => { }); }); -describe('leaky direct events', () => { - const leakyEventCases: Array<{ +describe('deprecated bubbling events', () => { + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + const deprecatedBubblingEventCases: Array<{ name: string; eventName: string; ui: (handler: jest.Mock) => React.ReactElement; @@ -892,33 +905,29 @@ describe('leaky direct events', () => { name: `${eventName} from Modal content`, eventName, ui: (handler: jest.Mock) => ( - + Content ), })), ]; - test.each(leakyEventCases)('bubbles $name with a warning', async ({ eventName, ui }) => { - const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); - const handler = jest.fn(); - await render(ui(handler)); + test.each(deprecatedBubblingEventCases)( + 'bubbles $name with a warning', + async ({ eventName, ui }) => { + const handler = jest.fn(); + await render(ui(handler)); - await fireEvent(screen.getByTestId('target'), eventName); + await fireEvent(screen.getByTestId('target'), eventName); - expect(handler).toHaveBeenCalledTimes(1); - expect(warnSpy).toHaveBeenCalledTimes(1); - warnSpy.mockRestore(); - }); + expect(handler).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledTimes(1); + }, + ); - test.each(leakyEventCases)( + test.each(deprecatedBubblingEventCases)( 'does not warn for $name when fired on the emitting element', async ({ eventName, ui }) => { - const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); const handler = jest.fn(); await render(ui(handler)); @@ -926,12 +935,10 @@ describe('leaky direct events', () => { expect(handler).toHaveBeenCalledTimes(1); expect(warnSpy).not.toHaveBeenCalled(); - warnSpy.mockRestore(); }, ); test('warns about stopping bubbling in the next major version', async () => { - const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); await render( {}}> @@ -946,11 +953,9 @@ describe('leaky direct events', () => { /> " `); - warnSpy.mockRestore(); }); test('warns when handler is on composite component above the emitting element', async () => { - const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); const onScroll = jest.fn(); const Screen = (_props: { onScroll: () => void }) => ( @@ -963,12 +968,10 @@ describe('leaky direct events', () => { expect(onScroll).toHaveBeenCalledTimes(1); expect(warnSpy).toHaveBeenCalledTimes(1); - warnSpy.mockRestore(); }); // Known gap: only the type of the element with the handler is checked. test('does not warn when handler is on an element that does not emit the event', async () => { - const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); const onScroll = jest.fn(); const Screen = (_props: { onScroll: () => void }) => ( @@ -983,11 +986,9 @@ describe('leaky direct events', () => { expect(onScroll).toHaveBeenCalledTimes(1); expect(warnSpy).not.toHaveBeenCalled(); - warnSpy.mockRestore(); }); test('does not warn when bubbling to composite component handler', async () => { - const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); const onShow = jest.fn(); const onDismiss = jest.fn(); const Toast = (_props: { onShow: () => void; onDismiss: () => void }) => ( @@ -1003,7 +1004,6 @@ describe('leaky direct events', () => { expect(onShow).toHaveBeenCalledTimes(1); expect(onDismiss).toHaveBeenCalledTimes(1); expect(warnSpy).not.toHaveBeenCalled(); - warnSpy.mockRestore(); }); }); diff --git a/src/events/propagation.ts b/src/events/propagation.ts index 270a58701..0e2295d2a 100644 --- a/src/events/propagation.ts +++ b/src/events/propagation.ts @@ -13,7 +13,7 @@ import { getEventHandlerFromProps, normalizeEventName } from './handler'; import { isEventEnabled, isTouchResponder } from './is-enabled'; import type { EventHandler } from './types'; -/** Intentionally incomplete, see `*_LEAKY_DIRECT_EVENTS` below. */ +/** Intentionally incomplete, see `*_DEPRECATED_BUBBLING_EVENTS` below. */ const COMMON_DIRECT_EVENTS = ['layout']; const TEXT_DIRECT_EVENTS = ['textLayout']; const TEXT_INPUT_DIRECT_EVENTS = ['selectionChange', 'contentSizeChange']; @@ -56,29 +56,29 @@ export function isDirectEvent(instance: TestInstance, eventName: string) { } /** - * Direct in React Native, but still bubble (leak) with a warning for backward compatibility. + * Direct in React Native, but still bubble with a deprecation warning for backward compatibility. * Make them direct in the next major version. See `contributing/native-events.md`. */ -const COMMON_LEAKY_DIRECT_EVENTS = ['accessibilityAction']; -const TEXT_INPUT_LEAKY_DIRECT_EVENTS = ['scroll']; -const SCROLL_VIEW_LEAKY_DIRECT_EVENTS = ['scroll', 'refresh']; -const MODAL_LEAKY_DIRECT_EVENTS = ['requestClose', 'show', 'dismiss', 'orientationChange']; +const COMMON_DEPRECATED_BUBBLING_EVENTS = ['accessibilityAction']; +const TEXT_INPUT_DEPRECATED_BUBBLING_EVENTS = ['scroll']; +const SCROLL_VIEW_DEPRECATED_BUBBLING_EVENTS = ['scroll', 'refresh']; +const MODAL_DEPRECATED_BUBBLING_EVENTS = ['requestClose', 'show', 'dismiss', 'orientationChange']; -function isLeakyDirectEvent(instance: TestInstance, eventName: string) { - if (COMMON_LEAKY_DIRECT_EVENTS.includes(eventName)) { +function isDeprecatedBubblingEvent(instance: TestInstance, eventName: string) { + if (COMMON_DEPRECATED_BUBBLING_EVENTS.includes(eventName)) { return true; } if (isHostTextInput(instance)) { - return TEXT_INPUT_LEAKY_DIRECT_EVENTS.includes(eventName); + return TEXT_INPUT_DEPRECATED_BUBBLING_EVENTS.includes(eventName); } if (isHostScrollView(instance)) { - return SCROLL_VIEW_LEAKY_DIRECT_EVENTS.includes(eventName); + return SCROLL_VIEW_DEPRECATED_BUBBLING_EVENTS.includes(eventName); } if (isHostModal(instance)) { - return MODAL_LEAKY_DIRECT_EVENTS.includes(eventName); + return MODAL_DEPRECATED_BUBBLING_EVENTS.includes(eventName); } return false; @@ -116,8 +116,11 @@ export function findEventHandler( return { handler: null, skippedTargets }; } - // React Native delivers leaky direct events only to elements that emit them, never from children. - if (owner.instance !== instance && isLeakyDirectEvent(owner.instance, normalizedEventName)) { + // React Native delivers these events only to elements that emit them, never from children. + if ( + owner.instance !== instance && + isDeprecatedBubblingEvent(owner.instance, normalizedEventName) + ) { logger.warn( `fireEvent: "${eventName}" event bubbled to the handler of an ancestor element. React Native does not bubble this event, and fireEvent will stop bubbling it in the next major version. Fire it on the element that has the handler instead.`, formatElement(owner.instance), From 5f7706e512670486858dd14ecbc395a5e09380b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Mon, 5 Oct 2026 15:58:43 +0200 Subject: [PATCH 04/16] contentSizeChange as a warning --- CHANGELOG.md | 11 ++++---- contributing/native-events.md | 6 +++-- docs/api/fire-event.md | 4 +-- src/events/__tests__/fire-event.test.tsx | 25 ++++++++++++++++--- src/events/propagation.ts | 3 +-- .../docs/14.x/docs/api/events/fire-event.mdx | 4 +-- 6 files changed, 35 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3ef20b31..315f1468f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,16 +22,15 @@ with v14. - `fireEvent` no longer bubbles events to the host element that emits them directly in React Native: `Text` `textLayout`; `TextInput` `selectionChange` and `contentSizeChange`; `Image` `loadStart`, `progress`, `load`, `error` and `loadEnd`; and `ScrollView` `scrollBeginDrag`, - `scrollEndDrag`, `momentumScrollBegin`, `momentumScrollEnd` and `contentSizeChange`. Fire these - events on the emitting element instead. Handlers with the same name on other components still - receive bubbled events. + `scrollEndDrag`, `momentumScrollBegin` and `momentumScrollEnd`. Fire these events on the emitting + element instead. Handlers with the same name on other components still receive bubbled events. ### Deprecations - `fireEvent` warns when other direct events bubble from a nested element to their emitting - element or past it: `accessibilityAction`; `TextInput` `scroll`; `ScrollView` `scroll` and - `refresh`; and `Modal` `requestClose`, `show`, `dismiss` and `orientationChange`. They will stop - bubbling in the next major version. + element or past it: `accessibilityAction`; `TextInput` `scroll`; `ScrollView` `scroll`, + `refresh` and `contentSizeChange`; and `Modal` `requestClose`, `show`, `dismiss` and + `orientationChange`. They will stop bubbling in the next major version. ## 14.0.0 diff --git a/contributing/native-events.md b/contributing/native-events.md index 620a89136..cdc08b343 100644 --- a/contributing/native-events.md +++ b/contributing/native-events.md @@ -32,7 +32,7 @@ This is simplified. A few events differ between iOS and Android. Check the sourc - `Text`: `textLayout` - `TextInput`: `selectionChange`, `contentSizeChange` - `Image`: `loadStart`, `progress`, `load`, `error`, `loadEnd` -- `ScrollView`: `scrollBeginDrag`, `scrollEndDrag`, `momentumScrollBegin`, `momentumScrollEnd`, `contentSizeChange` +- `ScrollView`: `scrollBeginDrag`, `scrollEndDrag`, `momentumScrollBegin`, `momentumScrollEnd` A direct event fired on its emitting element only checks that element. Fired on a nested element, it bubbles as usual but stops at the first ancestor that emits it, as React Native never delivers it there from a child. Handlers with the same name elsewhere, like an `onLoad` prop of a custom composite component, still receive bubbled events. @@ -42,13 +42,15 @@ The other direct events still bubble for backward compatibility (see `isDeprecat - `accessibilityAction` on all elements - `TextInput`: `scroll` -- `ScrollView`: `scroll`, `refresh` +- `ScrollView`: `scroll`, `refresh`, `contentSizeChange` - `Modal`: `requestClose`, `show`, `dismiss`, `orientationChange` `fireEvent` logs a warning when one of these events bubbles from a nested element to the handler of an ancestor that emits it, e.g. `scroll` from `ScrollView` content to the `ScrollView`'s `onScroll`. Only the type of the element with the handler is checked, so a handler further up on an element that doesn't emit the event gets no warning, although it will stop receiving the event too. In the next major release, move these events to the direct lists and remove the warning. `refresh` is emitted by `RefreshControl`, but the Jest `ScrollView` mock doesn't render the `refreshControl` element. `FlatList` and `SectionList` pass `onRefresh` to the host `ScrollView`, so the rule uses `ScrollView` as the emitting element. +`contentSizeChange` is not a native `ScrollView` event, so the table above doesn't list it. The `ScrollView` component calls `onContentSizeChange` from the `onLayout` of its content view and passes `onContentSizeChange: null` to the host element. The Jest `ScrollView` mock passes the prop to the host element instead, so the rule uses `ScrollView` as the emitting element. `FlatList` and `SectionList` always set this handler, and tests fire the event on list items, so it bubbles with a warning instead of being direct. + ## Sources The table is based on `react-native@0.88.0-rc.1`. Re-check it after React Native upgrades. In `node_modules/react-native`: diff --git a/docs/api/fire-event.md b/docs/api/fire-event.md index 1b2770839..c40361377 100644 --- a/docs/api/fire-event.md +++ b/docs/api/fire-event.md @@ -19,7 +19,7 @@ Some events are direct in React Native: they are delivered only to the host elem - `textLayout` on `Text` - `selectionChange` and `contentSizeChange` on `TextInput` - `loadStart`, `progress`, `load`, `error` and `loadEnd` on `Image` -- `scrollBeginDrag`, `scrollEndDrag`, `momentumScrollBegin`, `momentumScrollEnd` and `contentSizeChange` on `ScrollView` +- `scrollBeginDrag`, `scrollEndDrag`, `momentumScrollBegin` and `momentumScrollEnd` on `ScrollView` Events with these names still bubble to other handlers, such as an `onLoad` prop of your own composite component. @@ -27,7 +27,7 @@ These direct events still bubble for backward compatibility, but `fireEvent` log - `accessibilityAction` on all elements - `scroll` on `TextInput` -- `scroll` and `refresh` on `ScrollView` +- `scroll`, `refresh` and `contentSizeChange` on `ScrollView` - `requestClose`, `show`, `dismiss` and `orientationChange` on `Modal` Unlike User Event, this API does not automatically pass event object to event handler, this is responsibility of the user to construct such object. diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index d41dd61c6..40a9d48c3 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -691,10 +691,6 @@ describe('direct events', () => { eventName: 'momentumScrollEnd', ui: (handler) => , }, - { - eventName: 'contentSizeChange', - ui: (handler) => , - }, { eventName: 'contentSizeChange', ui: (handler) => , @@ -888,6 +884,27 @@ describe('deprecated bubbling events', () => { /> ), }, + { + name: 'contentSizeChange from ScrollView content', + eventName: 'contentSizeChange', + ui: (handler) => ( + + + + ), + }, + { + name: 'contentSizeChange from FlatList item', + eventName: 'contentSizeChange', + ui: (handler) => ( + {item}} + onContentSizeChange={handler} + /> + ), + }, { name: 'accessibilityAction from Pressable content', eventName: 'accessibilityAction', diff --git a/src/events/propagation.ts b/src/events/propagation.ts index 0e2295d2a..103b3af73 100644 --- a/src/events/propagation.ts +++ b/src/events/propagation.ts @@ -23,7 +23,6 @@ const SCROLL_VIEW_DIRECT_EVENTS = [ 'scrollEndDrag', 'momentumScrollBegin', 'momentumScrollEnd', - 'contentSizeChange', ]; /** @@ -61,7 +60,7 @@ export function isDirectEvent(instance: TestInstance, eventName: string) { */ const COMMON_DEPRECATED_BUBBLING_EVENTS = ['accessibilityAction']; const TEXT_INPUT_DEPRECATED_BUBBLING_EVENTS = ['scroll']; -const SCROLL_VIEW_DEPRECATED_BUBBLING_EVENTS = ['scroll', 'refresh']; +const SCROLL_VIEW_DEPRECATED_BUBBLING_EVENTS = ['scroll', 'refresh', 'contentSizeChange']; const MODAL_DEPRECATED_BUBBLING_EVENTS = ['requestClose', 'show', 'dismiss', 'orientationChange']; function isDeprecatedBubblingEvent(instance: TestInstance, eventName: string) { diff --git a/website/docs/14.x/docs/api/events/fire-event.mdx b/website/docs/14.x/docs/api/events/fire-event.mdx index 1a5063469..099497b23 100644 --- a/website/docs/14.x/docs/api/events/fire-event.mdx +++ b/website/docs/14.x/docs/api/events/fire-event.mdx @@ -20,7 +20,7 @@ Some events are direct in React Native: they are delivered only to the host elem - `textLayout` on `Text` - `selectionChange` and `contentSizeChange` on `TextInput` - `loadStart`, `progress`, `load`, `error` and `loadEnd` on `Image` -- `scrollBeginDrag`, `scrollEndDrag`, `momentumScrollBegin`, `momentumScrollEnd` and `contentSizeChange` on `ScrollView` +- `scrollBeginDrag`, `scrollEndDrag`, `momentumScrollBegin` and `momentumScrollEnd` on `ScrollView` Events with these names still bubble to other handlers, such as an `onLoad` prop of your own composite component. @@ -28,7 +28,7 @@ These direct events still bubble for backward compatibility, but `fireEvent` log - `accessibilityAction` on all elements - `scroll` on `TextInput` -- `scroll` and `refresh` on `ScrollView` +- `scroll`, `refresh` and `contentSizeChange` on `ScrollView` - `requestClose`, `show`, `dismiss` and `orientationChange` on `Modal` Unlike User Event, this API does not automatically pass event object to event handler, this is responsibility of the user to construct such object. From 85b2f10222b266b63acc7dd0f3c868661da8fb4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Mon, 5 Oct 2026 17:09:59 +0200 Subject: [PATCH 05/16] warn in all cases, fireEvent.layout only direct option --- CHANGELOG.md | 23 +- contributing/event-dispatch.md | 2 +- contributing/native-events.md | 25 +- docs/api/fire-event.md | 20 +- src/events/__tests__/fire-event.test.tsx | 342 +++++++----------- src/events/fire-event.ts | 20 +- src/events/propagation.ts | 75 ++-- src/events/warnings.ts | 8 - .../docs/14.x/docs/api/events/fire-event.mdx | 20 +- 9 files changed, 197 insertions(+), 338 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 315f1468f..e4f3aa838 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,8 @@ with v14. ### Features - Added `fireEvent.layout()` to simulate the layout engine measuring an element, invoking the - `onLayout` handler with a synthetic layout event. Layout events do not bubble to parent - elements. + `onLayout` handler with a synthetic layout event. Unlike `fireEvent(element, 'layout')`, it + does not bubble to parent elements. - `fireEvent.scroll()` and `userEvent.scrollTo()` use the size from the last layout event on the same `ScrollView` as the default `layoutMeasurement`. - Added `userEvent.accessibilityAction()` to dispatch a named accessibility action to an @@ -17,20 +17,15 @@ with v14. - Added `userEvent.pullToRefresh()` to simulate the pull-to-refresh gesture on a host `ScrollView` element, invoking the `onRefresh` handler of its `refreshControl` prop. -### Fixes - -- `fireEvent` no longer bubbles events to the host element that emits them directly in React - Native: `Text` `textLayout`; `TextInput` `selectionChange` and `contentSizeChange`; `Image` - `loadStart`, `progress`, `load`, `error` and `loadEnd`; and `ScrollView` `scrollBeginDrag`, - `scrollEndDrag`, `momentumScrollBegin` and `momentumScrollEnd`. Fire these events on the emitting - element instead. Handlers with the same name on other components still receive bubbled events. - ### Deprecations -- `fireEvent` warns when other direct events bubble from a nested element to their emitting - element or past it: `accessibilityAction`; `TextInput` `scroll`; `ScrollView` `scroll`, - `refresh` and `contentSizeChange`; and `Modal` `requestClose`, `show`, `dismiss` and - `orientationChange`. They will stop bubbling in the next major version. +- `fireEvent` warns when a direct event bubbles from a nested element to the host element that + emits it, e.g. `scroll` from `ScrollView` content to the `ScrollView`: `layout` and + `accessibilityAction`; `Text` `textLayout`; `TextInput` `scroll`, `selectionChange` and + `contentSizeChange`; `Image` `loadStart`, `progress`, `load`, `error` and `loadEnd`; + `ScrollView` `scroll`, `scrollBeginDrag`, `scrollEndDrag`, `momentumScrollBegin`, + `momentumScrollEnd`, `refresh` and `contentSizeChange`; and `Modal` `requestClose`, `show`, + `dismiss` and `orientationChange`. They will stop bubbling in the next major version. ## 14.0.0 diff --git a/contributing/event-dispatch.md b/contributing/event-dispatch.md index 7a0bb6981..db90c0c6b 100644 --- a/contributing/event-dispatch.md +++ b/contributing/event-dispatch.md @@ -22,7 +22,7 @@ Both are built on the shared event subsystem in `src/events/`, which also holds `src/events/fire-event.ts` is the public API. It calls a single handler for a single event, found with `findEventHandler()` from `src/events/propagation.ts`. The work is in finding the right handler: - It starts at the target and moves up the tree until it finds a handler. It also checks props of composite components, not only host elements. -- Direct events (see [Native event propagation](native-events.md)) only check the target. +- Direct events (see [Native event propagation](native-events.md)) still bubble, with a warning when they reach an ancestor that emits them. `fireEvent.layout()` only checks the target. - It mimics cases where a device would not deliver the event, like `pointerEvents`, a non-editable `TextInput`, or a touch responder that declines. ## `userEvent` diff --git a/contributing/native-events.md b/contributing/native-events.md index cdc08b343..9c40e4ec8 100644 --- a/contributing/native-events.md +++ b/contributing/native-events.md @@ -2,7 +2,7 @@ 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` treats only some of the direct events below as direct. The rest still bubble (see [Known gaps](#known-gaps)). The rules live in `isDirectEvent()` in `src/events/propagation.ts`. +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/propagation.ts`. ## Which events are which @@ -26,30 +26,17 @@ This is simplified. A few events differ between iOS and Android. Check the sourc ## Known gaps -`fireEvent` treats these events as direct, based on the host element type (see `isDirectEvent()`): +`fireEvent` still bubbles the direct events above for backward compatibility, as tests fire them on nested elements, e.g. `scroll` on `ScrollView` content. Making them direct is a breaking change. -- `layout` on all elements -- `Text`: `textLayout` -- `TextInput`: `selectionChange`, `contentSizeChange` -- `Image`: `loadStart`, `progress`, `load`, `error`, `loadEnd` -- `ScrollView`: `scrollBeginDrag`, `scrollEndDrag`, `momentumScrollBegin`, `momentumScrollEnd` +`isDirectEvent()` checks whether an event is direct based on the host element type. `fireEvent` logs a warning when a direct event bubbles from a nested element to the handler of an ancestor that emits it, e.g. `scroll` from `ScrollView` content to the `ScrollView`'s `onScroll`. Handlers with the same name elsewhere, like an `onLoad` prop of a custom composite component, receive bubbled events without a warning. Only the type of the element with the handler is checked, so a handler further up on an element that doesn't emit the event gets no warning, although it will stop receiving the event too. -A direct event fired on its emitting element only checks that element. Fired on a nested element, it bubbles as usual but stops at the first ancestor that emits it, as React Native never delivers it there from a child. Handlers with the same name elsewhere, like an `onLoad` prop of a custom composite component, still receive bubbled events. +`fireEvent.layout()` is the exception: it only checks the handler of the given element, while `fireEvent(element, 'layout')` bubbles with a warning like other direct events. -These were chosen because tests rarely fire them on a nested element: `TextInput` and `Image` have no children, `Text` queries usually match the `Text` that owns the handler, and the drag and momentum events are usually fired on the `ScrollView` itself. - -The other direct events still bubble for backward compatibility (see `isDeprecatedBubblingEvent()`), as tests fire them on nested elements, e.g. `scroll` on `ScrollView` content: - -- `accessibilityAction` on all elements -- `TextInput`: `scroll` -- `ScrollView`: `scroll`, `refresh`, `contentSizeChange` -- `Modal`: `requestClose`, `show`, `dismiss`, `orientationChange` - -`fireEvent` logs a warning when one of these events bubbles from a nested element to the handler of an ancestor that emits it, e.g. `scroll` from `ScrollView` content to the `ScrollView`'s `onScroll`. Only the type of the element with the handler is checked, so a handler further up on an element that doesn't emit the event gets no warning, although it will stop receiving the event too. In the next major release, move these events to the direct lists and remove the warning. +In the next major release, stop bubbling direct events in `fireEvent` and remove the warning. `refresh` is emitted by `RefreshControl`, but the Jest `ScrollView` mock doesn't render the `refreshControl` element. `FlatList` and `SectionList` pass `onRefresh` to the host `ScrollView`, so the rule uses `ScrollView` as the emitting element. -`contentSizeChange` is not a native `ScrollView` event, so the table above doesn't list it. The `ScrollView` component calls `onContentSizeChange` from the `onLayout` of its content view and passes `onContentSizeChange: null` to the host element. The Jest `ScrollView` mock passes the prop to the host element instead, so the rule uses `ScrollView` as the emitting element. `FlatList` and `SectionList` always set this handler, and tests fire the event on list items, so it bubbles with a warning instead of being direct. +`contentSizeChange` is not a native `ScrollView` event, so the table above doesn't list it. The `ScrollView` component calls `onContentSizeChange` from the `onLayout` of its content view and passes `onContentSizeChange: null` to the host element. The Jest `ScrollView` mock passes the prop to the host element instead, so the rule uses `ScrollView` as the emitting element. `FlatList` and `SectionList` always set this handler, and tests fire the event on list items, so making it direct will break more tests than other events. ## Sources diff --git a/docs/api/fire-event.md b/docs/api/fire-event.md index c40361377..08929302d 100644 --- a/docs/api/fire-event.md +++ b/docs/api/fire-event.md @@ -13,23 +13,17 @@ function fireEvent(instance: TestInstance, eventName: string, ...data: unknown[] The `fireEvent` API triggers event handlers on both host and composite components. It traverses the component tree bottom-up from the passed element to find an enabled event handler named `onXxx` where `xxx` is the event name. -Some events are direct in React Native: they are delivered only to the host element that emitted them. When you fire such an event on its emitting element, `fireEvent` only checks that element. When you fire it on a nested element, `fireEvent` stops looking for handlers at the emitting element. These events are: +Some events are direct in React Native: they are delivered only to the host element that emitted them. `fireEvent` still bubbles them for backward compatibility, but logs a warning when they bubble from a nested element to the handler of an ancestor that emits them, e.g. `scroll` from `ScrollView` content to the `ScrollView`. They will stop bubbling in the next major version, so fire them on the element that has the handler. These events are: -- `layout` on all elements +- `layout` and `accessibilityAction` on all elements - `textLayout` on `Text` -- `selectionChange` and `contentSizeChange` on `TextInput` +- `scroll`, `selectionChange` and `contentSizeChange` on `TextInput` - `loadStart`, `progress`, `load`, `error` and `loadEnd` on `Image` -- `scrollBeginDrag`, `scrollEndDrag`, `momentumScrollBegin` and `momentumScrollEnd` on `ScrollView` - -Events with these names still bubble to other handlers, such as an `onLoad` prop of your own composite component. - -These direct events still bubble for backward compatibility, but `fireEvent` logs a warning when they bubble from a nested element to the handler of an ancestor that emits them, e.g. `scroll` from `ScrollView` content to the `ScrollView`. They will stop bubbling in the next major version, so fire them on the element that has the handler: - -- `accessibilityAction` on all elements -- `scroll` on `TextInput` -- `scroll`, `refresh` and `contentSizeChange` on `ScrollView` +- `scroll`, `scrollBeginDrag`, `scrollEndDrag`, `momentumScrollBegin`, `momentumScrollEnd`, `refresh` and `contentSizeChange` on `ScrollView` - `requestClose`, `show`, `dismiss` and `orientationChange` on `Modal` +Events with these names bubble without a warning to other handlers, such as an `onLoad` prop of your own composite component. + Unlike User Event, this API does not automatically pass event object to event handler, this is responsibility of the user to construct such object. The base `fireEvent(instance, eventName, ...data)` API can pass multiple custom arguments to the handler. Convenience helpers such as `fireEvent.press` and `fireEvent.scroll` are different: they create a default event object and accept one optional object to merge into it. @@ -197,7 +191,7 @@ fireEvent.layout: ( Builds a layout event carrying the given `layout` rectangle and invokes the `onLayout` handler of the given element. Use it to simulate the layout engine measuring an element, e.g. to test components that adapt to a measured size. -Layout events do not bubble: React Native delivers them only to the measured element, so the handler is not looked up on parent elements. +Unlike `fireEvent(element, 'layout')`, layout events fired with this helper do not bubble: React Native delivers them only to the measured element, so the handler is not looked up on parent elements. The `layout` values are merged onto a zeroed rectangle (`{ x: 0, y: 0, width: 0, height: 0 }`), so pass only the fields your component reads. diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index 40a9d48c3..786cb7ac8 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -573,7 +573,8 @@ describe('fireEvent.layout', () => { expect(onLayout).not.toHaveBeenCalled(); }); - test('does not bubble when fired as generic layout event', async () => { + test('bubbles with a warning when fired as generic layout event', async () => { + const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); const onLayout = jest.fn(); await render( @@ -584,7 +585,9 @@ describe('fireEvent.layout', () => { await fireEvent(screen.getByTestId('child'), 'layout', layoutEvent); await fireEvent(screen.getByTestId('child'), 'onLayout', layoutEvent); - expect(onLayout).not.toHaveBeenCalled(); + expect(onLayout).toHaveBeenCalledTimes(2); + expect(warnSpy).toHaveBeenCalledTimes(2); + warnSpy.mockRestore(); }); test('does not warn when layout size is saved in native state without onLayout handler', async () => { @@ -607,7 +610,7 @@ describe('fireEvent.layout', () => { expect(warnSpy).toHaveBeenCalledTimes(1); expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - " ▲ No "onLayout" handler found on the element. "layout" events do not bubble to ancestors. + " ▲ No "onLayout" handler found on the element or its ancestors. If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. { }); const directEventCases: Array<{ + name: string; eventName: string; ui: (handler: jest.Mock) => React.ReactElement; }> = [ { - eventName: 'scrollBeginDrag', - ui: (handler) => , - }, - { - eventName: 'scrollEndDrag', - ui: (handler) => , - }, - { - eventName: 'momentumScrollBegin', - ui: (handler) => , - }, - { - eventName: 'momentumScrollEnd', - ui: (handler) => , - }, - { - eventName: 'contentSizeChange', - ui: (handler) => , + name: 'layout from View content', + eventName: 'layout', + ui: (handler) => ( + + + + ), }, { - eventName: 'selectionChange', - ui: (handler) => , + name: 'accessibilityAction from Pressable content', + eventName: 'accessibilityAction', + ui: (handler) => ( + + Button + + ), }, { + name: 'textLayout from nested Text', eventName: 'textLayout', ui: (handler) => ( - - Text + + Nested ), }, - ...(['loadStart', 'progress', 'load', 'error', 'loadEnd'] as const).map((eventName) => ({ + ...(['scroll', 'selectionChange', 'contentSizeChange'] as const).map((eventName) => ({ + name: `${eventName} from TextInput content`, eventName, ui: (handler: jest.Mock) => ( - + + Nested + ), })), - ]; - - test.each(directEventCases)( - 'calls "$eventName" handler on the target element', - async ({ eventName, ui }) => { - const handler = jest.fn(); - await render(ui(handler)); - - await fireEvent(screen.getByTestId('target'), eventName); - - expect(handler).toHaveBeenCalledTimes(1); - }, - ); - - test.each(directEventCases)( - 'does not bubble "$eventName" from a nested element to the emitting element', - async ({ eventName, ui }) => { - const handler = jest.fn(); - await render(React.cloneElement(ui(handler), {}, Nested)); - - await fireEvent(screen.getByTestId('nested'), eventName); - - expect(handler).not.toHaveBeenCalled(); - }, - ); - - test('does not bubble when fired with "on" prefixed event name', async () => { - const onMomentumScrollEnd = jest.fn(); - await render( - - - , - ); - - await fireEvent(screen.getByTestId('child'), 'onMomentumScrollEnd'); - - expect(onMomentumScrollEnd).not.toHaveBeenCalled(); - expect(warnSpy).toHaveBeenCalledTimes(1); - }); - - test('warns when direct event would bubble to the emitting element', async () => { - await render( - {}}> - - , - ); - - await fireEvent(screen.getByTestId('child'), 'momentumScrollEnd'); - - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - " ▲ fireEvent: "momentumScrollEnd" event does not bubble, fire it on the element that has the handler instead. - " - `); - }); - - test('stops bubbling at the emitting element', async () => { - const onMomentumScrollEnd = jest.fn(); - const Screen = (_props: { onMomentumScrollEnd: () => void }) => ( - - - - - - ); - await render(); - - await fireEvent(screen.getByTestId('child'), 'momentumScrollEnd'); - - expect(onMomentumScrollEnd).not.toHaveBeenCalled(); - expect(warnSpy).not.toHaveBeenCalled(); - }); - - test('bubbles event with direct event name to composite component handler', async () => { - const onLoad = jest.fn(); - const onError = jest.fn(); - const Card = (_props: { onLoad: () => void; onError: () => void }) => ( - - Card - - ); - await render(); - - await fireEvent(screen.getByText('Card'), 'load'); - await fireEvent(screen.getByText('Card'), 'error'); - - expect(onLoad).toHaveBeenCalledTimes(1); - expect(onError).toHaveBeenCalledTimes(1); - }); - - test('bubbles event with direct event name to host element that does not emit it', async () => { - const onLoad = jest.fn(); - await render( - // @ts-expect-error View does not have onLoad prop - - Content - , - ); - - await fireEvent(screen.getByText('Content'), 'load'); - - expect(onLoad).toHaveBeenCalledTimes(1); - }); - - test('bubbles load event from ImageBackground children to ImageBackground handler', async () => { - const onLoad = jest.fn(); - await render( - - Caption - , - ); - - await fireEvent(screen.getByText('Caption'), 'load'); - - expect(onLoad).toHaveBeenCalledTimes(1); - }); -}); - -describe('deprecated bubbling events', () => { - let warnSpy: jest.SpyInstance; - - beforeEach(() => { - warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); - }); - - afterEach(() => { - warnSpy.mockRestore(); - }); - - const deprecatedBubblingEventCases: Array<{ - name: string; - eventName: string; - ui: (handler: jest.Mock) => React.ReactElement; - }> = [ - { - name: 'scroll from ScrollView content', - eventName: 'scroll', - ui: (handler) => ( - + ...(['loadStart', 'progress', 'load', 'error', 'loadEnd'] as const).map((eventName) => ({ + name: `${eventName} from Image content`, + eventName, + // Image does not accept children, clone it to fire the event on a nested element. + ui: (handler: jest.Mock) => + React.cloneElement( + , + {}, + Nested, + ), + })), + ...( + [ + 'scroll', + 'scrollBeginDrag', + 'scrollEndDrag', + 'momentumScrollBegin', + 'momentumScrollEnd', + 'contentSizeChange', + ] as const + ).map((eventName) => ({ + name: `${eventName} from ScrollView content`, + eventName, + ui: (handler: jest.Mock) => ( + ), - }, + })), { name: 'scroll from TextInput to ancestor ScrollView', eventName: 'scroll', @@ -884,15 +774,6 @@ describe('deprecated bubbling events', () => { /> ), }, - { - name: 'contentSizeChange from ScrollView content', - eventName: 'contentSizeChange', - ui: (handler) => ( - - - - ), - }, { name: 'contentSizeChange from FlatList item', eventName: 'contentSizeChange', @@ -905,19 +786,6 @@ describe('deprecated bubbling events', () => { /> ), }, - { - name: 'accessibilityAction from Pressable content', - eventName: 'accessibilityAction', - ui: (handler) => ( - - Button - - ), - }, ...(['requestClose', 'show', 'dismiss', 'orientationChange'] as const).map((eventName) => ({ name: `${eventName} from Modal content`, eventName, @@ -929,20 +797,17 @@ describe('deprecated bubbling events', () => { })), ]; - test.each(deprecatedBubblingEventCases)( - 'bubbles $name with a warning', - async ({ eventName, ui }) => { - const handler = jest.fn(); - await render(ui(handler)); + test.each(directEventCases)('bubbles $name with a warning', async ({ eventName, ui }) => { + const handler = jest.fn(); + await render(ui(handler)); - await fireEvent(screen.getByTestId('target'), eventName); + await fireEvent(screen.getByTestId('target'), eventName); - expect(handler).toHaveBeenCalledTimes(1); - expect(warnSpy).toHaveBeenCalledTimes(1); - }, - ); + expect(handler).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); - test.each(deprecatedBubblingEventCases)( + test.each(directEventCases)( 'does not warn for $name when fired on the emitting element', async ({ eventName, ui }) => { const handler = jest.fn(); @@ -955,6 +820,20 @@ describe('deprecated bubbling events', () => { }, ); + test('warns when fired with "on" prefixed event name', async () => { + const onMomentumScrollEnd = jest.fn(); + await render( + + + , + ); + + await fireEvent(screen.getByTestId('child'), 'onMomentumScrollEnd'); + + expect(onMomentumScrollEnd).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + test('warns about stopping bubbling in the next major version', async () => { await render( {}}> @@ -1006,20 +885,49 @@ describe('deprecated bubbling events', () => { }); test('does not warn when bubbling to composite component handler', async () => { + const onLoad = jest.fn(); const onShow = jest.fn(); - const onDismiss = jest.fn(); - const Toast = (_props: { onShow: () => void; onDismiss: () => void }) => ( + const Card = (_props: { onLoad: () => void; onShow: () => void }) => ( - Toast + Card ); - await render(); + await render(); - await fireEvent(screen.getByText('Toast'), 'show'); - await fireEvent(screen.getByText('Toast'), 'dismiss'); + await fireEvent(screen.getByText('Card'), 'load'); + await fireEvent(screen.getByText('Card'), 'show'); + expect(onLoad).toHaveBeenCalledTimes(1); expect(onShow).toHaveBeenCalledTimes(1); - expect(onDismiss).toHaveBeenCalledTimes(1); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test('does not warn when bubbling to host element that does not emit the event', async () => { + const onLoad = jest.fn(); + await render( + // @ts-expect-error View does not have onLoad prop + + Content + , + ); + + await fireEvent(screen.getByText('Content'), 'load'); + + expect(onLoad).toHaveBeenCalledTimes(1); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test('does not warn when bubbling load event to ImageBackground handler', async () => { + const onLoad = jest.fn(); + await render( + + Caption + , + ); + + await fireEvent(screen.getByText('Caption'), 'load'); + + expect(onLoad).toHaveBeenCalledTimes(1); expect(warnSpy).not.toHaveBeenCalled(); }); }); @@ -1652,6 +1560,7 @@ describe('non-editable TextInput', () => { }); test('blocks touch-related events when firing on nested Text child', async () => { + const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); const onFocus = jest.fn(); const onChangeText = jest.fn(); const onSubmitEditing = jest.fn(); @@ -1686,8 +1595,11 @@ describe('non-editable TextInput', () => { expect(onFocus).not.toHaveBeenCalled(); expect(onChangeText).not.toHaveBeenCalled(); expect(onSubmitEditing).not.toHaveBeenCalled(); - // Layout is a direct event, so it does not bubble to the parent TextInput - expect(onLayout).not.toHaveBeenCalled(); + // Layout is a direct event, so it bubbles to the parent TextInput with a warning + expect(onLayout).toHaveBeenCalledTimes(2); + expect(onLayout).toHaveBeenCalledWith(layoutEvent); + expect(warnSpy).toHaveBeenCalledTimes(2); + warnSpy.mockRestore(); }); test.each([ diff --git a/src/events/fire-event.ts b/src/events/fire-event.ts index 9921f66b9..84a3bf417 100644 --- a/src/events/fire-event.ts +++ b/src/events/fire-event.ts @@ -14,6 +14,20 @@ import { updateNativeStateFromEvent } from './update-native-state'; import { warnAboutUnhandledEvent } from './warnings'; async function fireEvent(instance: TestInstance, eventName: EventName, ...data: unknown[]) { + return await fireEventWithOptions(instance, eventName, data); +} + +type FireEventOptions = { + /** Only check the handler of the given element, without bubbling. */ + direct?: boolean; +}; + +async function fireEventWithOptions( + instance: TestInstance, + eventName: EventName, + data: unknown[], + options?: FireEventOptions, +) { if (!isInstanceMounted(instance)) { return; } @@ -25,7 +39,7 @@ async function fireEvent(instance: TestInstance, eventName: EventName, ...data: data[0], ); - const { handler, skippedTargets } = findEventHandler(instance, eventName); + const { handler, skippedTargets } = findEventHandler(instance, eventName, options); if (!handler) { warnAboutUnhandledEvent(instance, eventName, { skippedTargets, hasUpdatedNativeState }); return; @@ -54,8 +68,10 @@ fireEvent.scroll = async (instance: TestInstance, eventProps?: EventProps) => { await fireEvent(instance, 'scroll', mergeEventProps(event, eventProps)); }; +// Unlike `fireEvent(instance, 'layout')`, does not bubble, as React Native delivers layout events +// only to the measured element. fireEvent.layout = async (instance: TestInstance, layout?: Partial) => { - await fireEvent(instance, 'layout', buildLayoutEvent(layout)); + await fireEventWithOptions(instance, 'layout', [buildLayoutEvent(layout)], { direct: true }); }; export { fireEvent }; diff --git a/src/events/propagation.ts b/src/events/propagation.ts index 103b3af73..9a704a204 100644 --- a/src/events/propagation.ts +++ b/src/events/propagation.ts @@ -13,22 +13,28 @@ import { getEventHandlerFromProps, normalizeEventName } from './handler'; import { isEventEnabled, isTouchResponder } from './is-enabled'; import type { EventHandler } from './types'; -/** Intentionally incomplete, see `*_DEPRECATED_BUBBLING_EVENTS` below. */ -const COMMON_DIRECT_EVENTS = ['layout']; +const COMMON_DIRECT_EVENTS = ['layout', 'accessibilityAction']; const TEXT_DIRECT_EVENTS = ['textLayout']; -const TEXT_INPUT_DIRECT_EVENTS = ['selectionChange', 'contentSizeChange']; +const TEXT_INPUT_DIRECT_EVENTS = ['scroll', 'selectionChange', 'contentSizeChange']; const IMAGE_DIRECT_EVENTS = ['loadStart', 'progress', 'load', 'error', 'loadEnd']; const SCROLL_VIEW_DIRECT_EVENTS = [ + 'scroll', 'scrollBeginDrag', 'scrollEndDrag', 'momentumScrollBegin', 'momentumScrollEnd', + 'refresh', + 'contentSizeChange', ]; +const MODAL_DIRECT_EVENTS = ['requestClose', 'show', 'dismiss', 'orientationChange']; /** * Direct events are delivered by React Native only to the host element that emitted them and do * not bubble. Whether an event is direct depends on the host element type, e.g. `load` is direct * for `Image` elements, while custom `onLoad` props of composite components still bubble. + * + * Note: `fireEvent` still bubbles these events for backward compatibility, with a warning. + * Stop bubbling them in the next major version. See `contributing/native-events.md`. */ export function isDirectEvent(instance: TestInstance, eventName: string) { if (COMMON_DIRECT_EVENTS.includes(eventName)) { @@ -51,38 +57,18 @@ export function isDirectEvent(instance: TestInstance, eventName: string) { return SCROLL_VIEW_DIRECT_EVENTS.includes(eventName); } - return false; -} - -/** - * Direct in React Native, but still bubble with a deprecation warning for backward compatibility. - * Make them direct in the next major version. See `contributing/native-events.md`. - */ -const COMMON_DEPRECATED_BUBBLING_EVENTS = ['accessibilityAction']; -const TEXT_INPUT_DEPRECATED_BUBBLING_EVENTS = ['scroll']; -const SCROLL_VIEW_DEPRECATED_BUBBLING_EVENTS = ['scroll', 'refresh', 'contentSizeChange']; -const MODAL_DEPRECATED_BUBBLING_EVENTS = ['requestClose', 'show', 'dismiss', 'orientationChange']; - -function isDeprecatedBubblingEvent(instance: TestInstance, eventName: string) { - if (COMMON_DEPRECATED_BUBBLING_EVENTS.includes(eventName)) { - return true; - } - - if (isHostTextInput(instance)) { - return TEXT_INPUT_DEPRECATED_BUBBLING_EVENTS.includes(eventName); - } - - if (isHostScrollView(instance)) { - return SCROLL_VIEW_DEPRECATED_BUBBLING_EVENTS.includes(eventName); - } - if (isHostModal(instance)) { - return MODAL_DEPRECATED_BUBBLING_EVENTS.includes(eventName); + return MODAL_DIRECT_EVENTS.includes(eventName); } return false; } +type FindEventHandlerOptions = { + /** Only check the handler of the given element, e.g. for `fireEvent.layout`. */ + direct?: boolean; +}; + type FindEventHandlerResult = { handler: EventHandler | null; /** @@ -93,9 +79,8 @@ type FindEventHandlerResult = { }; /** - * Finds the handler that should receive the event, as `fireEvent` does: direct events only - * check the target, other events bubble up the tree until an enabled handler is found. Bubbling - * stops at an ancestor that emits the event as direct, as such events never come from children. + * Finds the handler that should receive the event, as `fireEvent` does: events bubble up the + * tree until an enabled handler is found, unless `direct` option is set. * * Note: handlers are looked up by the event name as passed, while event rules (direct events, * `isEventEnabled`) use the name without the `on*` prefix. @@ -103,9 +88,9 @@ type FindEventHandlerResult = { export function findEventHandler( instance: TestInstance, eventName: string, + options?: FindEventHandlerOptions, ): FindEventHandlerResult { - const normalizedEventName = normalizeEventName(eventName); - if (isDirectEvent(instance, normalizedEventName)) { + if (options?.direct) { const handler = getEventHandlerFromProps(instance.props, eventName, { loose: true }); return { handler: handler ?? null, skippedTargets: [] }; } @@ -115,11 +100,8 @@ export function findEventHandler( return { handler: null, skippedTargets }; } - // React Native delivers these events only to elements that emit them, never from children. - if ( - owner.instance !== instance && - isDeprecatedBubblingEvent(owner.instance, normalizedEventName) - ) { + // React Native delivers direct events only to elements that emit them, never from children. + if (owner.instance !== instance && isDirectEvent(owner.instance, normalizeEventName(eventName))) { logger.warn( `fireEvent: "${eventName}" event bubbled to the handler of an ancestor element. React Native does not bubble this event, and fireEvent will stop bubbling it in the next major version. Fire it on the element that has the handler instead.`, formatElement(owner.instance), @@ -146,25 +128,12 @@ function findBubblingHandlerOwner( skippedTargets: TestInstance[], ): FindHandlerOwnerResult { const touchResponder = isTouchResponder(instance) ? instance : nearestTouchResponder; - const normalizedEventName = normalizeEventName(eventName); const handler = getEventHandlerFromProps(instance.props, eventName, { loose: true }) ?? findEventHandlerFromFiber(instance.unstable_fiber, eventName); - - if (isDirectEvent(instance, normalizedEventName)) { - if (handler) { - logger.warn( - `fireEvent: "${eventName}" event does not bubble, fire it on the element that has the handler instead.`, - formatElement(instance), - ); - } - - return { owner: null, skippedTargets }; - } - if (handler) { - if (isEventEnabled(instance, normalizedEventName, touchResponder)) { + if (isEventEnabled(instance, normalizeEventName(eventName), touchResponder)) { return { owner: { handler, instance }, skippedTargets }; } diff --git a/src/events/warnings.ts b/src/events/warnings.ts index 4dfec464b..e828231d7 100644 --- a/src/events/warnings.ts +++ b/src/events/warnings.ts @@ -9,7 +9,6 @@ import { logger } from '../helpers/logger'; import { isEditableTextInput } from '../helpers/text-input'; import { getEventHandlerName, normalizeEventName } from './handler'; import { getPointerEventsBlocker, isEventBlockableByPointerEvents } from './is-enabled'; -import { isDirectEvent } from './propagation'; type UnhandledEventInfo = { skippedTargets: TestInstance[]; @@ -103,13 +102,6 @@ function getUnhandledEventWarning( } const handlerName = getEventHandlerName(eventName); - if (isDirectEvent(instance, normalizeEventName(eventName))) { - return { - message: `No "${handlerName}" handler found on the element. "${eventName}" events do not bubble to ancestors.`, - elements: [instance], - }; - } - return { message: `No "${handlerName}" handler found on the element or its ancestors.`, elements: [instance], diff --git a/website/docs/14.x/docs/api/events/fire-event.mdx b/website/docs/14.x/docs/api/events/fire-event.mdx index 099497b23..8c05b4338 100644 --- a/website/docs/14.x/docs/api/events/fire-event.mdx +++ b/website/docs/14.x/docs/api/events/fire-event.mdx @@ -14,23 +14,17 @@ function fireEvent(instance: TestInstance, eventName: string, ...data: unknown[] The `fireEvent` API triggers event handlers on both host and composite components. It traverses the component tree bottom-up from the passed element to find an enabled event handler named `onXxx` where `xxx` is the event name. -Some events are direct in React Native: they are delivered only to the host element that emitted them. When you fire such an event on its emitting element, `fireEvent` only checks that element. When you fire it on a nested element, `fireEvent` stops looking for handlers at the emitting element. These events are: +Some events are direct in React Native: they are delivered only to the host element that emitted them. `fireEvent` still bubbles them for backward compatibility, but logs a warning when they bubble from a nested element to the handler of an ancestor that emits them, e.g. `scroll` from `ScrollView` content to the `ScrollView`. They will stop bubbling in the next major version, so fire them on the element that has the handler. These events are: -- `layout` on all elements +- `layout` and `accessibilityAction` on all elements - `textLayout` on `Text` -- `selectionChange` and `contentSizeChange` on `TextInput` +- `scroll`, `selectionChange` and `contentSizeChange` on `TextInput` - `loadStart`, `progress`, `load`, `error` and `loadEnd` on `Image` -- `scrollBeginDrag`, `scrollEndDrag`, `momentumScrollBegin` and `momentumScrollEnd` on `ScrollView` - -Events with these names still bubble to other handlers, such as an `onLoad` prop of your own composite component. - -These direct events still bubble for backward compatibility, but `fireEvent` logs a warning when they bubble from a nested element to the handler of an ancestor that emits them, e.g. `scroll` from `ScrollView` content to the `ScrollView`. They will stop bubbling in the next major version, so fire them on the element that has the handler: - -- `accessibilityAction` on all elements -- `scroll` on `TextInput` -- `scroll`, `refresh` and `contentSizeChange` on `ScrollView` +- `scroll`, `scrollBeginDrag`, `scrollEndDrag`, `momentumScrollBegin`, `momentumScrollEnd`, `refresh` and `contentSizeChange` on `ScrollView` - `requestClose`, `show`, `dismiss` and `orientationChange` on `Modal` +Events with these names bubble without a warning to other handlers, such as an `onLoad` prop of your own composite component. + Unlike User Event, this API does not automatically pass event object to event handler, this is responsibility of the user to construct such object. The base `fireEvent(instance, eventName, ...data)` API can pass multiple custom arguments to the handler. Convenience helpers such as `fireEvent.press` and `fireEvent.scroll` are different: they create a default event object and accept one optional object to merge into it. @@ -203,7 +197,7 @@ fireEvent.layout: ( Builds a layout event carrying the given `layout` rectangle and invokes the `onLayout` handler of the given element. Use it to simulate the layout engine measuring an element, e.g. to test components that adapt to a measured size. -Layout events do not bubble: React Native delivers them only to the measured element, so the handler is not looked up on parent elements. +Unlike `fireEvent(element, 'layout')`, layout events fired with this helper do not bubble: React Native delivers them only to the measured element, so the handler is not looked up on parent elements. The `layout` values are merged onto a zeroed rectangle (`{ x: 0, y: 0, width: 0, height: 0 }`), so pass only the fields your component reads. From 000f035e5112c40bd456f0c23f3930b285e89ee8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Mon, 5 Oct 2026 17:13:30 +0200 Subject: [PATCH 06/16] simplify --- CHANGELOG.md | 10 ++++------ contributing/native-events.md | 8 ++------ src/events/__tests__/fire-event.test.tsx | 2 +- src/events/fire-event.ts | 9 ++------- src/events/propagation.ts | 9 ++++----- 5 files changed, 13 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4f3aa838..083ad6f83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,12 +20,10 @@ with v14. ### Deprecations - `fireEvent` warns when a direct event bubbles from a nested element to the host element that - emits it, e.g. `scroll` from `ScrollView` content to the `ScrollView`: `layout` and - `accessibilityAction`; `Text` `textLayout`; `TextInput` `scroll`, `selectionChange` and - `contentSizeChange`; `Image` `loadStart`, `progress`, `load`, `error` and `loadEnd`; - `ScrollView` `scroll`, `scrollBeginDrag`, `scrollEndDrag`, `momentumScrollBegin`, - `momentumScrollEnd`, `refresh` and `contentSizeChange`; and `Modal` `requestClose`, `show`, - `dismiss` and `orientationChange`. They will stop bubbling in the next major version. + emits it, e.g. `scroll` from `ScrollView` content to the `ScrollView`. These events will stop + bubbling in the next major version. See the + [`fireEvent` docs](./website/docs/14.x/docs/api/events/fire-event.mdx) for the list of direct + events. ## 14.0.0 diff --git a/contributing/native-events.md b/contributing/native-events.md index 9c40e4ec8..56cb477f7 100644 --- a/contributing/native-events.md +++ b/contributing/native-events.md @@ -26,13 +26,9 @@ This is simplified. A few events differ between iOS and Android. Check the sourc ## Known gaps -`fireEvent` still bubbles the direct events above for backward compatibility, as tests fire them on nested elements, e.g. `scroll` on `ScrollView` content. Making them direct is a breaking change. +`fireEvent` still bubbles the direct events above for backward compatibility, as tests fire them on nested elements, e.g. `scroll` on `ScrollView` content. Making them direct is a breaking change, planned for the next major release, which should also remove the warning. -`isDirectEvent()` checks whether an event is direct based on the host element type. `fireEvent` logs a warning when a direct event bubbles from a nested element to the handler of an ancestor that emits it, e.g. `scroll` from `ScrollView` content to the `ScrollView`'s `onScroll`. Handlers with the same name elsewhere, like an `onLoad` prop of a custom composite component, receive bubbled events without a warning. Only the type of the element with the handler is checked, so a handler further up on an element that doesn't emit the event gets no warning, although it will stop receiving the event too. - -`fireEvent.layout()` is the exception: it only checks the handler of the given element, while `fireEvent(element, 'layout')` bubbles with a warning like other direct events. - -In the next major release, stop bubbling direct events in `fireEvent` and remove the warning. +Until then, `fireEvent` logs a warning when a direct event bubbles from a nested element to the handler of an ancestor that emits it, based on the host element type, e.g. `scroll` from `ScrollView` content to the `ScrollView`'s `onScroll`. Handlers with the same name elsewhere, like an `onLoad` prop of a custom composite component, receive bubbled events without a warning. Only the type of the element with the handler is checked, so a handler further up on an element that doesn't emit the event gets no warning, although it will stop receiving the event too. `refresh` is emitted by `RefreshControl`, but the Jest `ScrollView` mock doesn't render the `refreshControl` element. `FlatList` and `SectionList` pass `onRefresh` to the host `ScrollView`, so the rule uses `ScrollView` as the emitting element. diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index 786cb7ac8..a169861b1 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -1595,7 +1595,7 @@ describe('non-editable TextInput', () => { expect(onFocus).not.toHaveBeenCalled(); expect(onChangeText).not.toHaveBeenCalled(); expect(onSubmitEditing).not.toHaveBeenCalled(); - // Layout is a direct event, so it bubbles to the parent TextInput with a warning + // Layout is a direct event, but still bubbles to the parent TextInput with a warning expect(onLayout).toHaveBeenCalledTimes(2); expect(onLayout).toHaveBeenCalledWith(layoutEvent); expect(warnSpy).toHaveBeenCalledTimes(2); diff --git a/src/events/fire-event.ts b/src/events/fire-event.ts index 84a3bf417..989c69e51 100644 --- a/src/events/fire-event.ts +++ b/src/events/fire-event.ts @@ -8,7 +8,7 @@ import { mergeEventProps } from './builders/merge'; import { buildScrollEvent } from './builders/scroll'; import { normalizeEventName } from './handler'; import { nativeState } from './native-state'; -import { findEventHandler } from './propagation'; +import { findEventHandler, type FindEventHandlerOptions } from './propagation'; import type { EventName, EventProps, LayoutRectangle } from './types'; import { updateNativeStateFromEvent } from './update-native-state'; import { warnAboutUnhandledEvent } from './warnings'; @@ -17,16 +17,11 @@ async function fireEvent(instance: TestInstance, eventName: EventName, ...data: return await fireEventWithOptions(instance, eventName, data); } -type FireEventOptions = { - /** Only check the handler of the given element, without bubbling. */ - direct?: boolean; -}; - async function fireEventWithOptions( instance: TestInstance, eventName: EventName, data: unknown[], - options?: FireEventOptions, + options?: FindEventHandlerOptions, ) { if (!isInstanceMounted(instance)) { return; diff --git a/src/events/propagation.ts b/src/events/propagation.ts index 9a704a204..8419164e3 100644 --- a/src/events/propagation.ts +++ b/src/events/propagation.ts @@ -33,10 +33,10 @@ const MODAL_DIRECT_EVENTS = ['requestClose', 'show', 'dismiss', 'orientationChan * not bubble. Whether an event is direct depends on the host element type, e.g. `load` is direct * for `Image` elements, while custom `onLoad` props of composite components still bubble. * - * Note: `fireEvent` still bubbles these events for backward compatibility, with a warning. - * Stop bubbling them in the next major version. See `contributing/native-events.md`. + * `fireEvent` still bubbles these events with a warning, until the next major version. See + * `contributing/native-events.md`. */ -export function isDirectEvent(instance: TestInstance, eventName: string) { +function isDirectEvent(instance: TestInstance, eventName: string) { if (COMMON_DIRECT_EVENTS.includes(eventName)) { return true; } @@ -64,7 +64,7 @@ export function isDirectEvent(instance: TestInstance, eventName: string) { return false; } -type FindEventHandlerOptions = { +export type FindEventHandlerOptions = { /** Only check the handler of the given element, e.g. for `fireEvent.layout`. */ direct?: boolean; }; @@ -100,7 +100,6 @@ export function findEventHandler( return { handler: null, skippedTargets }; } - // React Native delivers direct events only to elements that emit them, never from children. if (owner.instance !== instance && isDirectEvent(owner.instance, normalizeEventName(eventName))) { logger.warn( `fireEvent: "${eventName}" event bubbled to the handler of an ancestor element. React Native does not bubble this event, and fireEvent will stop bubbling it in the next major version. Fire it on the element that has the handler instead.`, From 6a7921c66a5b0484ace5e8e2914aa8a859c1e56e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Wed, 7 Oct 2026 15:09:01 +0200 Subject: [PATCH 07/16] rename eventname => event type --- docs/api/fire-event.md | 4 +- docs/api/user-event.md | 2 +- docs/guides/llm-guidelines.md | 2 +- src/events/__tests__/handler.test.tsx | 16 +++--- src/events/dispatch.ts | 6 +-- src/events/fire-event.ts | 35 ++++++------ src/events/handler.ts | 38 ++++++------- src/events/is-enabled.ts | 14 ++--- src/events/propagation.ts | 54 +++++++++---------- src/events/types.ts | 14 ++--- src/events/update-native-state.ts | 12 ++--- src/events/warnings.ts | 18 +++---- .../docs/14.x/docs/api/events/fire-event.mdx | 4 +- .../docs/14.x/docs/api/events/user-event.mdx | 2 +- .../docs/14.x/docs/guides/llm-guidelines.mdx | 2 +- 15 files changed, 114 insertions(+), 109 deletions(-) diff --git a/docs/api/fire-event.md b/docs/api/fire-event.md index 08929302d..0e69d89d6 100644 --- a/docs/api/fire-event.md +++ b/docs/api/fire-event.md @@ -8,7 +8,7 @@ > Use Fire Event for cases not supported by User Event and for triggering event handlers on composite components. ```ts -function fireEvent(instance: TestInstance, eventName: string, ...data: unknown[]): Promise; +function fireEvent(instance: TestInstance, eventType: string, ...data: unknown[]): Promise; ``` The `fireEvent` API triggers event handlers on both host and composite components. It traverses the component tree bottom-up from the passed element to find an enabled event handler named `onXxx` where `xxx` is the event name. @@ -26,7 +26,7 @@ Events with these names bubble without a warning to other handlers, such as an ` Unlike User Event, this API does not automatically pass event object to event handler, this is responsibility of the user to construct such object. -The base `fireEvent(instance, eventName, ...data)` API can pass multiple custom arguments to the handler. Convenience helpers such as `fireEvent.press` and `fireEvent.scroll` are different: they create a default event object and accept one optional object to merge into it. +The base `fireEvent(instance, eventType, ...data)` API can pass multiple custom arguments to the handler. Convenience helpers such as `fireEvent.press` and `fireEvent.scroll` are different: they create a default event object and accept one optional object to merge into it. This function uses async `act` internally to execute all pending React updates during event handling. diff --git a/docs/api/user-event.md b/docs/api/user-event.md index 7ef87273a..bfee6e2c3 100644 --- a/docs/api/user-event.md +++ b/docs/api/user-event.md @@ -2,7 +2,7 @@ ## Comparison with Fire Event API -Fire Event is our original event simulation API. It can invoke **any event handler** declared on **either host or composite elements**. Suppose the element does not have `onEventName` event handler for the passed `eventName` event, or the element is disabled. In that case, Fire Event will traverse up the component tree, looking for an event handler on both host and composite elements along the way. By default, it will **not pass any event data**, but the user might provide it in the last argument. +Fire Event is our original event simulation API. It can invoke **any event handler** declared on **either host or composite elements**. Suppose the element does not have `onEventName` event handler for the passed `eventType` event, or the element is disabled. In that case, Fire Event will traverse up the component tree, looking for an event handler on both host and composite elements along the way. By default, it will **not pass any event data**, but the user might provide it in the last argument. In contrast, User Event provides realistic event simulation for user interactions like `press` or `type`. Each interaction will trigger a **sequence of events** corresponding to React Native runtime behavior. These events will be invoked **only on host elements**, and **will automatically receive event data** corresponding to each event. diff --git a/docs/guides/llm-guidelines.md b/docs/guides/llm-guidelines.md index 583ffe453..d09155457 100644 --- a/docs/guides/llm-guidelines.md +++ b/docs/guides/llm-guidelines.md @@ -113,7 +113,7 @@ Use only when `userEvent` doesn't support the event or when you need direct cont | Method | Description | | ---------------------------------------- | --------------------------------------------- | -| `fireEvent(element, eventName, ...data)` | Fire any event by name | +| `fireEvent(element, eventType, ...data)` | Fire any event by name | | `fireEvent.press(element)` | Fire `onPress` only (no `pressIn`/`pressOut`) | | `fireEvent.changeText(element, text)` | Fire `onChangeText` directly | | `fireEvent.scroll(element, eventData)` | Fire `onScroll` with event data | diff --git a/src/events/__tests__/handler.test.tsx b/src/events/__tests__/handler.test.tsx index f1712545e..de3574779 100644 --- a/src/events/__tests__/handler.test.tsx +++ b/src/events/__tests__/handler.test.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { Text, View } from 'react-native'; import { render, screen } from '../..'; -import { getEventHandlerFromProps, normalizeEventName } from '../handler'; +import { getEventHandlerFromProps, normalizeEventType } from '../handler'; test('getEventHandlerFromProps strict mode', async () => { const onPress = jest.fn(); @@ -68,11 +68,11 @@ test('getEventHandlerFromProps loose mode', async () => { expect(getEventHandlerFromProps(both.props, 'onPress', { loose: true })).toBe(onPress); }); -test('normalizeEventName strips the `on*` prefix', () => { - expect(normalizeEventName('onLayout')).toBe('layout'); - expect(normalizeEventName('onChangeText')).toBe('changeText'); - expect(normalizeEventName('layout')).toBe('layout'); - expect(normalizeEventName('changeText')).toBe('changeText'); - expect(normalizeEventName('once')).toBe('once'); - expect(normalizeEventName('on')).toBe('on'); +test('normalizeEventType strips the `on*` prefix', () => { + expect(normalizeEventType('onLayout')).toBe('layout'); + expect(normalizeEventType('onChangeText')).toBe('changeText'); + expect(normalizeEventType('layout')).toBe('layout'); + expect(normalizeEventType('changeText')).toBe('changeText'); + expect(normalizeEventType('once')).toBe('once'); + expect(normalizeEventType('on')).toBe('on'); }); diff --git a/src/events/dispatch.ts b/src/events/dispatch.ts index cc8481f7d..400b2c080 100644 --- a/src/events/dispatch.ts +++ b/src/events/dispatch.ts @@ -8,20 +8,20 @@ import { getEventHandlerFromProps } from './handler'; * Basic dispatch event function used by User Event module. * * @param instance instance to trigger event on - * @param eventName name of the event + * @param eventType type of the event * @param event event payload(s) * @returns `true` if a handler was called. */ export async function dispatchEvent( instance: TestInstance, - eventName: string, + eventType: string, ...event: unknown[] ): Promise { if (!isInstanceMounted(instance)) { return false; } - const handler = getEventHandlerFromProps(instance.props, eventName); + const handler = getEventHandlerFromProps(instance.props, eventType); if (!handler) { return false; } diff --git a/src/events/fire-event.ts b/src/events/fire-event.ts index 989c69e51..d9a56a000 100644 --- a/src/events/fire-event.ts +++ b/src/events/fire-event.ts @@ -6,37 +6,38 @@ import { isHostScrollView } from '../helpers/host-component-names'; import { buildLayoutEvent, buildTouchEvent } from './builders/common'; import { mergeEventProps } from './builders/merge'; import { buildScrollEvent } from './builders/scroll'; -import { normalizeEventName } from './handler'; +import { normalizeEventType } from './handler'; import { nativeState } from './native-state'; import { findEventHandler, type FindEventHandlerOptions } from './propagation'; -import type { EventName, EventProps, LayoutRectangle } from './types'; +import type { EventProps, EventType, LayoutRectangle } from './types'; import { updateNativeStateFromEvent } from './update-native-state'; import { warnAboutUnhandledEvent } from './warnings'; -async function fireEvent(instance: TestInstance, eventName: EventName, ...data: unknown[]) { - return await fireEventWithOptions(instance, eventName, data); +async function fireEvent(instance: TestInstance, eventType: EventType, ...data: unknown[]) { + return await fireEventInternal(instance, { type: eventType, data, bubbles: true }); } -async function fireEventWithOptions( - instance: TestInstance, - eventName: EventName, - data: unknown[], - options?: FindEventHandlerOptions, -) { +type FireEventOptions = FindEventHandlerOptions & { + type: EventType; + data: unknown[]; +}; + +async function fireEventInternal(instance: TestInstance, options: FireEventOptions) { + const { type, data } = options; if (!isInstanceMounted(instance)) { return; } - // `fireEvent` accepts event names with and without the `on*` prefix. + // `fireEvent` accepts event types with and without the `on*` prefix. const hasUpdatedNativeState = updateNativeStateFromEvent( instance, - normalizeEventName(eventName), + normalizeEventType(type), data[0], ); - const { handler, skippedTargets } = findEventHandler(instance, eventName, options); + const { handler, skippedTargets } = findEventHandler(instance, type, options); if (!handler) { - warnAboutUnhandledEvent(instance, eventName, { skippedTargets, hasUpdatedNativeState }); + warnAboutUnhandledEvent(instance, type, { skippedTargets, hasUpdatedNativeState }); return; } @@ -66,7 +67,11 @@ fireEvent.scroll = async (instance: TestInstance, eventProps?: EventProps) => { // Unlike `fireEvent(instance, 'layout')`, does not bubble, as React Native delivers layout events // only to the measured element. fireEvent.layout = async (instance: TestInstance, layout?: Partial) => { - await fireEventWithOptions(instance, 'layout', [buildLayoutEvent(layout)], { direct: true }); + await fireEventInternal(instance, { + type: 'layout', + data: [buildLayoutEvent(layout)], + bubbles: false, + }); }; export { fireEvent }; diff --git a/src/events/handler.ts b/src/events/handler.ts index e7835ec8d..8e0088942 100644 --- a/src/events/handler.ts +++ b/src/events/handler.ts @@ -7,52 +7,52 @@ export type EventHandlerOptions = { export function getEventHandlerFromProps( props: Record, - eventName: string, + eventType: string, options?: EventHandlerOptions, ): EventHandler | undefined { - const handlerName = getEventHandlerName(eventName); + const handlerName = getEventHandlerName(eventType); if (typeof props[handlerName] === 'function') { return props[handlerName] as EventHandler; } - if (options?.loose && typeof props[eventName] === 'function') { - return props[eventName] as EventHandler; + if (options?.loose && typeof props[eventType] === 'function') { + return props[eventType] as EventHandler; } if (typeof props[`testOnly_${handlerName}`] === 'function') { return props[`testOnly_${handlerName}`] as EventHandler; } - if (options?.loose && typeof props[`testOnly_${eventName}`] === 'function') { - return props[`testOnly_${eventName}`] as EventHandler; + if (options?.loose && typeof props[`testOnly_${eventType}`] === 'function') { + return props[`testOnly_${eventType}`] as EventHandler; } return undefined; } /** - * Returns the event name without the `on*` prefix, e.g. `onLayout` -> `layout`. - * Note: `fireEvent` accepts event names with and without the prefix, so use this - * before comparing event names. + * Returns the event type without the `on*` prefix, e.g. `onLayout` -> `layout`. + * Note: `fireEvent` accepts event types with and without the prefix, so use this + * before comparing event types. */ -export function normalizeEventName(eventName: string) { - if (hasOnPrefix(eventName)) { - return eventName.charAt(2).toLowerCase() + eventName.slice(3); +export function normalizeEventType(eventType: string) { + if (hasOnPrefix(eventType)) { + return eventType.charAt(2).toLowerCase() + eventType.slice(3); } - return eventName; + return eventType; } -export function getEventHandlerName(eventName: string) { - if (hasOnPrefix(eventName)) { - return eventName; +export function getEventHandlerName(eventType: string) { + if (hasOnPrefix(eventType)) { + return eventType; } - return `on${capitalizeFirstLetter(eventName)}`; + return `on${capitalizeFirstLetter(eventType)}`; } -function hasOnPrefix(eventName: string) { - return /^on[A-Z]/.test(eventName); +function hasOnPrefix(eventType: string) { + return /^on[A-Z]/.test(eventType); } function capitalizeFirstLetter(str: string) { diff --git a/src/events/is-enabled.ts b/src/events/is-enabled.ts index 5d6d220e9..3c5552d32 100644 --- a/src/events/is-enabled.ts +++ b/src/events/is-enabled.ts @@ -33,10 +33,10 @@ export function isTouchResponder(instance: TestInstance) { const eventsAffectedByPointerEventsProp = new Set(['press']); /** - * Expects event name without the `on*` prefix (see `normalizeEventName`). + * Expects event type without the `on*` prefix (see `normalizeEventType`). */ -export function isEventBlockableByPointerEvents(eventName: string): boolean { - return eventsAffectedByPointerEventsProp.has(eventName); +export function isEventBlockableByPointerEvents(eventType: string): boolean { + return eventsAffectedByPointerEventsProp.has(eventType); } /** @@ -47,21 +47,21 @@ const textInputEventsIgnoringEditableProp = new Set(['contentSizeChange', 'layou /** * Checks whether a device would deliver the event to the instance, taking into account * `pointerEvents`, non-editable `TextInput` and touch responders that decline the touch. - * Expects event name without the `on*` prefix (see `normalizeEventName`). + * Expects event type without the `on*` prefix (see `normalizeEventType`). */ export function isEventEnabled( instance: TestInstance, - eventName: string, + eventType: string, nearestTouchResponder?: TestInstance, ) { if (nearestTouchResponder != null && isHostTextInput(nearestTouchResponder)) { return ( isEditableTextInput(nearestTouchResponder) || - textInputEventsIgnoringEditableProp.has(eventName) + textInputEventsIgnoringEditableProp.has(eventType) ); } - if (isEventBlockableByPointerEvents(eventName) && !isPointerEventEnabled(instance)) { + if (isEventBlockableByPointerEvents(eventType) && !isPointerEventEnabled(instance)) { return false; } diff --git a/src/events/propagation.ts b/src/events/propagation.ts index 8419164e3..203d234ef 100644 --- a/src/events/propagation.ts +++ b/src/events/propagation.ts @@ -9,7 +9,7 @@ import { isHostTextInput, } from '../helpers/host-component-names'; import { logger } from '../helpers/logger'; -import { getEventHandlerFromProps, normalizeEventName } from './handler'; +import { getEventHandlerFromProps, normalizeEventType } from './handler'; import { isEventEnabled, isTouchResponder } from './is-enabled'; import type { EventHandler } from './types'; @@ -36,37 +36,37 @@ const MODAL_DIRECT_EVENTS = ['requestClose', 'show', 'dismiss', 'orientationChan * `fireEvent` still bubbles these events with a warning, until the next major version. See * `contributing/native-events.md`. */ -function isDirectEvent(instance: TestInstance, eventName: string) { - if (COMMON_DIRECT_EVENTS.includes(eventName)) { +function isDirectEvent(instance: TestInstance, eventType: string) { + if (COMMON_DIRECT_EVENTS.includes(eventType)) { return true; } if (isHostText(instance)) { - return TEXT_DIRECT_EVENTS.includes(eventName); + return TEXT_DIRECT_EVENTS.includes(eventType); } if (isHostTextInput(instance)) { - return TEXT_INPUT_DIRECT_EVENTS.includes(eventName); + return TEXT_INPUT_DIRECT_EVENTS.includes(eventType); } if (isHostImage(instance)) { - return IMAGE_DIRECT_EVENTS.includes(eventName); + return IMAGE_DIRECT_EVENTS.includes(eventType); } if (isHostScrollView(instance)) { - return SCROLL_VIEW_DIRECT_EVENTS.includes(eventName); + return SCROLL_VIEW_DIRECT_EVENTS.includes(eventType); } if (isHostModal(instance)) { - return MODAL_DIRECT_EVENTS.includes(eventName); + return MODAL_DIRECT_EVENTS.includes(eventType); } return false; } export type FindEventHandlerOptions = { - /** Only check the handler of the given element, e.g. for `fireEvent.layout`. */ - direct?: boolean; + /** When `false`, only checks the handler of the given element, e.g. for `fireEvent.layout`. */ + bubbles: boolean; }; type FindEventHandlerResult = { @@ -80,29 +80,29 @@ type FindEventHandlerResult = { /** * Finds the handler that should receive the event, as `fireEvent` does: events bubble up the - * tree until an enabled handler is found, unless `direct` option is set. + * tree until an enabled handler is found, unless `bubbles` option is `false`. * - * Note: handlers are looked up by the event name as passed, while event rules (direct events, + * Note: handlers are looked up by the event type as passed, while event rules (direct events, * `isEventEnabled`) use the name without the `on*` prefix. */ export function findEventHandler( instance: TestInstance, - eventName: string, - options?: FindEventHandlerOptions, + eventType: string, + options: FindEventHandlerOptions, ): FindEventHandlerResult { - if (options?.direct) { - const handler = getEventHandlerFromProps(instance.props, eventName, { loose: true }); + if (!options.bubbles) { + const handler = getEventHandlerFromProps(instance.props, eventType, { loose: true }); return { handler: handler ?? null, skippedTargets: [] }; } - const { owner, skippedTargets } = findBubblingHandlerOwner(instance, eventName, undefined, []); + const { owner, skippedTargets } = findBubblingHandlerOwner(instance, eventType, undefined, []); if (!owner) { return { handler: null, skippedTargets }; } - if (owner.instance !== instance && isDirectEvent(owner.instance, normalizeEventName(eventName))) { + if (owner.instance !== instance && isDirectEvent(owner.instance, normalizeEventType(eventType))) { logger.warn( - `fireEvent: "${eventName}" event bubbled to the handler of an ancestor element. React Native does not bubble this event, and fireEvent will stop bubbling it in the next major version. Fire it on the element that has the handler instead.`, + `fireEvent: "${eventType}" event bubbled to the handler of an ancestor element. React Native does not bubble this event, and fireEvent will stop bubbling it in the next major version. Fire it on the element that has the handler instead.`, formatElement(owner.instance), ); } @@ -122,17 +122,17 @@ type FindHandlerOwnerResult = { function findBubblingHandlerOwner( instance: TestInstance, - eventName: string, + eventType: string, nearestTouchResponder: TestInstance | undefined, skippedTargets: TestInstance[], ): FindHandlerOwnerResult { const touchResponder = isTouchResponder(instance) ? instance : nearestTouchResponder; const handler = - getEventHandlerFromProps(instance.props, eventName, { loose: true }) ?? - findEventHandlerFromFiber(instance.unstable_fiber, eventName); + getEventHandlerFromProps(instance.props, eventType, { loose: true }) ?? + findEventHandlerFromFiber(instance.unstable_fiber, eventType); if (handler) { - if (isEventEnabled(instance, normalizeEventName(eventName), touchResponder)) { + if (isEventEnabled(instance, normalizeEventType(eventType), touchResponder)) { return { owner: { handler, instance }, skippedTargets }; } @@ -147,16 +147,16 @@ function findBubblingHandlerOwner( return { owner: null, skippedTargets }; } - return findBubblingHandlerOwner(instance.parent, eventName, touchResponder, skippedTargets); + return findBubblingHandlerOwner(instance.parent, eventType, touchResponder, skippedTargets); } -function findEventHandlerFromFiber(fiber: Fiber | null, eventName: string): EventHandler | null { +function findEventHandlerFromFiber(fiber: Fiber | null, eventType: string): EventHandler | null { // Container fibers have memoizedProps set to null if (!fiber?.memoizedProps) { return null; } - const handler = getEventHandlerFromProps(fiber.memoizedProps, eventName, { + const handler = getEventHandlerFromProps(fiber.memoizedProps, eventType, { loose: true, }); if (handler) { @@ -168,5 +168,5 @@ function findEventHandlerFromFiber(fiber: Fiber | null, eventName: string): Even return null; } - return findEventHandlerFromFiber(fiber.return, eventName); + return findEventHandlerFromFiber(fiber.return, eventType); } diff --git a/src/events/types.ts b/src/events/types.ts index bce54d5c6..d01d50f8d 100644 --- a/src/events/types.ts +++ b/src/events/types.ts @@ -13,16 +13,16 @@ export type EventHandler = (...args: unknown[]) => unknown; export type EventProps = Record; // String union type of keys of T that start with on, stripped of 'on' -type EventNameExtractor = keyof { +type EventTypeExtractor = keyof { [K in keyof T as K extends `on${infer Rest}` ? Uncapitalize : never]: T[K]; }; -export type EventName = StringWithAutocomplete< - | EventNameExtractor - | EventNameExtractor - | EventNameExtractor - | EventNameExtractor - | EventNameExtractor +export type EventType = StringWithAutocomplete< + | EventTypeExtractor + | EventTypeExtractor + | EventTypeExtractor + | EventTypeExtractor + | EventTypeExtractor >; /** diff --git a/src/events/update-native-state.ts b/src/events/update-native-state.ts index 236c6a391..c10726ad2 100644 --- a/src/events/update-native-state.ts +++ b/src/events/update-native-state.ts @@ -5,7 +5,7 @@ import { isEditableTextInput } from '../helpers/text-input'; import type { Point, Size } from '../types'; import { nativeState } from './native-state'; -const scrollEventNames = new Set([ +const scrollEventTypes = new Set([ 'scroll', 'scrollBeginDrag', 'scrollEndDrag', @@ -15,21 +15,21 @@ const scrollEventNames = new Set([ /** * Updates native state the way a device would have before emitting the event. - * Expects event name without the `on*` prefix (see `normalizeEventName`). + * Expects event type without the `on*` prefix (see `normalizeEventType`). * * @returns `true` if native state was updated. */ export function updateNativeStateFromEvent( instance: TestInstance, - eventName: string, + eventType: string, value: unknown, ): boolean { - if (eventName === 'changeText' && typeof value === 'string' && isEditableTextInput(instance)) { + if (eventType === 'changeText' && typeof value === 'string' && isEditableTextInput(instance)) { nativeState.valueForInstance.set(instance, value); return true; } - if (scrollEventNames.has(eventName) && isHostScrollView(instance)) { + if (scrollEventTypes.has(eventType) && isHostScrollView(instance)) { const contentOffset = tryGetContentOffset(value); if (contentOffset) { nativeState.contentOffsetForInstance.set(instance, contentOffset); @@ -37,7 +37,7 @@ export function updateNativeStateFromEvent( } } - if (eventName === 'layout') { + if (eventType === 'layout') { const layoutSize = tryGetLayoutSize(value); if (layoutSize) { nativeState.layoutSizeForInstance.set(instance, layoutSize); diff --git a/src/events/warnings.ts b/src/events/warnings.ts index e828231d7..34387dbde 100644 --- a/src/events/warnings.ts +++ b/src/events/warnings.ts @@ -7,7 +7,7 @@ import { formatElement, formatJson } from '../helpers/format-element'; import { isHostTextInput } from '../helpers/host-component-names'; import { logger } from '../helpers/logger'; import { isEditableTextInput } from '../helpers/text-input'; -import { getEventHandlerName, normalizeEventName } from './handler'; +import { getEventHandlerName, normalizeEventType } from './handler'; import { getPointerEventsBlocker, isEventBlockableByPointerEvents } from './is-enabled'; type UnhandledEventInfo = { @@ -27,14 +27,14 @@ export type EventWarning = { */ export function warnAboutUnhandledEvent( instance: TestInstance, - eventName: string, + eventType: string, info: UnhandledEventInfo, ) { if (!getConfig().eventDiagnostics) { return; } - const warning = getUnhandledEventWarning(instance, eventName, info); + const warning = getUnhandledEventWarning(instance, eventType, info); if (warning != null) { logEventWarning(warning); } @@ -92,7 +92,7 @@ export function formatDisabledTargets(targets: TestInstance[]): string { function getUnhandledEventWarning( instance: TestInstance, - eventName: string, + eventType: string, { skippedTargets, hasUpdatedNativeState }: UnhandledEventInfo, ): EventWarning | null { if (skippedTargets.length === 0) { @@ -101,7 +101,7 @@ function getUnhandledEventWarning( return null; } - const handlerName = getEventHandlerName(eventName); + const handlerName = getEventHandlerName(eventType); return { message: `No "${handlerName}" handler found on the element or its ancestors.`, elements: [instance], @@ -109,15 +109,15 @@ function getUnhandledEventWarning( } // `pointerEvents` is checked first: it blocks the event even if the element is enabled. - const blocked = isEventBlockableByPointerEvents(normalizeEventName(eventName)) + const blocked = isEventBlockableByPointerEvents(normalizeEventType(eventType)) ? getPointerEventsBlockedTargets(skippedTargets) : null; if (blocked != null) { return { message: blocked.elements.length === 1 - ? `Cannot fire the "${eventName}" event on an element blocked by pointerEvents.` - : `Cannot fire the "${eventName}" event on elements blocked by pointerEvents.`, + ? `Cannot fire the "${eventType}" event on an element blocked by pointerEvents.` + : `Cannot fire the "${eventType}" event on elements blocked by pointerEvents.`, ...blocked, }; } @@ -130,7 +130,7 @@ function getUnhandledEventWarning( } return { - message: `Cannot fire the "${eventName}" event on ${formatDisabledTargets(disabledTargets)}.`, + message: `Cannot fire the "${eventType}" event on ${formatDisabledTargets(disabledTargets)}.`, elements: disabledTargets, }; } diff --git a/website/docs/14.x/docs/api/events/fire-event.mdx b/website/docs/14.x/docs/api/events/fire-event.mdx index 8c05b4338..f7d939623 100644 --- a/website/docs/14.x/docs/api/events/fire-event.mdx +++ b/website/docs/14.x/docs/api/events/fire-event.mdx @@ -9,7 +9,7 @@ Use Fire Event for cases not supported by User Event and for triggering event ha ::: ```ts -function fireEvent(instance: TestInstance, eventName: string, ...data: unknown[]): Promise; +function fireEvent(instance: TestInstance, eventType: string, ...data: unknown[]): Promise; ``` The `fireEvent` API triggers event handlers on both host and composite components. It traverses the component tree bottom-up from the passed element to find an enabled event handler named `onXxx` where `xxx` is the event name. @@ -27,7 +27,7 @@ Events with these names bubble without a warning to other handlers, such as an ` Unlike User Event, this API does not automatically pass event object to event handler, this is responsibility of the user to construct such object. -The base `fireEvent(instance, eventName, ...data)` API can pass multiple custom arguments to the handler. Convenience helpers such as `fireEvent.press` and `fireEvent.scroll` are different: they create a default event object and accept one optional object to merge into it. +The base `fireEvent(instance, eventType, ...data)` API can pass multiple custom arguments to the handler. Convenience helpers such as `fireEvent.press` and `fireEvent.scroll` are different: they create a default event object and accept one optional object to merge into it. This function uses async `act` internally to execute all pending React updates during event handling. diff --git a/website/docs/14.x/docs/api/events/user-event.mdx b/website/docs/14.x/docs/api/events/user-event.mdx index c5e1886b2..2c7f5fcd6 100644 --- a/website/docs/14.x/docs/api/events/user-event.mdx +++ b/website/docs/14.x/docs/api/events/user-event.mdx @@ -2,7 +2,7 @@ ## Comparison with Fire Event API -Fire Event is our original event simulation API. It can invoke **any event handler** declared on **either host or composite elements**. Suppose the element does not have `onEventName` event handler for the passed `eventName` event, or the element is disabled. In that case, Fire Event will traverse up the component tree, looking for an event handler on both host and composite elements along the way. By default, it will **not pass any event data**, but the user might provide it in the last argument. +Fire Event is our original event simulation API. It can invoke **any event handler** declared on **either host or composite elements**. Suppose the element does not have `onEventName` event handler for the passed `eventType` event, or the element is disabled. In that case, Fire Event will traverse up the component tree, looking for an event handler on both host and composite elements along the way. By default, it will **not pass any event data**, but the user might provide it in the last argument. In contrast, User Event provides realistic event simulation for user interactions like `press` or `type`. Each interaction will trigger a **sequence of events** corresponding to React Native runtime behavior. These events will be invoked **only on host elements**, and **will automatically receive event data** corresponding to each event. diff --git a/website/docs/14.x/docs/guides/llm-guidelines.mdx b/website/docs/14.x/docs/guides/llm-guidelines.mdx index 583ffe453..d09155457 100644 --- a/website/docs/14.x/docs/guides/llm-guidelines.mdx +++ b/website/docs/14.x/docs/guides/llm-guidelines.mdx @@ -113,7 +113,7 @@ Use only when `userEvent` doesn't support the event or when you need direct cont | Method | Description | | ---------------------------------------- | --------------------------------------------- | -| `fireEvent(element, eventName, ...data)` | Fire any event by name | +| `fireEvent(element, eventType, ...data)` | Fire any event by name | | `fireEvent.press(element)` | Fire `onPress` only (no `pressIn`/`pressOut`) | | `fireEvent.changeText(element, text)` | Fire `onChangeText` directly | | `fireEvent.scroll(element, eventData)` | Fire `onScroll` with event data | From f12e919eff572dd82a25ed5faaa07f0b91a352fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Wed, 7 Oct 2026 15:20:37 +0200 Subject: [PATCH 08/16] msg tweaks --- src/events/__tests__/fire-event.test.tsx | 8 +++++--- src/events/propagation.ts | 5 +++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index a169861b1..a43962124 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -844,9 +844,11 @@ describe('direct events', () => { await fireEvent.scroll(screen.getByTestId('child')); expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - " ▲ fireEvent: "scroll" event bubbled to the handler of an ancestor element. React Native does not bubble this event, and fireEvent will stop bubbling it in the next major version. Fire it on the element that has the handler instead. + " ▲ fireEvent: "scroll" does not bubble in React Native. fireEvent will stop bubbling it in the next major version. Fire it on: + + " `); }); diff --git a/src/events/propagation.ts b/src/events/propagation.ts index 203d234ef..02360e0f5 100644 --- a/src/events/propagation.ts +++ b/src/events/propagation.ts @@ -1,3 +1,4 @@ +import redent from 'redent'; import type { Fiber, TestInstance } from 'test-renderer'; import { formatElement } from '../helpers/format-element'; @@ -102,8 +103,8 @@ export function findEventHandler( if (owner.instance !== instance && isDirectEvent(owner.instance, normalizeEventType(eventType))) { logger.warn( - `fireEvent: "${eventType}" event bubbled to the handler of an ancestor element. React Native does not bubble this event, and fireEvent will stop bubbling it in the next major version. Fire it on the element that has the handler instead.`, - formatElement(owner.instance), + `fireEvent: "${eventType}" does not bubble in React Native. fireEvent will stop bubbling it in the next major version. ` + + `Fire it on:\n\n${redent(formatElement(owner.instance), 2)}`, ); } From 28015f08f40169242cb9f2c5fa6655925a161871 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Wed, 7 Oct 2026 15:22:48 +0200 Subject: [PATCH 09/16] tests --- src/events/__tests__/fire-event.test.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index a43962124..3345b2ff7 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -853,6 +853,23 @@ describe('direct events', () => { `); }); + test.each([true, false])( + 'warns about bubbling regardless of eventDiagnostics (%s)', + async (eventDiagnostics) => { + configure({ eventDiagnostics }); + await render( + {}}> + + , + ); + + await fireEvent.scroll(screen.getByTestId('child')); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toContain('"scroll" does not bubble in React Native'); + }, + ); + test('warns when handler is on composite component above the emitting element', async () => { const onScroll = jest.fn(); const Screen = (_props: { onScroll: () => void }) => ( From d6ec2529269374e189c4590c71f9d8f699c0bc5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Wed, 7 Oct 2026 15:29:16 +0200 Subject: [PATCH 10/16] review fixes --- src/events/__tests__/fire-event.test.tsx | 64 ++++++------- src/events/propagation.ts | 111 +++++++++++------------ src/events/warnings.ts | 11 +++ 3 files changed, 96 insertions(+), 90 deletions(-) diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index 3345b2ff7..31170f7a3 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -371,11 +371,11 @@ describe('fireEvent.scroll', () => { ['onScrollEndDrag', 'scrollEndDrag'], ['onMomentumScrollBegin', 'momentumScrollBegin'], ['onMomentumScrollEnd', 'momentumScrollEnd'], - ])('fires %s on ScrollView', async (propName, eventName) => { + ])('fires %s on ScrollView', async (propName, eventType) => { const handler = jest.fn(); await render(); const scrollView = screen.getByTestId('scroll'); - await fireEvent(scrollView, eventName, verticalScrollEvent); + await fireEvent(scrollView, eventType, verticalScrollEvent); expect(handler).toHaveBeenCalledWith(verticalScrollEvent); expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ x: 0, @@ -676,12 +676,12 @@ describe('direct events', () => { const directEventCases: Array<{ name: string; - eventName: string; + eventType: string; ui: (handler: jest.Mock) => React.ReactElement; }> = [ { name: 'layout from View content', - eventName: 'layout', + eventType: 'layout', ui: (handler) => ( @@ -690,7 +690,7 @@ describe('direct events', () => { }, { name: 'accessibilityAction from Pressable content', - eventName: 'accessibilityAction', + eventType: 'accessibilityAction', ui: (handler) => ( { }, { name: 'textLayout from nested Text', - eventName: 'textLayout', + eventType: 'textLayout', ui: (handler) => ( Nested ), }, - ...(['scroll', 'selectionChange', 'contentSizeChange'] as const).map((eventName) => ({ - name: `${eventName} from TextInput content`, - eventName, + ...(['scroll', 'selectionChange', 'contentSizeChange'] as const).map((eventType) => ({ + name: `${eventType} from TextInput content`, + eventType, ui: (handler: jest.Mock) => ( - + Nested ), })), - ...(['loadStart', 'progress', 'load', 'error', 'loadEnd'] as const).map((eventName) => ({ - name: `${eventName} from Image content`, - eventName, + ...(['loadStart', 'progress', 'load', 'error', 'loadEnd'] as const).map((eventType) => ({ + name: `${eventType} from Image content`, + eventType, // Image does not accept children, clone it to fire the event on a nested element. ui: (handler: jest.Mock) => React.cloneElement( , {}, Nested, @@ -743,18 +743,18 @@ describe('direct events', () => { 'momentumScrollEnd', 'contentSizeChange', ] as const - ).map((eventName) => ({ - name: `${eventName} from ScrollView content`, - eventName, + ).map((eventType) => ({ + name: `${eventType} from ScrollView content`, + eventType, ui: (handler: jest.Mock) => ( - + ), })), { name: 'scroll from TextInput to ancestor ScrollView', - eventName: 'scroll', + eventType: 'scroll', ui: (handler) => ( @@ -763,7 +763,7 @@ describe('direct events', () => { }, { name: 'refresh from FlatList item', - eventName: 'refresh', + eventType: 'refresh', ui: (handler) => ( { }, { name: 'contentSizeChange from FlatList item', - eventName: 'contentSizeChange', + eventType: 'contentSizeChange', ui: (handler) => ( { /> ), }, - ...(['requestClose', 'show', 'dismiss', 'orientationChange'] as const).map((eventName) => ({ - name: `${eventName} from Modal content`, - eventName, + ...(['requestClose', 'show', 'dismiss', 'orientationChange'] as const).map((eventType) => ({ + name: `${eventType} from Modal content`, + eventType, ui: (handler: jest.Mock) => ( - + Content ), })), ]; - test.each(directEventCases)('bubbles $name with a warning', async ({ eventName, ui }) => { + test.each(directEventCases)('bubbles $name with a warning', async ({ eventType, ui }) => { const handler = jest.fn(); await render(ui(handler)); - await fireEvent(screen.getByTestId('target'), eventName); + await fireEvent(screen.getByTestId('target'), eventType); expect(handler).toHaveBeenCalledTimes(1); expect(warnSpy).toHaveBeenCalledTimes(1); @@ -809,11 +809,11 @@ describe('direct events', () => { test.each(directEventCases)( 'does not warn for $name when fired on the emitting element', - async ({ eventName, ui }) => { + async ({ eventType, ui }) => { const handler = jest.fn(); await render(ui(handler)); - await fireEvent(screen.getByTestId('emitter'), eventName); + await fireEvent(screen.getByTestId('emitter'), eventType); expect(handler).toHaveBeenCalledTimes(1); expect(warnSpy).not.toHaveBeenCalled(); @@ -1382,14 +1382,14 @@ describe('unhandled event warning', () => { ['focus', 'onFocus'], ['blur', 'onBlur'], ['press', 'onPress'], - ])('warns when "%s" is blocked by non-editable TextInput', async (eventName, handlerName) => { + ])('warns when "%s" is blocked by non-editable TextInput', async (eventType, handlerName) => { await render(); - await fireEvent(screen.getByTestId('input'), eventName); + await fireEvent(screen.getByTestId('input'), eventType); expect(warnSpy).toHaveBeenCalledTimes(1); expect(warnSpy.mock.calls[0][0]).toMatch( - new RegExp(`^Cannot fire the "${eventName}" event on a non-editable TextInput\\.`), + new RegExp(`^Cannot fire the "${eventType}" event on a non-editable TextInput\\.`), ); }); diff --git a/src/events/propagation.ts b/src/events/propagation.ts index 02360e0f5..fb2588dbe 100644 --- a/src/events/propagation.ts +++ b/src/events/propagation.ts @@ -1,7 +1,5 @@ -import redent from 'redent'; import type { Fiber, TestInstance } from 'test-renderer'; -import { formatElement } from '../helpers/format-element'; import { isHostImage, isHostModal, @@ -9,61 +7,10 @@ import { isHostText, isHostTextInput, } from '../helpers/host-component-names'; -import { logger } from '../helpers/logger'; import { getEventHandlerFromProps, normalizeEventType } from './handler'; import { isEventEnabled, isTouchResponder } from './is-enabled'; import type { EventHandler } from './types'; - -const COMMON_DIRECT_EVENTS = ['layout', 'accessibilityAction']; -const TEXT_DIRECT_EVENTS = ['textLayout']; -const TEXT_INPUT_DIRECT_EVENTS = ['scroll', 'selectionChange', 'contentSizeChange']; -const IMAGE_DIRECT_EVENTS = ['loadStart', 'progress', 'load', 'error', 'loadEnd']; -const SCROLL_VIEW_DIRECT_EVENTS = [ - 'scroll', - 'scrollBeginDrag', - 'scrollEndDrag', - 'momentumScrollBegin', - 'momentumScrollEnd', - 'refresh', - 'contentSizeChange', -]; -const MODAL_DIRECT_EVENTS = ['requestClose', 'show', 'dismiss', 'orientationChange']; - -/** - * Direct events are delivered by React Native only to the host element that emitted them and do - * not bubble. Whether an event is direct depends on the host element type, e.g. `load` is direct - * for `Image` elements, while custom `onLoad` props of composite components still bubble. - * - * `fireEvent` still bubbles these events with a warning, until the next major version. See - * `contributing/native-events.md`. - */ -function isDirectEvent(instance: TestInstance, eventType: string) { - if (COMMON_DIRECT_EVENTS.includes(eventType)) { - return true; - } - - if (isHostText(instance)) { - return TEXT_DIRECT_EVENTS.includes(eventType); - } - - if (isHostTextInput(instance)) { - return TEXT_INPUT_DIRECT_EVENTS.includes(eventType); - } - - if (isHostImage(instance)) { - return IMAGE_DIRECT_EVENTS.includes(eventType); - } - - if (isHostScrollView(instance)) { - return SCROLL_VIEW_DIRECT_EVENTS.includes(eventType); - } - - if (isHostModal(instance)) { - return MODAL_DIRECT_EVENTS.includes(eventType); - } - - return false; -} +import { warnAboutBubblingDirectEvent } from './warnings'; export type FindEventHandlerOptions = { /** When `false`, only checks the handler of the given element, e.g. for `fireEvent.layout`. */ @@ -102,10 +49,7 @@ export function findEventHandler( } if (owner.instance !== instance && isDirectEvent(owner.instance, normalizeEventType(eventType))) { - logger.warn( - `fireEvent: "${eventType}" does not bubble in React Native. fireEvent will stop bubbling it in the next major version. ` + - `Fire it on:\n\n${redent(formatElement(owner.instance), 2)}`, - ); + warnAboutBubblingDirectEvent(eventType, owner.instance); } return { handler: owner.handler, skippedTargets }; @@ -151,6 +95,57 @@ function findBubblingHandlerOwner( return findBubblingHandlerOwner(instance.parent, eventType, touchResponder, skippedTargets); } +const COMMON_DIRECT_EVENTS = ['layout', 'accessibilityAction']; +const TEXT_DIRECT_EVENTS = ['textLayout']; +const TEXT_INPUT_DIRECT_EVENTS = ['scroll', 'selectionChange', 'contentSizeChange']; +const IMAGE_DIRECT_EVENTS = ['loadStart', 'progress', 'load', 'error', 'loadEnd']; +const SCROLL_VIEW_DIRECT_EVENTS = [ + 'scroll', + 'scrollBeginDrag', + 'scrollEndDrag', + 'momentumScrollBegin', + 'momentumScrollEnd', + 'refresh', + 'contentSizeChange', +]; +const MODAL_DIRECT_EVENTS = ['requestClose', 'show', 'dismiss', 'orientationChange']; + +/** + * Direct events are delivered by React Native only to the host element that emitted them and do + * not bubble. Whether an event is direct depends on the host element type, e.g. `load` is direct + * for `Image` elements, while custom `onLoad` props of composite components still bubble. + * + * `fireEvent` still bubbles these events with a warning, until the next major version. See + * `contributing/native-events.md`. + */ +function isDirectEvent(instance: TestInstance, eventType: string) { + if (COMMON_DIRECT_EVENTS.includes(eventType)) { + return true; + } + + if (isHostText(instance)) { + return TEXT_DIRECT_EVENTS.includes(eventType); + } + + if (isHostTextInput(instance)) { + return TEXT_INPUT_DIRECT_EVENTS.includes(eventType); + } + + if (isHostImage(instance)) { + return IMAGE_DIRECT_EVENTS.includes(eventType); + } + + if (isHostScrollView(instance)) { + return SCROLL_VIEW_DIRECT_EVENTS.includes(eventType); + } + + if (isHostModal(instance)) { + return MODAL_DIRECT_EVENTS.includes(eventType); + } + + return false; +} + function findEventHandlerFromFiber(fiber: Fiber | null, eventType: string): EventHandler | null { // Container fibers have memoizedProps set to null if (!fiber?.memoizedProps) { diff --git a/src/events/warnings.ts b/src/events/warnings.ts index 34387dbde..14e758618 100644 --- a/src/events/warnings.ts +++ b/src/events/warnings.ts @@ -40,6 +40,17 @@ export function warnAboutUnhandledEvent( } } +/** + * Warns when `fireEvent` bubbles a direct event from the fired element up to `owner`. React Native + * delivers direct events only to the emitting element, so the event should be fired on `owner`. + */ +export function warnAboutBubblingDirectEvent(eventType: string, owner: TestInstance) { + logger.warn( + `fireEvent: "${eventType}" does not bubble in React Native. fireEvent will stop bubbling it in the next major version. ` + + `Fire it on:\n\n${redent(formatElement(owner), 2)}`, + ); +} + export function logEventWarning({ message, elements, pointerEventsBlockers = [] }: EventWarning) { const header = `${message}\n` + From 6604bd846450b0313db5c741806f6a4640a46521 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Wed, 7 Oct 2026 15:35:29 +0200 Subject: [PATCH 11/16] more events --- contributing/native-events.md | 22 +++++----- docs/api/fire-event.md | 6 +-- src/events/__tests__/fire-event.test.tsx | 42 ++++++++++++------- src/events/propagation.ts | 11 ++++- .../docs/14.x/docs/api/events/fire-event.mdx | 6 +-- 5 files changed, 54 insertions(+), 33 deletions(-) diff --git a/contributing/native-events.md b/contributing/native-events.md index 56cb477f7..4d5ab48b5 100644 --- a/contributing/native-events.md +++ b/contributing/native-events.md @@ -12,15 +12,15 @@ There is no simple rule for which events bubble. Coming from user input doesn't **Direct:** -| Component | Direct events | -| ---------------- | ---------------------------------------------------------------------------------------- | -| All components | `layout`, accessibility actions | -| `ScrollView` | `scroll`, `scrollBeginDrag`, `scrollEndDrag`, `momentumScrollBegin`, `momentumScrollEnd` | -| `TextInput` | `scroll`, `selectionChange`, `contentSizeChange` | -| `Text` | `textLayout` | -| `Image` | `loadStart`, `progress`, `load`, `error`, `loadEnd` | -| `Modal` | `requestClose`, `show`, `dismiss`, `orientationChange` | -| `RefreshControl` | `refresh` | +| Component | Direct events | +| ---------------- | ------------------------------------------------------------------------------------------------------- | +| All components | `layout`, `accessibilityAction`, `accessibilityTap`, `magicTap`, `accessibilityEscape` | +| `ScrollView` | `scroll`, `scrollBeginDrag`, `scrollEndDrag`, `momentumScrollBegin`, `momentumScrollEnd`, `scrollToTop` | +| `TextInput` | `scroll`, `selectionChange`, `contentSizeChange` | +| `Text` | `textLayout` | +| `Image` | `loadStart`, `progress`, `partialLoad`, `load`, `error`, `loadEnd` | +| `Modal` | `requestClose`, `show`, `dismiss`, `orientationChange` | +| `RefreshControl` | `refresh` | This is simplified. A few events differ between iOS and Android. Check the sources below for the exact details. @@ -30,10 +30,12 @@ This is simplified. A few events differ between iOS and Android. Check the sourc Until then, `fireEvent` logs a warning when a direct event bubbles from a nested element to the handler of an ancestor that emits it, based on the host element type, e.g. `scroll` from `ScrollView` content to the `ScrollView`'s `onScroll`. Handlers with the same name elsewhere, like an `onLoad` prop of a custom composite component, receive bubbled events without a warning. Only the type of the element with the handler is checked, so a handler further up on an element that doesn't emit the event gets no warning, although it will stop receiving the event too. -`refresh` is emitted by `RefreshControl`, but the Jest `ScrollView` mock doesn't render the `refreshControl` element. `FlatList` and `SectionList` pass `onRefresh` to the host `ScrollView`, so the rule uses `ScrollView` as the emitting element. +`refresh` is emitted by `RefreshControl`. The Jest `ScrollView` mock renders the `refreshControl` element next to the content view, not around it, so it is never an ancestor of list items. `FlatList` and `SectionList` also pass `onRefresh` to the host `ScrollView`, so the rule uses `ScrollView` as the emitting element. `contentSizeChange` is not a native `ScrollView` event, so the table above doesn't list it. The `ScrollView` component calls `onContentSizeChange` from the `onLayout` of its content view and passes `onContentSizeChange: null` to the host element. The Jest `ScrollView` mock passes the prop to the host element instead, so the rule uses `ScrollView` as the emitting element. `FlatList` and `SectionList` always set this handler, and tests fire the event on list items, so making it direct will break more tests than other events. +Both rules depend on the Jest mock. The `FlatList` cases in `src/events/__tests__/fire-event.test.tsx` cover both, so a mock change that moves these handlers fails them. + ## Sources The table is based on `react-native@0.88.0-rc.1`. Re-check it after React Native upgrades. In `node_modules/react-native`: diff --git a/docs/api/fire-event.md b/docs/api/fire-event.md index 0e69d89d6..8c0def247 100644 --- a/docs/api/fire-event.md +++ b/docs/api/fire-event.md @@ -15,11 +15,11 @@ The `fireEvent` API triggers event handlers on both host and composite component Some events are direct in React Native: they are delivered only to the host element that emitted them. `fireEvent` still bubbles them for backward compatibility, but logs a warning when they bubble from a nested element to the handler of an ancestor that emits them, e.g. `scroll` from `ScrollView` content to the `ScrollView`. They will stop bubbling in the next major version, so fire them on the element that has the handler. These events are: -- `layout` and `accessibilityAction` on all elements +- `layout`, `accessibilityAction`, `accessibilityTap`, `magicTap` and `accessibilityEscape` on all elements - `textLayout` on `Text` - `scroll`, `selectionChange` and `contentSizeChange` on `TextInput` -- `loadStart`, `progress`, `load`, `error` and `loadEnd` on `Image` -- `scroll`, `scrollBeginDrag`, `scrollEndDrag`, `momentumScrollBegin`, `momentumScrollEnd`, `refresh` and `contentSizeChange` on `ScrollView` +- `loadStart`, `progress`, `partialLoad`, `load`, `error` and `loadEnd` on `Image` +- `scroll`, `scrollBeginDrag`, `scrollEndDrag`, `momentumScrollBegin`, `momentumScrollEnd`, `scrollToTop`, `refresh` and `contentSizeChange` on `ScrollView` - `requestClose`, `show`, `dismiss` and `orientationChange` on `Modal` Events with these names bubble without a warning to other handlers, such as an `onLoad` prop of your own composite component. diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index 31170f7a3..61a922d55 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -701,6 +701,15 @@ describe('direct events', () => { ), }, + ...(['accessibilityTap', 'magicTap', 'accessibilityEscape'] as const).map((eventType) => ({ + name: `${eventType} from View content`, + eventType, + ui: (handler: jest.Mock) => ( + + + + ), + })), { name: 'textLayout from nested Text', eventType: 'textLayout', @@ -719,21 +728,23 @@ describe('direct events', () => { ), })), - ...(['loadStart', 'progress', 'load', 'error', 'loadEnd'] as const).map((eventType) => ({ - name: `${eventType} from Image content`, - eventType, - // Image does not accept children, clone it to fire the event on a nested element. - ui: (handler: jest.Mock) => - React.cloneElement( - , - {}, - Nested, - ), - })), + ...(['loadStart', 'progress', 'partialLoad', 'load', 'error', 'loadEnd'] as const).map( + (eventType) => ({ + name: `${eventType} from Image content`, + eventType, + // Image does not accept children, clone it to fire the event on a nested element. + ui: (handler: jest.Mock) => + React.cloneElement( + , + {}, + Nested, + ), + }), + ), ...( [ 'scroll', @@ -741,6 +752,7 @@ describe('direct events', () => { 'scrollEndDrag', 'momentumScrollBegin', 'momentumScrollEnd', + 'scrollToTop', 'contentSizeChange', ] as const ).map((eventType) => ({ diff --git a/src/events/propagation.ts b/src/events/propagation.ts index fb2588dbe..34396a31e 100644 --- a/src/events/propagation.ts +++ b/src/events/propagation.ts @@ -95,16 +95,23 @@ function findBubblingHandlerOwner( return findBubblingHandlerOwner(instance.parent, eventType, touchResponder, skippedTargets); } -const COMMON_DIRECT_EVENTS = ['layout', 'accessibilityAction']; +const COMMON_DIRECT_EVENTS = [ + 'layout', + 'accessibilityAction', + 'accessibilityTap', + 'magicTap', + 'accessibilityEscape', +]; const TEXT_DIRECT_EVENTS = ['textLayout']; const TEXT_INPUT_DIRECT_EVENTS = ['scroll', 'selectionChange', 'contentSizeChange']; -const IMAGE_DIRECT_EVENTS = ['loadStart', 'progress', 'load', 'error', 'loadEnd']; +const IMAGE_DIRECT_EVENTS = ['loadStart', 'progress', 'partialLoad', 'load', 'error', 'loadEnd']; const SCROLL_VIEW_DIRECT_EVENTS = [ 'scroll', 'scrollBeginDrag', 'scrollEndDrag', 'momentumScrollBegin', 'momentumScrollEnd', + 'scrollToTop', 'refresh', 'contentSizeChange', ]; diff --git a/website/docs/14.x/docs/api/events/fire-event.mdx b/website/docs/14.x/docs/api/events/fire-event.mdx index f7d939623..e5dc43c1a 100644 --- a/website/docs/14.x/docs/api/events/fire-event.mdx +++ b/website/docs/14.x/docs/api/events/fire-event.mdx @@ -16,11 +16,11 @@ The `fireEvent` API triggers event handlers on both host and composite component Some events are direct in React Native: they are delivered only to the host element that emitted them. `fireEvent` still bubbles them for backward compatibility, but logs a warning when they bubble from a nested element to the handler of an ancestor that emits them, e.g. `scroll` from `ScrollView` content to the `ScrollView`. They will stop bubbling in the next major version, so fire them on the element that has the handler. These events are: -- `layout` and `accessibilityAction` on all elements +- `layout`, `accessibilityAction`, `accessibilityTap`, `magicTap` and `accessibilityEscape` on all elements - `textLayout` on `Text` - `scroll`, `selectionChange` and `contentSizeChange` on `TextInput` -- `loadStart`, `progress`, `load`, `error` and `loadEnd` on `Image` -- `scroll`, `scrollBeginDrag`, `scrollEndDrag`, `momentumScrollBegin`, `momentumScrollEnd`, `refresh` and `contentSizeChange` on `ScrollView` +- `loadStart`, `progress`, `partialLoad`, `load`, `error` and `loadEnd` on `Image` +- `scroll`, `scrollBeginDrag`, `scrollEndDrag`, `momentumScrollBegin`, `momentumScrollEnd`, `scrollToTop`, `refresh` and `contentSizeChange` on `ScrollView` - `requestClose`, `show`, `dismiss` and `orientationChange` on `Modal` Events with these names bubble without a warning to other handlers, such as an `onLoad` prop of your own composite component. From 58a474d84e4fdfcf670e32c6244383094880280c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Wed, 7 Oct 2026 15:43:39 +0200 Subject: [PATCH 12/16] edge case --- src/events/__tests__/fire-event.test.tsx | 26 ++++++++++++++++++++++++ src/events/builders/common.ts | 8 +++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index 61a922d55..3467232ff 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -560,6 +560,32 @@ describe('fireEvent.layout', () => { }); }); + test('uses zero for layout fields passed as undefined', async () => { + configure({ eventDiagnostics: true }); + const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); + const onLayout = jest.fn(); + await render( + + + + , + ); + const withoutHandler = screen.getByTestId('without-handler'); + + await fireEvent.layout(screen.getByTestId('with-handler'), { x: undefined, width: undefined }); + await fireEvent.layout(withoutHandler, { width: undefined, height: undefined }); + + expect(onLayout.mock.calls[0][0].nativeEvent.layout).toEqual({ + x: 0, + y: 0, + width: 0, + height: 0, + }); + expect(nativeState.layoutSizeForInstance.get(withoutHandler)).toEqual({ width: 0, height: 0 }); + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + test('does not bubble to the handler on an ancestor element', async () => { const onLayout = jest.fn(); await render( diff --git a/src/events/builders/common.ts b/src/events/builders/common.ts index 5c9628f37..64f961129 100644 --- a/src/events/builders/common.ts +++ b/src/events/builders/common.ts @@ -95,7 +95,13 @@ export function buildLayoutEvent(layout?: Partial) { return { ...baseSyntheticEvent(), nativeEvent: { - layout: { x: 0, y: 0, width: 0, height: 0, ...layout }, + // `??` rather than spread, so that explicit `undefined` values fall back to `0`. + layout: { + x: layout?.x ?? 0, + y: layout?.y ?? 0, + width: layout?.width ?? 0, + height: layout?.height ?? 0, + }, target: 0, }, }; From d5396ababd12aa7e67e0140861ea1ec285ad3558 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Wed, 7 Oct 2026 16:50:46 +0200 Subject: [PATCH 13/16] align --- src/events/is-enabled.ts | 8 ++++---- src/events/update-native-state.ts | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/events/is-enabled.ts b/src/events/is-enabled.ts index 3c5552d32..0aef969a9 100644 --- a/src/events/is-enabled.ts +++ b/src/events/is-enabled.ts @@ -30,19 +30,19 @@ export function isTouchResponder(instance: TestInstance) { /** * List of events affected by `pointerEvents` prop. */ -const eventsAffectedByPointerEventsProp = new Set(['press']); +const EVENTS_AFFECTED_BY_POINTER_EVENTS = ['press']; /** * Expects event type without the `on*` prefix (see `normalizeEventType`). */ export function isEventBlockableByPointerEvents(eventType: string): boolean { - return eventsAffectedByPointerEventsProp.has(eventType); + return EVENTS_AFFECTED_BY_POINTER_EVENTS.includes(eventType); } /** * List of `TextInput` events not affected by `editable` prop. */ -const textInputEventsIgnoringEditableProp = new Set(['contentSizeChange', 'layout', 'scroll']); +const TEXT_INPUT_EVENTS_IGNORING_EDITABLE = ['contentSizeChange', 'layout', 'scroll']; /** * Checks whether a device would deliver the event to the instance, taking into account @@ -57,7 +57,7 @@ export function isEventEnabled( if (nearestTouchResponder != null && isHostTextInput(nearestTouchResponder)) { return ( isEditableTextInput(nearestTouchResponder) || - textInputEventsIgnoringEditableProp.has(eventType) + TEXT_INPUT_EVENTS_IGNORING_EDITABLE.includes(eventType) ); } diff --git a/src/events/update-native-state.ts b/src/events/update-native-state.ts index c10726ad2..739905380 100644 --- a/src/events/update-native-state.ts +++ b/src/events/update-native-state.ts @@ -5,13 +5,13 @@ import { isEditableTextInput } from '../helpers/text-input'; import type { Point, Size } from '../types'; import { nativeState } from './native-state'; -const scrollEventTypes = new Set([ +const SCROLL_EVENTS = [ 'scroll', 'scrollBeginDrag', 'scrollEndDrag', 'momentumScrollBegin', 'momentumScrollEnd', -]); +]; /** * Updates native state the way a device would have before emitting the event. @@ -29,7 +29,7 @@ export function updateNativeStateFromEvent( return true; } - if (scrollEventTypes.has(eventType) && isHostScrollView(instance)) { + if (SCROLL_EVENTS.includes(eventType) && isHostScrollView(instance)) { const contentOffset = tryGetContentOffset(value); if (contentOffset) { nativeState.contentOffsetForInstance.set(instance, contentOffset); From b014c39947f38224a550ad3617fde47aedec1701 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Wed, 7 Oct 2026 16:53:57 +0200 Subject: [PATCH 14/16] tweaks --- src/events/fire-event.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/events/fire-event.ts b/src/events/fire-event.ts index d9a56a000..ebb5526dc 100644 --- a/src/events/fire-event.ts +++ b/src/events/fire-event.ts @@ -8,7 +8,7 @@ import { mergeEventProps } from './builders/merge'; import { buildScrollEvent } from './builders/scroll'; import { normalizeEventType } from './handler'; import { nativeState } from './native-state'; -import { findEventHandler, type FindEventHandlerOptions } from './propagation'; +import { findEventHandler } from './propagation'; import type { EventProps, EventType, LayoutRectangle } from './types'; import { updateNativeStateFromEvent } from './update-native-state'; import { warnAboutUnhandledEvent } from './warnings'; @@ -17,13 +17,14 @@ async function fireEvent(instance: TestInstance, eventType: EventType, ...data: return await fireEventInternal(instance, { type: eventType, data, bubbles: true }); } -type FireEventOptions = FindEventHandlerOptions & { +type FireEventOptions = { type: EventType; data: unknown[]; + bubbles: boolean; }; async function fireEventInternal(instance: TestInstance, options: FireEventOptions) { - const { type, data } = options; + const { type, data, bubbles } = options; if (!isInstanceMounted(instance)) { return; } @@ -35,7 +36,7 @@ async function fireEventInternal(instance: TestInstance, options: FireEventOptio data[0], ); - const { handler, skippedTargets } = findEventHandler(instance, type, options); + const { handler, skippedTargets } = findEventHandler(instance, type, { bubbles }); if (!handler) { warnAboutUnhandledEvent(instance, type, { skippedTargets, hasUpdatedNativeState }); return; From b4e0275f2af35ea45c71a3390a1d12d768580396 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Wed, 7 Oct 2026 16:55:29 +0200 Subject: [PATCH 15/16] fixes --- src/events/__tests__/fire-event.test.tsx | 99 +++++++++++++----------- src/events/fire-event.ts | 50 ++++++------ 2 files changed, 80 insertions(+), 69 deletions(-) diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index 3467232ff..41260592c 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -700,11 +700,13 @@ describe('direct events', () => { warnSpy.mockRestore(); }); - const directEventCases: Array<{ + type DirectEventCase = { name: string; eventType: string; ui: (handler: jest.Mock) => React.ReactElement; - }> = [ + }; + + const directEventCases: DirectEventCase[] = [ { name: 'layout from View content', eventType: 'layout', @@ -727,15 +729,15 @@ describe('direct events', () => { ), }, - ...(['accessibilityTap', 'magicTap', 'accessibilityEscape'] as const).map((eventType) => ({ - name: `${eventType} from View content`, - eventType, - ui: (handler: jest.Mock) => ( - + ...buildDirectEventCases( + 'View content', + ['accessibilityTap', 'magicTap', 'accessibilityEscape'], + (handlerProps) => ( + ), - })), + ), { name: 'textLayout from nested Text', eventType: 'textLayout', @@ -745,33 +747,32 @@ describe('direct events', () => { ), }, - ...(['scroll', 'selectionChange', 'contentSizeChange'] as const).map((eventType) => ({ - name: `${eventType} from TextInput content`, - eventType, - ui: (handler: jest.Mock) => ( - + ...buildDirectEventCases( + 'TextInput content', + ['scroll', 'selectionChange', 'contentSizeChange'], + (handlerProps) => ( + Nested ), - })), - ...(['loadStart', 'progress', 'partialLoad', 'load', 'error', 'loadEnd'] as const).map( - (eventType) => ({ - name: `${eventType} from Image content`, - eventType, - // Image does not accept children, clone it to fire the event on a nested element. - ui: (handler: jest.Mock) => - React.cloneElement( - , - {}, - Nested, - ), - }), ), - ...( + ...buildDirectEventCases( + 'Image content', + ['loadStart', 'progress', 'partialLoad', 'load', 'error', 'loadEnd'], + // Image does not accept children, clone it to fire the event on a nested element. + (handlerProps) => + React.cloneElement( + , + {}, + Nested, + ), + ), + ...buildDirectEventCases( + 'ScrollView content', [ 'scroll', 'scrollBeginDrag', @@ -780,16 +781,13 @@ describe('direct events', () => { 'momentumScrollEnd', 'scrollToTop', 'contentSizeChange', - ] as const - ).map((eventType) => ({ - name: `${eventType} from ScrollView content`, - eventType, - ui: (handler: jest.Mock) => ( - + ], + (handlerProps) => ( + ), - })), + ), { name: 'scroll from TextInput to ancestor ScrollView', eventType: 'scroll', @@ -824,17 +822,30 @@ describe('direct events', () => { /> ), }, - ...(['requestClose', 'show', 'dismiss', 'orientationChange'] as const).map((eventType) => ({ - name: `${eventType} from Modal content`, - eventType, - ui: (handler: jest.Mock) => ( - + ...buildDirectEventCases( + 'Modal content', + ['requestClose', 'show', 'dismiss', 'orientationChange'], + (handlerProps) => ( + Content ), - })), + ), ]; + // Builds one case per event type, passing the matching `on*` handler prop to `ui`. + function buildDirectEventCases( + source: string, + eventTypes: string[], + ui: (handlerProps: Record) => React.ReactElement, + ): DirectEventCase[] { + return eventTypes.map((eventType) => ({ + name: `${eventType} from ${source}`, + eventType, + ui: (handler) => ui({ [getEventHandlerName(eventType)]: handler }), + })); + } + test.each(directEventCases)('bubbles $name with a warning', async ({ eventType, ui }) => { const handler = jest.fn(); await render(ui(handler)); diff --git a/src/events/fire-event.ts b/src/events/fire-event.ts index ebb5526dc..1d0d107c9 100644 --- a/src/events/fire-event.ts +++ b/src/events/fire-event.ts @@ -17,6 +17,31 @@ async function fireEvent(instance: TestInstance, eventType: EventType, ...data: return await fireEventInternal(instance, { type: eventType, data, bubbles: true }); } +fireEvent.changeText = async (instance: TestInstance, text: string) => + await fireEvent(instance, 'changeText', text); + +fireEvent.press = async (instance: TestInstance, eventProps?: EventProps) => { + await fireEvent(instance, 'press', mergeEventProps(buildTouchEvent(), eventProps)); +}; + +fireEvent.scroll = async (instance: TestInstance, eventProps?: EventProps) => { + const layoutMeasurement = isHostScrollView(instance) + ? nativeState.layoutSizeForInstance.get(instance) + : undefined; + const event = buildScrollEvent(undefined, { layoutMeasurement }); + await fireEvent(instance, 'scroll', mergeEventProps(event, eventProps)); +}; + +// Unlike `fireEvent(instance, 'layout')`, does not bubble, as React Native delivers layout events +// only to the measured element. +fireEvent.layout = async (instance: TestInstance, layout?: Partial) => { + await fireEventInternal(instance, { + type: 'layout', + data: [buildLayoutEvent(layout)], + bubbles: false, + }); +}; + type FireEventOptions = { type: EventType; data: unknown[]; @@ -50,29 +75,4 @@ async function fireEventInternal(instance: TestInstance, options: FireEventOptio return returnValue; } -fireEvent.changeText = async (instance: TestInstance, text: string) => - await fireEvent(instance, 'changeText', text); - -fireEvent.press = async (instance: TestInstance, eventProps?: EventProps) => { - await fireEvent(instance, 'press', mergeEventProps(buildTouchEvent(), eventProps)); -}; - -fireEvent.scroll = async (instance: TestInstance, eventProps?: EventProps) => { - const layoutMeasurement = isHostScrollView(instance) - ? nativeState.layoutSizeForInstance.get(instance) - : undefined; - const event = buildScrollEvent(undefined, { layoutMeasurement }); - await fireEvent(instance, 'scroll', mergeEventProps(event, eventProps)); -}; - -// Unlike `fireEvent(instance, 'layout')`, does not bubble, as React Native delivers layout events -// only to the measured element. -fireEvent.layout = async (instance: TestInstance, layout?: Partial) => { - await fireEventInternal(instance, { - type: 'layout', - data: [buildLayoutEvent(layout)], - bubbles: false, - }); -}; - export { fireEvent }; From c9f0cf15bce64c6bacd88656f68e61fd319303c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Wed, 7 Oct 2026 17:21:11 +0200 Subject: [PATCH 16/16] fix minor --- src/user-event/scroll/pull-to-refresh.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/user-event/scroll/pull-to-refresh.ts b/src/user-event/scroll/pull-to-refresh.ts index 6ce4a149e..367b7883b 100644 --- a/src/user-event/scroll/pull-to-refresh.ts +++ b/src/user-event/scroll/pull-to-refresh.ts @@ -21,11 +21,11 @@ export async function pullToRefresh( // `refreshControl` is an element prop, not a rendered host instance, so `dispatchEvent` // can't reach its `onRefresh` handler. - const onRefresh = instance.props.refreshControl?.props?.onRefresh; - const hasHandler = typeof onRefresh === 'function'; + const refreshControl = instance.props.refreshControl; + const hasHandler = typeof refreshControl?.props?.onRefresh === 'function'; if (hasHandler) { await act(() => { - onRefresh(); + refreshControl.props.onRefresh(); }); }