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
10 changes: 9 additions & 1 deletion contributing/event-dispatch.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@

RNTL has two ways to trigger events. Neither goes through React Native's native event system. Both find `on*` props in the rendered tree and call them inside `act()`.

Both are built on the shared event subsystem in `src/events/legacy/`, which also holds `fireEvent` itself. This is the `'legacy'` event system, the default for the `unstable_eventSystem` config option. A `'modern'` event system that follows React Native's event dispatch is in progress.
Both are built on the shared event subsystem in `src/events/legacy/`, which also holds `fireEvent` itself. This is the `'legacy'` event system, the default for the `unstable_eventSystem` config option.

The `'modern'` event system (`src/events/modern/`), still in progress, follows the W3C [`EventTarget`](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget) dispatch that React Native enables by default from 0.88 (the `enableNativeEventTargetEventDispatching` feature flag; `src/private/renderer/events/dispatchNativeEvent.js` and `src/private/webapis/dom/events/EventTarget.js` in `node_modules/react-native`). The path holds only host elements. Bubbling events run a capture phase from the root to the target (`on*Capture` props), then a bubble phase back up (`on*` props), and direct events reach only the target. Event objects have the W3C `Event` fields (`eventPhase`, `currentTarget`, `composedPath()`, `stopPropagation()`, `preventDefault()`) next to the legacy synthetic event API (`nativeEvent`, `persist()`, `isDefaultPrevented()`). Check changes to modern dispatch against these upstream files, not React's legacy plugin system.

RNTL uses this dispatch with every supported React Native version (`>=0.78`), including those before 0.88, which still dispatch through React's legacy plugin system. Prop names, handler order and the synthetic event API are the same on both paths, so tests on older versions get the 0.88 behavior and the W3C fields on top. The modern event system doesn't check the React Native version or its feature flag. See [the 0.88 alignment research](research/rn-0.88-w3c-event-dispatch-alignment.md) for where RNTL matches React Native and where it still differs, e.g. responder negotiation for fired touch events and `addEventListener()` listeners.

The files in `src/events/legacy/`:

