diff --git a/contributing/event-dispatch.md b/contributing/event-dispatch.md index 9bb951c1c..05eee8511 100644 --- a/contributing/event-dispatch.md +++ b/contributing/event-dispatch.md @@ -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/`: @@ -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`). diff --git a/contributing/native-events.md b/contributing/native-events.md index f1ccee9f6..3291319d2 100644 --- a/contributing/native-events.md +++ b/contributing/native-events.md @@ -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 diff --git a/refs/react-native b/refs/react-native index db42fda57..e04aaf62f 160000 --- a/refs/react-native +++ b/refs/react-native @@ -1 +1 @@ -Subproject commit db42fda57495eecb35fa2fa8f09a0f6c42ec2434 +Subproject commit e04aaf62f70491c803f0e0653075ec088c7c88f4 diff --git a/src/__tests__/modern-types/fire-event.test.tsx b/src/__tests__/modern-types/fire-event.test.tsx index dfe56b248..bf2c405a6 100644 --- a/src/__tests__/modern-types/fire-event.test.tsx +++ b/src/__tests__/modern-types/fire-event.test.tsx @@ -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(); }); @@ -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 @@ -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(); 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 diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index f736dfb58..3bdb88d09 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -8,6 +8,7 @@ import { PanResponder, Pressable, ScrollView, + Switch, Text, TextInput, TouchableHighlight, @@ -378,40 +379,51 @@ test('fireEvent does not call props of composite components (legacy: calls them) }); describe('event object', () => { - test('throws without an event object (legacy: calls the handler without arguments)', async () => { + test.each([ + ['without an event object', []], + ['with an undefined event object', [undefined]], + ])('fires %s (legacy: calls the handler without arguments)', async (_, args) => { const onFocus = jest.fn(); await render(); - const error = await getErrorMessage(fireEvent(screen.getByTestId('input'), 'focus')); + await fireEvent(screen.getByTestId('input'), 'focus', ...args); // Modern matches React Native, where handlers always receive an event. - expect(error).toBe( - isModern() - ? `Unable to fire a "focus" event. Please provide an event object, e.g. fireEvent(element, 'focus', { nativeEvent: {} }).` - : undefined, - ); - expect(onFocus.mock.calls).toEqual(isModern() ? [] : [[]]); + expect(onFocus.mock.calls).toEqual(isModern() ? [[anySyntheticEvent]] : [args]); + expect(onFocus.mock.calls[0][0]?.nativeEvent).toEqual(isModern() ? {} : undefined); }); - test('throws without an event object for Pressability events (legacy: calls the callback without arguments)', async () => { + test('fires Pressability events without an event object (legacy: calls the callback without arguments)', async () => { const onPressIn = jest.fn(); await render(); - const error = await getErrorMessage(fireEvent(screen.getByTestId('pressable'), 'pressIn')); + await fireEvent(screen.getByTestId('pressable'), 'pressIn'); + + expect(onPressIn.mock.calls).toEqual(isModern() ? [[anySyntheticEvent]] : [[]]); + }); + + test('throws without an event object for unknown event types (legacy: calls the handler without arguments)', async () => { + const onPageSelected = jest.fn(); + await render(); + + const error = await getErrorMessage(fireEvent(screen.getByTestId('pager'), 'pageSelected')); + // Modern needs `dispatch` in the event object to know how to dispatch the event. expect(error).toBe( isModern() - ? `Unable to fire a "pressIn" event. Please provide an event object, e.g. fireEvent(element, 'pressIn', { nativeEvent: {} }).` + ? `Unable to fire a "pageSelected" event. React Native doesn't know "pageSelected". ` + + 'For a native event of a third-party component, pass dispatch: "direct" or "bubbling" ' + + 'in the event object, matching its DirectEventHandler or BubblingEventHandler prop type. ' + + 'To call a handler prop directly, use UNSAFE_invokeHandler().' : undefined, ); - expect(onPressIn.mock.calls).toEqual(isModern() ? [] : [[]]); + expect(onPageSelected.mock.calls).toEqual(isModern() ? [] : [[]]); }); test.each([ ['text', 'string "text"'], [42, 'number 42'], [null, 'null'], - [undefined, 'undefined'], [[{ nativeEvent: {} }], 'an array'], [new Date(0), 'an instance of Date'], [() => {}, 'a function'], @@ -3241,6 +3253,342 @@ describe('pointerEvents prop', () => { }); }); +describe('touch events', () => { + test.each([ + ['touchStart', true], + ['touchMove', true], + ['touchEnd', false], + ['touchCancel', false], + ] as const)( + '"%s" gets a default touch payload (legacy: passes the event object as given)', + async (eventType, isActive) => { + const handler = jest.fn(); + await render(); + + await fireEvent(screen.getByTestId('view'), eventType); + + // Modern matches React Native, where `touches` has the touch until it ends. + expect(handler.mock.calls).toEqual(isModern() ? [[anySyntheticEvent]] : [[]]); + expect(handler.mock.calls[0][0]?.nativeEvent).toEqual( + isModern() ? getTouchPayload(isActive) : undefined, + ); + }, + ); + + test('deep merges the passed nativeEvent onto the default touch payload (legacy: passes it as given)', async () => { + const onTouchMove = jest.fn(); + await render(); + + await fireEvent(screen.getByTestId('view'), 'touchMove', { + nativeEvent: { pageX: 110, pageY: 120 }, + }); + + expect(onTouchMove.mock.calls[0][0].nativeEvent).toEqual( + isModern() ? getTouchPayload(true, { pageX: 110, pageY: 120 }) : { pageX: 110, pageY: 120 }, + ); + }); + + test('keeps touch arrays of the passed nativeEvent', async () => { + const onTouchEnd = jest.fn(); + await render(); + const touches = [{ identifier: 1, pageX: 5, pageY: 5 }]; + + await fireEvent(screen.getByTestId('view'), 'touchEnd', { nativeEvent: { touches } }); + + expect(onTouchEnd.mock.calls[0][0].nativeEvent.touches).toEqual(touches); + expect(onTouchEnd.mock.calls[0][0].nativeEvent.changedTouches).toEqual( + isModern() ? [defaultTouch] : undefined, + ); + }); + + test('takes timeStamp from the touch timestamp', async () => { + const onTouchStart = jest.fn(); + await render(); + + const nowSpy = jest.spyOn(performance, 'now').mockReturnValue(456); + await fireEvent(screen.getByTestId('view'), 'touchStart', {}); + nowSpy.mockRestore(); + + // Legacy passes the event object as given. + expect(onTouchStart.mock.calls[0][0].nativeEvent?.timestamp).toBe(isModern() ? 456 : undefined); + expect(onTouchStart.mock.calls[0][0].timeStamp).toBe(isModern() ? 456 : undefined); + }); + + test('passes the nativeEvent as given with dispatch', async () => { + const onTouchStart = jest.fn(); + await render(); + + await fireEvent(screen.getByTestId('view'), 'touchStart', { + nativeEvent: { pageX: 1 }, + dispatch: 'direct', + }); + + expect(onTouchStart.mock.calls[0][0].nativeEvent).toEqual({ pageX: 1 }); + }); + + // Drag detection from the touch handlers of a `Pressable`, as in Expensify's + // `ToggleDistanceUnitButton`. Its tests fire `touchEnd` without an event object. + function DragAwareButton({ onPress }: { onPress: () => void }) { + const start = React.useRef<{ x: number; y: number } | null>(null); + return ( + { + start.current = { x: event.nativeEvent.pageX, y: event.nativeEvent.pageY }; + }} + onTouchMove={(event) => { + if ( + start.current != null && + (Math.abs(event.nativeEvent.pageX - start.current.x) > 1 || + Math.abs(event.nativeEvent.pageY - start.current.y) > 1) + ) { + start.current = null; + } + }} + onTouchEnd={() => { + if (start.current != null) { + onPress(); + } + start.current = null; + }} + /> + ); + } + + test('runs a tap from touch handlers', async () => { + const onPress = jest.fn(); + await render(); + const button = screen.getByTestId('button'); + + await fireEvent(button, 'touchStart', { nativeEvent: { pageX: 100, pageY: 100 } }); + await fireEvent(button, 'touchEnd'); + + expect(onPress).toHaveBeenCalledTimes(1); + }); + + test('runs a drag from touch handlers', async () => { + const onPress = jest.fn(); + await render(); + const button = screen.getByTestId('button'); + + await fireEvent(button, 'touchStart', { nativeEvent: { pageX: 100, pageY: 100 } }); + await fireEvent(button, 'touchMove', { nativeEvent: { pageX: 110, pageY: 110 } }); + await fireEvent(button, 'touchEnd'); + + expect(onPress).not.toHaveBeenCalled(); + }); +}); + +describe('touch event hit testing', () => { + test('moves touchStart from View with pointerEvents="none" to its parent (legacy: fires on the View)', async () => { + const onParentTouchStart = jest.fn(); + const onTouchStart = jest.fn(); + await render( + + + , + ); + + await fireEvent(screen.getByTestId('view'), 'touchStart', emptyEvent); + + // Modern matches React Native, where hit testing skips the View, so the touch targets its + // parent. Legacy blocks only `press`. + expect(onTouchStart).toHaveBeenCalledTimes(isModern() ? 0 : 1); + expect(onParentTouchStart).toHaveBeenCalledTimes(isModern() ? 1 : 0); + expect(onParentTouchStart.mock.calls[0]?.[0].target).toBe( + isModern() ? screen.getByTestId('parent') : undefined, + ); + }); + + test('drops touchStart when pointerEvents="none" blocks every element (legacy: fires on the View)', async () => { + const onTouchStart = jest.fn(); + await render(); + + await fireEvent(screen.getByTestId('view'), 'touchStart', emptyEvent); + + expect(onTouchStart).toHaveBeenCalledTimes(isModern() ? 0 : 1); + }); + + test('drops touchStart on View with pointerEvents="box-none" (legacy: fires on the View)', async () => { + const onTouchStart = jest.fn(); + await render(); + + await fireEvent(screen.getByTestId('view'), 'touchStart', emptyEvent); + + // Modern matches React Native: the View is never the touch target, and has no ancestor. + expect(onTouchStart).toHaveBeenCalledTimes(isModern() ? 0 : 1); + }); + + test('fires touchStart on child of View with pointerEvents="box-none" (legacy: calls only the child)', async () => { + const onParentTouchStart = jest.fn(); + const onTouchStart = jest.fn(); + await render( + + + , + ); + + await fireEvent(screen.getByTestId('child'), 'touchStart', emptyEvent); + + // Modern matches React Native: the child is the target, and the event bubbles to the parent. + expect(onTouchStart).toHaveBeenCalledTimes(1); + expect(onParentTouchStart).toHaveBeenCalledTimes(isModern() ? 1 : 0); + expect(onTouchStart.mock.calls[0]?.[0].target).toBe( + isModern() ? screen.getByTestId('child') : undefined, + ); + }); + + test('moves touchStart from child to View with pointerEvents="box-only" (legacy: fires on the child)', async () => { + const onParentTouchStart = jest.fn(); + const onTouchStart = jest.fn(); + await render( + + + , + ); + + await fireEvent(screen.getByTestId('child'), 'touchStart', emptyEvent); + + expect(onTouchStart).toHaveBeenCalledTimes(isModern() ? 0 : 1); + expect(onParentTouchStart).toHaveBeenCalledTimes(isModern() ? 1 : 0); + expect(onParentTouchStart.mock.calls[0]?.[0].target).toBe( + isModern() ? screen.getByTestId('parent') : undefined, + ); + }); + + test('moves pointerDown from View with pointerEvents="none" to its parent (legacy: fires on the View)', async () => { + const onParentPointerDown = jest.fn(); + const onPointerDown = jest.fn(); + await render( + + + , + ); + + await fireEvent(screen.getByTestId('view'), 'pointerDown', emptyEvent); + + expect(onPointerDown).toHaveBeenCalledTimes(isModern() ? 0 : 1); + expect(onParentPointerDown).toHaveBeenCalledTimes(isModern() ? 1 : 0); + }); + + test('fires touch event with dispatch on View with pointerEvents="none"', async () => { + const onTouchStart = jest.fn(); + await render(); + + // A native component declaring its own event decides when to emit it. + await fireEvent(screen.getByTestId('view'), 'touchStart', { + nativeEvent: {}, + dispatch: 'bubbling', + }); + + expect(onTouchStart).toHaveBeenCalledTimes(1); + }); + + test('fires focus inside View with pointerEvents="none"', async () => { + const onFocus = jest.fn(); + await render( + + + , + ); + + await fireEvent(screen.getByTestId('input'), 'focus', emptyEvent); + + // `pointerEvents` only affects touches. The input can still be focused, e.g. from a keyboard. + expect(onFocus).toHaveBeenCalledTimes(1); + }); + + test('fires touchStart on disabled Pressable (legacy: blocks it)', async () => { + const onPress = jest.fn(); + const onTouchStart = jest.fn(); + await render( + , + ); + + await fireEvent(screen.getByTestId('pressable'), 'touchStart', emptyEvent); + + // Modern matches iOS, where only Pressability checks `disabled`. Legacy blocks events whose + // touch responder declines the touch. + expect(onTouchStart).toHaveBeenCalledTimes(isModern() ? 1 : 0); + expect(onPress).not.toHaveBeenCalled(); + }); +}); + +describe('display: none', () => { + test('does not press inside View with display: none (legacy: presses)', async () => { + const onPress = jest.fn(); + await render( + + + , + ); + + await fireEvent.press(screen.getByTestId('pressable', { includeHiddenElements: true })); + + // Modern matches React Native, where a hidden view has no frame, so nothing in it can be + // touched. Legacy doesn't check `display`. + expect(onPress).toHaveBeenCalledTimes(isModern() ? 0 : 1); + }); + + test('drops touchStart on View with display: none instead of moving it to the parent (legacy: fires on the View)', async () => { + const onParentTouchStart = jest.fn(); + const onTouchStart = jest.fn(); + await render( + + + , + ); + + await fireEvent( + screen.getByTestId('view', { includeHiddenElements: true }), + 'touchStart', + emptyEvent, + ); + + // Unlike `pointerEvents="none"`, the hidden View covers no area of its parent to touch. + expect(onTouchStart).toHaveBeenCalledTimes(isModern() ? 0 : 1); + expect(onParentTouchStart).not.toHaveBeenCalled(); + }); + + test('does not press an element that became hidden after it was queried (legacy: presses)', async () => { + const onPress = jest.fn(); + function Screen({ hidden }: { hidden: boolean }) { + return ( + + + + ); + } + + await render(