diff --git a/contributing/event-dispatch.md b/contributing/event-dispatch.md index 9bb951c1c..7d84ca1bf 100644 --- a/contributing/event-dispatch.md +++ b/contributing/event-dispatch.md @@ -20,6 +20,8 @@ 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` doesn't log `eventDiagnostics` warnings yet. Only legacy `fireEvent` and `userEvent` do. diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index f736dfb58..781adfcff 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, @@ -3241,6 +3242,216 @@ describe('pointerEvents prop', () => { }); }); +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(