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/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 6949f3a6a..92a9ec83c 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, + warnOnUnhandledEvent: true, }); }); diff --git a/src/config.ts b/src/config.ts index b910b40d8..8a5510a0e 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` calls no handler, because the target is disabled or + * no element handles the event. Set to `false` to opt out. + */ + warnOnUnhandledEvent: boolean; + /** Default options for `debug` helper. */ defaultDebugOptions?: Partial; }; @@ -24,6 +30,7 @@ export type ConfigAliasOptions = { const defaultConfig: Config = { asyncUtilTimeout: 1000, defaultIncludeHiddenElements: false, + warnOnUnhandledEvent: true, }; let config = { ...defaultConfig }; @@ -37,6 +44,7 @@ export function configure(options: Partial) { defaultDebugOptions, defaultHidden, defaultIncludeHiddenElements, + warnOnUnhandledEvent, ...rest } = options; @@ -50,6 +58,7 @@ export function configure(options: Partial) { asyncUtilTimeout: asyncUtilTimeout ?? config.asyncUtilTimeout, defaultDebugOptions, defaultIncludeHiddenElements: resolvedDefaultIncludeHiddenElements, + warnOnUnhandledEvent: warnOnUnhandledEvent ?? config.warnOnUnhandledEvent, }; } diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index bec40de84..56c4b8eab 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 } } }; @@ -38,6 +39,7 @@ test('fireEvent accepts event name with or without "on" prefix', async () => { }); test('fireEvent with "on" prefixed name does not call unprefixed handler props', async () => { + const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => {}); const press = jest.fn(); const testOnlyPress = jest.fn(); // @ts-expect-error Intentionally passing such props @@ -49,6 +51,8 @@ test('fireEvent with "on" prefixed name does not call unprefixed handler props', await fireEvent(screen.getByTestId('view'), 'press'); expect(press).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledTimes(1); + warnSpy.mockRestore(); }); test('fireEvent passes event data to handler', async () => { @@ -593,9 +597,12 @@ describe('fireEvent.layout', () => { expect(warnSpy).toHaveBeenCalledTimes(1); expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - " ▲ fireEvent: element has no handler for "layout" event. + " ▲ No handler found for the "layout" event on the element. "layout" events do not bubble to ancestors. + If this is intentional, you can disable this warning via \`configure({ warnOnUnhandledEvent: false })\`. + + " `); warnSpy.mockRestore(); @@ -709,9 +716,12 @@ test('fireEvent does nothing when element is unmounted', async () => { }); test('fireEvent does not throw when called with non-existent event name', async () => { + const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => {}); await render(); const element = screen.getByTestId('btn'); await expect(fireEvent(element, 'nonExistentEvent' as any)).resolves.toBeUndefined(); + expect(warnSpy).toHaveBeenCalledTimes(1); + warnSpy.mockRestore(); }); test('fireEvent handles handler that throws gracefully', async () => { @@ -725,6 +735,16 @@ test('fireEvent handles handler that throws gracefully', async () => { }); describe('disabled elements', () => { + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + test('does not fire on disabled Pressable', async () => { const onPress = jest.fn(); await render( @@ -790,6 +810,127 @@ describe('disabled elements', () => { }); }); +describe('unhandled event warning', () => { + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + 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(` + "Tried to fire the "press" event on a disabled element, so its handler was not called. + If this is intentional, you can disable this warning via \`configure({ warnOnUnhandledEvent: false })\`. + + + + 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 handler found for the "press" event on the element or any of its ancestors. + If this is intentional, you can disable this warning via \`configure({ warnOnUnhandledEvent: 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('does not warn when the handler is blocked by pointerEvents="none"', async () => { + await render( + + + , + ); + + await fireEvent.press(screen.getByTestId('btn')); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test('does not warn when the handler is blocked by non-editable TextInput', async () => { + await render(); + + await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + 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 warnOnUnhandledEvent is turned off', async () => { + configure({ warnOnUnhandledEvent: 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(); @@ -999,6 +1140,16 @@ describe('non-editable TextInput', () => { }); describe('responder system', () => { + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + test('respects disabled prop through composite wrappers', async () => { function TestChildTouchableComponent({ onPress, diff --git a/src/events/fire-event.ts b/src/events/fire-event.ts index 890654726..7980400c4 100644 --- a/src/events/fire-event.ts +++ b/src/events/fire-event.ts @@ -10,6 +10,7 @@ import { normalizeEventName } from './handler'; import { nativeState } from './native-state'; import { findEventHandler } from './propagation'; import type { EventName, EventProps, LayoutRectangle } from './types'; +import { warnAboutUnhandledEvent } from './warnings'; import { updateNativeStateFromEvent } from './update-native-state'; async function fireEvent(instance: TestInstance, eventName: EventName, ...data: unknown[]) { @@ -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 didUpdateNativeState = updateNativeStateFromEvent( + instance, + normalizeEventName(eventName), + data[0], + ); - const handler = findEventHandler(instance, eventName); + const { handler, rejectedTarget } = findEventHandler(instance, eventName); if (!handler) { + warnAboutUnhandledEvent(instance, eventName, { rejectedTarget, didUpdateNativeState }); return; } diff --git a/src/events/propagation.ts b/src/events/propagation.ts index e607f4a9a..99d550686 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'; } +export type FindEventHandlerResult = { + handler: EventHandler | null; + /** + * Nearest element (to the fired instance) whose handler was found but rejected by + * `isEventEnabled`. Lets callers tell "blocked handler" apart from "no handler at all". + */ + rejectedTarget: TestInstance | null; +}; + /** * 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,44 @@ 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, rejectedTarget: null }; } - return handler; + return findBubblingEventHandler(instance, eventName, undefined, null); } function findBubblingEventHandler( instance: TestInstance, eventName: string, - nearestTouchResponder?: TestInstance, -): EventHandler | null { + nearestTouchResponder: TestInstance | undefined, + rejectedTarget: TestInstance | null, +): 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, rejectedTarget: null }; + } + + // Keep only the first (nearest to the fired instance) rejection. + rejectedTarget ??= touchResponder ?? instance; } if (instance.parent === null) { - return null; + return { handler: null, rejectedTarget }; } - return findBubblingEventHandler(instance.parent, eventName, touchResponder); + return findBubblingEventHandler(instance.parent, eventName, touchResponder, rejectedTarget); } 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..13f1e31fc --- /dev/null +++ b/src/events/warnings.ts @@ -0,0 +1,89 @@ +import redent from 'redent'; +import type { TestInstance } from 'test-renderer'; + +import { getConfig } from '../config'; +import { computeAriaDisabled } from '../helpers/accessibility'; +import { formatJson } from '../helpers/format-element'; +import { isHostTextInput } from '../helpers/host-component-names'; +import { logger } from '../helpers/logger'; +import { isEditableTextInput } from '../helpers/text-input'; +import { normalizeEventName } from './handler'; +import { isDirectEvent } from './propagation'; + +export type UnhandledEventInfo = { + /** Nearest element whose handler was rejected by `isEventEnabled`, if any. */ + rejectedTarget: TestInstance | null; + didUpdateNativeState: boolean; +}; + +/** + * Warns when no handler ran because the target is disabled or nothing handles the event. + * Opt out via `configure({ warnOnUnhandledEvent: false })`. + */ +export function warnAboutUnhandledEvent( + instance: TestInstance, + eventName: string, + info: UnhandledEventInfo, +) { + if (!getConfig().warnOnUnhandledEvent) { + return; + } + + const warning = getUnhandledEventWarning(instance, eventName, info); + if (warning == null) { + return; + } + + const elementJson = warning.element.toJSON(); + logger.warn( + `${warning.message}\n` + + 'If this is intentional, you can disable this warning via `configure({ warnOnUnhandledEvent: false })`.\n\n' + + redent(elementJson ? formatJson(elementJson) : '(hidden)', 2), + ); +} + +function getUnhandledEventWarning( + instance: TestInstance, + eventName: string, + { rejectedTarget, didUpdateNativeState }: UnhandledEventInfo, +): { message: string; element: TestInstance } | null { + if (rejectedTarget == null) { + if (isDirectEvent(normalizeEventName(eventName))) { + return { + message: `No handler found for the "${eventName}" event on the element. "${eventName}" events do not bubble to ancestors.`, + element: instance, + }; + } + + // The event still had an effect, e.g. `changeText` on an uncontrolled TextInput updates its value. + if (didUpdateNativeState) { + return null; + } + + return { + message: `No handler found for the "${eventName}" event on the element or any of its ancestors.`, + element: instance, + }; + } + + // Other rejections (`pointerEvents`, non-editable `TextInput`, responder declining the touch) + // are deliberate ways of blocking events, so they are not reported. + if (isWarnableDisabledTarget(rejectedTarget)) { + return { + message: `Tried to fire the "${eventName}" event on a disabled element, so its handler was not called.`, + element: rejectedTarget, + }; + } + + return null; +} + +function isWarnableDisabledTarget(target: TestInstance): boolean { + // `computeAriaDisabled` treats non-editable TextInput as disabled for a11y purposes, + // but firing events on it is expected, not a bug worth warning about. + if (isHostTextInput(target) && !isEditableTextInput(target)) { + return false; + } + + return computeAriaDisabled(target); +} diff --git a/website/docs/14.x/docs/api/misc/config.mdx b/website/docs/14.x/docs/api/misc/config.mdx index a72fa8dea..4f1268cd5 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` calls no handler (disabled element or no handler found). */ + warnOnUnhandledEvent: boolean; + /** Default options for `debug` helper. */ defaultDebugOptions?: Partial; }; @@ -32,6 +35,19 @@ 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). +### `warnOnUnhandledEvent` option + +When `fireEvent` doesn't call any handler, the test can silently do nothing, which can be confusing while debugging. When this option is enabled (the default), a warning is logged in these cases: + +- The handler is on a disabled element (e.g. a `Pressable` with `disabled={true}`). +- 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. + +No warning is logged when the event is blocked on purpose (`pointerEvents="none"`, non-editable `TextInput`), or when it updates native state, e.g. `fireEvent.changeText` on an uncontrolled `TextInput`. Set it to `false` to opt out: + +```ts +configure({ warnOnUnhandledEvent: false }); +``` + ### `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()`.