Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions agents/native-events.md
Original file line number Diff line number Diff line change
@@ -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`
8 changes: 7 additions & 1 deletion docs/api/fire-event.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -176,10 +178,14 @@ fireEvent.layout: (
) => Promise<void>
```

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';
Expand Down
2 changes: 1 addition & 1 deletion docs/api/user-event.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
14 changes: 11 additions & 3 deletions src/__tests__/event-handler.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<View testID="view" onOnline={onOnline} />);

expect(getEventHandlerFromProps(screen.getByTestId('view').props, 'online')).toBe(onOnline);
});

test('getEventHandler loose mode', async () => {
Expand Down
Loading
Loading