Expand All @@ -20,8 +24,12 @@ In the modern event system, `src/events/modern/pressability.ts` calls the `onPre

Modern `fireEvent` throws for event types React Native doesn't know (`ensureEventType()` in `src/events/modern/fire-event-utils.ts`), unless the event object has `dispatch: 'direct' | 'bubbling'`. This is for native events of third-party components, which render as host elements with all their props in Jest, e.g. `fireEvent(pager, 'pageSelected', { nativeEvent: { position: 1 }, dispatch: 'direct' })`. `getCustomEventTypeConfig()` in `src/events/modern/event-types.ts` builds the config codegen would: handler props named after the event type, and no `skipBubbling`, which codegen can't declare. `dispatch` is required, not defaulted, so a legacy call for a JavaScript callback that the Jest mocks put on a host, like `fireEvent(list, 'endReached', ...)`, still throws and points to `UNSAFE_invokeHandler()`. For known event types, `dispatch` overrides React Native's config, as a native component can declare its own event under a built-in name with different semantics, e.g. `react-native-maps` declares `onPress` as a `BubblingEventHandler`. `createEvent()` then uses `getCustomEventTypeConfig()`, and Pressability event types skip Pressability and are dispatched with `dispatchEvent()`. `fireEvent.press()` with `dispatch` also skips the default touch payload. `dispatch` on a `Pressable` therefore calls no callback, as its host has no `onPress` prop.

Modern `fireEvent` drops or moves events a device wouldn't emit from the element (`getEmittingTarget()` in `src/events/modern/emitting-target.ts`), before updating native state, so a dropped `change` leaves it alone. Touch and pointer events (`touchStart`, `pointerDown`, `click`, ...) go to the hit target, found with the same `getTouchPath()` as `press`, so `pointerEvents` moves them to an ancestor or drops them, and `event.target` is the hit target. `display: none` on the element or an ancestor drops them instead of moving them, as a device lays out a hidden view with no frame (iOS doesn't mount it, Android makes it `INVISIBLE`), so no touch can land in its subtree. Text editing events (`focus`, `blur`, `change`, `submitEditing`, `keyPress`, ...) go to the nearest host `TextInput`, as a nested `Text` isn't a native view, and are dropped when it isn't editable. `selectionChange` isn't one of them, as iOS emits it for a non-editable multiline `TextInput`. `change` of a disabled `Switch` is dropped. `disabled` doesn't block touch and pointer events, as on iOS, where only Pressability checks it; on Android, a disabled view is never the hit target, which RNTL doesn't model. Events with `dispatch` keep their target, as the native component declaring them decides when to emit them. The checks aren't in `dispatchEvent()`, which `userEvent` also uses and which stays simple; `userEvent` actions do their own checks. Events are dropped silently, as modern `fireEvent` has no `eventDiagnostics` warnings yet.

Modern `fireEvent` takes event types without the `on*` prefix (`press`, not `onPress`), as React Native names them. Like legacy `fireEvent`, it still strips the prefix, but logs a deprecation warning (`warnOnPrefix()` in `src/events/modern/fire-event-utils.ts`), after its checks, so a call that throws doesn't also warn. Support for the prefix will be removed in the next major release. Legacy `fireEvent` accepts both without a warning.

Modern `fireEvent`'s event object is optional and defaults to `{}`, as the handler gets a `SyntheticEvent` either way. `ensureAtMostOneEventArg()` rejects only extra arguments, which catch legacy calls passing several handler arguments. Event types React Native doesn't know still throw without one, as they need `dispatch`. Touch events (`touchStart`, `touchMove`, `touchEnd`, `touchCancel`) get a default touch payload from `buildTouchPayload()` (`src/events/modern/touch-payload.ts`), the one Pressability callbacks get, with the passed `nativeEvent` deep merged onto it. Other event types, and events with `dispatch`, get the passed `nativeEvent` as is.

Modern `fireEvent` doesn't log `eventDiagnostics` warnings yet. Only legacy `fireEvent` and `userEvent` do.

`src/user-event/` is a separate module on top of the event subsystem. It creates and dispatches native events through the facades `src/events/create-event.ts` and `src/events/dispatch-event.ts`. For a legacy event object, `dispatchEvent()` calls the target's own handler with `invokeEventHandler()` from `src/events/shared/invoke-event-handler.ts`. Other modules are imported directly: legacy touch and responder event builders from `src/events/legacy/builders/common.ts`, and `src/events/modern/` and `src/events/shared/` modules, e.g. `src/user-event/modern/press.ts` (hit testing and responder negotiation from `pressability.ts`, handler lookup from `handler.ts`) and `src/user-event/utils/` (`invokeEventHandler()`, the `SyntheticEvent` type and the warning helpers from `warnings.ts`).
Expand Down
4 changes: 3 additions & 1 deletion contributing/native-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

In React Native, some events **bubble** up to parent elements and others are **direct**, meaning only the element that emitted them receives them. `fireEvent` should behave the same way.

Today, `fireEvent` still bubbles direct events, with a warning (see [Known gaps](#known-gaps)). Only `fireEvent.layout()` does not bubble. The rules live in `isDirectEvent()` in `src/events/legacy/propagation.ts`.
In the `'legacy'` event system, the default, `fireEvent` still bubbles direct events, with a warning (see [Known gaps](#known-gaps)). Only `fireEvent.layout()` does not bubble. The rules live in `isDirectEvent()` in `src/events/legacy/propagation.ts`.

The `'modern'` event system dispatches as React Native 0.88 does, through its W3C `EventTarget` dispatch: direct events reach only the target, and bubbling events run capture and bubble phases. It does so on every supported React Native version, including those before 0.88. Bubbling and direct event types come from `src/events/modern/event-types.ts`. See [Event dispatch](event-dispatch.md).

## Which events are which

Expand Down
2 changes: 1 addition & 1 deletion refs/react-native
15 changes: 9 additions & 6 deletions src/__tests__/modern-types/fire-event.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,13 @@ test('accepts known event types, with or without the deprecated "on" prefix', as

await fireEvent(input, 'focus', emptyEvent);
await fireEvent(input, 'onLayout', { nativeEvent: { layout: { width: 100 } }, timeStamp: 1 });
// The event object is optional.
await fireEvent(input, 'focus');
await fireEvent(input, 'onLayout');

expect(onFocus).toHaveBeenCalledTimes(1);
expect(onLayout).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(onFocus).toHaveBeenCalledTimes(2);
expect(onLayout).toHaveBeenCalledTimes(2);
expect(warnSpy).toHaveBeenCalledTimes(2);
warnSpy.mockRestore();
});

Expand Down Expand Up @@ -78,6 +81,8 @@ test('rejects custom event types without dispatch', async () => {

// @ts-expect-error `dispatch` is required for event types React Native doesn't know
await expect(fireEvent(pager, 'pageSelected', emptyEvent)).rejects.toThrow();
// @ts-expect-error the event object with `dispatch` is required for these event types
await expect(fireEvent(pager, 'pageSelected')).rejects.toThrow();
// @ts-expect-error `changeText` is not a native event type, the type error suggests alternatives
await expect(fireEvent(pager, 'changeText', 'Hello')).rejects.toThrow();
// @ts-expect-error `changeText` is not a native event type, the type error suggests alternatives
Expand All @@ -86,12 +91,10 @@ test('rejects custom event types without dispatch', async () => {
await expect(fireEvent(pager, 'pageSelected', { dispatch: 'capture' })).rejects.toThrow();
});

test('rejects anything but a single event object', async () => {
test('rejects anything but at most one event object', async () => {
await render(<View testID="view" />);
const view = screen.getByTestId('view');

// @ts-expect-error missing event object
await expect(fireEvent(view, 'focus')).rejects.toThrow();
// @ts-expect-error more than one argument
await expect(fireEvent(view, 'focus', emptyEvent, emptyEvent)).rejects.toThrow();
// @ts-expect-error not an event object
Expand Down
Loading
Loading