Skip to content

Commit 2155247

Browse files
fix: fireEvent.layout direct event (#1937)
1 parent b87a246 commit 2155247

14 files changed

Lines changed: 329 additions & 21 deletions

File tree

‎AGENTS.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,6 @@
1919
- [Build, validation, and repo layout](agents/build-and-validation.md)
2020
- [TypeScript and code style](agents/code-style.md)
2121
- [Testing conventions](agents/testing.md)
22+
- [Native event propagation (bubbling vs direct)](agents/native-events.md)
2223
- [Example app regeneration](agents/example-apps.md)
2324
- [Git, releases, and PR workflow](agents/git-workflow.md)

‎CHANGELOG.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ with v14.
88
### Features
99

1010
- Added `fireEvent.layout()` to simulate the layout engine measuring an element, invoking the
11-
`onLayout` handler with a synthetic layout event.
11+
`onLayout` handler with a synthetic layout event. Layout events do not bubble to parent
12+
elements.
13+
- `fireEvent.scroll()` and `userEvent.scrollTo()` use the size from the last layout event on the
14+
same `ScrollView` as the default `layoutMeasurement`.
1215
- Added `userEvent.accessibilityAction()` to dispatch a named accessibility action to an
1316
element, invoking its `onAccessibilityAction` handler.
1417
- Added `userEvent.pullToRefresh()` to simulate the pull-to-refresh gesture on a host

‎agents/native-events.md‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# Native Event Propagation
2+
3+
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.
4+
5+
## How to read this
6+
7+
- Native event names use a `top` prefix that maps to the `on*` prop: `topLayout` → `onLayout`. The tables below use the short name (`layout`).
8+
- Every host component inherits the **base view config** events and adds its own component-specific events on top of them.
9+
- `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.
10+
- Snapshot taken from `react-native@0.88.0-rc.1`. See [Sources](#sources) to re-check after RN upgrades.
11+
12+
## Base view config (all host components)
13+
14+
| Kind | iOS | Android |
15+
| -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
16+
| 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*`\* |
17+
| Direct | `layout`, `accessibilityAction`, `accessibilityTap`, `magicTap`, `accessibilityEscape` | `layout`, `accessibilityAction`, `scroll`, `scrollBeginDrag`, `scrollEndDrag`, `momentumScrollBegin`, `momentumScrollEnd`, `contentSizeChange`, `selectionChange`, `message`, `loadingStart`, `loadingFinish`, `loadingError` |
18+
19+
\* `pointer*` = `pointerDown`, `pointerMove`, `pointerUp`, `pointerCancel`, `pointerEnter`, `pointerLeave`, `pointerOver`, `pointerOut`, `gotPointerCapture`, `lostPointerCapture`.
20+
21+
Both platforms also register `onGestureHandlerEvent` and `onGestureHandlerStateChange` as direct events for React Native Gesture Handler.
22+
23+
## Component-specific events
24+
25+
Events listed here are added on top of the base view config. "Host name" is the native `uiViewClassName` (or codegen component name).
26+
27+
| Component | Host name | Bubbling | Direct |
28+
| ---------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- |
29+
| `View`, `Pressable`, etc. | `RCTView` | — | — |
30+
| `Text` | `RCTText` (nested: `RCTVirtualText`, no extra events) | — | `textLayout` |
31+
| `TextInput` (iOS) | `RCTSinglelineTextInputView`, `RCTMultilineTextInputView` | `blur`, `change`, `endEditing`, `focus`, `keyPress`, `submitEditing`, `touchMove`, `touchEnd`, `touchCancel` | `scroll`, `selectionChange`, `contentSizeChange`, `changeSync`, `keyPressSync` |
32+
| `TextInput` (Android) | `AndroidTextInput` | `endEditing`, `keyPress`, `submitEditing` | `scroll` |
33+
| `ScrollView` | `RCTScrollView` | — | `scroll`, `scrollBeginDrag`, `scrollEndDrag`, `momentumScrollBegin`, `momentumScrollEnd`; iOS also `scrollToTop` |
34+
| `ScrollView` (horizontal, Android) | `AndroidHorizontalScrollView` | — | — (scroll events come from the Android base config) |
35+
| `Image` | `RCTImageView` | — | `loadStart`, `progress`, `error`, `load`, `loadEnd`; iOS also `partialLoad` |
36+
| `Switch` (iOS) | `Switch` | `change` | — |
37+
| `Switch` (Android) | `AndroidSwitch` | `change` | — |
38+
| `Modal` | `ModalHostView` | — | `requestClose`, `show`, `dismiss`, `orientationChange` |
39+
| `RefreshControl` (iOS) | `PullToRefreshView` | — | `refresh` |
40+
| `RefreshControl` (Android) | `AndroidSwipeRefreshLayout` | — | `refresh` |
41+
| `DrawerLayoutAndroid` | `AndroidDrawerLayout` | — | `drawerSlide`, `drawerStateChanged`, `drawerOpen`, `drawerClose` |
42+
43+
## Known gaps in RNTL
44+
45+
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:
46+
47+
- Scroll events: `scroll`, `scrollBeginDrag`, `scrollEndDrag`, `momentumScrollBegin`, `momentumScrollEnd`
48+
- `contentSizeChange`, `selectionChange`, `textLayout`
49+
- `Image` load events, `Modal` events, `refresh`
50+
51+
## Sources
52+
53+
All paths are relative to `node_modules/react-native`:
54+
55+
- Base config: `Libraries/NativeComponent/BaseViewConfig.ios.js`, `Libraries/NativeComponent/BaseViewConfig.android.js`
56+
- 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`
57+
- Codegen specs (`DirectEventHandler` vs `BubblingEventHandler` prop types): `src/private/components/*/specs/*NativeComponent.js`

‎docs/api/fire-event.md‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,8 @@ fireEvent.scroll: (
140140

141141
Builds a scroll event object, merges `eventProps` into it, and invokes the `scroll` handler on the element or nearest eligible parent.
142142

143+
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`.
144+
143145
#### On a `ScrollView`
144146

145147
```jsx
@@ -176,10 +178,14 @@ fireEvent.layout: (
176178
) => Promise<void>
177179
```
178180

179-
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.
181+
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.
182+
183+
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.
180184

181185
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.
182186

187+
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`.
188+
183189
```jsx
184190
import { View } from 'react-native';
185191
import { render, screen, fireEvent } from '@testing-library/react-native';

‎docs/api/user-event.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ Each scroll interaction consists of a mandatory drag scroll part, which simulate
269269
- `momentumY` - target vertical momentum scroll offset
270270
- `momentumX` - target horizontal momentum scroll offset
271271
- `contentSize` - passed to `ScrollView` events and enabling `FlatList` updates
272-
- `layoutMeasurement` - passed to `ScrollView` events and enabling `FlatList` updates
272+
- `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.
273273

274274
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.
275275

‎src/__tests__/event-handler.test.tsx‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,17 @@ test('getEventHandler strict mode', async () => {
2626
expect(getEventHandlerFromProps(testOnly.props, 'press')).toBe(testOnlyOnPress);
2727
expect(getEventHandlerFromProps(both.props, 'press')).toBe(onPress);
2828

29-
expect(getEventHandlerFromProps(regular.props, 'onPress')).toBe(undefined);
30-
expect(getEventHandlerFromProps(testOnly.props, 'onPress')).toBe(undefined);
31-
expect(getEventHandlerFromProps(both.props, 'onPress')).toBe(undefined);
29+
expect(getEventHandlerFromProps(regular.props, 'onPress')).toBe(onPress);
30+
expect(getEventHandlerFromProps(testOnly.props, 'onPress')).toBe(testOnlyOnPress);
31+
expect(getEventHandlerFromProps(both.props, 'onPress')).toBe(onPress);
32+
});
33+
34+
test('getEventHandler does not treat event names starting with "on" as prefixed', async () => {
35+
const onOnline = jest.fn();
36+
// @ts-expect-error Intentionally passing such props
37+
await render(<View testID="view" onOnline={onOnline} />);
38+
39+
expect(getEventHandlerFromProps(screen.getByTestId('view').props, 'online')).toBe(onOnline);
3240
});
3341

3442
test('getEventHandler loose mode', async () => {

0 commit comments

Comments
 (0)