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/CHANGELOG.md b/CHANGELOG.md index 6e6a1754c..2816691b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,10 @@ 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. +- `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/agents/native-events.md b/agents/native-events.md new file mode 100644 index 000000000..9c4a1fa89 --- /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. 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) + +| 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..8e550c925 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. +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` ```jsx @@ -176,10 +178,14 @@ 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. 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__/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/__tests__/fire-event.test.tsx b/src/__tests__/fire-event.test.tsx index ea39223ac..82fbcf69e 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 } } }; @@ -423,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', () => { @@ -468,18 +517,99 @@ 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('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.layout(screen.getByText('Content'), { height: 80 }); + 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(); + + await fireEvent.layout(screen.getByTestId('view')); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + " ▲ fireEvent: element has no handler for "layout" event. + " + `); + warnSpy.mockRestore(); + }); + + test('is not blocked by element responder rejecting touches', async () => { + const onLayout = jest.fn(); + await render( + false} />, + ); + + await fireEvent.layout(screen.getByTestId('view')); expect(onLayout).toHaveBeenCalledTimes(1); - expect(onLayout.mock.calls[0][0].nativeEvent.layout.height).toBe(80); + }); + + 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(); + const Box = (_props: { onLayout: () => void }) => ; + await render(); + + await fireEvent.layout(screen.getByTestId('view')); + + expect(onLayout).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledTimes(1); + warnSpy.mockRestore(); }); }); @@ -745,6 +875,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(); @@ -779,8 +910,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 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 4b64b652c..c5a6742f6 100644 --- a/src/fire-event.ts +++ b/src/fire-event.ts @@ -13,11 +13,13 @@ 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'; 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); @@ -71,6 +73,27 @@ function isEventEnabled( return touchStart === undefined && touchMove === undefined; } +/** + * 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'; +} + +function getOwnEventHandler(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.`, + formatElement(instance), + ); + return null; + } + + return handler; +} + function findEventHandler( instance: TestInstance, eventName: string, @@ -133,7 +156,9 @@ async function fireEvent(instance: TestInstance, eventName: EventName, ...data: setNativeStateIfNeeded(instance, eventName, data[0]); - const handler = findEventHandler(instance, eventName); + const handler = isDirectEvent(eventName) + ? getOwnEventHandler(instance, eventName) + : findEventHandler(instance, eventName); if (!handler) { return; } @@ -161,7 +186,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); } @@ -194,6 +222,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 { @@ -216,6 +251,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..f51bf6ffb 100644 --- a/src/user-event/scroll/scroll-to.ts +++ b/src/user-event/scroll/scroll-to.ts @@ -50,6 +50,11 @@ export async function scrollTo( ensureScrollViewDirection(instance, options); + const eventOptions: ScrollToOptions = { + ...options, + layoutMeasurement: options.layoutMeasurement ?? nativeState.layoutSizeForInstance.get(instance), + }; + await dispatchEvent( instance, 'contentSizeChange', @@ -66,7 +71,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 +79,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); 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..b1d51e086 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. +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` ```jsx @@ -182,10 +184,14 @@ 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. 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.