diff --git a/contributing/code-style.md b/contributing/code-style.md index fd0008fa7..86b13beff 100644 --- a/contributing/code-style.md +++ b/contributing/code-style.md @@ -4,3 +4,10 @@ Tooling enforces the style. Run `yarn lint` and `yarn format:fix` before you pus - **Formatting:** oxfmt with single quotes, trailing commas, and sorted imports. - **Linting:** ESLint with `@callstack/eslint-config` and `typescript-eslint`. Notable rules: no `console`, and use `import type` for type-only imports. + +## File Layout + +Order each file top-down, so it reads from the public API to the details: + +1. Exported functions (and their types) first. +2. Then non-exported helpers, in descending order: a helper comes after the functions that call it, and helpers called from it come after it. diff --git a/contributing/event-dispatch.md b/contributing/event-dispatch.md index 9dfb0876c..7a0bb6981 100644 --- a/contributing/event-dispatch.md +++ b/contributing/event-dispatch.md @@ -11,6 +11,7 @@ Both are built on the shared event subsystem in `src/events/`, which also holds | `propagation.ts` | Bubbling vs direct events, walking up host and composite elements | | `is-enabled.ts` | Whether a device would deliver the event: `pointerEvents`, `editable`, touch responders | | `dispatch.ts` | `dispatchEvent()`: calls the target's own handler in `act()`, used by `userEvent` | +| `warnings.ts` | `eventDiagnostics` warnings for `fireEvent`, and helpers shared with `userEvent` | | `builders/` | Event payloads, matching what React Native sends on a device | | `native-state.ts`, `update-native-state.ts` | [Native state](native-state.md) and how `fireEvent` updates it | @@ -30,6 +31,13 @@ Both are built on the shared event subsystem in `src/events/`, which also holds Each step uses `dispatchEvent()`, which only calls the target's own handler. It doesn't bubble or check whether the element is enabled. Each action does those checks itself, so the rules for an interaction live in one place. +For the `eventDiagnostics` warning, each action tracks itself with an `Interaction` from `src/user-event/utils/interaction.ts`: + +- Dispatch events with `interaction.dispatchEvent()`, so it records whether any handler ran. Events go to `interaction.target`, which is the element the action was called with, unless the action moves it (as `press()` does when an ancestor handles the press). If the action has to call a handler itself, record it with `interaction.recordEvent()` (as `pullToRefresh()` does for `onRefresh` on the `refreshControl` prop). +- Set `hasUpdatedNativeState` when the action writes to `nativeState`. +- Add elements that could handle the action but don't accept it to `skippedTargets`: disabled, non-editable `TextInput`, blocked by `pointerEvents`, or with a responder that declines the touch. The warning first reports the ones blocked by `pointerEvents`, with the element that blocks them (`getPointerEventsBlocker()`). Otherwise it reports the disabled ones (`computeAriaDisabled()`, which includes non-editable `TextInput`; when all of them are non-editable `TextInput`, the message calls them non-editable, see `formatDisabledTargets()`), and skips the warning if every skipped element has a responder that declines the touch. Text actions (`type()`, `clear()`, `paste()`) add the `TextInput` when it is non-editable or blocked by `pointerEvents`. +- Call `warnAboutUnhandledInteraction()` from `src/user-event/utils/warnings.ts` at the end. It warns only if no handler ran and native state didn't change. + ## Guidelines - To change which handler gets a single event, change `fireEvent`. To make an interaction more realistic, change the `userEvent` action. diff --git a/docs/api/configuration.md b/docs/api/configuration.md index 8164dfee1..fe44194b5 100644 --- a/docs/api/configuration.md +++ b/docs/api/configuration.md @@ -10,6 +10,9 @@ type Config = { /** Default value for `includeHiddenElements` query option. */ defaultIncludeHiddenElements: boolean; + /** Warn when `fireEvent` or a `userEvent` interaction calls no handler. Off by default. */ + eventDiagnostics: boolean; + /** Default options for `debug` helper. */ defaultDebugOptions?: Partial; }; @@ -32,6 +35,25 @@ Default value for [includeHiddenElements](./queries.md#includehiddenelements-opt This option is also available as `defaultHidden` alias for compatibility with [React Testing Library](https://testing-library.com/docs/dom-testing-library/api-configuration/#defaulthidden). +### `eventDiagnostics` option + +Logs a warning when `fireEvent` or `userEvent` doesn't call any handler, so a test doesn't silently do nothing. Defaults to `false`. + +A warning is logged in these cases: + +- The handler is on a disabled element, e.g. a `Pressable` with `disabled={true}`. +- The element is a non-editable `TextInput` (`editable={false}`). It blocks most events, including `changeText`, `focus`, `blur`, `press` and `submitEditing`, also when the handler is on one of its ancestors. The warning shows the `TextInput`. +- The element is blocked by `pointerEvents`, e.g. it is inside a `View` with `pointerEvents="none"`. The warning shows the element that sets `pointerEvents`. This takes precedence over the disabled warning, because the event wouldn't reach the element even if it were enabled. +- Neither the element nor any of its ancestors has a handler for the event. For direct events like `layout`, which don't bubble, only the element itself is checked. + +A `userEvent` interaction, like `press()` or `type()`, dispatches several events. It warns only when none of them called a handler. For example, `longPress()` on an element that has only `onPress` warns, because `longPress()` doesn't dispatch a `press` event. + +No warning is logged when the event updates native state, e.g. `fireEvent.changeText` or `userEvent.type` on an uncontrolled `TextInput`. Turn it on while debugging a test, or for the whole test suite in your Jest setup file: + +```ts +configure({ eventDiagnostics: true }); +``` + ### `defaultDebugOptions` option Default [debug options](#debug) to be used when calling `debug()`. These default options will be overridden by the ones you specify directly when calling `debug()`. diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 6949f3a6a..1fecd4f33 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -22,6 +22,7 @@ test('configure() overrides existing config values', () => { asyncUtilTimeout: 5000, defaultDebugOptions: { message: 'debug message' }, defaultIncludeHiddenElements: false, + eventDiagnostics: false, }); }); diff --git a/src/config.ts b/src/config.ts index b910b40d8..b19cb18cf 100644 --- a/src/config.ts +++ b/src/config.ts @@ -12,6 +12,12 @@ export type Config = { /** Default value for `includeHiddenElements` query option. */ defaultIncludeHiddenElements: boolean; + /** + * Warn when `fireEvent` or a `userEvent` interaction calls no handler, because the target + * is disabled, blocked by `pointerEvents`, or no element handles the event. Off by default. + */ + eventDiagnostics: boolean; + /** Default options for `debug` helper. */ defaultDebugOptions?: Partial; }; @@ -24,6 +30,7 @@ export type ConfigAliasOptions = { const defaultConfig: Config = { asyncUtilTimeout: 1000, defaultIncludeHiddenElements: false, + eventDiagnostics: false, }; let config = { ...defaultConfig }; @@ -37,6 +44,7 @@ export function configure(options: Partial) { defaultDebugOptions, defaultHidden, defaultIncludeHiddenElements, + eventDiagnostics, ...rest } = options; @@ -50,6 +58,7 @@ export function configure(options: Partial) { asyncUtilTimeout: asyncUtilTimeout ?? config.asyncUtilTimeout, defaultDebugOptions, defaultIncludeHiddenElements: resolvedDefaultIncludeHiddenElements, + eventDiagnostics: eventDiagnostics ?? config.eventDiagnostics, }; } diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index bec40de84..3b9382ae6 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -14,7 +14,8 @@ import { } from 'react-native'; import { fireEvent, render, screen } from '../..'; -import { _console } from '../../helpers/logger'; +import { configure } from '../../config'; +import { _console, logger } from '../../helpers/logger'; import { nativeState } from '../native-state'; const layoutEvent = { nativeEvent: { layout: { width: 100, height: 100 } } }; @@ -553,7 +554,6 @@ describe('fireEvent.layout', () => { }); test('does not bubble to the handler on an ancestor element', async () => { - const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); const onLayout = jest.fn(); await render( @@ -564,12 +564,9 @@ describe('fireEvent.layout', () => { await fireEvent.layout(screen.getByTestId('child'), { height: 80 }); expect(onLayout).not.toHaveBeenCalled(); - expect(warnSpy).toHaveBeenCalledTimes(1); - warnSpy.mockRestore(); }); test('does not bubble when fired as generic layout event', async () => { - const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); const onLayout = jest.fn(); await render( @@ -581,21 +578,34 @@ describe('fireEvent.layout', () => { await fireEvent(screen.getByTestId('child'), 'onLayout', layoutEvent); expect(onLayout).not.toHaveBeenCalled(); - expect(warnSpy).toHaveBeenCalledTimes(2); - warnSpy.mockRestore(); }); - test('warns when element has no onLayout handler', async () => { + test('does not warn when layout size is saved in native state without onLayout handler', async () => { + configure({ eventDiagnostics: true }); const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); await render(); await fireEvent.layout(screen.getByTestId('view')); + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + test('warns when element has no onLayout handler and event has no layout', async () => { + configure({ eventDiagnostics: true }); + const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); + await render(); + + await fireEvent(screen.getByTestId('view'), 'layout', { nativeEvent: {} }); + expect(warnSpy).toHaveBeenCalledTimes(1); expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - " ▲ fireEvent: element has no handler for "layout" event. + " ▲ No "onLayout" handler found on the element. "layout" events do not bubble to ancestors. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + " `); warnSpy.mockRestore(); @@ -624,18 +634,15 @@ describe('fireEvent.layout', () => { }); test('saves layout size in native state even without onLayout handler', async () => { - const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); await render(); const view = screen.getByTestId('view'); await fireEvent.layout(view, { width: 100, height: 80 }); expect(nativeState.layoutSizeForInstance.get(view)).toEqual({ width: 100, height: 80 }); - warnSpy.mockRestore(); }); test('does not call onLayout of composite component that does not forward it', async () => { - const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); const onLayout = jest.fn(); const Box = (_props: { onLayout: () => void }) => ; await render(); @@ -643,8 +650,6 @@ describe('fireEvent.layout', () => { await fireEvent.layout(screen.getByTestId('view')); expect(onLayout).not.toHaveBeenCalled(); - expect(warnSpy).toHaveBeenCalledTimes(1); - warnSpy.mockRestore(); }); }); @@ -790,6 +795,372 @@ describe('disabled elements', () => { }); }); +describe('unhandled event warning', () => { + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + configure({ eventDiagnostics: true }); + warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + test('warns when the handler is on a disabled element', async () => { + await render( + + Trigger + , + ); + + await fireEvent.press(screen.getByText('Trigger')); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "Cannot fire the "press" event on a disabled element. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + + + Trigger + + " + `); + }); + + test('lists every disabled element the event skipped', async () => { + await render( + + + Trigger + + , + ); + + await fireEvent.press(screen.getByText('Trigger')); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "Cannot fire the "press" event on disabled elements. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + + + Trigger + + + + + + + Trigger + + + " + `); + }); + + test('warns when no element handles the event', async () => { + await render( + + Trigger + , + ); + + await fireEvent.press(screen.getByText('Trigger')); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "No "onPress" handler found on the element or its ancestors. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + + Trigger + " + `); + }); + + test('does not warn when the event bubbles to an enabled parent', async () => { + await render( + + + Inner Trigger + + , + ); + + await fireEvent.press(screen.getByText('Inner Trigger')); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test('warns when the handler is blocked by pointerEvents="none" on an ancestor', async () => { + await render( + + + , + ); + + await fireEvent.press(screen.getByTestId('btn')); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "Cannot fire the "press" event on an element blocked by pointerEvents. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + + + Blocked by: + + " + `); + }); + + test('reports pointerEvents rather than disabled when both block the handler', async () => { + await render( + + + , + ); + + await fireEvent.press(screen.getByTestId('btn')); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "Cannot fire the "press" event on an element blocked by pointerEvents. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + + + Blocked by: + + " + `); + }); + + test('reports the element that blocks with pointerEvents', async () => { + await render( + + + + + + , + ); + + await fireEvent.press(screen.getByTestId('inside-box-only')); + await fireEvent.press(screen.getByTestId('box-none')); + + expect(warnSpy).toHaveBeenCalledTimes(2); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "Cannot fire the "press" event on an element blocked by pointerEvents. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + + + Blocked by: + + " + `); + expect(warnSpy.mock.calls[1][0]).toMatchInlineSnapshot(` + "Cannot fire the "press" event on an element blocked by pointerEvents. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + + + Blocked by: + + " + `); + }); + + test('reports non-editable TextInput for events not affected by pointerEvents', async () => { + await render( + + + , + ); + + await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatch( + /^Cannot fire the "changeText" event on a non-editable TextInput\./, + ); + }); + + test('does not warn when the responder declines the touch', async () => { + const onPress = jest.fn(); + // @ts-expect-error Host View does not declare `onPress`. + await render( false} onPress={onPress} />); + + await fireEvent.press(screen.getByTestId('view')); + + expect(onPress).not.toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test('warns when the handler is blocked by non-editable TextInput', async () => { + await render(); + + await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "Cannot fire the "changeText" event on a non-editable TextInput. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + " + `); + }); + + test.each([ + ['focus', 'onFocus'], + ['blur', 'onBlur'], + ['press', 'onPress'], + ])('warns when "%s" is blocked by non-editable TextInput', async (eventName, handlerName) => { + await render(); + + await fireEvent(screen.getByTestId('input'), eventName); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatch( + new RegExp(`^Cannot fire the "${eventName}" event on a non-editable TextInput\\.`), + ); + }); + + test('names the non-editable TextInput when the handler is on its parent', async () => { + const onFocus = jest.fn(); + await render( + // Spread because `View` types include `onFocus` only since RN 0.88. + + + , + ); + + await fireEvent(screen.getByTestId('input'), 'focus'); + + expect(onFocus).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "Cannot fire the "focus" event on a non-editable TextInput. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + " + `); + }); + + test('reports disabled elements when they include a non-editable TextInput', async () => { + await render( + + + , + ); + + await fireEvent.press(screen.getByTestId('input')); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatch( + /^Cannot fire the "press" event on disabled elements\./, + ); + }); + + test('does not warn when the event updates native state (uncontrolled TextInput)', async () => { + await render(); + + await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test('does not warn when eventDiagnostics is turned off', async () => { + configure({ eventDiagnostics: false }); + await render( + + + Disabled + + No handler + , + ); + + await fireEvent.press(screen.getByText('Disabled')); + await fireEvent.press(screen.getByText('No handler')); + await fireEvent.layout(screen.getByText('No handler')); + + expect(warnSpy).not.toHaveBeenCalled(); + }); +}); + describe('pointerEvents prop', () => { test('does not fire inside View with pointerEvents="none"', async () => { const onPress = jest.fn(); @@ -910,7 +1281,6 @@ describe('non-editable TextInput', () => { }); test('blocks touch-related events when firing on nested Text child', async () => { - const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); const onFocus = jest.fn(); const onChangeText = jest.fn(); const onSubmitEditing = jest.fn(); @@ -947,8 +1317,6 @@ describe('non-editable TextInput', () => { expect(onSubmitEditing).not.toHaveBeenCalled(); // Layout is a direct event, so it does not bubble to the parent TextInput expect(onLayout).not.toHaveBeenCalled(); - expect(warnSpy).toHaveBeenCalledTimes(2); - warnSpy.mockRestore(); }); test.each([ diff --git a/src/events/dispatch.ts b/src/events/dispatch.ts index 941f90d34..cc8481f7d 100644 --- a/src/events/dispatch.ts +++ b/src/events/dispatch.ts @@ -10,22 +10,25 @@ import { getEventHandlerFromProps } from './handler'; * @param instance instance to trigger event on * @param eventName name of the event * @param event event payload(s) + * @returns `true` if a handler was called. */ export async function dispatchEvent( instance: TestInstance, eventName: string, ...event: unknown[] -) { +): Promise { if (!isInstanceMounted(instance)) { - return; + return false; } const handler = getEventHandlerFromProps(instance.props, eventName); if (!handler) { - return; + return false; } await act(() => { handler(...event); }); + + return true; } diff --git a/src/events/fire-event.ts b/src/events/fire-event.ts index 890654726..9921f66b9 100644 --- a/src/events/fire-event.ts +++ b/src/events/fire-event.ts @@ -11,6 +11,7 @@ import { nativeState } from './native-state'; import { findEventHandler } from './propagation'; import type { EventName, EventProps, LayoutRectangle } from './types'; import { updateNativeStateFromEvent } from './update-native-state'; +import { warnAboutUnhandledEvent } from './warnings'; async function fireEvent(instance: TestInstance, eventName: EventName, ...data: unknown[]) { if (!isInstanceMounted(instance)) { @@ -18,10 +19,15 @@ async function fireEvent(instance: TestInstance, eventName: EventName, ...data: } // `fireEvent` accepts event names with and without the `on*` prefix. - updateNativeStateFromEvent(instance, normalizeEventName(eventName), data[0]); + const hasUpdatedNativeState = updateNativeStateFromEvent( + instance, + normalizeEventName(eventName), + data[0], + ); - const handler = findEventHandler(instance, eventName); + const { handler, skippedTargets } = findEventHandler(instance, eventName); if (!handler) { + warnAboutUnhandledEvent(instance, eventName, { skippedTargets, hasUpdatedNativeState }); return; } diff --git a/src/events/handler.ts b/src/events/handler.ts index 06bf2e006..e7835ec8d 100644 --- a/src/events/handler.ts +++ b/src/events/handler.ts @@ -43,7 +43,7 @@ export function normalizeEventName(eventName: string) { return eventName; } -function getEventHandlerName(eventName: string) { +export function getEventHandlerName(eventName: string) { if (hasOnPrefix(eventName)) { return eventName; } diff --git a/src/events/index.ts b/src/events/index.ts index 7e5961058..117425c85 100644 --- a/src/events/index.ts +++ b/src/events/index.ts @@ -5,3 +5,5 @@ export * from './dispatch'; export * from './handler'; export { isPointerEventEnabled } from './is-enabled'; export * from './native-state'; +export type { EventWarning } from './warnings'; +export { formatDisabledTargets, getPointerEventsBlockedTargets, logEventWarning } from './warnings'; diff --git a/src/events/is-enabled.ts b/src/events/is-enabled.ts index cb7d46475..5d6d220e9 100644 --- a/src/events/is-enabled.ts +++ b/src/events/is-enabled.ts @@ -11,23 +11,17 @@ import { isEditableTextInput } from '../helpers/text-input'; * 'box-none': The View is never the target of touch events but its subviews can be * 'box-only': The view can be the target of touch events but its subviews cannot be * see the official react native doc https://reactnative.dev/docs/view#pointerevents */ -export const isPointerEventEnabled = (instance: TestInstance, isParent?: boolean): boolean => { - // Check both props.pointerEvents and props.style.pointerEvents - const pointerEvents = - instance?.props.pointerEvents ?? StyleSheet.flatten(instance?.props.style)?.pointerEvents; - - const parentCondition = isParent ? pointerEvents === 'box-only' : pointerEvents === 'box-none'; - - if (pointerEvents === 'none' || parentCondition) { - return false; - } - - if (!instance.parent) { - return true; - } +export function isPointerEventEnabled(instance: TestInstance): boolean { + return getPointerEventsBlocker(instance) == null; +} - return isPointerEventEnabled(instance.parent, true); -}; +/** + * Returns the element whose `pointerEvents` prevents the instance from being the target of + * touch events: the instance itself or one of its ancestors. Returns `null` if nothing blocks it. + */ +export function getPointerEventsBlocker(instance: TestInstance): TestInstance | null { + return findPointerEventsBlocker(instance, false); +} export function isTouchResponder(instance: TestInstance) { return Boolean(instance.props.onStartShouldSetResponder) || isHostTextInput(instance); @@ -38,6 +32,13 @@ export function isTouchResponder(instance: TestInstance) { */ const eventsAffectedByPointerEventsProp = new Set(['press']); +/** + * Expects event name without the `on*` prefix (see `normalizeEventName`). + */ +export function isEventBlockableByPointerEvents(eventName: string): boolean { + return eventsAffectedByPointerEventsProp.has(eventName); +} + /** * List of `TextInput` events not affected by `editable` prop. */ @@ -60,7 +61,7 @@ export function isEventEnabled( ); } - if (eventsAffectedByPointerEventsProp.has(eventName) && !isPointerEventEnabled(instance)) { + if (isEventBlockableByPointerEvents(eventName) && !isPointerEventEnabled(instance)) { return false; } @@ -72,3 +73,21 @@ export function isEventEnabled( return touchStart === undefined && touchMove === undefined; } + +function findPointerEventsBlocker(instance: TestInstance, isParent: boolean): TestInstance | null { + // Check both props.pointerEvents and props.style.pointerEvents + const pointerEvents = + instance?.props.pointerEvents ?? StyleSheet.flatten(instance?.props.style)?.pointerEvents; + + const parentCondition = isParent ? pointerEvents === 'box-only' : pointerEvents === 'box-none'; + + if (pointerEvents === 'none' || parentCondition) { + return instance; + } + + if (!instance.parent) { + return null; + } + + return findPointerEventsBlocker(instance.parent, true); +} diff --git a/src/events/propagation.ts b/src/events/propagation.ts index e607f4a9a..29613a563 100644 --- a/src/events/propagation.ts +++ b/src/events/propagation.ts @@ -1,7 +1,5 @@ import type { Fiber, TestInstance } from 'test-renderer'; -import { formatElement } from '../helpers/format-element'; -import { logger } from '../helpers/logger'; import { getEventHandlerFromProps, normalizeEventName } from './handler'; import { isEventEnabled, isTouchResponder } from './is-enabled'; import type { EventHandler } from './types'; @@ -13,6 +11,15 @@ export function isDirectEvent(eventName: string) { return eventName === 'layout'; } +type FindEventHandlerResult = { + handler: EventHandler | null; + /** + * Elements whose handler was found but rejected by `isEventEnabled`, nearest to the fired + * instance first. Lets callers tell "blocked handler" apart from "no handler at all". + */ + skippedTargets: TestInstance[]; +}; + /** * Finds the handler that should receive the event, as `fireEvent` does: direct events only * check the target, other events bubble up the tree until an enabled handler is found. @@ -20,44 +27,47 @@ export function isDirectEvent(eventName: string) { * Note: handlers are looked up by the event name as passed, while event rules (direct events, * `isEventEnabled`) use the name without the `on*` prefix. */ -export function findEventHandler(instance: TestInstance, eventName: string): EventHandler | null { - return isDirectEvent(normalizeEventName(eventName)) - ? getOwnEventHandler(instance, eventName) - : findBubblingEventHandler(instance, eventName); -} - -function getOwnEventHandler(instance: TestInstance, eventName: string): EventHandler | null { - const handler = getEventHandlerFromProps(instance.props, eventName, { loose: true }); - if (!handler) { - logger.warn( - `fireEvent: element has no handler for "${eventName}" event.`, - formatElement(instance), - ); - return null; +export function findEventHandler( + instance: TestInstance, + eventName: string, +): FindEventHandlerResult { + if (isDirectEvent(normalizeEventName(eventName))) { + const handler = getEventHandlerFromProps(instance.props, eventName, { loose: true }); + return { handler: handler ?? null, skippedTargets: [] }; } - return handler; + return findBubblingEventHandler(instance, eventName, undefined, []); } function findBubblingEventHandler( instance: TestInstance, eventName: string, - nearestTouchResponder?: TestInstance, -): EventHandler | null { + nearestTouchResponder: TestInstance | undefined, + skippedTargets: TestInstance[], +): FindEventHandlerResult { const touchResponder = isTouchResponder(instance) ? instance : nearestTouchResponder; const handler = getEventHandlerFromProps(instance.props, eventName, { loose: true }) ?? findEventHandlerFromFiber(instance.unstable_fiber, eventName); - if (handler && isEventEnabled(instance, normalizeEventName(eventName), touchResponder)) { - return handler; + + if (handler) { + if (isEventEnabled(instance, normalizeEventName(eventName), touchResponder)) { + return { handler, skippedTargets }; + } + + // Handlers on the same touch responder report it only once. + const skippedTarget = touchResponder ?? instance; + if (!skippedTargets.includes(skippedTarget)) { + skippedTargets.push(skippedTarget); + } } if (instance.parent === null) { - return null; + return { handler: null, skippedTargets }; } - return findBubblingEventHandler(instance.parent, eventName, touchResponder); + return findBubblingEventHandler(instance.parent, eventName, touchResponder, skippedTargets); } function findEventHandlerFromFiber(fiber: Fiber | null, eventName: string): EventHandler | null { diff --git a/src/events/update-native-state.ts b/src/events/update-native-state.ts index 982287a6d..236c6a391 100644 --- a/src/events/update-native-state.ts +++ b/src/events/update-native-state.ts @@ -16,20 +16,24 @@ const scrollEventNames = new Set([ /** * Updates native state the way a device would have before emitting the event. * Expects event name without the `on*` prefix (see `normalizeEventName`). + * + * @returns `true` if native state was updated. */ export function updateNativeStateFromEvent( instance: TestInstance, eventName: string, value: unknown, -) { +): boolean { if (eventName === 'changeText' && typeof value === 'string' && isEditableTextInput(instance)) { nativeState.valueForInstance.set(instance, value); + return true; } if (scrollEventNames.has(eventName) && isHostScrollView(instance)) { const contentOffset = tryGetContentOffset(value); if (contentOffset) { nativeState.contentOffsetForInstance.set(instance, contentOffset); + return true; } } @@ -37,8 +41,11 @@ export function updateNativeStateFromEvent( const layoutSize = tryGetLayoutSize(value); if (layoutSize) { nativeState.layoutSizeForInstance.set(instance, layoutSize); + return true; } } + + return false; } function tryGetContentOffset(event: unknown): Point | null { diff --git a/src/events/warnings.ts b/src/events/warnings.ts new file mode 100644 index 000000000..ba1832cbe --- /dev/null +++ b/src/events/warnings.ts @@ -0,0 +1,144 @@ +import redent from 'redent'; +import type { TestInstance } from 'test-renderer'; + +import { getConfig } from '../config'; +import { computeAriaDisabled } from '../helpers/accessibility'; +import { formatElement, formatJson } from '../helpers/format-element'; +import { isHostTextInput } from '../helpers/host-component-names'; +import { logger } from '../helpers/logger'; +import { isEditableTextInput } from '../helpers/text-input'; +import { getEventHandlerName, normalizeEventName } from './handler'; +import { getPointerEventsBlocker, isEventBlockableByPointerEvents } from './is-enabled'; +import { isDirectEvent } from './propagation'; + +type UnhandledEventInfo = { + skippedTargets: TestInstance[]; + hasUpdatedNativeState: boolean; +}; + +export type EventWarning = { + message: string; + elements: TestInstance[]; + /** Elements whose `pointerEvents` blocked the event. */ + pointerEventsBlockers?: TestInstance[]; +}; + +/** + * Warns when no handler ran because the target is disabled or nothing handles the event. + */ +export function warnAboutUnhandledEvent( + instance: TestInstance, + eventName: string, + info: UnhandledEventInfo, +) { + if (!getConfig().eventDiagnostics) { + return; + } + + const warning = getUnhandledEventWarning(instance, eventName, info); + if (warning != null) { + logEventWarning(warning); + } +} + +export function logEventWarning({ message, elements, pointerEventsBlockers = [] }: EventWarning) { + const header = + `${message}\n` + + 'If this is intentional, you can disable this warning via `configure({ eventDiagnostics: false })`.'; + const elementBlocks = elements + .map((element) => element.toJSON()) + .filter((json) => json != null) + .map((json) => redent(formatJson(json), 2)); + // Blockers are often large containers, so they are printed without their children. + const blockerBlocks = [...new Set(pointerEventsBlockers)].map((blocker) => + redent(formatElement(blocker), 2), + ); + const blockerSection = blockerBlocks.length > 0 ? ['Blocked by:', ...blockerBlocks] : []; + logger.warn([header, ...elementBlocks, ...blockerSection].join('\n\n')); +} + +/** + * Returns the targets that `pointerEvents` blocks along with their blockers, or `null` if it + * blocks none of them. + * + * @param targets Skipped elements, nearest first. + */ +export function getPointerEventsBlockedTargets( + targets: TestInstance[], +): Pick | null { + const elements: TestInstance[] = []; + const pointerEventsBlockers: TestInstance[] = []; + for (const target of targets) { + const blocker = getPointerEventsBlocker(target); + if (blocker != null) { + elements.push(target); + pointerEventsBlockers.push(blocker); + } + } + + return elements.length > 0 ? { elements, pointerEventsBlockers } : null; +} + +/** + * Describes the elements that `computeAriaDisabled` treats as disabled, e.g. "a disabled element". + * Non-editable `TextInput`s are called non-editable, which is what users set (`editable={false}`). + */ +export function formatDisabledTargets(targets: TestInstance[]): string { + if (targets.every((target) => isHostTextInput(target) && !isEditableTextInput(target))) { + return targets.length === 1 ? 'a non-editable TextInput' : 'non-editable TextInputs'; + } + + return targets.length === 1 ? 'a disabled element' : 'disabled elements'; +} + +function getUnhandledEventWarning( + instance: TestInstance, + eventName: string, + { skippedTargets, hasUpdatedNativeState }: UnhandledEventInfo, +): EventWarning | null { + if (skippedTargets.length === 0) { + // The event still had an effect, e.g. `changeText` on an uncontrolled TextInput updates its value. + if (hasUpdatedNativeState) { + return null; + } + + const handlerName = getEventHandlerName(eventName); + if (isDirectEvent(normalizeEventName(eventName))) { + return { + message: `No "${handlerName}" handler found on the element. "${eventName}" events do not bubble to ancestors.`, + elements: [instance], + }; + } + + return { + message: `No "${handlerName}" handler found on the element or its ancestors.`, + elements: [instance], + }; + } + + // `pointerEvents` is checked first: it blocks the event even if the element is enabled. + const blocked = isEventBlockableByPointerEvents(normalizeEventName(eventName)) + ? getPointerEventsBlockedTargets(skippedTargets) + : null; + if (blocked != null) { + return { + message: + blocked.elements.length === 1 + ? `Cannot fire the "${eventName}" event on an element blocked by pointerEvents.` + : `Cannot fire the "${eventName}" event on elements blocked by pointerEvents.`, + ...blocked, + }; + } + + // `computeAriaDisabled` also covers non-editable `TextInput`. A responder declining the touch + // is a deliberate way of blocking events, so it doesn't warn. + const disabledTargets = skippedTargets.filter(computeAriaDisabled); + if (disabledTargets.length === 0) { + return null; + } + + return { + message: `Cannot fire the "${eventName}" event on ${formatDisabledTargets(disabledTargets)}.`, + elements: disabledTargets, + }; +} diff --git a/src/user-event/__tests__/event-diagnostics.test.tsx b/src/user-event/__tests__/event-diagnostics.test.tsx new file mode 100644 index 000000000..b82e80638 --- /dev/null +++ b/src/user-event/__tests__/event-diagnostics.test.tsx @@ -0,0 +1,327 @@ +import * as React from 'react'; +import { Pressable, ScrollView, Text, TextInput, View } from 'react-native'; + +import { render, screen, userEvent } from '../..'; +import { configure } from '../../config'; +import { logger } from '../../helpers/logger'; + +let warnSpy: jest.SpyInstance; + +beforeEach(() => { + configure({ eventDiagnostics: true }); + warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + warnSpy.mockRestore(); +}); + +test('warns when pressing a disabled element', async () => { + await render( + + Trigger + , + ); + const user = userEvent.setup(); + + await user.press(screen.getByText('Trigger')); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "press() interaction did not call any event handlers. Cannot interact with a disabled element. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + + + Trigger + + " + `); +}); + +test('warns when no element handles the press', async () => { + await render( + + Trigger + , + ); + const user = userEvent.setup(); + + await user.press(screen.getByText('Trigger')); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "press() interaction did not call any event handlers. The element and its ancestors have no handlers for this interaction. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + + Trigger + " + `); +}); + +test('warns when none of the dispatched events has a handler', async () => { + // `longPress` dispatches `pressIn`, `longPress` and `pressOut`, but not `press`. + // @ts-expect-error Host View does not declare `onPress`. + await render(); + const user = userEvent.setup(); + + await user.longPress(screen.getByTestId('view')); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "longPress() interaction did not call any event handlers. The element has no handler for the "pressIn", "longPress" or "pressOut" events. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + " + `); +}); + +test('warns when pulling to refresh without an onRefresh handler', async () => { + await render(); + const user = userEvent.setup(); + + await user.pullToRefresh(screen.getByTestId('view')); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "pullToRefresh() interaction did not call any event handlers. The element has no handler for the "refresh" event. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + + + " + `); +}); + +test('warns when triggering an accessibility action without a handler', async () => { + // Missing `onAccessibilityAction` is the case under test. + // eslint-disable-next-line react-native-a11y/has-valid-accessibility-actions + await render(); + const user = userEvent.setup(); + + await user.accessibilityAction(screen.getByTestId('view'), 'activate'); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "accessibilityAction() interaction did not call any event handlers. The element has no handler for the "accessibilityAction" event. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + " + `); +}); + +test('does not warn when at least one event calls a handler', async () => { + // `press` dispatches `pressIn`, `pressOut` and `press`, and only `pressIn` has a handler. + const onPressIn = jest.fn(); + // @ts-expect-error Host View does not declare `onPressIn`. + await render(); + const user = userEvent.setup(); + + await user.press(screen.getByTestId('view')); + + expect(onPressIn).toHaveBeenCalledTimes(1); + expect(warnSpy).not.toHaveBeenCalled(); +}); + +test('does not warn when the interaction updates native state (uncontrolled TextInput)', async () => { + await render(); + const user = userEvent.setup(); + + await user.type(screen.getByTestId('input'), 'Hello'); + await user.clear(screen.getByTestId('input')); + await user.paste(screen.getByTestId('input'), 'World'); + + expect(warnSpy).not.toHaveBeenCalled(); +}); + +test('warns when typing into a non-editable TextInput', async () => { + await render(); + const user = userEvent.setup(); + + await user.type(screen.getByTestId('input'), 'Hello'); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "type() interaction did not call any event handlers. Cannot interact with a non-editable TextInput. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + " + `); +}); + +test('warns when clearing or pasting into a non-editable TextInput', async () => { + await render(); + const user = userEvent.setup(); + + await user.clear(screen.getByTestId('input')); + await user.paste(screen.getByTestId('input'), 'Hello'); + + expect(warnSpy).toHaveBeenCalledTimes(2); + expect(warnSpy.mock.calls[0][0]).toMatch( + /^clear\(\) interaction did not call any event handlers\. Cannot interact with a non-editable TextInput\./, + ); + expect(warnSpy.mock.calls[1][0]).toMatch( + /^paste\(\) interaction did not call any event handlers\. Cannot interact with a non-editable TextInput\./, + ); +}); + +test('warns when pressing a non-editable TextInput', async () => { + await render( + , + ); + const user = userEvent.setup(); + + await user.press(screen.getByTestId('input')); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatch( + /^press\(\) interaction did not call any event handlers\. Cannot interact with a non-editable TextInput\./, + ); +}); + +test('warns when typing into a TextInput blocked by pointerEvents="none"', async () => { + await render( + + + , + ); + const user = userEvent.setup(); + + await user.type(screen.getByTestId('input'), 'Hello'); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "type() interaction did not call any event handlers. The element is blocked by pointerEvents. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + + + Blocked by: + + " + `); +}); + +test('warns when the press is blocked by pointerEvents="none"', async () => { + await render( + + + Trigger + + , + ); + const user = userEvent.setup(); + + await user.press(screen.getByText('Trigger')); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "press() interaction did not call any event handlers. The element is blocked by pointerEvents. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + + + Trigger + + + + Blocked by: + + " + `); +}); + +test('reports pointerEvents rather than disabled when both block the interaction', async () => { + await render( + + + Trigger + + + , + ); + const user = userEvent.setup(); + + await user.press(screen.getByText('Trigger')); + await user.type(screen.getByTestId('input'), 'Hello'); + + expect(warnSpy).toHaveBeenCalledTimes(2); + expect(warnSpy.mock.calls[0][0]).toMatch( + /^press\(\) interaction did not call any event handlers\. The element is blocked by pointerEvents\./, + ); + expect(warnSpy.mock.calls[1][0]).toMatch( + /^type\(\) interaction did not call any event handlers\. The element is blocked by pointerEvents\./, + ); +}); + +test('does not warn when the responder declines the touch', async () => { + const onResponderGrant = jest.fn(); + await render( + false} + onResponderGrant={onResponderGrant} + />, + ); + const user = userEvent.setup(); + + await user.press(screen.getByTestId('view')); + + expect(onResponderGrant).not.toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); +}); + +test('does not warn when scrolling a ScrollView without scroll handlers', async () => { + // `scrollTo` always updates the content offset in native state. + await render(); + const user = userEvent.setup(); + + await user.scrollTo(screen.getByTestId('view'), { y: 100 }); + + expect(warnSpy).not.toHaveBeenCalled(); +}); + +test('does not warn when eventDiagnostics is turned off', async () => { + configure({ eventDiagnostics: false }); + await render( + + + Disabled + + No handler + , + ); + const user = userEvent.setup(); + + await user.press(screen.getByText('Disabled')); + await user.press(screen.getByText('No handler')); + + expect(warnSpy).not.toHaveBeenCalled(); +}); diff --git a/src/user-event/accessibility-action/accessibility-action.ts b/src/user-event/accessibility-action/accessibility-action.ts index c32a4f0db..85f94329e 100644 --- a/src/user-event/accessibility-action/accessibility-action.ts +++ b/src/user-event/accessibility-action/accessibility-action.ts @@ -1,12 +1,13 @@ import type { AccessibilityActionInfo } from 'react-native'; import type { TestInstance } from 'test-renderer'; -import { buildAccessibilityActionEvent, dispatchEvent } from '../../events'; +import { buildAccessibilityActionEvent } from '../../events'; import { computeAriaDisabled } from '../../helpers/accessibility'; import { isTestInstance } from '../../helpers/component-tree'; import { ErrorWithStack } from '../../helpers/errors'; import type { StringWithAutocomplete } from '../../types'; import type { UserEventInstance } from '../setup'; +import { Interaction, warnAboutUnhandledInteraction } from '../utils'; /** * Standard accessibility action names recognized by React Native (`activate`, @@ -79,5 +80,7 @@ export async function accessibilityAction( ); } - await dispatchEvent(instance, 'accessibilityAction', buildAccessibilityActionEvent(actionName)); + const interaction = new Interaction('accessibilityAction', instance); + await interaction.dispatchEvent('accessibilityAction', buildAccessibilityActionEvent(actionName)); + warnAboutUnhandledInteraction(interaction); } diff --git a/src/user-event/clear.ts b/src/user-event/clear.ts index 54f9173cb..407c58359 100644 --- a/src/user-event/clear.ts +++ b/src/user-event/clear.ts @@ -5,7 +5,6 @@ import { buildEndEditingEvent, buildFocusEvent, buildTextSelectionChangeEvent, - dispatchEvent, isPointerEventEnabled, } from '../events'; import { ErrorWithStack } from '../helpers/errors'; @@ -13,7 +12,7 @@ import { isHostTextInput } from '../helpers/host-component-names'; import { getTextInputValue, isEditableTextInput } from '../helpers/text-input'; import type { UserEventInstance } from './setup'; import { emitTypingEvents } from './type/type'; -import { wait } from './utils'; +import { Interaction, wait, warnAboutUnhandledInteraction } from './utils'; export async function clear(this: UserEventInstance, instance: TestInstance): Promise { if (!isHostTextInput(instance)) { @@ -23,12 +22,15 @@ export async function clear(this: UserEventInstance, instance: TestInstance): Pr ); } + const interaction = new Interaction('clear', instance); if (!isEditableTextInput(instance) || !isPointerEventEnabled(instance)) { + interaction.skippedTargets.push(instance); + warnAboutUnhandledInteraction(interaction); return; } // 1. Enter instance - await dispatchEvent(instance, 'focus', buildFocusEvent()); + await interaction.dispatchEvent('focus', buildFocusEvent()); // 2. Select all const textToClear = getTextInputValue(instance); @@ -36,18 +38,21 @@ export async function clear(this: UserEventInstance, instance: TestInstance): Pr start: 0, end: textToClear.length, }; - await dispatchEvent(instance, 'selectionChange', buildTextSelectionChangeEvent(selectionRange)); + await interaction.dispatchEvent('selectionChange', buildTextSelectionChangeEvent(selectionRange)); // 3. Press backspace with selected text const emptyText = ''; await emitTypingEvents(instance, { config: this.config, + interaction, key: 'Backspace', text: emptyText, }); // 4. Exit instance await wait(this.config); - await dispatchEvent(instance, 'endEditing', buildEndEditingEvent(emptyText)); - await dispatchEvent(instance, 'blur', buildBlurEvent()); + await interaction.dispatchEvent('endEditing', buildEndEditingEvent(emptyText)); + await interaction.dispatchEvent('blur', buildBlurEvent()); + + warnAboutUnhandledInteraction(interaction); } diff --git a/src/user-event/paste.ts b/src/user-event/paste.ts index fa012d016..49f63c022 100644 --- a/src/user-event/paste.ts +++ b/src/user-event/paste.ts @@ -7,7 +7,6 @@ import { buildFocusEvent, buildTextChangeEvent, buildTextSelectionChangeEvent, - dispatchEvent, isPointerEventEnabled, nativeState, } from '../events'; @@ -15,7 +14,7 @@ import { ErrorWithStack } from '../helpers/errors'; import { isHostTextInput } from '../helpers/host-component-names'; import { getTextInputValue, isEditableTextInput } from '../helpers/text-input'; import type { UserEventInstance } from './setup'; -import { getTextContentSize, wait } from './utils'; +import { Interaction, getTextContentSize, wait, warnAboutUnhandledInteraction } from './utils'; export async function paste( this: UserEventInstance, @@ -29,36 +28,42 @@ export async function paste( ); } + const interaction = new Interaction('paste', instance); if (!isEditableTextInput(instance) || !isPointerEventEnabled(instance)) { + interaction.skippedTargets.push(instance); + warnAboutUnhandledInteraction(interaction); return; } // 1. Enter instance - await dispatchEvent(instance, 'focus', buildFocusEvent()); + await interaction.dispatchEvent('focus', buildFocusEvent()); // 2. Select all const textToClear = getTextInputValue(instance); const rangeToClear = { start: 0, end: textToClear.length }; - await dispatchEvent(instance, 'selectionChange', buildTextSelectionChangeEvent(rangeToClear)); + await interaction.dispatchEvent('selectionChange', buildTextSelectionChangeEvent(rangeToClear)); // 3. Paste the text nativeState.valueForInstance.set(instance, text); + interaction.hasUpdatedNativeState = true; const rangeAfter = { start: text.length, end: text.length }; - await dispatchEvent(instance, 'change', buildTextChangeEvent(text, rangeAfter)); - await dispatchEvent(instance, 'changeText', text); - await dispatchEvent(instance, 'selectionChange', buildTextSelectionChangeEvent(rangeAfter)); + await interaction.dispatchEvent('change', buildTextChangeEvent(text, rangeAfter)); + await interaction.dispatchEvent('changeText', text); + await interaction.dispatchEvent('selectionChange', buildTextSelectionChangeEvent(rangeAfter)); // According to the docs only multiline TextInput emits contentSizeChange event // @see: https://reactnative.dev/docs/textinput#oncontentsizechange const isMultiline = instance.props.multiline === true; if (isMultiline) { const contentSize = getTextContentSize(text); - await dispatchEvent(instance, 'contentSizeChange', buildContentSizeChangeEvent(contentSize)); + await interaction.dispatchEvent('contentSizeChange', buildContentSizeChangeEvent(contentSize)); } // 4. Exit instance await wait(this.config); - await dispatchEvent(instance, 'endEditing', buildEndEditingEvent(text)); - await dispatchEvent(instance, 'blur', buildBlurEvent()); + await interaction.dispatchEvent('endEditing', buildEndEditingEvent(text)); + await interaction.dispatchEvent('blur', buildBlurEvent()); + + warnAboutUnhandledInteraction(interaction); } diff --git a/src/user-event/press/press.ts b/src/user-event/press/press.ts index 3da5a7feb..8d9745935 100644 --- a/src/user-event/press/press.ts +++ b/src/user-event/press/press.ts @@ -5,7 +5,6 @@ import { buildResponderGrantEvent, buildResponderReleaseEvent, buildTouchEvent, - dispatchEvent, getEventHandlerFromProps, isPointerEventEnabled, } from '../../events'; @@ -13,7 +12,7 @@ import { isTestInstance } from '../../helpers/component-tree'; import { ErrorWithStack } from '../../helpers/errors'; import { isHostText, isHostTextInput } from '../../helpers/host-component-names'; import type { UserEventConfig, UserEventInstance } from '../setup'; -import { wait } from '../utils'; +import { Interaction, wait, warnAboutUnhandledInteraction } from '../utils'; // These are constants defined in the React Native repo // See: https://github.com/facebook/react-native/blob/50e38cc9f1e6713228a91ad50f426c4f65e65e1a/packages/react-native/Libraries/Pressability/Pressability.js#L264 @@ -29,9 +28,11 @@ export async function press(this: UserEventInstance, instance: TestInstance): Pr throw new ErrorWithStack(`press() works only with host instances.`, press); } - await basePress(this.config, instance, { + const interaction = new Interaction('press', instance); + await basePress(this.config, interaction, instance, { type: 'press', }); + warnAboutUnhandledInteraction(interaction); } export async function longPress( @@ -43,10 +44,12 @@ export async function longPress( throw new ErrorWithStack(`longPress() works only with host instances.`, longPress); } - await basePress(this.config, instance, { + const interaction = new Interaction('longPress', instance); + await basePress(this.config, interaction, instance, { type: 'longPress', duration: options?.duration ?? DEFAULT_LONG_PRESS_DELAY_MS, }); + warnAboutUnhandledInteraction(interaction); } interface BasePressOptions { @@ -56,24 +59,32 @@ interface BasePressOptions { const basePress = async ( config: UserEventConfig, + interaction: Interaction, instance: TestInstance, options: BasePressOptions, ): Promise => { if (isEnabledHostElement(instance) && hasPressEventHandler(instance)) { - await emitDirectPressEvents(config, instance, options); + interaction.target = instance; + await emitDirectPressEvents(config, interaction, options); return; } if (isEnabledTouchResponder(instance)) { - await emitPressabilityPressEvents(config, instance, options); + interaction.target = instance; + await emitPressabilityPressEvents(config, interaction, options); return; } + // The element could handle the press, but is disabled or blocks touches. + if (hasPressEventHandler(instance) || instance.props.onStartShouldSetResponder) { + interaction.skippedTargets.push(instance); + } + if (!instance.parent) { return; } - await basePress(config, instance.parent, options); + await basePress(config, interaction, instance.parent, options); }; function isEnabledHostElement(instance: TestInstance) { @@ -110,43 +121,43 @@ function hasPressEventHandler(instance: TestInstance) { */ async function emitDirectPressEvents( config: UserEventConfig, - instance: TestInstance, + interaction: Interaction, options: BasePressOptions, ) { await wait(config); - await dispatchEvent(instance, 'pressIn', buildTouchEvent()); + await interaction.dispatchEvent('pressIn', buildTouchEvent()); await wait(config, options.duration); // Long press events are emitted before `pressOut`. if (options.type === 'longPress') { - await dispatchEvent(instance, 'longPress', buildTouchEvent()); + await interaction.dispatchEvent('longPress', buildTouchEvent()); } - await dispatchEvent(instance, 'pressOut', buildTouchEvent()); + await interaction.dispatchEvent('pressOut', buildTouchEvent()); // Regular press events are emitted after `pressOut` according to the React Native docs. // See: https://reactnative.dev/docs/pressable#onpress // Experimentally for very short presses (< 130ms) `press` events are actually emitted before `onPressOut`, but // we will ignore that as in reality most pressed would be above the 130ms threshold. if (options.type === 'press') { - await dispatchEvent(instance, 'press', buildTouchEvent()); + await interaction.dispatchEvent('press', buildTouchEvent()); } } async function emitPressabilityPressEvents( config: UserEventConfig, - instance: TestInstance, + interaction: Interaction, options: BasePressOptions, ) { await wait(config); - await dispatchEvent(instance, 'responderGrant', buildResponderGrantEvent()); + await interaction.dispatchEvent('responderGrant', buildResponderGrantEvent()); const duration = options.duration ?? DEFAULT_MIN_PRESS_DURATION; await wait(config, duration); - await dispatchEvent(instance, 'responderRelease', buildResponderReleaseEvent()); + await interaction.dispatchEvent('responderRelease', buildResponderReleaseEvent()); // React Native will wait for minimal delay of DEFAULT_MIN_PRESS_DURATION // before emitting the `pressOut` event. We need to wait here, so that diff --git a/src/user-event/scroll/pull-to-refresh.ts b/src/user-event/scroll/pull-to-refresh.ts index e22fe2645..6ce4a149e 100644 --- a/src/user-event/scroll/pull-to-refresh.ts +++ b/src/user-event/scroll/pull-to-refresh.ts @@ -4,6 +4,7 @@ import { act } from '../../act'; import { ErrorWithStack } from '../../helpers/errors'; import { isHostScrollView } from '../../helpers/host-component-names'; import type { UserEventInstance } from '../setup'; +import { Interaction, warnAboutUnhandledInteraction } from '../utils'; export async function pullToRefresh( this: UserEventInstance, @@ -16,12 +17,18 @@ export async function pullToRefresh( ); } - const refreshControl = instance.props.refreshControl; - if (typeof refreshControl?.props?.onRefresh !== 'function') { - return; + const interaction = new Interaction('pullToRefresh', instance); + + // `refreshControl` is an element prop, not a rendered host instance, so `dispatchEvent` + // can't reach its `onRefresh` handler. + const onRefresh = instance.props.refreshControl?.props?.onRefresh; + const hasHandler = typeof onRefresh === 'function'; + if (hasHandler) { + await act(() => { + onRefresh(); + }); } - await act(() => { - refreshControl.props.onRefresh(); - }); + interaction.recordEvent('refresh', hasHandler); + warnAboutUnhandledInteraction(interaction); } diff --git a/src/user-event/scroll/scroll-to.ts b/src/user-event/scroll/scroll-to.ts index d029ffc48..19f29d06b 100644 --- a/src/user-event/scroll/scroll-to.ts +++ b/src/user-event/scroll/scroll-to.ts @@ -1,13 +1,13 @@ import { stringify } from 'jest-matcher-utils'; import type { TestInstance } from 'test-renderer'; -import { buildScrollEvent, dispatchEvent, nativeState } from '../../events'; +import { buildScrollEvent, nativeState } from '../../events'; import { ErrorWithStack } from '../../helpers/errors'; import { isHostScrollView } from '../../helpers/host-component-names'; import { pick } from '../../helpers/object'; import type { Point, Size } from '../../types'; import type { UserEventConfig, UserEventInstance } from '../setup'; -import { wait } from '../utils'; +import { Interaction, wait, warnAboutUnhandledInteraction } from '../utils'; import { createScrollSteps, inertialInterpolator, linearInterpolator } from './utils'; interface CommonScrollToOptions { @@ -48,14 +48,14 @@ export async function scrollTo( } ensureScrollViewDirection(instance, options); + const interaction = new Interaction('scrollTo', instance); const eventOptions: ScrollToOptions = { ...options, layoutMeasurement: options.layoutMeasurement ?? nativeState.layoutSizeForInstance.get(instance), }; - await dispatchEvent( - instance, + await interaction.dispatchEvent( 'contentSizeChange', options.contentSize?.width ?? 0, options.contentSize?.height ?? 0, @@ -70,7 +70,7 @@ export async function scrollTo( initialOffset, linearInterpolator, ); - await emitDragScrollEvents(this.config, instance, dragSteps, eventOptions); + await emitDragScrollEvents(this.config, interaction, dragSteps, eventOptions); const momentumStart = dragSteps.at(-1) ?? initialOffset; const momentumSteps = createScrollSteps( @@ -78,15 +78,18 @@ export async function scrollTo( momentumStart, inertialInterpolator, ); - await emitMomentumScrollEvents(this.config, instance, momentumSteps, eventOptions); + await emitMomentumScrollEvents(this.config, interaction, momentumSteps, eventOptions); const finalOffset = momentumSteps.at(-1) ?? dragSteps.at(-1) ?? initialOffset; nativeState.contentOffsetForInstance.set(instance, finalOffset); + interaction.hasUpdatedNativeState = true; + + warnAboutUnhandledInteraction(interaction); } async function emitDragScrollEvents( config: UserEventConfig, - instance: TestInstance, + interaction: Interaction, scrollSteps: Point[], scrollOptions: ScrollToOptions, ) { @@ -95,24 +98,27 @@ async function emitDragScrollEvents( } await wait(config); - await dispatchEvent(instance, 'scrollBeginDrag', buildScrollEvent(scrollSteps[0], scrollOptions)); + await interaction.dispatchEvent( + 'scrollBeginDrag', + buildScrollEvent(scrollSteps[0], scrollOptions), + ); // Note: experimentally, in case of drag scroll the last scroll step // will not trigger `scroll` event. // See: https://github.com/callstack/react-native-testing-library/wiki/ScrollView-Events for (let i = 1; i < scrollSteps.length - 1; i += 1) { await wait(config); - await dispatchEvent(instance, 'scroll', buildScrollEvent(scrollSteps[i], scrollOptions)); + await interaction.dispatchEvent('scroll', buildScrollEvent(scrollSteps[i], scrollOptions)); } await wait(config); const lastStep = scrollSteps.at(-1); - await dispatchEvent(instance, 'scrollEndDrag', buildScrollEvent(lastStep, scrollOptions)); + await interaction.dispatchEvent('scrollEndDrag', buildScrollEvent(lastStep, scrollOptions)); } async function emitMomentumScrollEvents( config: UserEventConfig, - instance: TestInstance, + interaction: Interaction, scrollSteps: Point[], scrollOptions: ScrollToOptions, ) { @@ -121,8 +127,7 @@ async function emitMomentumScrollEvents( } await wait(config); - await dispatchEvent( - instance, + await interaction.dispatchEvent( 'momentumScrollBegin', buildScrollEvent(scrollSteps[0], scrollOptions), ); @@ -132,12 +137,12 @@ async function emitMomentumScrollEvents( // See: https://github.com/callstack/react-native-testing-library/wiki/ScrollView-Events for (let i = 1; i < scrollSteps.length; i += 1) { await wait(config); - await dispatchEvent(instance, 'scroll', buildScrollEvent(scrollSteps[i], scrollOptions)); + await interaction.dispatchEvent('scroll', buildScrollEvent(scrollSteps[i], scrollOptions)); } await wait(config); const lastStep = scrollSteps.at(-1); - await dispatchEvent(instance, 'momentumScrollEnd', buildScrollEvent(lastStep, scrollOptions)); + await interaction.dispatchEvent('momentumScrollEnd', buildScrollEvent(lastStep, scrollOptions)); } function ensureScrollViewDirection(instance: TestInstance, options: ScrollToOptions) { diff --git a/src/user-event/type/type.ts b/src/user-event/type/type.ts index 94c04e8ed..cd78d2be3 100644 --- a/src/user-event/type/type.ts +++ b/src/user-event/type/type.ts @@ -10,7 +10,6 @@ import { buildTextChangeEvent, buildTextSelectionChangeEvent, buildTouchEvent, - dispatchEvent, isPointerEventEnabled, nativeState, } from '../../events'; @@ -18,7 +17,7 @@ import { ErrorWithStack } from '../../helpers/errors'; import { isHostTextInput } from '../../helpers/host-component-names'; import { getTextInputValue, isEditableTextInput } from '../../helpers/text-input'; import type { UserEventConfig, UserEventInstance } from '../setup'; -import { getTextContentSize, wait } from '../utils'; +import { Interaction, getTextContentSize, wait, warnAboutUnhandledInteraction } from '../utils'; import { parseKeys } from './parse-keys'; export interface TypeOptions { @@ -40,22 +39,24 @@ export async function type( ); } - // Skip events if the instance is disabled + const interaction = new Interaction('type', instance); if (!isEditableTextInput(instance) || !isPointerEventEnabled(instance)) { + interaction.skippedTargets.push(instance); + warnAboutUnhandledInteraction(interaction); return; } const keys = parseKeys(text); if (!options?.skipPress) { - await dispatchEvent(instance, 'pressIn', buildTouchEvent()); + await interaction.dispatchEvent('pressIn', buildTouchEvent()); } - await dispatchEvent(instance, 'focus', buildFocusEvent()); + await interaction.dispatchEvent('focus', buildFocusEvent()); if (!options?.skipPress) { await wait(this.config); - await dispatchEvent(instance, 'pressOut', buildTouchEvent()); + await interaction.dispatchEvent('pressOut', buildTouchEvent()); } for (const key of keys) { @@ -66,6 +67,7 @@ export async function type( await emitTypingEvents(instance, { config: this.config, + interaction, key, text: currentText, isAccepted, @@ -76,17 +78,20 @@ export async function type( await wait(this.config); if (options?.submitEditing) { - await dispatchEvent(instance, 'submitEditing', buildSubmitEditingEvent(finalText)); + await interaction.dispatchEvent('submitEditing', buildSubmitEditingEvent(finalText)); } if (!options?.skipBlur) { - await dispatchEvent(instance, 'endEditing', buildEndEditingEvent(finalText)); - await dispatchEvent(instance, 'blur', buildBlurEvent()); + await interaction.dispatchEvent('endEditing', buildEndEditingEvent(finalText)); + await interaction.dispatchEvent('blur', buildBlurEvent()); } + + warnAboutUnhandledInteraction(interaction); } type EmitTypingEventsContext = { config: UserEventConfig; + interaction: Interaction; key: string; text: string; isAccepted?: boolean; @@ -94,12 +99,12 @@ type EmitTypingEventsContext = { export async function emitTypingEvents( instance: TestInstance, - { config, key, text, isAccepted }: EmitTypingEventsContext, + { config, interaction, key, text, isAccepted }: EmitTypingEventsContext, ) { const isMultiline = instance.props.multiline === true; await wait(config); - await dispatchEvent(instance, 'keyPress', buildKeyPressEvent(key)); + await interaction.dispatchEvent('keyPress', buildKeyPressEvent(key)); // Platform difference (based on experiments): // - iOS and RN Web: TextInput emits only `keyPress` event when max length has been reached @@ -109,21 +114,22 @@ export async function emitTypingEvents( } nativeState.valueForInstance.set(instance, text); + interaction.hasUpdatedNativeState = true; const selectionRange = { start: text.length, end: text.length, }; - await dispatchEvent(instance, 'change', buildTextChangeEvent(text, selectionRange)); - await dispatchEvent(instance, 'changeText', text); - await dispatchEvent(instance, 'selectionChange', buildTextSelectionChangeEvent(selectionRange)); + await interaction.dispatchEvent('change', buildTextChangeEvent(text, selectionRange)); + await interaction.dispatchEvent('changeText', text); + await interaction.dispatchEvent('selectionChange', buildTextSelectionChangeEvent(selectionRange)); // According to the docs only multiline TextInput emits contentSizeChange event // @see: https://reactnative.dev/docs/textinput#oncontentsizechange if (isMultiline) { const contentSize = getTextContentSize(text); - await dispatchEvent(instance, 'contentSizeChange', buildContentSizeChangeEvent(contentSize)); + await interaction.dispatchEvent('contentSizeChange', buildContentSizeChangeEvent(contentSize)); } } diff --git a/src/user-event/utils/index.ts b/src/user-event/utils/index.ts index aea309381..3fa1e45f3 100644 --- a/src/user-event/utils/index.ts +++ b/src/user-event/utils/index.ts @@ -1,2 +1,4 @@ export * from './content-size'; +export * from './interaction'; export * from './wait'; +export * from './warnings'; diff --git a/src/user-event/utils/interaction.ts b/src/user-event/utils/interaction.ts new file mode 100644 index 000000000..7ea2f7b3f --- /dev/null +++ b/src/user-event/utils/interaction.ts @@ -0,0 +1,41 @@ +import type { TestInstance } from 'test-renderer'; + +import { dispatchEvent } from '../../events'; + +/** + * Tracks what a single `userEvent` interaction did, so `warnAboutUnhandledInteraction` + * can tell whether any of its events had an effect. + */ +export class Interaction { + readonly eventNames: string[] = []; + /** Elements that could handle the interaction but did not accept it, nearest first. */ + readonly skippedTargets: TestInstance[] = []; + hasCalledHandler = false; + hasUpdatedNativeState = false; + + /** + * @param name Name of the `userEvent` method, e.g. `press`. + * @param target Element to dispatch events to. `press()` moves it to the element that + * handles the press. + */ + constructor( + readonly name: string, + public target: TestInstance, + ) {} + + async dispatchEvent(eventName: string, ...event: unknown[]) { + const hasCalledHandler = await dispatchEvent(this.target, eventName, ...event); + this.recordEvent(eventName, hasCalledHandler); + } + + /** + * Records an event whose handler the action called itself instead of using `dispatchEvent`, + * e.g. `pullToRefresh()` calling `onRefresh` from the `refreshControl` prop. + */ + recordEvent(eventName: string, hasCalledHandler: boolean) { + this.eventNames.push(eventName); + if (hasCalledHandler) { + this.hasCalledHandler = true; + } + } +} diff --git a/src/user-event/utils/warnings.ts b/src/user-event/utils/warnings.ts new file mode 100644 index 000000000..1625c92cf --- /dev/null +++ b/src/user-event/utils/warnings.ts @@ -0,0 +1,83 @@ +import { getConfig } from '../../config'; +import type { EventWarning } from '../../events'; +import { + formatDisabledTargets, + getPointerEventsBlockedTargets, + logEventWarning, +} from '../../events'; +import { computeAriaDisabled } from '../../helpers/accessibility'; +import type { Interaction } from './interaction'; + +/** + * Warns when none of the events of a `userEvent` interaction called a handler or updated + * native state. + */ +export function warnAboutUnhandledInteraction(interaction: Interaction) { + if (!getConfig().eventDiagnostics) { + return; + } + + // The interaction had an effect, e.g. typing into an uncontrolled TextInput updates its value. + if (interaction.hasCalledHandler || interaction.hasUpdatedNativeState) { + return; + } + + const warning = getUnhandledInteractionWarning(interaction); + if (warning != null) { + logEventWarning(warning); + } +} + +function getUnhandledInteractionWarning({ + name, + target, + eventNames, + skippedTargets, +}: Interaction): EventWarning | null { + const summary = `${name}() interaction did not call any event handlers.`; + + // `pointerEvents` is checked first: it blocks the interaction even if the element is enabled. + const blocked = getPointerEventsBlockedTargets(skippedTargets); + if (blocked != null) { + return { + message: + blocked.elements.length === 1 + ? `${summary} The element is blocked by pointerEvents.` + : `${summary} The elements are blocked by pointerEvents.`, + ...blocked, + }; + } + + // `computeAriaDisabled` also covers non-editable `TextInput`. + const disabledTargets = skippedTargets.filter(computeAriaDisabled); + if (disabledTargets.length > 0) { + return { + message: `${summary} Cannot interact with ${formatDisabledTargets(disabledTargets)}.`, + elements: disabledTargets, + }; + } + + if (eventNames.length === 0) { + if (skippedTargets.length > 0) { + return null; + } + + return { + message: `${summary} The element and its ancestors have no handlers for this interaction.`, + elements: [target], + }; + } + + const quotedNames = [...new Set(eventNames)].map((eventName) => `"${eventName}"`); + return { + message: + quotedNames.length === 1 + ? `${summary} The element has no handler for the ${quotedNames[0]} event.` + : `${summary} The element has no handler for the ${formatOrList(quotedNames)} events.`, + elements: [target], + }; +} + +function formatOrList(items: string[]): string { + return `${items.slice(0, -1).join(', ')} or ${items.at(-1)}`; +} diff --git a/website/docs/14.x/docs/api/misc/config.mdx b/website/docs/14.x/docs/api/misc/config.mdx index a72fa8dea..08e1d64ca 100644 --- a/website/docs/14.x/docs/api/misc/config.mdx +++ b/website/docs/14.x/docs/api/misc/config.mdx @@ -10,6 +10,9 @@ type Config = { /** Default value for `includeHiddenElements` query option. */ defaultIncludeHiddenElements: boolean; + /** Warn when `fireEvent` or a `userEvent` interaction calls no handler. Off by default. */ + eventDiagnostics: boolean; + /** Default options for `debug` helper. */ defaultDebugOptions?: Partial; }; @@ -32,6 +35,25 @@ Default value for [includeHiddenElements](/docs/api/queries#includehiddenelement This option is also available as `defaultHidden` alias for compatibility with [React Testing Library](https://testing-library.com/docs/dom-testing-library/api-configuration/#defaulthidden). +### `eventDiagnostics` option + +Logs a warning when `fireEvent` or `userEvent` doesn't call any handler, so a test doesn't silently do nothing. Defaults to `false`. + +A warning is logged in these cases: + +- The handler is on a disabled element, e.g. a `Pressable` with `disabled={true}`. +- The element is a non-editable `TextInput` (`editable={false}`). It blocks most events, including `changeText`, `focus`, `blur`, `press` and `submitEditing`, also when the handler is on one of its ancestors. The warning shows the `TextInput`. +- The element is blocked by `pointerEvents`, e.g. it is inside a `View` with `pointerEvents="none"`. The warning shows the element that sets `pointerEvents`. This takes precedence over the disabled warning, because the event wouldn't reach the element even if it were enabled. +- Neither the element nor any of its ancestors has a handler for the event. For direct events like `layout`, which don't bubble, only the element itself is checked. + +A `userEvent` interaction, like `press()` or `type()`, dispatches several events. It warns only when none of them called a handler. For example, `longPress()` on an element that has only `onPress` warns, because `longPress()` doesn't dispatch a `press` event. + +No warning is logged when the event updates native state, e.g. `fireEvent.changeText` or `userEvent.type` on an uncontrolled `TextInput`. Turn it on while debugging a test, or for the whole test suite in your Jest setup file: + +```ts +configure({ eventDiagnostics: true }); +``` + ### `defaultDebugOptions` option Default [debug options](#debug) to be used when calling `debug()`. These default options will be overridden by the ones you specify directly when calling `debug()`.