From 120822cafd63699eb52d0ddbd90c06e677f18121 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 1/6] common direct events --- CHANGELOG.md | 9 + contributing/native-events.md | 16 +- docs/api/fire-event.md | 12 +- src/events/__tests__/fire-event.test.tsx | 197 ++++++++++++++++++ src/events/propagation.ts | 71 ++++++- .../docs/14.x/docs/api/events/fire-event.mdx | 12 +- 6 files changed, 308 insertions(+), 9 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 bec40de84..90ea0ccae 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, @@ -648,6 +650,201 @@ 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(); + expect(warnSpy).toHaveBeenCalledTimes(1); + 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 e607f4a9a..99d601dae 100644 --- a/src/events/propagation.ts +++ b/src/events/propagation.ts @@ -1,27 +1,75 @@ 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; } /** * 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. */ export function findEventHandler(instance: TestInstance, eventName: string): EventHandler | null { - return isDirectEvent(normalizeEventName(eventName)) + return isDirectEvent(instance, normalizeEventName(eventName)) ? getOwnEventHandler(instance, eventName) : findBubblingEventHandler(instance, eventName); } @@ -49,6 +97,19 @@ function findBubblingEventHandler( 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 (handler) { + logger.warn( + `fireEvent: "${eventName}" event does not bubble, fire it on the element that has the handler instead.`, + formatElement(instance), + ); + } + + return null; + } + if (handler && isEventEnabled(instance, normalizeEventName(eventName), touchResponder)) { return handler; } 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 086e177e26d0d87a9dfe88b6baea9fdcbb95ebb0 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 2/6] 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 | 79 +++++-- .../docs/14.x/docs/api/events/fire-event.mdx | 11 +- 6 files changed, 281 insertions(+), 48 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 90ea0ccae..14f85933a 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, @@ -493,6 +495,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( @@ -508,6 +511,7 @@ describe('fireEvent.scroll', () => { width: 0, height: 0, }); + warnSpy.mockRestore(); }); }); @@ -653,39 +657,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 @@ -693,7 +697,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); @@ -717,12 +721,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); @@ -830,18 +832,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 99d601dae..faa574c9f 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; +} + /** * 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 @@ -69,9 +93,25 @@ export function isDirectEvent(instance: TestInstance, eventName: string) { * `isEventEnabled`) use the name without the `on*` prefix. */ export function findEventHandler(instance: TestInstance, eventName: string): EventHandler | null { - return isDirectEvent(instance, normalizeEventName(eventName)) - ? getOwnEventHandler(instance, eventName) - : findBubblingEventHandler(instance, eventName); + const normalizedEventName = normalizeEventName(eventName); + if (isDirectEvent(instance, normalizedEventName)) { + return getOwnEventHandler(instance, eventName); + } + + const owner = findBubblingHandlerOwner(instance, eventName); + if (!owner) { + return null; + } + + // 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 owner.handler; } function getOwnEventHandler(instance: TestInstance, eventName: string): EventHandler | null { @@ -87,19 +127,24 @@ function getOwnEventHandler(instance: TestInstance, eventName: string): EventHan return handler; } -function findBubblingEventHandler( +type HandlerOwner = { + handler: EventHandler; + instance: TestInstance; +}; + +function findBubblingHandlerOwner( instance: TestInstance, eventName: string, nearestTouchResponder?: TestInstance, -): EventHandler | null { +): HandlerOwner | null { 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,15 +155,15 @@ function findBubblingEventHandler( return null; } - if (handler && isEventEnabled(instance, normalizeEventName(eventName), touchResponder)) { - return handler; + if (handler && isEventEnabled(instance, normalizedEventName, touchResponder)) { + return { handler, instance }; } if (instance.parent === null) { return null; } - return findBubblingEventHandler(instance.parent, eventName, touchResponder); + return findBubblingHandlerOwner(instance.parent, eventName, touchResponder); } 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 be571104ed8cadd3f1442c2b99234d293ad478dd 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 3/6] tweaks --- contributing/native-events.md | 2 +- src/events/__tests__/fire-event.test.tsx | 72 ++++++++++++------------ src/events/handler.ts | 2 +- src/events/propagation.ts | 29 +++++----- 4 files changed, 54 insertions(+), 51 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 14f85933a..8ca453504 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -19,6 +19,7 @@ import { import { fireEvent, render, screen } from '../..'; import { _console } from '../../helpers/logger'; +import { getEventHandlerName } from '../handler'; import { nativeState } from '../native-state'; const layoutEvent = { nativeEvent: { layout: { width: 100, height: 100 } } }; @@ -655,6 +656,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; @@ -701,7 +712,7 @@ describe('direct events', () => { ), })), @@ -722,7 +733,6 @@ 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)); @@ -730,12 +740,10 @@ describe('direct events', () => { expect(handler).not.toHaveBeenCalled(); expect(warnSpy).toHaveBeenCalledTimes(1); - 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( @@ -747,11 +755,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( {}}> @@ -767,11 +773,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 }) => ( @@ -786,7 +790,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 () => { @@ -834,8 +837,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; @@ -888,33 +901,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)); @@ -922,12 +931,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( {}}> @@ -942,11 +949,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 }) => ( @@ -959,12 +964,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 }) => ( @@ -979,11 +982,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 }) => ( @@ -999,7 +1000,6 @@ describe('leaky direct events', () => { expect(onShow).toHaveBeenCalledTimes(1); expect(onDismiss).toHaveBeenCalledTimes(1); expect(warnSpy).not.toHaveBeenCalled(); - warnSpy.mockRestore(); }); }); diff --git a/src/events/handler.ts b/src/events/handler.ts index 06bf2e006..e7835ec8d 100644 --- a/src/events/handler.ts +++ b/src/events/handler.ts @@ -43,7 +43,7 @@ export function normalizeEventName(eventName: string) { return eventName; } -function getEventHandlerName(eventName: string) { +export function getEventHandlerName(eventName: string) { if (hasOnPrefix(eventName)) { return eventName; } diff --git a/src/events/propagation.ts b/src/events/propagation.ts index faa574c9f..8b43b9b2a 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; @@ -103,8 +103,11 @@ export function findEventHandler(instance: TestInstance, eventName: string): Eve return null; } - // 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 0db6618d24e96eb3aa15ff8031d47ab63ce85b7f 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 4/6] 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 8ca453504..c74b598e8 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -686,10 +686,6 @@ describe('direct events', () => { eventName: 'momentumScrollEnd', ui: (handler) => , }, - { - eventName: 'contentSizeChange', - ui: (handler) => , - }, { eventName: 'contentSizeChange', ui: (handler) => , @@ -884,6 +880,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 8b43b9b2a..67892442d 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 085f6d3e382559ceb177f048a7da744d06225b8b 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 5/6] 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 | 335 +++++++----------- src/events/fire-event.ts | 20 +- src/events/propagation.ts | 80 ++--- .../docs/14.x/docs/api/events/fire-event.mdx | 20 +- 8 files changed, 194 insertions(+), 331 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 9dfb0876c..a5933d2d1 100644 --- a/contributing/event-dispatch.md +++ b/contributing/event-dispatch.md @@ -21,7 +21,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 c74b598e8..aa298b375 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -575,7 +575,7 @@ describe('fireEvent.layout', () => { warnSpy.mockRestore(); }); - 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( @@ -587,7 +587,7 @@ 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(); }); @@ -667,197 +667,83 @@ describe('direct events', () => { }); 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(); - expect(warnSpy).toHaveBeenCalledTimes(1); - }, - ); - - 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', @@ -880,15 +766,6 @@ describe('deprecated bubbling events', () => { /> ), }, - { - name: 'contentSizeChange from ScrollView content', - eventName: 'contentSizeChange', - ui: (handler) => ( - - - - ), - }, { name: 'contentSizeChange from FlatList item', eventName: 'contentSizeChange', @@ -901,19 +778,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, @@ -925,20 +789,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(); @@ -951,6 +812,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( {}}> @@ -1002,20 +877,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(); }); }); @@ -1317,8 +1221,9 @@ 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(); }); diff --git a/src/events/fire-event.ts b/src/events/fire-event.ts index 890654726..3e4dffe21 100644 --- a/src/events/fire-event.ts +++ b/src/events/fire-event.ts @@ -13,6 +13,20 @@ import type { EventName, EventProps, LayoutRectangle } from './types'; import { updateNativeStateFromEvent } from './update-native-state'; 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; } @@ -20,7 +34,7 @@ async function fireEvent(instance: TestInstance, eventName: EventName, ...data: // `fireEvent` accepts event names with and without the `on*` prefix. updateNativeStateFromEvent(instance, normalizeEventName(eventName), data[0]); - const handler = findEventHandler(instance, eventName); + const handler = findEventHandler(instance, eventName, options); if (!handler) { return; } @@ -48,8 +62,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 67892442d..e07b0110a 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,49 +57,31 @@ 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; +}; + /** - * 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. */ -export function findEventHandler(instance: TestInstance, eventName: string): EventHandler | null { - const normalizedEventName = normalizeEventName(eventName); - if (isDirectEvent(instance, normalizedEventName)) { +export function findEventHandler( + instance: TestInstance, + eventName: string, + options?: FindEventHandlerOptions, +): EventHandler | null { + if (options?.direct) { return getOwnEventHandler(instance, eventName); } @@ -102,11 +90,8 @@ export function findEventHandler(instance: TestInstance, eventName: string): Eve return null; } - // 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), @@ -140,24 +125,11 @@ function findBubblingHandlerOwner( nearestTouchResponder?: TestInstance, ): HandlerOwner | null { 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 null; - } - - if (handler && isEventEnabled(instance, normalizedEventName, touchResponder)) { + if (handler && isEventEnabled(instance, normalizeEventName(eventName), touchResponder)) { return { handler, 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 cad6c61651738d44479fda0c5fa2fc2b23b0287c 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 6/6] 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 aa298b375..315a4adae 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -1221,7 +1221,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 3e4dffe21..d59e13c15 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'; @@ -16,16 +16,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 e07b0110a..b86577f5a 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; }; @@ -90,7 +90,6 @@ export function findEventHandler( return null; } - // 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.`,