From 04747b33026d1f3734ee74a605f8e9d4529cbde8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrz=C4=99bski?= Date: Sat, 3 Oct 2026 22:09:43 +0200 Subject: [PATCH 1/9] fix: fireEvent.layout to be non-bubbling event --- AGENTS.md | 1 + agents/native-events.md | 57 +++++++++++++++++++ docs/api/fire-event.md | 4 +- src/__tests__/fire-event.test.tsx | 45 +++++++++++++-- src/event-handler.ts | 2 +- src/fire-event.ts | 35 +++++++++++- .../docs/14.x/docs/api/events/fire-event.mdx | 4 +- 7 files changed, 137 insertions(+), 11 deletions(-) create mode 100644 agents/native-events.md diff --git a/AGENTS.md b/AGENTS.md index d233adba0..989b4f699 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,5 +19,6 @@ - [Build, validation, and repo layout](agents/build-and-validation.md) - [TypeScript and code style](agents/code-style.md) - [Testing conventions](agents/testing.md) + - [Native event propagation (bubbling vs direct)](agents/native-events.md) - [Example app regeneration](agents/example-apps.md) - [Git, releases, and PR workflow](agents/git-workflow.md) diff --git a/agents/native-events.md b/agents/native-events.md new file mode 100644 index 000000000..a20e6f649 --- /dev/null +++ b/agents/native-events.md @@ -0,0 +1,57 @@ +# Native Event Propagation + +React Native declares, for each native (host) component, which events **bubble** up the tree and which are **direct**, meaning they are delivered only to the element that emitted them. Use this reference when deciding whether an event helper should look for handlers on ancestor elements. + +## How to read this + +- Native event names use a `top` prefix that maps to the `on*` prop: `topLayout` → `onLayout`. The tables below use the short name (`layout`). +- Every host component inherits the **base view config** events and adds its own component-specific events on top of them. +- `fireEvent` walks up the tree to find a handler, which matches bubbling events. Direct events should go through `fireDirectEvent` in `src/fire-event.ts`, which invokes only the target element's handler. Today only `fireEvent.layout` uses it. +- Snapshot taken from `react-native@0.88.0-rc.1`. See [Sources](#sources) to re-check after RN upgrades. + +## Base view config (all host components) + +| Kind | iOS | Android | +| -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Bubbling | `press`, `click`, `change`, `focus`, `blur`, `submitEditing`, `endEditing`, `keyPress`, `touchStart`, `touchMove`, `touchEnd`, `touchCancel`, `pointer*`\* | `click`, `change`, `select`, `focus`, `blur`, `keyDown`, `keyUp`, `touchStart`, `touchMove`, `touchEnd`, `touchCancel`, `pointer*`\* | +| Direct | `layout`, `accessibilityAction`, `accessibilityTap`, `magicTap`, `accessibilityEscape` | `layout`, `accessibilityAction`, `scroll`, `scrollBeginDrag`, `scrollEndDrag`, `momentumScrollBegin`, `momentumScrollEnd`, `contentSizeChange`, `selectionChange`, `message`, `loadingStart`, `loadingFinish`, `loadingError` | + +\* `pointer*` = `pointerDown`, `pointerMove`, `pointerUp`, `pointerCancel`, `pointerEnter`, `pointerLeave`, `pointerOver`, `pointerOut`, `gotPointerCapture`, `lostPointerCapture`. + +Both platforms also register `onGestureHandlerEvent` and `onGestureHandlerStateChange` as direct events for React Native Gesture Handler. + +## Component-specific events + +Events listed here are added on top of the base view config. "Host name" is the native `uiViewClassName` (or codegen component name). + +| Component | Host name | Bubbling | Direct | +| ---------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | +| `View`, `Pressable`, etc. | `RCTView` | — | — | +| `Text` | `RCTText` (nested: `RCTVirtualText`, no extra events) | — | `textLayout` | +| `TextInput` (iOS) | `RCTSinglelineTextInputView`, `RCTMultilineTextInputView` | `blur`, `change`, `endEditing`, `focus`, `keyPress`, `submitEditing`, `touchMove`, `touchEnd`, `touchCancel` | `scroll`, `selectionChange`, `contentSizeChange`, `changeSync`, `keyPressSync` | +| `TextInput` (Android) | `AndroidTextInput` | `endEditing`, `keyPress`, `submitEditing` | `scroll` | +| `ScrollView` | `RCTScrollView` | — | `scroll`, `scrollBeginDrag`, `scrollEndDrag`, `momentumScrollBegin`, `momentumScrollEnd`; iOS also `scrollToTop` | +| `ScrollView` (horizontal, Android) | `AndroidHorizontalScrollView` | — | — (scroll events come from the Android base config) | +| `Image` | `RCTImageView` | — | `loadStart`, `progress`, `error`, `load`, `loadEnd`; iOS also `partialLoad` | +| `Switch` (iOS) | `Switch` | `change` | — | +| `Switch` (Android) | `AndroidSwitch` | `change` | — | +| `Modal` | `ModalHostView` | — | `requestClose`, `show`, `dismiss`, `orientationChange` | +| `RefreshControl` (iOS) | `PullToRefreshView` | — | `refresh` | +| `RefreshControl` (Android) | `AndroidSwipeRefreshLayout` | — | `refresh` | +| `DrawerLayoutAndroid` | `AndroidDrawerLayout` | — | `drawerSlide`, `drawerStateChanged`, `drawerOpen`, `drawerClose` | + +## Known gaps in RNTL + +These events are direct in React Native but still bubble through `fireEvent`. Changing them is a breaking change for users who fire them on a child element: + +- Scroll events: `scroll`, `scrollBeginDrag`, `scrollEndDrag`, `momentumScrollBegin`, `momentumScrollEnd` +- `contentSizeChange`, `selectionChange`, `textLayout` +- `Image` load events, `Modal` events, `refresh` + +## Sources + +All paths are relative to `node_modules/react-native`: + +- Base config: `Libraries/NativeComponent/BaseViewConfig.ios.js`, `Libraries/NativeComponent/BaseViewConfig.android.js` +- Static view configs: `Libraries/Text/TextNativeComponent.js`, `Libraries/Image/ImageViewNativeComponent.js`, `Libraries/Components/ScrollView/*NativeComponent.js`, `Libraries/Components/TextInput/RCTTextInputViewConfig.js`, `Libraries/Components/TextInput/AndroidTextInputNativeComponent.js` +- Codegen specs (`DirectEventHandler` vs `BubblingEventHandler` prop types): `src/private/components/*/specs/*NativeComponent.js` diff --git a/docs/api/fire-event.md b/docs/api/fire-event.md index 1fb1a62d8..3ee607448 100644 --- a/docs/api/fire-event.md +++ b/docs/api/fire-event.md @@ -176,7 +176,9 @@ fireEvent.layout: ( ) => Promise ``` -Builds a layout event carrying the given `layout` rectangle and invokes the `layout` handler on the element or nearest eligible parent. Use it to simulate the layout engine measuring an element, e.g. to test components that adapt to a measured size. +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. If the element has no `onLayout` handler, a warning is logged and nothing is called. The event is also not blocked by `pointerEvents` or a non-editable `TextInput`. 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/__tests__/fire-event.test.tsx b/src/__tests__/fire-event.test.tsx index ea39223ac..7b5400f2e 100644 --- a/src/__tests__/fire-event.test.tsx +++ b/src/__tests__/fire-event.test.tsx @@ -14,6 +14,7 @@ import { } from 'react-native'; import { fireEvent, render, screen } from '..'; +import { _console } from '../helpers/logger'; import { nativeState } from '../native-state'; const layoutEvent = { nativeEvent: { layout: { width: 100, height: 100 } } }; @@ -468,18 +469,50 @@ describe('fireEvent.layout', () => { }); }); - test('bubbles up to find the handler on an ancestor element', async () => { + test('does not bubble to the handler on an ancestor element', async () => { + const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); const onLayout = jest.fn(); await render( - - Content + + + , + ); + + await fireEvent.layout(screen.getByTestId('child'), { height: 80 }); + + expect(onLayout).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledTimes(1); + warnSpy.mockRestore(); + }); + + test('warns when element has no onLayout handler', async () => { + const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); + await render(); + + await fireEvent.layout(screen.getByTestId('view')); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toContain( + 'fireEvent.layout: element has no "onLayout" handler.', + ); + warnSpy.mockRestore(); + }); + + test('is not blocked by pointerEvents or non-editable TextInput', async () => { + const onViewLayout = jest.fn(); + const onInputLayout = jest.fn(); + await render( + + + , ); - await fireEvent.layout(screen.getByText('Content'), { height: 80 }); + await fireEvent.layout(screen.getByTestId('view')); + await fireEvent.layout(screen.getByTestId('input')); - expect(onLayout).toHaveBeenCalledTimes(1); - expect(onLayout.mock.calls[0][0].nativeEvent.layout.height).toBe(80); + expect(onViewLayout).toHaveBeenCalledTimes(1); + expect(onInputLayout).toHaveBeenCalledTimes(1); }); }); diff --git a/src/event-handler.ts b/src/event-handler.ts index 67c4e2044..f2c6ec83e 100644 --- a/src/event-handler.ts +++ b/src/event-handler.ts @@ -30,7 +30,7 @@ export function getEventHandlerFromProps( return undefined; } -function getEventHandlerName(eventName: string) { +export function getEventHandlerName(eventName: string) { return `on${capitalizeFirstLetter(eventName)}`; } diff --git a/src/fire-event.ts b/src/fire-event.ts index 4b64b652c..25cdc3020 100644 --- a/src/fire-event.ts +++ b/src/fire-event.ts @@ -11,9 +11,10 @@ import { act } from './act'; import type { LayoutRectangle } from './event-builder'; import { buildLayoutEvent, buildScrollEvent, buildTouchEvent } from './event-builder'; import type { EventHandler } from './event-handler'; -import { getEventHandlerFromProps } from './event-handler'; +import { getEventHandlerFromProps, getEventHandlerName } from './event-handler'; import { isInstanceMounted } from './helpers/component-tree'; import { isHostScrollView, isHostTextInput } from './helpers/host-component-names'; +import { logger } from './helpers/logger'; import { isPointerEventEnabled } from './helpers/pointer-events'; import { isEditableTextInput } from './helpers/text-input'; import { nativeState } from './native-state'; @@ -146,6 +147,31 @@ async function fireEvent(instance: TestInstance, eventName: EventName, ...data: return returnValue; } +/** + * Fires a direct (non-bubbling) event, invoking only the handler of the given element. + * Used for events that React Native emits straight to the target element, e.g. `layout`. + */ +async function fireDirectEvent(instance: TestInstance, eventName: EventName, ...data: unknown[]) { + if (!isInstanceMounted(instance)) { + return; + } + + const handler = getEventHandlerFromProps(instance.props, eventName); + if (!handler) { + logger.warn( + `fireEvent.${eventName}: element has no "${getEventHandlerName(eventName)}" handler.`, + ); + return; + } + + let returnValue; + await act(() => { + returnValue = handler(...data); + }); + + return returnValue; +} + type EventProps = Record; fireEvent.changeText = async (instance: TestInstance, text: string) => @@ -169,8 +195,13 @@ fireEvent.scroll = async (instance: TestInstance, eventProps?: EventProps) => { await fireEvent(instance, 'scroll', event); }; +/** + * Layout events are emitted by the native layout engine directly to the measured element + * and do not bubble, so unlike other `fireEvent` calls this one does not look for the + * handler on ancestor elements. + */ fireEvent.layout = async (instance: TestInstance, layout?: Partial) => { - await fireEvent(instance, 'layout', buildLayoutEvent(layout)); + await fireDirectEvent(instance, 'layout', buildLayoutEvent(layout)); }; export { fireEvent }; 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 93a646ac7..7e77cf275 100644 --- a/website/docs/14.x/docs/api/events/fire-event.mdx +++ b/website/docs/14.x/docs/api/events/fire-event.mdx @@ -182,7 +182,9 @@ fireEvent.layout: ( ) => Promise ``` -Builds a layout event carrying the given `layout` rectangle and invokes the `layout` handler on the element or nearest eligible parent. Use it to simulate the layout engine measuring an element, e.g. to test components that adapt to a measured size. +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. If the element has no `onLayout` handler, a warning is logged and nothing is called. The event is also not blocked by `pointerEvents` or a non-editable `TextInput`. 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 76fa38ae3e6deaa6f6255abc1155f12aaff3dada Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Mon, 5 Oct 2026 10:33:59 +0200 Subject: [PATCH 2/9] simplify --- agents/native-events.md | 2 +- docs/api/fire-event.md | 2 +- src/__tests__/fire-event.test.tsx | 53 ++++++++++++---- src/event-handler.ts | 2 +- src/fire-event.ts | 61 +++++++++---------- .../docs/14.x/docs/api/events/fire-event.mdx | 2 +- 6 files changed, 72 insertions(+), 50 deletions(-) diff --git a/agents/native-events.md b/agents/native-events.md index a20e6f649..9c4a1fa89 100644 --- a/agents/native-events.md +++ b/agents/native-events.md @@ -6,7 +6,7 @@ React Native declares, for each native (host) component, which events **bubble** - Native event names use a `top` prefix that maps to the `on*` prop: `topLayout` → `onLayout`. The tables below use the short name (`layout`). - Every host component inherits the **base view config** events and adds its own component-specific events on top of them. -- `fireEvent` walks up the tree to find a handler, which matches bubbling events. Direct events should go through `fireDirectEvent` in `src/fire-event.ts`, which invokes only the target element's handler. Today only `fireEvent.layout` uses it. +- `fireEvent` walks up the tree to find a handler, which matches bubbling events. Events for which `isDirectEvent()` in `src/fire-event.ts` returns `true` skip that walk and invoke only the target element's handler. Today only `layout` is treated as direct. - Snapshot taken from `react-native@0.88.0-rc.1`. See [Sources](#sources) to re-check after RN upgrades. ## Base view config (all host components) diff --git a/docs/api/fire-event.md b/docs/api/fire-event.md index 3ee607448..b15a4994a 100644 --- a/docs/api/fire-event.md +++ b/docs/api/fire-event.md @@ -178,7 +178,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. If the element has no `onLayout` handler, a warning is logged and nothing is called. The event is also not blocked by `pointerEvents` or a non-editable `TextInput`. +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. If the element has no `onLayout` handler, a warning is logged and nothing is called. The handler is called even if the element's own `onStartShouldSetResponder` returns `false`. An `onLayout` prop on a composite component is only called if the component passes it down to a host element. 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/__tests__/fire-event.test.tsx b/src/__tests__/fire-event.test.tsx index 7b5400f2e..4f26a23bc 100644 --- a/src/__tests__/fire-event.test.tsx +++ b/src/__tests__/fire-event.test.tsx @@ -485,6 +485,23 @@ describe('fireEvent.layout', () => { warnSpy.mockRestore(); }); + test('does not bubble when fired as generic layout event', async () => { + const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); + const onLayout = jest.fn(); + await render( + + + , + ); + + await fireEvent(screen.getByTestId('child'), 'layout', layoutEvent); + await fireEvent(screen.getByTestId('child'), 'onLayout', layoutEvent); + + expect(onLayout).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledTimes(2); + warnSpy.mockRestore(); + }); + test('warns when element has no onLayout handler', async () => { const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); await render(); @@ -493,26 +510,33 @@ describe('fireEvent.layout', () => { expect(warnSpy).toHaveBeenCalledTimes(1); expect(warnSpy.mock.calls[0][0]).toContain( - 'fireEvent.layout: element has no "onLayout" handler.', + 'fireEvent: element has no handler for "layout" event.', ); warnSpy.mockRestore(); }); - test('is not blocked by pointerEvents or non-editable TextInput', async () => { - const onViewLayout = jest.fn(); - const onInputLayout = jest.fn(); + test('is not blocked by element responder rejecting touches', async () => { + const onLayout = jest.fn(); await render( - - - - , + false} />, ); await fireEvent.layout(screen.getByTestId('view')); - await fireEvent.layout(screen.getByTestId('input')); - expect(onViewLayout).toHaveBeenCalledTimes(1); - expect(onInputLayout).toHaveBeenCalledTimes(1); + expect(onLayout).toHaveBeenCalledTimes(1); + }); + + test('does not call onLayout of composite component that does not forward it', async () => { + const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); + const onLayout = jest.fn(); + const Box = (_props: { onLayout: () => void }) => ; + await render(); + + await fireEvent.layout(screen.getByTestId('view')); + + expect(onLayout).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledTimes(1); + warnSpy.mockRestore(); }); }); @@ -778,6 +802,7 @@ describe('non-editable TextInput', () => { }); test('blocks touch-related events when firing on nested Text child', async () => { + const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); const onFocus = jest.fn(); const onChangeText = jest.fn(); const onSubmitEditing = jest.fn(); @@ -812,8 +837,10 @@ describe('non-editable TextInput', () => { expect(onFocus).not.toHaveBeenCalled(); expect(onChangeText).not.toHaveBeenCalled(); expect(onSubmitEditing).not.toHaveBeenCalled(); - expect(onLayout).toHaveBeenCalledTimes(2); - expect(onLayout).toHaveBeenCalledWith(layoutEvent); + // Layout is a direct event, so it does not bubble to the parent TextInput + expect(onLayout).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledTimes(2); + warnSpy.mockRestore(); }); test.each([ diff --git a/src/event-handler.ts b/src/event-handler.ts index f2c6ec83e..67c4e2044 100644 --- a/src/event-handler.ts +++ b/src/event-handler.ts @@ -30,7 +30,7 @@ export function getEventHandlerFromProps( return undefined; } -export function getEventHandlerName(eventName: string) { +function getEventHandlerName(eventName: string) { return `on${capitalizeFirstLetter(eventName)}`; } diff --git a/src/fire-event.ts b/src/fire-event.ts index 25cdc3020..bcec4390a 100644 --- a/src/fire-event.ts +++ b/src/fire-event.ts @@ -11,7 +11,7 @@ import { act } from './act'; import type { LayoutRectangle } from './event-builder'; import { buildLayoutEvent, buildScrollEvent, buildTouchEvent } from './event-builder'; import type { EventHandler } from './event-handler'; -import { getEventHandlerFromProps, getEventHandlerName } from './event-handler'; +import { getEventHandlerFromProps } from './event-handler'; import { isInstanceMounted } from './helpers/component-tree'; import { isHostScrollView, isHostTextInput } from './helpers/host-component-names'; import { logger } from './helpers/logger'; @@ -72,6 +72,29 @@ function isEventEnabled( return touchStart === undefined && touchMove === undefined; } +/** + * Checks if the event is a direct event, which React Native delivers only to the element + * that emitted it, without bubbling to its ancestors. + * + * Note: `fireEvent` is accepting both `layout` and `onLayout` for event names, + * so we need cover both forms. + */ +function isDirectEvent(eventName: string) { + return eventName === 'layout' || eventName === 'onLayout'; +} + +function getDirectEventHandler(instance: TestInstance, eventName: string): EventHandler | null { + const handler = getEventHandlerFromProps(instance.props, eventName, { loose: true }); + if (!handler) { + logger.warn( + `fireEvent: element has no handler for "${eventName}" event. Direct events are not looked up on ancestor elements.`, + ); + return null; + } + + return handler; +} + function findEventHandler( instance: TestInstance, eventName: string, @@ -134,33 +157,10 @@ async function fireEvent(instance: TestInstance, eventName: EventName, ...data: setNativeStateIfNeeded(instance, eventName, data[0]); - const handler = findEventHandler(instance, eventName); - if (!handler) { - return; - } - - let returnValue; - await act(() => { - returnValue = handler(...data); - }); - - return returnValue; -} - -/** - * Fires a direct (non-bubbling) event, invoking only the handler of the given element. - * Used for events that React Native emits straight to the target element, e.g. `layout`. - */ -async function fireDirectEvent(instance: TestInstance, eventName: EventName, ...data: unknown[]) { - if (!isInstanceMounted(instance)) { - return; - } - - const handler = getEventHandlerFromProps(instance.props, eventName); + const handler = isDirectEvent(eventName) + ? getDirectEventHandler(instance, eventName) + : findEventHandler(instance, eventName); if (!handler) { - logger.warn( - `fireEvent.${eventName}: element has no "${getEventHandlerName(eventName)}" handler.`, - ); return; } @@ -195,13 +195,8 @@ fireEvent.scroll = async (instance: TestInstance, eventProps?: EventProps) => { await fireEvent(instance, 'scroll', event); }; -/** - * Layout events are emitted by the native layout engine directly to the measured element - * and do not bubble, so unlike other `fireEvent` calls this one does not look for the - * handler on ancestor elements. - */ fireEvent.layout = async (instance: TestInstance, layout?: Partial) => { - await fireDirectEvent(instance, 'layout', buildLayoutEvent(layout)); + await fireEvent(instance, 'layout', buildLayoutEvent(layout)); }; export { fireEvent }; 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 7e77cf275..df456637a 100644 --- a/website/docs/14.x/docs/api/events/fire-event.mdx +++ b/website/docs/14.x/docs/api/events/fire-event.mdx @@ -184,7 +184,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. If the element has no `onLayout` handler, a warning is logged and nothing is called. The event is also not blocked by `pointerEvents` or a non-editable `TextInput`. +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. If the element has no `onLayout` handler, a warning is logged and nothing is called. The handler is called even if the element's own `onStartShouldSetResponder` returns `false`. An `onLayout` prop on a composite component is only called if the component passes it down to a host element. 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 58dde0d0ba9a6297c78fe4aac92ca60f130ad8a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Mon, 5 Oct 2026 10:36:38 +0200 Subject: [PATCH 3/9] improve event handler picking --- src/__tests__/event-handler.test.tsx | 14 +++++++++++--- src/event-handler.ts | 4 ++++ src/fire-event.ts | 7 ++----- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/__tests__/event-handler.test.tsx b/src/__tests__/event-handler.test.tsx index 0b200d7ca..6c615e603 100644 --- a/src/__tests__/event-handler.test.tsx +++ b/src/__tests__/event-handler.test.tsx @@ -26,9 +26,17 @@ test('getEventHandler strict mode', async () => { expect(getEventHandlerFromProps(testOnly.props, 'press')).toBe(testOnlyOnPress); expect(getEventHandlerFromProps(both.props, 'press')).toBe(onPress); - expect(getEventHandlerFromProps(regular.props, 'onPress')).toBe(undefined); - expect(getEventHandlerFromProps(testOnly.props, 'onPress')).toBe(undefined); - expect(getEventHandlerFromProps(both.props, 'onPress')).toBe(undefined); + expect(getEventHandlerFromProps(regular.props, 'onPress')).toBe(onPress); + expect(getEventHandlerFromProps(testOnly.props, 'onPress')).toBe(testOnlyOnPress); + expect(getEventHandlerFromProps(both.props, 'onPress')).toBe(onPress); +}); + +test('getEventHandler does not treat event names starting with "on" as prefixed', async () => { + const onOnline = jest.fn(); + // @ts-expect-error Intentionally passing such props + await render(); + + expect(getEventHandlerFromProps(screen.getByTestId('view').props, 'online')).toBe(onOnline); }); test('getEventHandler loose mode', async () => { diff --git a/src/event-handler.ts b/src/event-handler.ts index 67c4e2044..2abcd9415 100644 --- a/src/event-handler.ts +++ b/src/event-handler.ts @@ -31,6 +31,10 @@ export function getEventHandlerFromProps( } function getEventHandlerName(eventName: string) { + if (/^on[A-Z]/.test(eventName)) { + return eventName; + } + return `on${capitalizeFirstLetter(eventName)}`; } diff --git a/src/fire-event.ts b/src/fire-event.ts index bcec4390a..695d515a9 100644 --- a/src/fire-event.ts +++ b/src/fire-event.ts @@ -73,11 +73,8 @@ function isEventEnabled( } /** - * Checks if the event is a direct event, which React Native delivers only to the element - * that emitted it, without bubbling to its ancestors. - * - * Note: `fireEvent` is accepting both `layout` and `onLayout` for event names, - * so we need cover both forms. + * Direct events are delivered by React Native only to the emitting element and do not bubble. + * Note: `fireEvent` accepts both `layout` and `onLayout` event names, so check both forms. */ function isDirectEvent(eventName: string) { return eventName === 'layout' || eventName === 'onLayout'; From 6ae17e9d4ab6d44292fb5a177bd81b7ebb2c5367 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Mon, 5 Oct 2026 11:07:43 +0200 Subject: [PATCH 4/9] layout native state --- CHANGELOG.md | 4 +- docs/api/fire-event.md | 4 ++ docs/api/user-event.md | 2 +- src/__tests__/fire-event.test.tsx | 70 +++++++++++++++++++ src/fire-event.ts | 34 ++++++++- src/native-state.ts | 4 +- .../scroll/__tests__/scroll-to.test.tsx | 28 ++++++++ src/user-event/scroll/scroll-to.ts | 14 ++-- .../docs/14.x/docs/api/events/fire-event.mdx | 4 ++ .../docs/14.x/docs/api/events/user-event.mdx | 2 +- 10 files changed, 156 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e6a1754c..28f4a9d2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,9 @@ with v14. ### Features - Added `fireEvent.layout()` to simulate the layout engine measuring an element, invoking the - `onLayout` handler with a synthetic layout event. + `onLayout` handler with a synthetic layout event. Layout events do not bubble to parent + elements, and the measured size is used as `layoutMeasurement` in later `fireEvent.scroll()` + and `userEvent.scrollTo()` calls on the same `ScrollView`. - Added `userEvent.accessibilityAction()` to dispatch a named accessibility action to an element, invoking its `onAccessibilityAction` handler. - Added `userEvent.pullToRefresh()` to simulate the pull-to-refresh gesture on a host diff --git a/docs/api/fire-event.md b/docs/api/fire-event.md index b15a4994a..0ca462895 100644 --- a/docs/api/fire-event.md +++ b/docs/api/fire-event.md @@ -140,6 +140,8 @@ fireEvent.scroll: ( Builds a scroll event object, merges `eventProps` into it, and invokes the `scroll` handler on the element or nearest eligible parent. +When fired on a host `ScrollView` that received a [layout event](#layout) before, the event's `layoutMeasurement` defaults to the size from that layout event. Values passed in `eventProps` take precedence. + #### On a `ScrollView` ```jsx @@ -182,6 +184,8 @@ Unlike other `fireEvent` calls, layout events do not bubble: React Native delive 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. +The element's layout size is remembered, so later [scroll events](#scroll) and [`userEvent.scrollTo()`](./user-event.md#scroll-to) calls on the same `ScrollView` use it as their `layoutMeasurement`. + ```jsx import { View } from 'react-native'; import { render, screen, fireEvent } from '@testing-library/react-native'; diff --git a/docs/api/user-event.md b/docs/api/user-event.md index 5e38ac315..7ef87273a 100644 --- a/docs/api/user-event.md +++ b/docs/api/user-event.md @@ -269,7 +269,7 @@ Each scroll interaction consists of a mandatory drag scroll part, which simulate - `momentumY` - target vertical momentum scroll offset - `momentumX` - target horizontal momentum scroll offset - `contentSize` - passed to `ScrollView` events and enabling `FlatList` updates -- `layoutMeasurement` - passed to `ScrollView` events and enabling `FlatList` updates +- `layoutMeasurement` - passed to `ScrollView` events and enabling `FlatList` updates. Defaults to the size from the last [`fireEvent.layout()`](./fire-event.md#layout) on the `ScrollView`, if any. User Event will generate several intermediate scroll steps to simulate user scroll interaction. You should not rely on exact number or values of these scrolls steps as they might be change in the future version. diff --git a/src/__tests__/fire-event.test.tsx b/src/__tests__/fire-event.test.tsx index 4f26a23bc..72733394b 100644 --- a/src/__tests__/fire-event.test.tsx +++ b/src/__tests__/fire-event.test.tsx @@ -424,6 +424,54 @@ describe('fireEvent.scroll', () => { y: 0, }); }); + + test('uses layout size from previous layout event as layoutMeasurement', async () => { + const onScroll = jest.fn(); + await render( {}} />); + const scrollView = screen.getByTestId('scroll'); + + await fireEvent.layout(scrollView, { width: 390, height: 750 }); + await fireEvent.scroll(scrollView); + + expect(onScroll.mock.calls[0][0].nativeEvent.layoutMeasurement).toEqual({ + width: 390, + height: 750, + }); + }); + + test('prefers passed layoutMeasurement over layout size from layout event', async () => { + const onScroll = jest.fn(); + await render( {}} />); + const scrollView = screen.getByTestId('scroll'); + + await fireEvent.layout(scrollView, { width: 390, height: 750 }); + await fireEvent.scroll(scrollView, { + nativeEvent: { layoutMeasurement: { width: 100, height: 200 } }, + }); + + expect(onScroll.mock.calls[0][0].nativeEvent.layoutMeasurement).toEqual({ + width: 100, + height: 200, + }); + }); + + test('does not use layout size of non-ScrollView element as layoutMeasurement', async () => { + const onScroll = jest.fn(); + await render( + + {}} /> + , + ); + const content = screen.getByTestId('content'); + + await fireEvent.layout(content, { width: 390, height: 750 }); + await fireEvent.scroll(content); + + expect(onScroll.mock.calls[0][0].nativeEvent.layoutMeasurement).toEqual({ + width: 0, + height: 0, + }); + }); }); describe('fireEvent.layout', () => { @@ -526,6 +574,28 @@ describe('fireEvent.layout', () => { expect(onLayout).toHaveBeenCalledTimes(1); }); + test('saves layout size in native state', async () => { + await render( {}} />); + const view = screen.getByTestId('view'); + + await fireEvent.layout(view, { x: 10, y: 20, width: 100, height: 80 }); + expect(nativeState.layoutSizeForInstance.get(view)).toEqual({ width: 100, height: 80 }); + + await fireEvent(view, 'layout', { nativeEvent: { layout: { width: 50, height: NaN } } }); + expect(nativeState.layoutSizeForInstance.get(view)).toEqual({ width: 50, height: 0 }); + }); + + test('saves layout size in native state even without onLayout handler', async () => { + const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); + await render(); + const view = screen.getByTestId('view'); + + await fireEvent.layout(view, { width: 100, height: 80 }); + + expect(nativeState.layoutSizeForInstance.get(view)).toEqual({ width: 100, height: 80 }); + warnSpy.mockRestore(); + }); + test('does not call onLayout of composite component that does not forward it', async () => { const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); const onLayout = jest.fn(); diff --git a/src/fire-event.ts b/src/fire-event.ts index 695d515a9..6615329fd 100644 --- a/src/fire-event.ts +++ b/src/fire-event.ts @@ -18,7 +18,7 @@ import { logger } from './helpers/logger'; import { isPointerEventEnabled } from './helpers/pointer-events'; import { isEditableTextInput } from './helpers/text-input'; import { nativeState } from './native-state'; -import type { Point, StringWithAutocomplete } from './types'; +import type { Point, Size, StringWithAutocomplete } from './types'; function isTouchResponder(instance: TestInstance) { return Boolean(instance.props.onStartShouldSetResponder) || isHostTextInput(instance); @@ -184,7 +184,10 @@ fireEvent.press = async (instance: TestInstance, eventProps?: EventProps) => { }; fireEvent.scroll = async (instance: TestInstance, eventProps?: EventProps) => { - const event = buildScrollEvent(); + const layoutMeasurement = isHostScrollView(instance) + ? nativeState.layoutSizeForInstance.get(instance) + : undefined; + const event = buildScrollEvent(undefined, { layoutMeasurement }); if (eventProps) { mergeEventProps(event, eventProps); } @@ -217,6 +220,13 @@ function setNativeStateIfNeeded(instance: TestInstance, eventName: string, value nativeState.contentOffsetForInstance.set(instance, contentOffset); } } + + if (eventName === 'layout' || eventName === 'onLayout') { + const layoutSize = tryGetLayoutSize(value); + if (layoutSize) { + nativeState.layoutSizeForInstance.set(instance, layoutSize); + } + } } function tryGetContentOffset(event: unknown): Point | null { @@ -239,6 +249,26 @@ function tryGetContentOffset(event: unknown): Point | null { return null; } +function tryGetLayoutSize(event: unknown): Size | null { + try { + // @ts-expect-error: try to extract layout from the event value + const layout = event?.nativeEvent?.layout; + const width = layout?.width; + const height = layout?.height; + + if (typeof width === 'number' || typeof height === 'number') { + return { + width: Number.isFinite(width) ? width : 0, + height: Number.isFinite(height) ? height : 0, + }; + } + } catch { + // Do nothing + } + + return null; +} + function mergeEventProps(target: Record, source: Record) { for (const key of Object.keys(source)) { const sourceValue = source[key]; diff --git a/src/native-state.ts b/src/native-state.ts index 4c78b41a7..14107f042 100644 --- a/src/native-state.ts +++ b/src/native-state.ts @@ -1,6 +1,6 @@ import type { TestInstance } from 'test-renderer'; -import type { Point } from './types'; +import type { Point, Size } from './types'; /** * Simulated native state for unmanaged controls. @@ -10,9 +10,11 @@ import type { Point } from './types'; export type NativeState = { valueForInstance: WeakMap; contentOffsetForInstance: WeakMap; + layoutSizeForInstance: WeakMap; }; export const nativeState: NativeState = { valueForInstance: new WeakMap(), contentOffsetForInstance: new WeakMap(), + layoutSizeForInstance: new WeakMap(), }; diff --git a/src/user-event/scroll/__tests__/scroll-to.test.tsx b/src/user-event/scroll/__tests__/scroll-to.test.tsx index d0ff2a9dd..3deb234fa 100644 --- a/src/user-event/scroll/__tests__/scroll-to.test.tsx +++ b/src/user-event/scroll/__tests__/scroll-to.test.tsx @@ -217,6 +217,34 @@ describe('scrollTo()', () => { ); }); + test('uses layout size from previous layout event as layoutMeasurement', async () => { + const { events } = await renderScrollViewWithToolkit({ onLayout: () => {} }); + const user = userEvent.setup(); + const scrollView = screen.getByTestId('scrollView'); + + await fireEvent.layout(scrollView, { width: 390, height: 750 }); + await user.scrollTo(scrollView, { y: 100 }); + + expect(events.length).toBeGreaterThan(0); + for (const event of events) { + expect(event.payload.nativeEvent.layoutMeasurement).toEqual({ width: 390, height: 750 }); + } + }); + + test('prefers layoutMeasurement option over layout size from layout event', async () => { + const { events } = await renderScrollViewWithToolkit({ onLayout: () => {} }); + const user = userEvent.setup(); + const scrollView = screen.getByTestId('scrollView'); + + await fireEvent.layout(scrollView, { width: 390, height: 750 }); + await user.scrollTo(scrollView, { y: 100, layoutMeasurement: { width: 100, height: 200 } }); + + expect(events.length).toBeGreaterThan(0); + for (const event of events) { + expect(event.payload.nativeEvent.layoutMeasurement).toEqual({ width: 100, height: 200 }); + } + }); + test('is accessible directly in userEvent', async () => { const { events } = await renderScrollViewWithToolkit(); diff --git a/src/user-event/scroll/scroll-to.ts b/src/user-event/scroll/scroll-to.ts index d9652ca43..d24a372d4 100644 --- a/src/user-event/scroll/scroll-to.ts +++ b/src/user-event/scroll/scroll-to.ts @@ -1,6 +1,7 @@ import { stringify } from 'jest-matcher-utils'; import type { TestInstance } from 'test-renderer'; +import type { ScrollEventOptions } from '../../event-builder'; import { buildScrollEvent } from '../../event-builder'; import { ErrorWithStack } from '../../helpers/errors'; import { isHostScrollView } from '../../helpers/host-component-names'; @@ -50,6 +51,11 @@ export async function scrollTo( ensureScrollViewDirection(instance, options); + const eventOptions: ScrollEventOptions = { + contentSize: options.contentSize, + layoutMeasurement: options.layoutMeasurement ?? nativeState.layoutSizeForInstance.get(instance), + }; + await dispatchEvent( instance, 'contentSizeChange', @@ -66,7 +72,7 @@ export async function scrollTo( initialOffset, linearInterpolator, ); - await emitDragScrollEvents(this.config, instance, dragSteps, options); + await emitDragScrollEvents(this.config, instance, dragSteps, eventOptions); const momentumStart = dragSteps.at(-1) ?? initialOffset; const momentumSteps = createScrollSteps( @@ -74,7 +80,7 @@ export async function scrollTo( momentumStart, inertialInterpolator, ); - await emitMomentumScrollEvents(this.config, instance, momentumSteps, options); + await emitMomentumScrollEvents(this.config, instance, momentumSteps, eventOptions); const finalOffset = momentumSteps.at(-1) ?? dragSteps.at(-1) ?? initialOffset; nativeState.contentOffsetForInstance.set(instance, finalOffset); @@ -84,7 +90,7 @@ async function emitDragScrollEvents( config: UserEventConfig, instance: TestInstance, scrollSteps: Point[], - scrollOptions: ScrollToOptions, + scrollOptions: ScrollEventOptions, ) { if (scrollSteps.length === 0) { return; @@ -110,7 +116,7 @@ async function emitMomentumScrollEvents( config: UserEventConfig, instance: TestInstance, scrollSteps: Point[], - scrollOptions: ScrollToOptions, + scrollOptions: ScrollEventOptions, ) { if (scrollSteps.length === 0) { return; 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 df456637a..f514fc031 100644 --- a/website/docs/14.x/docs/api/events/fire-event.mdx +++ b/website/docs/14.x/docs/api/events/fire-event.mdx @@ -145,6 +145,8 @@ fireEvent.scroll: ( Builds a scroll event object, merges `eventProps` into it, and invokes the `scroll` handler on the element or nearest eligible parent. +When fired on a host `ScrollView` that received a [layout event](#layout) before, the event's `layoutMeasurement` defaults to the size from that layout event. Values passed in `eventProps` take precedence. + #### On a `ScrollView` ```jsx @@ -188,6 +190,8 @@ Unlike other `fireEvent` calls, layout events do not bubble: React Native delive 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. +The element's layout size is remembered, so later [scroll events](#scroll) and [`userEvent.scrollTo()`](/docs/api/events/user-event#scroll-to) calls on the same `ScrollView` use it as their `layoutMeasurement`. + ```jsx import { View } from 'react-native'; import { render, screen, fireEvent } from '@testing-library/react-native'; diff --git a/website/docs/14.x/docs/api/events/user-event.mdx b/website/docs/14.x/docs/api/events/user-event.mdx index efa0e4dc2..c5e1886b2 100644 --- a/website/docs/14.x/docs/api/events/user-event.mdx +++ b/website/docs/14.x/docs/api/events/user-event.mdx @@ -270,7 +270,7 @@ Each scroll interaction consists of a mandatory drag scroll part, which simulate - `momentumY` - target vertical momentum scroll offset - `momentumX` - target horizontal momentum scroll offset - `contentSize` - passed to `ScrollView` events and enabling `FlatList` updates -- `layoutMeasurement` - passed to `ScrollView` events and enabling `FlatList` updates +- `layoutMeasurement` - passed to `ScrollView` events and enabling `FlatList` updates. Defaults to the size from the last [`fireEvent.layout()`](/docs/api/events/fire-event#layout) on the `ScrollView`, if any. User Event will generate several intermediate scroll steps to simulate user scroll interaction. You should not rely on exact number or values of these scrolls steps as they might be change in the future version. From 24018abc988d1fc04b0cf24eaf9ec0f639d4ee3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Mon, 5 Oct 2026 11:18:02 +0200 Subject: [PATCH 5/9] tweaks --- CHANGELOG.md | 5 +++-- src/fire-event.ts | 4 +++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28f4a9d2f..2816691b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,9 @@ with v14. - 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, and the measured size is used as `layoutMeasurement` in later `fireEvent.scroll()` - and `userEvent.scrollTo()` calls on the same `ScrollView`. + 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 element, invoking its `onAccessibilityAction` handler. - Added `userEvent.pullToRefresh()` to simulate the pull-to-refresh gesture on a host diff --git a/src/fire-event.ts b/src/fire-event.ts index 6615329fd..5675fda80 100644 --- a/src/fire-event.ts +++ b/src/fire-event.ts @@ -13,6 +13,7 @@ import { buildLayoutEvent, buildScrollEvent, buildTouchEvent } from './event-bui import type { EventHandler } from './event-handler'; import { getEventHandlerFromProps } from './event-handler'; import { isInstanceMounted } from './helpers/component-tree'; +import { formatElement } from './helpers/format-element'; import { isHostScrollView, isHostTextInput } from './helpers/host-component-names'; import { logger } from './helpers/logger'; import { isPointerEventEnabled } from './helpers/pointer-events'; @@ -84,7 +85,8 @@ function getDirectEventHandler(instance: TestInstance, eventName: string): Event const handler = getEventHandlerFromProps(instance.props, eventName, { loose: true }); if (!handler) { logger.warn( - `fireEvent: element has no handler for "${eventName}" event. Direct events are not looked up on ancestor elements.`, + `fireEvent: element has no handler for "${eventName}" event.`, + formatElement(instance), ); return null; } From f706fe6d9e4c86620326fc3f35eec0b2ff05de60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Mon, 5 Oct 2026 11:19:05 +0200 Subject: [PATCH 6/9] . --- src/__tests__/fire-event.test.tsx | 9 ++++++--- src/user-event/scroll/scroll-to.ts | 9 ++++----- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/__tests__/fire-event.test.tsx b/src/__tests__/fire-event.test.tsx index 72733394b..82fbcf69e 100644 --- a/src/__tests__/fire-event.test.tsx +++ b/src/__tests__/fire-event.test.tsx @@ -557,9 +557,12 @@ describe('fireEvent.layout', () => { await fireEvent.layout(screen.getByTestId('view')); expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy.mock.calls[0][0]).toContain( - 'fireEvent: element has no handler for "layout" event.', - ); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + " ▲ fireEvent: element has no handler for "layout" event. + " + `); warnSpy.mockRestore(); }); diff --git a/src/user-event/scroll/scroll-to.ts b/src/user-event/scroll/scroll-to.ts index d24a372d4..f51bf6ffb 100644 --- a/src/user-event/scroll/scroll-to.ts +++ b/src/user-event/scroll/scroll-to.ts @@ -1,7 +1,6 @@ import { stringify } from 'jest-matcher-utils'; import type { TestInstance } from 'test-renderer'; -import type { ScrollEventOptions } from '../../event-builder'; import { buildScrollEvent } from '../../event-builder'; import { ErrorWithStack } from '../../helpers/errors'; import { isHostScrollView } from '../../helpers/host-component-names'; @@ -51,8 +50,8 @@ export async function scrollTo( ensureScrollViewDirection(instance, options); - const eventOptions: ScrollEventOptions = { - contentSize: options.contentSize, + const eventOptions: ScrollToOptions = { + ...options, layoutMeasurement: options.layoutMeasurement ?? nativeState.layoutSizeForInstance.get(instance), }; @@ -90,7 +89,7 @@ async function emitDragScrollEvents( config: UserEventConfig, instance: TestInstance, scrollSteps: Point[], - scrollOptions: ScrollEventOptions, + scrollOptions: ScrollToOptions, ) { if (scrollSteps.length === 0) { return; @@ -116,7 +115,7 @@ async function emitMomentumScrollEvents( config: UserEventConfig, instance: TestInstance, scrollSteps: Point[], - scrollOptions: ScrollEventOptions, + scrollOptions: ScrollToOptions, ) { if (scrollSteps.length === 0) { return; From f67c437dd8b3365479376c84cceb031a5455d99c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Mon, 5 Oct 2026 11:23:06 +0200 Subject: [PATCH 7/9] docs --- docs/api/fire-event.md | 2 +- website/docs/14.x/docs/api/events/fire-event.mdx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/api/fire-event.md b/docs/api/fire-event.md index 0ca462895..c71a88e8d 100644 --- a/docs/api/fire-event.md +++ b/docs/api/fire-event.md @@ -140,7 +140,7 @@ fireEvent.scroll: ( Builds a scroll event object, merges `eventProps` into it, and invokes the `scroll` handler on the element or nearest eligible parent. -When fired on a host `ScrollView` that received a [layout event](#layout) before, the event's `layoutMeasurement` defaults to the size from that layout event. Values passed in `eventProps` take precedence. +The scroll event will include the layout size from the most recent [`fireEvent.layout()`](#layout) call on the same `ScrollView` as its `layoutMeasurement`, unless you pass one in `eventProps`. #### On a `ScrollView` 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 f514fc031..7ad6b68a0 100644 --- a/website/docs/14.x/docs/api/events/fire-event.mdx +++ b/website/docs/14.x/docs/api/events/fire-event.mdx @@ -145,7 +145,7 @@ fireEvent.scroll: ( Builds a scroll event object, merges `eventProps` into it, and invokes the `scroll` handler on the element or nearest eligible parent. -When fired on a host `ScrollView` that received a [layout event](#layout) before, the event's `layoutMeasurement` defaults to the size from that layout event. Values passed in `eventProps` take precedence. +The scroll event will include the layout size from the most recent [`fireEvent.layout()`](#layout) call on the same `ScrollView` as its `layoutMeasurement`, unless you pass one in `eventProps`. #### On a `ScrollView` From 4cbe55e3048df76cdba461be81eb8a4fca84b0ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Mon, 5 Oct 2026 11:26:30 +0200 Subject: [PATCH 8/9] tweaks --- docs/api/fire-event.md | 2 +- website/docs/14.x/docs/api/events/fire-event.mdx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/api/fire-event.md b/docs/api/fire-event.md index c71a88e8d..8e550c925 100644 --- a/docs/api/fire-event.md +++ b/docs/api/fire-event.md @@ -180,7 +180,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. If the element has no `onLayout` handler, a warning is logged and nothing is called. The handler is called even if the element's own `onStartShouldSetResponder` returns `false`. An `onLayout` prop on a composite component is only called if the component passes it down to a host element. +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. 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/website/docs/14.x/docs/api/events/fire-event.mdx b/website/docs/14.x/docs/api/events/fire-event.mdx index 7ad6b68a0..b1d51e086 100644 --- a/website/docs/14.x/docs/api/events/fire-event.mdx +++ b/website/docs/14.x/docs/api/events/fire-event.mdx @@ -186,7 +186,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. If the element has no `onLayout` handler, a warning is logged and nothing is called. The handler is called even if the element's own `onStartShouldSetResponder` returns `false`. An `onLayout` prop on a composite component is only called if the component passes it down to a host element. +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. 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 d3429167532e057c5e7a5ee168177ee2fa84dc7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Mon, 5 Oct 2026 11:31:35 +0200 Subject: [PATCH 9/9] . --- src/fire-event.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/fire-event.ts b/src/fire-event.ts index 5675fda80..c5a6742f6 100644 --- a/src/fire-event.ts +++ b/src/fire-event.ts @@ -81,7 +81,7 @@ function isDirectEvent(eventName: string) { return eventName === 'layout' || eventName === 'onLayout'; } -function getDirectEventHandler(instance: TestInstance, eventName: string): EventHandler | null { +function getOwnEventHandler(instance: TestInstance, eventName: string): EventHandler | null { const handler = getEventHandlerFromProps(instance.props, eventName, { loose: true }); if (!handler) { logger.warn( @@ -157,7 +157,7 @@ async function fireEvent(instance: TestInstance, eventName: EventName, ...data: setNativeStateIfNeeded(instance, eventName, data[0]); const handler = isDirectEvent(eventName) - ? getDirectEventHandler(instance, eventName) + ? getOwnEventHandler(instance, eventName) : findEventHandler(instance, eventName); if (!handler) { return;