From 8e5147886a6646b1853d150b2c6caba91a9ec57e Mon Sep 17 00:00:00 2001 From: trinadhkoya Date: Sat, 5 Sep 2026 23:35:55 +0530 Subject: [PATCH 01/22] feat(fire-event): warn when firing events on disabled elements Firing an event on a disabled element (e.g. a `Pressable` with `disabled={true}`) silently triggers no handler, which is confusing when debugging tests. Emit a warning in that case, reusing `computeAriaDisabled` for detection and the existing `logger`. - Gated on no handler being found, so events that bubble to an enabled parent do not warn. - Scoped to disabled state only; `pointerEvents="none"` and `TextInput` editability are intentionally excluded to avoid false positives. - Opt-out via `configure({ disabledEventWarning: false })`; on by default. Closes #1718 (fireEvent scope; userEvent is a follow-up). ^ Conflicts: ^ src/events/__tests__/fire-event.test.tsx ^ src/fire-event.ts --- src/__tests__/config.test.ts | 1 + src/config.ts | 9 +++ src/events/__tests__/fire-event.test.tsx | 79 +++++++++++++++++++++- src/events/fire-event.ts | 52 +++++++++++++- website/docs/14.x/docs/api/misc/config.mdx | 11 +++ 5 files changed, 150 insertions(+), 2 deletions(-) diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 6949f3a6a..1fb2cc51d 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, + disabledEventWarning: true, }); }); diff --git a/src/config.ts b/src/config.ts index b910b40d8..cf3977784 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 an event is fired on a disabled element and no handler is + * triggered as a result. Set to `false` to opt out. + */ + disabledEventWarning: boolean; + /** Default options for `debug` helper. */ defaultDebugOptions?: Partial; }; @@ -24,6 +30,7 @@ export type ConfigAliasOptions = { const defaultConfig: Config = { asyncUtilTimeout: 1000, defaultIncludeHiddenElements: false, + disabledEventWarning: true, }; let config = { ...defaultConfig }; @@ -37,6 +44,7 @@ export function configure(options: Partial) { defaultDebugOptions, defaultHidden, defaultIncludeHiddenElements, + disabledEventWarning, ...rest } = options; @@ -50,6 +58,7 @@ export function configure(options: Partial) { asyncUtilTimeout: asyncUtilTimeout ?? config.asyncUtilTimeout, defaultDebugOptions, defaultIncludeHiddenElements: resolvedDefaultIncludeHiddenElements, + disabledEventWarning: disabledEventWarning ?? config.disabledEventWarning, }; } diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index bec40de84..6867699b0 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, resetToDefaults } from '../../config'; +import { _console, logger } from '../../helpers/logger'; import { nativeState } from '../native-state'; const layoutEvent = { nativeEvent: { layout: { width: 100, height: 100 } } }; @@ -725,6 +726,17 @@ 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(); + resetToDefaults(); + }); + test('does not fire on disabled Pressable', async () => { const onPress = jest.fn(); await render( @@ -788,6 +800,61 @@ describe('disabled elements', () => { await fireEvent.press(screen.getByText('Trigger Test')); expect(handlePress).toHaveBeenCalledTimes(1); }); + + test('warns when firing an event 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 no handler was called. + If this is intentional, you can disable this warning via \`configure({ disabledEventWarning: false })\`." + `); + }); + + 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 element is not disabled (e.g. pointerEvents="none")', async () => { + await render( + + + , + ); + + await fireEvent.press(screen.getByTestId('btn')); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test('does not warn when disabledEventWarning is turned off', async () => { + configure({ disabledEventWarning: false }); + await render( + + Trigger + , + ); + + await fireEvent.press(screen.getByText('Trigger')); + + expect(warnSpy).not.toHaveBeenCalled(); + }); }); describe('pointerEvents prop', () => { @@ -999,6 +1066,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..33f8bd920 100644 --- a/src/events/fire-event.ts +++ b/src/events/fire-event.ts @@ -1,17 +1,66 @@ import type { TestInstance } from 'test-renderer'; import { act } from '../act'; +import { getConfig } from '../config'; +import { computeAriaDisabled } from '../helpers/accessibility'; import { isInstanceMounted } from '../helpers/component-tree'; -import { isHostScrollView } from '../helpers/host-component-names'; +import { isHostScrollView, isHostTextInput } from '../helpers/host-component-names'; +import { logger } from '../helpers/logger'; import { buildLayoutEvent, buildTouchEvent } from './builders/common'; import { mergeEventProps } from './builders/merge'; import { buildScrollEvent } from './builders/scroll'; import { normalizeEventName } from './handler'; +import { isTouchResponder } from './is-enabled'; import { nativeState } from './native-state'; import { findEventHandler } from './propagation'; import type { EventName, EventProps, LayoutRectangle } from './types'; import { updateNativeStateFromEvent } from './update-native-state'; +/** + * Walks up from the target to the nearest element that can respond to touches + * (a touch responder or a host `TextInput`), mirroring `findEventHandler`. + */ +function getNearestTouchResponder(instance: TestInstance): TestInstance | null { + let current: TestInstance | null = instance; + while (current != null) { + if (isTouchResponder(current)) { + return current; + } + + current = current.parent; + } + + return null; +} + +/** + * Warns when an event did not trigger any handler because the responding + * element is disabled. Helps debug tests that silently do nothing. + * Can be opted out via `configure({ disabledEventWarning: false })`. + */ +function warnAboutDisabledEventTarget(instance: TestInstance, eventName: string) { + if (!getConfig().disabledEventWarning) { + return; + } + + const target = getNearestTouchResponder(instance) ?? instance; + + // `TextInput` editability (`editable={false}`) is a separate concern from + // disabled state, so we don't warn about it here to avoid false positives. + if (isHostTextInput(target)) { + return; + } + + if (!computeAriaDisabled(target)) { + return; + } + + logger.warn( + `Tried to fire the "${eventName}" event on a disabled element, so no handler was called.\n` + + 'If this is intentional, you can disable this warning via `configure({ disabledEventWarning: false })`.', + ); +} + async function fireEvent(instance: TestInstance, eventName: EventName, ...data: unknown[]) { if (!isInstanceMounted(instance)) { return; @@ -22,6 +71,7 @@ async function fireEvent(instance: TestInstance, eventName: EventName, ...data: const handler = findEventHandler(instance, eventName); if (!handler) { + warnAboutDisabledEventTarget(instance, eventName); return; } diff --git a/website/docs/14.x/docs/api/misc/config.mdx b/website/docs/14.x/docs/api/misc/config.mdx index a72fa8dea..f1b7eda3c 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 firing an event on a disabled element triggers no handler. */ + disabledEventWarning: boolean; + /** Default options for `debug` helper. */ defaultDebugOptions?: Partial; }; @@ -32,6 +35,14 @@ 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). +### `disabledEventWarning` option + +When `fireEvent` is used on a disabled element (e.g. a `Pressable` with `disabled={true}`) the event is not dispatched to any handler, which can be surprising while debugging tests. When this option is enabled (the default), a warning is logged in that case. Set it to `false` to opt out: + +```ts +configure({ disabledEventWarning: 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()`. From 7a0b0d91f417930374c147f26d88c85fb4769ba0 Mon Sep 17 00:00:00 2001 From: trinadhkoya Date: Wed, 9 Sep 2026 11:18:26 +0530 Subject: [PATCH 02/22] Clarify comment on non-editable TextInput warning Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/events/fire-event.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/events/fire-event.ts b/src/events/fire-event.ts index 33f8bd920..93946fee6 100644 --- a/src/events/fire-event.ts +++ b/src/events/fire-event.ts @@ -6,6 +6,7 @@ import { computeAriaDisabled } from '../helpers/accessibility'; import { isInstanceMounted } from '../helpers/component-tree'; import { isHostScrollView, isHostTextInput } from '../helpers/host-component-names'; import { logger } from '../helpers/logger'; +import { isEditableTextInput } from '../helpers/text-input'; import { buildLayoutEvent, buildTouchEvent } from './builders/common'; import { mergeEventProps } from './builders/merge'; import { buildScrollEvent } from './builders/scroll'; @@ -46,8 +47,8 @@ function warnAboutDisabledEventTarget(instance: TestInstance, eventName: string) const target = getNearestTouchResponder(instance) ?? instance; // `TextInput` editability (`editable={false}`) is a separate concern from - // disabled state, so we don't warn about it here to avoid false positives. - if (isHostTextInput(target)) { + // disabled state, so we don't warn about non-editable TextInput here to avoid false positives. + if (isHostTextInput(target) && !isEditableTextInput(target)) { return; } From 9c9c1ecb4023a890dce093828fbac7a173794e82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Mon, 21 Sep 2026 10:36:35 +0200 Subject: [PATCH 03/22] rename warnOnDisabledElementEvent --- src/__tests__/config.test.ts | 2 +- src/config.ts | 8 ++++---- src/events/__tests__/fire-event.test.tsx | 6 +++--- src/events/fire-event.ts | 6 +++--- website/docs/14.x/docs/api/misc/config.mdx | 6 +++--- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 1fb2cc51d..21df8aae0 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -22,7 +22,7 @@ test('configure() overrides existing config values', () => { asyncUtilTimeout: 5000, defaultDebugOptions: { message: 'debug message' }, defaultIncludeHiddenElements: false, - disabledEventWarning: true, + warnOnDisabledElementEvent: true, }); }); diff --git a/src/config.ts b/src/config.ts index cf3977784..a66ae4f3e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -16,7 +16,7 @@ export type Config = { * Warn when an event is fired on a disabled element and no handler is * triggered as a result. Set to `false` to opt out. */ - disabledEventWarning: boolean; + warnOnDisabledElementEvent: boolean; /** Default options for `debug` helper. */ defaultDebugOptions?: Partial; @@ -30,7 +30,7 @@ export type ConfigAliasOptions = { const defaultConfig: Config = { asyncUtilTimeout: 1000, defaultIncludeHiddenElements: false, - disabledEventWarning: true, + warnOnDisabledElementEvent: true, }; let config = { ...defaultConfig }; @@ -44,7 +44,7 @@ export function configure(options: Partial) { defaultDebugOptions, defaultHidden, defaultIncludeHiddenElements, - disabledEventWarning, + warnOnDisabledElementEvent, ...rest } = options; @@ -58,7 +58,7 @@ export function configure(options: Partial) { asyncUtilTimeout: asyncUtilTimeout ?? config.asyncUtilTimeout, defaultDebugOptions, defaultIncludeHiddenElements: resolvedDefaultIncludeHiddenElements, - disabledEventWarning: disabledEventWarning ?? config.disabledEventWarning, + warnOnDisabledElementEvent: warnOnDisabledElementEvent ?? config.warnOnDisabledElementEvent, }; } diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index 6867699b0..4fb952323 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -813,7 +813,7 @@ describe('disabled elements', () => { expect(warnSpy).toHaveBeenCalledTimes(1); expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` "Tried to fire the "press" event on a disabled element, so no handler was called. - If this is intentional, you can disable this warning via \`configure({ disabledEventWarning: false })\`." + If this is intentional, you can disable this warning via \`configure({ warnOnDisabledElementEvent: false })\`." `); }); @@ -843,8 +843,8 @@ describe('disabled elements', () => { expect(warnSpy).not.toHaveBeenCalled(); }); - test('does not warn when disabledEventWarning is turned off', async () => { - configure({ disabledEventWarning: false }); + test('does not warn when warnOnDisabledElementEvent is turned off', async () => { + configure({ warnOnDisabledElementEvent: false }); await render( Trigger diff --git a/src/events/fire-event.ts b/src/events/fire-event.ts index 93946fee6..8390ee524 100644 --- a/src/events/fire-event.ts +++ b/src/events/fire-event.ts @@ -37,10 +37,10 @@ function getNearestTouchResponder(instance: TestInstance): TestInstance | null { /** * Warns when an event did not trigger any handler because the responding * element is disabled. Helps debug tests that silently do nothing. - * Can be opted out via `configure({ disabledEventWarning: false })`. + * Can be opted out via `configure({ warnOnDisabledElementEvent: false })`. */ function warnAboutDisabledEventTarget(instance: TestInstance, eventName: string) { - if (!getConfig().disabledEventWarning) { + if (!getConfig().warnOnDisabledElementEvent) { return; } @@ -58,7 +58,7 @@ function warnAboutDisabledEventTarget(instance: TestInstance, eventName: string) logger.warn( `Tried to fire the "${eventName}" event on a disabled element, so no handler was called.\n` + - 'If this is intentional, you can disable this warning via `configure({ disabledEventWarning: false })`.', + 'If this is intentional, you can disable this warning via `configure({ warnOnDisabledElementEvent: false })`.', ); } diff --git a/website/docs/14.x/docs/api/misc/config.mdx b/website/docs/14.x/docs/api/misc/config.mdx index f1b7eda3c..5b98f5893 100644 --- a/website/docs/14.x/docs/api/misc/config.mdx +++ b/website/docs/14.x/docs/api/misc/config.mdx @@ -11,7 +11,7 @@ type Config = { defaultIncludeHiddenElements: boolean; /** Warn when firing an event on a disabled element triggers no handler. */ - disabledEventWarning: boolean; + warnOnDisabledElementEvent: boolean; /** Default options for `debug` helper. */ defaultDebugOptions?: Partial; @@ -35,12 +35,12 @@ 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). -### `disabledEventWarning` option +### `warnOnDisabledElementEvent` option When `fireEvent` is used on a disabled element (e.g. a `Pressable` with `disabled={true}`) the event is not dispatched to any handler, which can be surprising while debugging tests. When this option is enabled (the default), a warning is logged in that case. Set it to `false` to opt out: ```ts -configure({ disabledEventWarning: false }); +configure({ warnOnDisabledElementEvent: false }); ``` ### `defaultDebugOptions` option From 6e3bf6ebbe9f12dbb2714a168cc00c66d7302805 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Mon, 21 Sep 2026 11:55:33 +0200 Subject: [PATCH 04/22] better capture logged element --- src/events/fire-event.ts | 45 +++++++++++++-------------------------- src/events/propagation.ts | 37 ++++++++++++++++++++++++++------ 2 files changed, 45 insertions(+), 37 deletions(-) diff --git a/src/events/fire-event.ts b/src/events/fire-event.ts index 8390ee524..5a05dd57f 100644 --- a/src/events/fire-event.ts +++ b/src/events/fire-event.ts @@ -11,48 +11,32 @@ import { buildLayoutEvent, buildTouchEvent } from './builders/common'; import { mergeEventProps } from './builders/merge'; import { buildScrollEvent } from './builders/scroll'; import { normalizeEventName } from './handler'; -import { isTouchResponder } from './is-enabled'; import { nativeState } from './native-state'; +import type { FindEventHandlerContext } from './propagation'; import { findEventHandler } from './propagation'; import type { EventName, EventProps, LayoutRectangle } from './types'; import { updateNativeStateFromEvent } from './update-native-state'; -/** - * Walks up from the target to the nearest element that can respond to touches - * (a touch responder or a host `TextInput`), mirroring `findEventHandler`. - */ -function getNearestTouchResponder(instance: TestInstance): TestInstance | null { - let current: TestInstance | null = instance; - while (current != null) { - if (isTouchResponder(current)) { - return current; - } - - current = current.parent; +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 null; + return computeAriaDisabled(target); } /** - * Warns when an event did not trigger any handler because the responding - * element is disabled. Helps debug tests that silently do nothing. - * Can be opted out via `configure({ warnOnDisabledElementEvent: false })`. + * Warns when no handler ran because the target is disabled. + * Opt out via `configure({ warnOnDisabledElementEvent: false })`. */ -function warnAboutDisabledEventTarget(instance: TestInstance, eventName: string) { - if (!getConfig().warnOnDisabledElementEvent) { - return; - } - - const target = getNearestTouchResponder(instance) ?? instance; - - // `TextInput` editability (`editable={false}`) is a separate concern from - // disabled state, so we don't warn about non-editable TextInput here to avoid false positives. - if (isHostTextInput(target) && !isEditableTextInput(target)) { +function warnAboutDisabledEventTarget(target: TestInstance | null, eventName: string) { + if (!getConfig().warnOnDisabledElementEvent || target == null) { return; } - if (!computeAriaDisabled(target)) { + if (!isWarnableDisabledTarget(target)) { return; } @@ -70,9 +54,10 @@ 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 handler = findEventHandler(instance, eventName); + const context: FindEventHandlerContext = { rejectedTargetRef: { current: null } }; + const handler = findEventHandler(instance, eventName, context); if (!handler) { - warnAboutDisabledEventTarget(instance, eventName); + warnAboutDisabledEventTarget(context.rejectedTargetRef.current, eventName); return; } diff --git a/src/events/propagation.ts b/src/events/propagation.ts index e607f4a9a..92fc9883a 100644 --- a/src/events/propagation.ts +++ b/src/events/propagation.ts @@ -13,6 +13,14 @@ export function isDirectEvent(eventName: string) { return eventName === 'layout'; } +// Carries state across the recursive `findEventHandler` walk. `rejectedTargetRef` is +// filled in with the element that owned the nearest handler rejected by `isEventEnabled`, +// so callers can report *why* no handler ran without re-walking the tree themselves. +export type FindEventHandlerContext = { + nearestTouchResponder?: TestInstance; + rejectedTargetRef: { current: 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,10 +28,14 @@ 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 { +export function findEventHandler( + instance: TestInstance, + eventName: string, + context: FindEventHandlerContext, +): EventHandler | null { return isDirectEvent(normalizeEventName(eventName)) ? getOwnEventHandler(instance, eventName) - : findBubblingEventHandler(instance, eventName); + : findBubblingEventHandler(instance, eventName, context); } function getOwnEventHandler(instance: TestInstance, eventName: string): EventHandler | null { @@ -42,22 +54,33 @@ function getOwnEventHandler(instance: TestInstance, eventName: string): EventHan function findBubblingEventHandler( instance: TestInstance, eventName: string, - nearestTouchResponder?: TestInstance, + context: FindEventHandlerContext, ): EventHandler | null { - const touchResponder = isTouchResponder(instance) ? instance : nearestTouchResponder; + const touchResponder = isTouchResponder(instance) ? instance : context.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; + } + + // Keep only the first (nearest to the fired instance) rejection. + if (context.rejectedTargetRef.current == null) { + context.rejectedTargetRef.current = touchResponder ?? instance; + } } if (instance.parent === null) { return null; } - return findBubblingEventHandler(instance.parent, eventName, touchResponder); + return findBubblingEventHandler(instance.parent, eventName, { + ...context, + nearestTouchResponder: touchResponder, + }); } function findEventHandlerFromFiber(fiber: Fiber | null, eventName: string): EventHandler | null { From fdaa25029bb0dd428c83041d6efa5a5551f2e985 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Mon, 21 Sep 2026 12:04:00 +0200 Subject: [PATCH 05/22] better message --- src/events/__tests__/fire-event.test.tsx | 15 ++++++++++++++- src/events/fire-event.ts | 6 +++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index 4fb952323..1404ca6ab 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -813,7 +813,20 @@ describe('disabled elements', () => { expect(warnSpy).toHaveBeenCalledTimes(1); expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` "Tried to fire the "press" event on a disabled element, so no handler was called. - If this is intentional, you can disable this warning via \`configure({ warnOnDisabledElementEvent: false })\`." + If this is intentional, you can disable this warning via \`configure({ warnOnDisabledElementEvent: false })\`. + + + + Trigger + + " `); }); diff --git a/src/events/fire-event.ts b/src/events/fire-event.ts index 5a05dd57f..cf1ba497b 100644 --- a/src/events/fire-event.ts +++ b/src/events/fire-event.ts @@ -1,9 +1,11 @@ +import redent from 'redent'; import type { TestInstance } from 'test-renderer'; import { act } from '../act'; import { getConfig } from '../config'; import { computeAriaDisabled } from '../helpers/accessibility'; import { isInstanceMounted } from '../helpers/component-tree'; +import { formatJson } from '../helpers/format-element'; import { isHostScrollView, isHostTextInput } from '../helpers/host-component-names'; import { logger } from '../helpers/logger'; import { isEditableTextInput } from '../helpers/text-input'; @@ -40,9 +42,11 @@ function warnAboutDisabledEventTarget(target: TestInstance | null, eventName: st return; } + const targetJson = target.toJSON(); logger.warn( `Tried to fire the "${eventName}" event on a disabled element, so no handler was called.\n` + - 'If this is intentional, you can disable this warning via `configure({ warnOnDisabledElementEvent: false })`.', + 'If this is intentional, you can disable this warning via `configure({ warnOnDisabledElementEvent: false })`.\n\n' + + redent(targetJson ? formatJson(targetJson) : '(hidden)', 2), ); } From bfaf39b56567d22e94e2c1d3319005dadaf21f1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Mon, 5 Oct 2026 17:33:10 +0200 Subject: [PATCH 06/22] warnOnUnhandledEvent --- contributing/code-style.md | 7 ++ src/__tests__/config.test.ts | 2 +- src/config.ts | 12 +-- src/events/__tests__/fire-event.test.tsx | 91 ++++++++++++++++++---- src/events/fire-event.ts | 52 +++---------- src/events/propagation.ts | 56 +++++-------- src/events/update-native-state.ts | 9 ++- src/events/warnings.ts | 89 +++++++++++++++++++++ website/docs/14.x/docs/api/misc/config.mdx | 15 ++-- 9 files changed, 226 insertions(+), 107 deletions(-) create mode 100644 src/events/warnings.ts 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 21df8aae0..92a9ec83c 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -22,7 +22,7 @@ test('configure() overrides existing config values', () => { asyncUtilTimeout: 5000, defaultDebugOptions: { message: 'debug message' }, defaultIncludeHiddenElements: false, - warnOnDisabledElementEvent: true, + warnOnUnhandledEvent: true, }); }); diff --git a/src/config.ts b/src/config.ts index a66ae4f3e..8a5510a0e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -13,10 +13,10 @@ export type Config = { defaultIncludeHiddenElements: boolean; /** - * Warn when an event is fired on a disabled element and no handler is - * triggered as a result. Set to `false` to opt out. + * Warn when `fireEvent` calls no handler, because the target is disabled or + * no element handles the event. Set to `false` to opt out. */ - warnOnDisabledElementEvent: boolean; + warnOnUnhandledEvent: boolean; /** Default options for `debug` helper. */ defaultDebugOptions?: Partial; @@ -30,7 +30,7 @@ export type ConfigAliasOptions = { const defaultConfig: Config = { asyncUtilTimeout: 1000, defaultIncludeHiddenElements: false, - warnOnDisabledElementEvent: true, + warnOnUnhandledEvent: true, }; let config = { ...defaultConfig }; @@ -44,7 +44,7 @@ export function configure(options: Partial) { defaultDebugOptions, defaultHidden, defaultIncludeHiddenElements, - warnOnDisabledElementEvent, + warnOnUnhandledEvent, ...rest } = options; @@ -58,7 +58,7 @@ export function configure(options: Partial) { asyncUtilTimeout: asyncUtilTimeout ?? config.asyncUtilTimeout, defaultDebugOptions, defaultIncludeHiddenElements: resolvedDefaultIncludeHiddenElements, - warnOnDisabledElementEvent: warnOnDisabledElementEvent ?? config.warnOnDisabledElementEvent, + warnOnUnhandledEvent: warnOnUnhandledEvent ?? config.warnOnUnhandledEvent, }; } diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index 1404ca6ab..56c4b8eab 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -14,7 +14,7 @@ import { } from 'react-native'; import { fireEvent, render, screen } from '../..'; -import { configure, resetToDefaults } from '../../config'; +import { configure } from '../../config'; import { _console, logger } from '../../helpers/logger'; import { nativeState } from '../native-state'; @@ -39,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 @@ -50,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 () => { @@ -594,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(); @@ -710,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 () => { @@ -734,7 +743,6 @@ describe('disabled elements', () => { afterEach(() => { warnSpy.mockRestore(); - resetToDefaults(); }); test('does not fire on disabled Pressable', async () => { @@ -800,8 +808,20 @@ describe('disabled elements', () => { await fireEvent.press(screen.getByText('Trigger Test')); expect(handlePress).toHaveBeenCalledTimes(1); }); +}); + +describe('unhandled event warning', () => { + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); - test('warns when firing an event on a disabled element', async () => { + test('warns when the handler is on a disabled element', async () => { await render( Trigger @@ -812,8 +832,8 @@ describe('disabled elements', () => { expect(warnSpy).toHaveBeenCalledTimes(1); expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - "Tried to fire the "press" event on a disabled element, so no handler was called. - If this is intentional, you can disable this warning via \`configure({ warnOnDisabledElementEvent: false })\`. + "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 })\`. { `); }); + 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( @@ -844,7 +884,7 @@ describe('disabled elements', () => { expect(warnSpy).not.toHaveBeenCalled(); }); - test('does not warn when the element is not disabled (e.g. pointerEvents="none")', async () => { + test('does not warn when the handler is blocked by pointerEvents="none"', async () => { await render( @@ -856,15 +896,36 @@ describe('disabled elements', () => { expect(warnSpy).not.toHaveBeenCalled(); }); - test('does not warn when warnOnDisabledElementEvent is turned off', async () => { - configure({ warnOnDisabledElementEvent: false }); + 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( - - Trigger - , + + + Disabled + + No handler + , ); - await fireEvent.press(screen.getByText('Trigger')); + await fireEvent.press(screen.getByText('Disabled')); + await fireEvent.press(screen.getByText('No handler')); + await fireEvent.layout(screen.getByText('No handler')); expect(warnSpy).not.toHaveBeenCalled(); }); diff --git a/src/events/fire-event.ts b/src/events/fire-event.ts index cf1ba497b..7980400c4 100644 --- a/src/events/fire-event.ts +++ b/src/events/fire-event.ts @@ -1,67 +1,33 @@ -import redent from 'redent'; import type { TestInstance } from 'test-renderer'; import { act } from '../act'; -import { getConfig } from '../config'; -import { computeAriaDisabled } from '../helpers/accessibility'; import { isInstanceMounted } from '../helpers/component-tree'; -import { formatJson } from '../helpers/format-element'; -import { isHostScrollView, isHostTextInput } from '../helpers/host-component-names'; -import { logger } from '../helpers/logger'; -import { isEditableTextInput } from '../helpers/text-input'; +import { isHostScrollView } from '../helpers/host-component-names'; import { buildLayoutEvent, buildTouchEvent } from './builders/common'; import { mergeEventProps } from './builders/merge'; import { buildScrollEvent } from './builders/scroll'; import { normalizeEventName } from './handler'; import { nativeState } from './native-state'; -import type { FindEventHandlerContext } from './propagation'; import { findEventHandler } from './propagation'; import type { EventName, EventProps, LayoutRectangle } from './types'; +import { warnAboutUnhandledEvent } from './warnings'; import { updateNativeStateFromEvent } from './update-native-state'; -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); -} - -/** - * Warns when no handler ran because the target is disabled. - * Opt out via `configure({ warnOnDisabledElementEvent: false })`. - */ -function warnAboutDisabledEventTarget(target: TestInstance | null, eventName: string) { - if (!getConfig().warnOnDisabledElementEvent || target == null) { - return; - } - - if (!isWarnableDisabledTarget(target)) { - return; - } - - const targetJson = target.toJSON(); - logger.warn( - `Tried to fire the "${eventName}" event on a disabled element, so no handler was called.\n` + - 'If this is intentional, you can disable this warning via `configure({ warnOnDisabledElementEvent: false })`.\n\n' + - redent(targetJson ? formatJson(targetJson) : '(hidden)', 2), - ); -} - async function fireEvent(instance: TestInstance, eventName: EventName, ...data: unknown[]) { if (!isInstanceMounted(instance)) { return; } // `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 context: FindEventHandlerContext = { rejectedTargetRef: { current: null } }; - const handler = findEventHandler(instance, eventName, context); + const { handler, rejectedTarget } = findEventHandler(instance, eventName); if (!handler) { - warnAboutDisabledEventTarget(context.rejectedTargetRef.current, eventName); + warnAboutUnhandledEvent(instance, eventName, { rejectedTarget, didUpdateNativeState }); return; } diff --git a/src/events/propagation.ts b/src/events/propagation.ts index 92fc9883a..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,12 +11,13 @@ export function isDirectEvent(eventName: string) { return eventName === 'layout'; } -// Carries state across the recursive `findEventHandler` walk. `rejectedTargetRef` is -// filled in with the element that owned the nearest handler rejected by `isEventEnabled`, -// so callers can report *why* no handler ran without re-walking the tree themselves. -export type FindEventHandlerContext = { - nearestTouchResponder?: TestInstance; - rejectedTargetRef: { current: TestInstance | null }; +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; }; /** @@ -31,32 +30,22 @@ export type FindEventHandlerContext = { export function findEventHandler( instance: TestInstance, eventName: string, - context: FindEventHandlerContext, -): EventHandler | null { - return isDirectEvent(normalizeEventName(eventName)) - ? getOwnEventHandler(instance, eventName) - : findBubblingEventHandler(instance, eventName, context); -} - -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; +): 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, - context: FindEventHandlerContext, -): EventHandler | null { - const touchResponder = isTouchResponder(instance) ? instance : context.nearestTouchResponder; + nearestTouchResponder: TestInstance | undefined, + rejectedTarget: TestInstance | null, +): FindEventHandlerResult { + const touchResponder = isTouchResponder(instance) ? instance : nearestTouchResponder; const handler = getEventHandlerFromProps(instance.props, eventName, { loose: true }) ?? @@ -64,23 +53,18 @@ function findBubblingEventHandler( if (handler) { if (isEventEnabled(instance, normalizeEventName(eventName), touchResponder)) { - return handler; + return { handler, rejectedTarget: null }; } // Keep only the first (nearest to the fired instance) rejection. - if (context.rejectedTargetRef.current == null) { - context.rejectedTargetRef.current = touchResponder ?? instance; - } + rejectedTarget ??= touchResponder ?? instance; } if (instance.parent === null) { - return null; + return { handler: null, rejectedTarget }; } - return findBubblingEventHandler(instance.parent, eventName, { - ...context, - nearestTouchResponder: 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 5b98f5893..4f1268cd5 100644 --- a/website/docs/14.x/docs/api/misc/config.mdx +++ b/website/docs/14.x/docs/api/misc/config.mdx @@ -10,8 +10,8 @@ type Config = { /** Default value for `includeHiddenElements` query option. */ defaultIncludeHiddenElements: boolean; - /** Warn when firing an event on a disabled element triggers no handler. */ - warnOnDisabledElementEvent: boolean; + /** Warn when `fireEvent` calls no handler (disabled element or no handler found). */ + warnOnUnhandledEvent: boolean; /** Default options for `debug` helper. */ defaultDebugOptions?: Partial; @@ -35,12 +35,17 @@ 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). -### `warnOnDisabledElementEvent` option +### `warnOnUnhandledEvent` option -When `fireEvent` is used on a disabled element (e.g. a `Pressable` with `disabled={true}`) the event is not dispatched to any handler, which can be surprising while debugging tests. When this option is enabled (the default), a warning is logged in that case. Set it to `false` to opt out: +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({ warnOnDisabledElementEvent: false }); +configure({ warnOnUnhandledEvent: false }); ``` ### `defaultDebugOptions` option From eedd26c82a9cdf190697544eb5240b29f6c902bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Wed, 7 Oct 2026 10:04:46 +0200 Subject: [PATCH 07/22] option name and default false --- src/__tests__/config.test.ts | 2 +- src/config.ts | 8 ++++---- src/events/__tests__/fire-event.test.tsx | 10 +++++----- src/events/fire-event.ts | 2 +- src/events/warnings.ts | 6 +++--- website/docs/14.x/docs/api/misc/config.mdx | 6 +++--- 6 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 92a9ec83c..575772476 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -22,7 +22,7 @@ test('configure() overrides existing config values', () => { asyncUtilTimeout: 5000, defaultDebugOptions: { message: 'debug message' }, defaultIncludeHiddenElements: false, - warnOnUnhandledEvent: true, + eventDiagnostics: true, }); }); diff --git a/src/config.ts b/src/config.ts index 8a5510a0e..c371f90cd 100644 --- a/src/config.ts +++ b/src/config.ts @@ -16,7 +16,7 @@ export type Config = { * 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; + eventDiagnostics: boolean; /** Default options for `debug` helper. */ defaultDebugOptions?: Partial; @@ -30,7 +30,7 @@ export type ConfigAliasOptions = { const defaultConfig: Config = { asyncUtilTimeout: 1000, defaultIncludeHiddenElements: false, - warnOnUnhandledEvent: true, + eventDiagnostics: false, }; let config = { ...defaultConfig }; @@ -44,7 +44,7 @@ export function configure(options: Partial) { defaultDebugOptions, defaultHidden, defaultIncludeHiddenElements, - warnOnUnhandledEvent, + eventDiagnostics, ...rest } = options; @@ -58,7 +58,7 @@ export function configure(options: Partial) { asyncUtilTimeout: asyncUtilTimeout ?? config.asyncUtilTimeout, defaultDebugOptions, defaultIncludeHiddenElements: resolvedDefaultIncludeHiddenElements, - warnOnUnhandledEvent: warnOnUnhandledEvent ?? config.warnOnUnhandledEvent, + eventDiagnostics: eventDiagnostics ?? config.eventDiagnostics, }; } diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index 56c4b8eab..982729868 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -598,7 +598,7 @@ describe('fireEvent.layout', () => { expect(warnSpy).toHaveBeenCalledTimes(1); expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` " ▲ 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 })\`. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. { 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 })\`. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. { 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 })\`. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. Trigger @@ -912,8 +912,8 @@ describe('unhandled event warning', () => { expect(warnSpy).not.toHaveBeenCalled(); }); - test('does not warn when warnOnUnhandledEvent is turned off', async () => { - configure({ warnOnUnhandledEvent: false }); + test('does not warn when eventDiagnostics is turned off', async () => { + configure({ eventDiagnostics: false }); await render( diff --git a/src/events/fire-event.ts b/src/events/fire-event.ts index 7980400c4..4352d9817 100644 --- a/src/events/fire-event.ts +++ b/src/events/fire-event.ts @@ -10,8 +10,8 @@ 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'; +import { warnAboutUnhandledEvent } from './warnings'; async function fireEvent(instance: TestInstance, eventName: EventName, ...data: unknown[]) { if (!isInstanceMounted(instance)) { diff --git a/src/events/warnings.ts b/src/events/warnings.ts index 13f1e31fc..31bacfcde 100644 --- a/src/events/warnings.ts +++ b/src/events/warnings.ts @@ -18,14 +18,14 @@ export type UnhandledEventInfo = { /** * Warns when no handler ran because the target is disabled or nothing handles the event. - * Opt out via `configure({ warnOnUnhandledEvent: false })`. + * Opt out via `configure({ eventDiagnostics: false })`. */ export function warnAboutUnhandledEvent( instance: TestInstance, eventName: string, info: UnhandledEventInfo, ) { - if (!getConfig().warnOnUnhandledEvent) { + if (!getConfig().eventDiagnostics) { return; } @@ -37,7 +37,7 @@ export function warnAboutUnhandledEvent( 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' + + 'If this is intentional, you can disable this warning via `configure({ eventDiagnostics: false })`.\n\n' + redent(elementJson ? formatJson(elementJson) : '(hidden)', 2), ); } diff --git a/website/docs/14.x/docs/api/misc/config.mdx b/website/docs/14.x/docs/api/misc/config.mdx index 4f1268cd5..42718ab74 100644 --- a/website/docs/14.x/docs/api/misc/config.mdx +++ b/website/docs/14.x/docs/api/misc/config.mdx @@ -11,7 +11,7 @@ type Config = { defaultIncludeHiddenElements: boolean; /** Warn when `fireEvent` calls no handler (disabled element or no handler found). */ - warnOnUnhandledEvent: boolean; + eventDiagnostics: boolean; /** Default options for `debug` helper. */ defaultDebugOptions?: Partial; @@ -35,7 +35,7 @@ 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 +### `eventDiagnostics` 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: @@ -45,7 +45,7 @@ When `fireEvent` doesn't call any handler, the test can silently do nothing, whi 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 }); +configure({ eventDiagnostics: false }); ``` ### `defaultDebugOptions` option From 6a2ef0c24a4b6b7bc3c83afdfa5d57f98e327ba0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Wed, 7 Oct 2026 10:09:10 +0200 Subject: [PATCH 08/22] default off --- src/__tests__/config.test.ts | 2 +- src/config.ts | 2 +- src/events/__tests__/fire-event.test.tsx | 98 +++++++++++++--------- website/docs/14.x/docs/api/misc/config.mdx | 8 +- 4 files changed, 64 insertions(+), 46 deletions(-) diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 575772476..1fecd4f33 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -22,7 +22,7 @@ test('configure() overrides existing config values', () => { asyncUtilTimeout: 5000, defaultDebugOptions: { message: 'debug message' }, defaultIncludeHiddenElements: false, - eventDiagnostics: true, + eventDiagnostics: false, }); }); diff --git a/src/config.ts b/src/config.ts index c371f90cd..b87a52074 100644 --- a/src/config.ts +++ b/src/config.ts @@ -14,7 +14,7 @@ export type Config = { /** * Warn when `fireEvent` calls no handler, because the target is disabled or - * no element handles the event. Set to `false` to opt out. + * no element handles the event. Off by default. */ eventDiagnostics: boolean; diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index 982729868..992d50e67 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -39,7 +39,6 @@ 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 @@ -51,8 +50,6 @@ 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 () => { @@ -557,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( @@ -568,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( @@ -585,11 +578,10 @@ 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 () => { + configure({ eventDiagnostics: true }); const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); await render(); @@ -631,18 +623,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(); @@ -650,8 +639,6 @@ describe('fireEvent.layout', () => { await fireEvent.layout(screen.getByTestId('view')); expect(onLayout).not.toHaveBeenCalled(); - expect(warnSpy).toHaveBeenCalledTimes(1); - warnSpy.mockRestore(); }); }); @@ -716,12 +703,9 @@ 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 () => { @@ -735,16 +719,6 @@ 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( @@ -814,6 +788,7 @@ describe('unhandled event warning', () => { let warnSpy: jest.SpyInstance; beforeEach(() => { + configure({ eventDiagnostics: true }); warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => {}); }); @@ -850,6 +825,62 @@ describe('unhandled event warning', () => { `); }); + 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(` + "Tried to fire the "press" event on 2 disabled elements, so their handlers were not called. + 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( @@ -1051,7 +1082,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(); @@ -1088,8 +1118,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([ @@ -1140,16 +1168,6 @@ 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/website/docs/14.x/docs/api/misc/config.mdx b/website/docs/14.x/docs/api/misc/config.mdx index 42718ab74..3ff4bb1b8 100644 --- a/website/docs/14.x/docs/api/misc/config.mdx +++ b/website/docs/14.x/docs/api/misc/config.mdx @@ -10,7 +10,7 @@ type Config = { /** Default value for `includeHiddenElements` query option. */ defaultIncludeHiddenElements: boolean; - /** Warn when `fireEvent` calls no handler (disabled element or no handler found). */ + /** Warn when `fireEvent` calls no handler (disabled element or no handler found). Off by default. */ eventDiagnostics: boolean; /** Default options for `debug` helper. */ @@ -37,15 +37,15 @@ This option is also available as `defaultHidden` alias for compatibility with [R ### `eventDiagnostics` 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: +When `fireEvent` doesn't call any handler, the test can silently do nothing, which can be confusing while debugging. When this option is enabled, 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: +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`. Defaults to `false`. Turn it on while debugging a test, or for the whole test suite in your Jest setup file: ```ts -configure({ eventDiagnostics: false }); +configure({ eventDiagnostics: true }); ``` ### `defaultDebugOptions` option From 6404821d87c0faef29abe20c233b61c7fa929505 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Wed, 7 Oct 2026 10:18:23 +0200 Subject: [PATCH 09/22] tweaks --- src/events/fire-event.ts | 4 ++-- src/events/propagation.ts | 25 +++++++++++--------- src/events/warnings.ts | 48 ++++++++++++++++++++++----------------- 3 files changed, 43 insertions(+), 34 deletions(-) diff --git a/src/events/fire-event.ts b/src/events/fire-event.ts index 4352d9817..28699b137 100644 --- a/src/events/fire-event.ts +++ b/src/events/fire-event.ts @@ -25,9 +25,9 @@ async function fireEvent(instance: TestInstance, eventName: EventName, ...data: data[0], ); - const { handler, rejectedTarget } = findEventHandler(instance, eventName); + const { handler, skippedTargets } = findEventHandler(instance, eventName); if (!handler) { - warnAboutUnhandledEvent(instance, eventName, { rejectedTarget, didUpdateNativeState }); + warnAboutUnhandledEvent(instance, eventName, { skippedTargets, didUpdateNativeState }); return; } diff --git a/src/events/propagation.ts b/src/events/propagation.ts index 99d550686..e60287b30 100644 --- a/src/events/propagation.ts +++ b/src/events/propagation.ts @@ -14,10 +14,10 @@ export function isDirectEvent(eventName: string) { 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". + * 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". */ - rejectedTarget: TestInstance | null; + skippedTargets: TestInstance[]; }; /** @@ -33,17 +33,17 @@ export function findEventHandler( ): FindEventHandlerResult { if (isDirectEvent(normalizeEventName(eventName))) { const handler = getEventHandlerFromProps(instance.props, eventName, { loose: true }); - return { handler: handler ?? null, rejectedTarget: null }; + return { handler: handler ?? null, skippedTargets: [] }; } - return findBubblingEventHandler(instance, eventName, undefined, null); + return findBubblingEventHandler(instance, eventName, undefined, []); } function findBubblingEventHandler( instance: TestInstance, eventName: string, nearestTouchResponder: TestInstance | undefined, - rejectedTarget: TestInstance | null, + skippedTargets: TestInstance[], ): FindEventHandlerResult { const touchResponder = isTouchResponder(instance) ? instance : nearestTouchResponder; @@ -53,18 +53,21 @@ function findBubblingEventHandler( if (handler) { if (isEventEnabled(instance, normalizeEventName(eventName), touchResponder)) { - return { handler, rejectedTarget: null }; + return { handler, skippedTargets }; } - // Keep only the first (nearest to the fired instance) rejection. - rejectedTarget ??= touchResponder ?? instance; + // 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 { handler: null, rejectedTarget }; + return { handler: null, skippedTargets }; } - return findBubblingEventHandler(instance.parent, eventName, touchResponder, rejectedTarget); + return findBubblingEventHandler(instance.parent, eventName, touchResponder, skippedTargets); } function findEventHandlerFromFiber(fiber: Fiber | null, eventName: string): EventHandler | null { diff --git a/src/events/warnings.ts b/src/events/warnings.ts index 31bacfcde..c7086b52f 100644 --- a/src/events/warnings.ts +++ b/src/events/warnings.ts @@ -11,14 +11,14 @@ import { normalizeEventName } from './handler'; import { isDirectEvent } from './propagation'; export type UnhandledEventInfo = { - /** Nearest element whose handler was rejected by `isEventEnabled`, if any. */ - rejectedTarget: TestInstance | null; + /** Elements whose handler was rejected by `isEventEnabled`, nearest first. */ + skippedTargets: TestInstance[]; didUpdateNativeState: boolean; }; /** * Warns when no handler ran because the target is disabled or nothing handles the event. - * Opt out via `configure({ eventDiagnostics: false })`. + * Enabled via `configure({ eventDiagnostics: true })`. */ export function warnAboutUnhandledEvent( instance: TestInstance, @@ -34,24 +34,26 @@ export function warnAboutUnhandledEvent( return; } - const elementJson = warning.element.toJSON(); - logger.warn( + const header = `${warning.message}\n` + - 'If this is intentional, you can disable this warning via `configure({ eventDiagnostics: false })`.\n\n' + - redent(elementJson ? formatJson(elementJson) : '(hidden)', 2), - ); + 'If this is intentional, you can disable this warning via `configure({ eventDiagnostics: false })`.'; + const elementBlocks = warning.elements + .map((element) => element.toJSON()) + .filter((json) => json != null) + .map((json) => redent(formatJson(json), 2)); + logger.warn([header, ...elementBlocks].join('\n\n')); } function getUnhandledEventWarning( instance: TestInstance, eventName: string, - { rejectedTarget, didUpdateNativeState }: UnhandledEventInfo, -): { message: string; element: TestInstance } | null { - if (rejectedTarget == null) { + { skippedTargets, didUpdateNativeState }: UnhandledEventInfo, +): { message: string; elements: TestInstance[] } | null { + if (skippedTargets.length === 0) { 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, + elements: [instance], }; } @@ -62,20 +64,24 @@ function getUnhandledEventWarning( return { message: `No handler found for the "${eventName}" event on the element or any of its ancestors.`, - element: instance, + elements: [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, - }; + // Only disabled elements are reported. Other rejections (`pointerEvents`, non-editable + // `TextInput`, responder declining the touch) are deliberate ways of blocking events. + const disabledTargets = skippedTargets.filter(isWarnableDisabledTarget); + if (disabledTargets.length === 0) { + return null; } - return null; + return { + message: + disabledTargets.length === 1 + ? `Tried to fire the "${eventName}" event on a disabled element, so its handler was not called.` + : `Tried to fire the "${eventName}" event on ${disabledTargets.length} disabled elements, so their handlers were not called.`, + elements: disabledTargets, + }; } function isWarnableDisabledTarget(target: TestInstance): boolean { From 39bc08815e2d0018f66a42fa558ac646c94433a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Wed, 7 Oct 2026 10:26:53 +0200 Subject: [PATCH 10/22] msg twaeks --- src/events/__tests__/fire-event.test.tsx | 8 ++++---- src/events/warnings.ts | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index 992d50e67..f69967f19 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -589,7 +589,7 @@ describe('fireEvent.layout', () => { expect(warnSpy).toHaveBeenCalledTimes(1); expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - " ▲ No handler found for the "layout" event on the element. "layout" events do not bubble to ancestors. + " ▲ The element has no handler for the "layout" event. "layout" events do not bubble to ancestors. If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. { 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. + "Cannot fire the "press" event on a disabled element. If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. { expect(warnSpy).toHaveBeenCalledTimes(1); expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - "Tried to fire the "press" event on 2 disabled elements, so their handlers were not called. + "Cannot fire the "press" event on disabled elements. If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. { 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. + "The element and its ancestors have no handler for the "press" event. If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. diff --git a/src/events/warnings.ts b/src/events/warnings.ts index c7086b52f..9299b10f6 100644 --- a/src/events/warnings.ts +++ b/src/events/warnings.ts @@ -52,7 +52,7 @@ function getUnhandledEventWarning( if (skippedTargets.length === 0) { if (isDirectEvent(normalizeEventName(eventName))) { return { - message: `No handler found for the "${eventName}" event on the element. "${eventName}" events do not bubble to ancestors.`, + message: `The element has no handler for the "${eventName}" event. "${eventName}" events do not bubble to ancestors.`, elements: [instance], }; } @@ -63,7 +63,7 @@ function getUnhandledEventWarning( } return { - message: `No handler found for the "${eventName}" event on the element or any of its ancestors.`, + message: `The element and its ancestors have no handler for the "${eventName}" event.`, elements: [instance], }; } @@ -78,8 +78,8 @@ function getUnhandledEventWarning( return { message: disabledTargets.length === 1 - ? `Tried to fire the "${eventName}" event on a disabled element, so its handler was not called.` - : `Tried to fire the "${eventName}" event on ${disabledTargets.length} disabled elements, so their handlers were not called.`, + ? `Cannot fire the "${eventName}" event on a disabled element.` + : `Cannot fire the "${eventName}" event on disabled elements.`, elements: disabledTargets, }; } From 67e5f54822cc3b28406af327f6c0d1e3e33d4321 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Wed, 7 Oct 2026 10:55:24 +0200 Subject: [PATCH 11/22] user event support --- PR.txt | 24 +++ contributing/event-dispatch.md | 8 + src/config.ts | 4 +- src/events/dispatch.ts | 9 +- src/events/index.ts | 2 + src/events/propagation.ts | 2 +- src/events/warnings.ts | 42 ++-- .../__tests__/event-diagnostics.test.tsx | 182 ++++++++++++++++++ .../accessibility-action.ts | 7 +- src/user-event/clear.ts | 16 +- src/user-event/paste.ts | 24 ++- src/user-event/press/press.ts | 41 ++-- src/user-event/scroll/pull-to-refresh.ts | 15 +- src/user-event/scroll/scroll-to.ts | 35 ++-- src/user-event/type/type.ts | 33 ++-- src/user-event/utils/index.ts | 2 + src/user-event/utils/interaction.ts | 35 ++++ src/user-event/utils/warnings.ts | 68 +++++++ website/docs/14.x/docs/api/misc/config.mdx | 8 +- 19 files changed, 465 insertions(+), 92 deletions(-) create mode 100644 PR.txt create mode 100644 src/user-event/__tests__/event-diagnostics.test.tsx create mode 100644 src/user-event/utils/interaction.ts create mode 100644 src/user-event/utils/warnings.ts diff --git a/PR.txt b/PR.txt new file mode 100644 index 000000000..1b3e5979b --- /dev/null +++ b/PR.txt @@ -0,0 +1,24 @@ +### Summary + +When `fireEvent` doesn't call any handler, the test silently does nothing, which is hard to debug. This PR adds an opt-in `eventDiagnostics` option that logs a warning in these cases: + +- The handler is on a disabled element (e.g. `Pressable` with `disabled={true}`). The warning lists every disabled element the event skipped while bubbling, nearest first, rather than the fired element. +- Neither the element nor any of its ancestors has a handler for the event. For direct events (`layout`), which don't bubble, only the element itself is checked. This replaces the old warning for direct events that had no handler, which was always on. + +No warning is logged when the event is blocked on purpose (`pointerEvents="none"`, non-editable `TextInput`, a responder declining the touch), or when the event updates native state (e.g. `changeText` on an uncontrolled `TextInput`). + +The option is off by default, so enable it with `configure({ eventDiagnostics: true })`. Behavior change: firing `layout` on an element without `onLayout` used to always warn, and now it only warns when the option is on. + +Implementation: + +- `findEventHandler` now returns `{ handler, skippedTargets }`, so callers can tell a blocked handler apart from no handler at all. `skippedTargets` lists every element whose handler `isEventEnabled` rejected, nearest first. +- `updateNativeStateFromEvent` returns whether it updated native state. +- Removed the warning spies from tests that only used them to silence warnings. +- The warning logic lives in the new `src/events/warnings.ts`. +- Documented `eventDiagnostics` in the 14.x config docs, and added a file layout section to `contributing/code-style.md`. + +### Test plan + +- New `fireEvent` tests cover: a disabled element, nested disabled elements, no handler on the element or its ancestors, a direct event with no handler, no warning for intentional blocking or native state updates, and no warning when `eventDiagnostics` is off. The warning tests turn the option on in their setup. +- Ran locally: `yarn test` (86 suites, 841 tests passed), `yarn typecheck`, `yarn lint`, `yarn format:check`. All pass. +- Not run: `yarn build`, `yarn validate`. diff --git a/contributing/event-dispatch.md b/contributing/event-dispatch.md index 9dfb0876c..810d71013 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). +- Set `hasUpdatedNativeState` when the action writes to `nativeState`. +- Add elements that could handle the action but are disabled to `skippedTargets`. +- 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/src/config.ts b/src/config.ts index b87a52074..b2d30dcfe 100644 --- a/src/config.ts +++ b/src/config.ts @@ -13,8 +13,8 @@ export type Config = { defaultIncludeHiddenElements: boolean; /** - * Warn when `fireEvent` calls no handler, because the target is disabled or - * no element handles the event. Off by default. + * Warn when `fireEvent` or a `userEvent` interaction calls no handler, because the + * target is disabled or no element handles the event. Off by default. */ eventDiagnostics: boolean; 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/index.ts b/src/events/index.ts index 7e5961058..55ca38a30 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 { isWarnableDisabledTarget, logEventWarning } from './warnings'; diff --git a/src/events/propagation.ts b/src/events/propagation.ts index e60287b30..29613a563 100644 --- a/src/events/propagation.ts +++ b/src/events/propagation.ts @@ -11,7 +11,7 @@ export function isDirectEvent(eventName: string) { return eventName === 'layout'; } -export type FindEventHandlerResult = { +type FindEventHandlerResult = { handler: EventHandler | null; /** * Elements whose handler was found but rejected by `isEventEnabled`, nearest to the fired diff --git a/src/events/warnings.ts b/src/events/warnings.ts index 9299b10f6..03e7e5ab5 100644 --- a/src/events/warnings.ts +++ b/src/events/warnings.ts @@ -10,12 +10,17 @@ import { isEditableTextInput } from '../helpers/text-input'; import { normalizeEventName } from './handler'; import { isDirectEvent } from './propagation'; -export type UnhandledEventInfo = { +type UnhandledEventInfo = { /** Elements whose handler was rejected by `isEventEnabled`, nearest first. */ skippedTargets: TestInstance[]; didUpdateNativeState: boolean; }; +export type EventWarning = { + message: string; + elements: TestInstance[]; +}; + /** * Warns when no handler ran because the target is disabled or nothing handles the event. * Enabled via `configure({ eventDiagnostics: true })`. @@ -30,25 +35,40 @@ export function warnAboutUnhandledEvent( } const warning = getUnhandledEventWarning(instance, eventName, info); - if (warning == null) { - return; + if (warning != null) { + logEventWarning(warning); } +} +/** + * Logs the warning with the opt-out hint and the elements it is about. + */ +export function logEventWarning({ message, elements }: EventWarning) { const header = - `${warning.message}\n` + + `${message}\n` + 'If this is intentional, you can disable this warning via `configure({ eventDiagnostics: false })`.'; - const elementBlocks = warning.elements + const elementBlocks = elements .map((element) => element.toJSON()) .filter((json) => json != null) .map((json) => redent(formatJson(json), 2)); logger.warn([header, ...elementBlocks].join('\n\n')); } +export 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); +} + function getUnhandledEventWarning( instance: TestInstance, eventName: string, { skippedTargets, didUpdateNativeState }: UnhandledEventInfo, -): { message: string; elements: TestInstance[] } | null { +): EventWarning | null { if (skippedTargets.length === 0) { if (isDirectEvent(normalizeEventName(eventName))) { return { @@ -83,13 +103,3 @@ function getUnhandledEventWarning( elements: disabledTargets, }; } - -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/src/user-event/__tests__/event-diagnostics.test.tsx b/src/user-event/__tests__/event-diagnostics.test.tsx new file mode 100644 index 000000000..23deb4257 --- /dev/null +++ b/src/user-event/__tests__/event-diagnostics.test.tsx @@ -0,0 +1,182 @@ +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() did not call any event handlers. The element is disabled. + 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() 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() 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() 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() 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('does not warn when the press is blocked by pointerEvents="none"', async () => { + await render( + + + Trigger + + , + ); + const user = userEvent.setup(); + + await user.press(screen.getByText('Trigger')); + + 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..dc8ff4be4 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)) { @@ -27,8 +26,10 @@ export async function clear(this: UserEventInstance, instance: TestInstance): Pr return; } + const interaction = new Interaction('clear', instance); + // 1. Enter instance - await dispatchEvent(instance, 'focus', buildFocusEvent()); + await interaction.dispatchEvent('focus', buildFocusEvent()); // 2. Select all const textToClear = getTextInputValue(instance); @@ -36,18 +37,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..80d30c3fc 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, @@ -33,32 +32,37 @@ export async function paste( return; } + const interaction = new Interaction('paste', instance); + // 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..78ddafdfc 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,16 @@ export async function pullToRefresh( ); } + const interaction = new Interaction('pullToRefresh', instance); + interaction.eventNames.push('refresh'); + const refreshControl = instance.props.refreshControl; - if (typeof refreshControl?.props?.onRefresh !== 'function') { - return; + if (typeof refreshControl?.props?.onRefresh === 'function') { + await act(() => { + refreshControl.props.onRefresh(); + }); + interaction.hasCalledHandler = true; } - await act(() => { - refreshControl.props.onRefresh(); - }); + 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..7b76faa75 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 { @@ -45,17 +44,18 @@ export async function type( return; } + const interaction = new Interaction('type', instance); 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 +66,7 @@ export async function type( await emitTypingEvents(instance, { config: this.config, + interaction, key, text: currentText, isAccepted, @@ -76,17 +77,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 +98,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 +113,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..df0a9d9df --- /dev/null +++ b/src/user-event/utils/interaction.ts @@ -0,0 +1,35 @@ +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, + ) {} + + /** + * Dispatches the event to `target` with `dispatchEvent` and records it. + */ + async dispatchEvent(eventName: string, ...event: unknown[]) { + this.eventNames.push(eventName); + if (await dispatchEvent(this.target, eventName, ...event)) { + 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..3a96e252f --- /dev/null +++ b/src/user-event/utils/warnings.ts @@ -0,0 +1,68 @@ +import { getConfig } from '../../config'; +import type { EventWarning } from '../../events'; +import { isWarnableDisabledTarget, logEventWarning } from '../../events'; +import type { Interaction } from './interaction'; + +/** + * Warns when none of the events of a `userEvent` interaction called a handler or updated + * native state. Enabled via `configure({ eventDiagnostics: true })`. + */ +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}() did not call any event handlers.`; + + const disabledTargets = skippedTargets.filter(isWarnableDisabledTarget); + if (disabledTargets.length > 0) { + return { + message: + disabledTargets.length === 1 + ? `${summary} The element is disabled.` + : `${summary} The elements are disabled.`, + 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 3ff4bb1b8..c9393a3fd 100644 --- a/website/docs/14.x/docs/api/misc/config.mdx +++ b/website/docs/14.x/docs/api/misc/config.mdx @@ -10,7 +10,7 @@ type Config = { /** Default value for `includeHiddenElements` query option. */ defaultIncludeHiddenElements: boolean; - /** Warn when `fireEvent` calls no handler (disabled element or no handler found). Off by default. */ + /** Warn when `fireEvent` or a `userEvent` interaction calls no handler. Off by default. */ eventDiagnostics: boolean; /** Default options for `debug` helper. */ @@ -37,12 +37,14 @@ This option is also available as `defaultHidden` alias for compatibility with [R ### `eventDiagnostics` 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, a warning is logged in these cases: +When `fireEvent` or `userEvent` doesn't call any handler, the test can silently do nothing, which can be confusing while debugging. When this option is enabled, 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`. Defaults to `false`. Turn it on while debugging a test, or for the whole test suite in your Jest setup file: +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 is blocked on purpose (`pointerEvents="none"`, non-editable `TextInput`), or when it updates native state, e.g. `fireEvent.changeText` or `userEvent.type` on an uncontrolled `TextInput`. Defaults to `false`. Turn it on while debugging a test, or for the whole test suite in your Jest setup file: ```ts configure({ eventDiagnostics: true }); From 2ed0a7283b568aca6c578ae47195cb6988d91801 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Wed, 7 Oct 2026 11:07:46 +0200 Subject: [PATCH 12/22] code review --- contributing/event-dispatch.md | 4 ++-- docs/api/configuration.md | 18 ++++++++++++++++++ src/events/fire-event.ts | 4 ++-- src/events/warnings.ts | 6 +++--- src/user-event/scroll/pull-to-refresh.ts | 12 +++++++----- src/user-event/utils/interaction.ts | 11 ++++++++++- 6 files changed, 42 insertions(+), 13 deletions(-) diff --git a/contributing/event-dispatch.md b/contributing/event-dispatch.md index 810d71013..621904c0e 100644 --- a/contributing/event-dispatch.md +++ b/contributing/event-dispatch.md @@ -33,9 +33,9 @@ Each step uses `dispatchEvent()`, which only calls the target's own handler. It 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). +- 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 are disabled to `skippedTargets`. +- Add elements that could handle the action but don't accept it to `skippedTargets`: disabled, blocked by `pointerEvents`, or with a responder that declines the touch. The warning names only the disabled ones, and skips the warning if every skipped element blocked the action on purpose. - 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 diff --git a/docs/api/configuration.md b/docs/api/configuration.md index 8164dfee1..339a78261 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,21 @@ 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 + +When `fireEvent` or `userEvent` doesn't call any handler, the test can silently do nothing, which can be confusing while debugging. When this option is enabled, 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. + +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 is blocked on purpose (`pointerEvents="none"`, non-editable `TextInput`), or when it updates native state, e.g. `fireEvent.changeText` or `userEvent.type` on an uncontrolled `TextInput`. Defaults to `false`. 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/events/fire-event.ts b/src/events/fire-event.ts index 28699b137..9921f66b9 100644 --- a/src/events/fire-event.ts +++ b/src/events/fire-event.ts @@ -19,7 +19,7 @@ async function fireEvent(instance: TestInstance, eventName: EventName, ...data: } // `fireEvent` accepts event names with and without the `on*` prefix. - const didUpdateNativeState = updateNativeStateFromEvent( + const hasUpdatedNativeState = updateNativeStateFromEvent( instance, normalizeEventName(eventName), data[0], @@ -27,7 +27,7 @@ async function fireEvent(instance: TestInstance, eventName: EventName, ...data: const { handler, skippedTargets } = findEventHandler(instance, eventName); if (!handler) { - warnAboutUnhandledEvent(instance, eventName, { skippedTargets, didUpdateNativeState }); + warnAboutUnhandledEvent(instance, eventName, { skippedTargets, hasUpdatedNativeState }); return; } diff --git a/src/events/warnings.ts b/src/events/warnings.ts index 03e7e5ab5..aff4bb115 100644 --- a/src/events/warnings.ts +++ b/src/events/warnings.ts @@ -13,7 +13,7 @@ import { isDirectEvent } from './propagation'; type UnhandledEventInfo = { /** Elements whose handler was rejected by `isEventEnabled`, nearest first. */ skippedTargets: TestInstance[]; - didUpdateNativeState: boolean; + hasUpdatedNativeState: boolean; }; export type EventWarning = { @@ -67,7 +67,7 @@ export function isWarnableDisabledTarget(target: TestInstance): boolean { function getUnhandledEventWarning( instance: TestInstance, eventName: string, - { skippedTargets, didUpdateNativeState }: UnhandledEventInfo, + { skippedTargets, hasUpdatedNativeState }: UnhandledEventInfo, ): EventWarning | null { if (skippedTargets.length === 0) { if (isDirectEvent(normalizeEventName(eventName))) { @@ -78,7 +78,7 @@ function getUnhandledEventWarning( } // The event still had an effect, e.g. `changeText` on an uncontrolled TextInput updates its value. - if (didUpdateNativeState) { + if (hasUpdatedNativeState) { return null; } diff --git a/src/user-event/scroll/pull-to-refresh.ts b/src/user-event/scroll/pull-to-refresh.ts index 78ddafdfc..6ce4a149e 100644 --- a/src/user-event/scroll/pull-to-refresh.ts +++ b/src/user-event/scroll/pull-to-refresh.ts @@ -18,15 +18,17 @@ export async function pullToRefresh( } const interaction = new Interaction('pullToRefresh', instance); - interaction.eventNames.push('refresh'); - const refreshControl = instance.props.refreshControl; - if (typeof refreshControl?.props?.onRefresh === 'function') { + // `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(() => { - refreshControl.props.onRefresh(); + onRefresh(); }); - interaction.hasCalledHandler = true; } + interaction.recordEvent('refresh', hasHandler); warnAboutUnhandledInteraction(interaction); } diff --git a/src/user-event/utils/interaction.ts b/src/user-event/utils/interaction.ts index df0a9d9df..df9eeb48c 100644 --- a/src/user-event/utils/interaction.ts +++ b/src/user-event/utils/interaction.ts @@ -27,8 +27,17 @@ export class Interaction { * Dispatches the event to `target` with `dispatchEvent` and records it. */ 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 (await dispatchEvent(this.target, eventName, ...event)) { + if (hasCalledHandler) { this.hasCalledHandler = true; } } From 40cb6f337f68431de4f312cd50554199670b4245 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Wed, 7 Oct 2026 11:39:10 +0200 Subject: [PATCH 13/22] treat non-editable as disable for warnings --- contributing/event-dispatch.md | 2 +- docs/api/configuration.md | 4 +- src/events/__tests__/fire-event.test.tsx | 13 ++++- src/events/index.ts | 2 +- src/events/warnings.ts | 18 ++----- .../__tests__/event-diagnostics.test.tsx | 47 +++++++++++++++++++ src/user-event/clear.ts | 5 +- src/user-event/paste.ts | 5 +- src/user-event/type/type.ts | 5 +- src/user-event/utils/warnings.ts | 6 ++- website/docs/14.x/docs/api/misc/config.mdx | 4 +- 11 files changed, 80 insertions(+), 31 deletions(-) diff --git a/contributing/event-dispatch.md b/contributing/event-dispatch.md index 621904c0e..95166d17e 100644 --- a/contributing/event-dispatch.md +++ b/contributing/event-dispatch.md @@ -35,7 +35,7 @@ For the `eventDiagnostics` warning, each action tracks itself with an `Interacti - 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, blocked by `pointerEvents`, or with a responder that declines the touch. The warning names only the disabled ones, and skips the warning if every skipped element blocked the action on purpose. +- 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 names only the disabled ones (`computeAriaDisabled()`, which includes non-editable `TextInput`), and skips the warning if every skipped element blocked the action on purpose. 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 diff --git a/docs/api/configuration.md b/docs/api/configuration.md index 339a78261..bc0f8b348 100644 --- a/docs/api/configuration.md +++ b/docs/api/configuration.md @@ -39,12 +39,12 @@ This option is also available as `defaultHidden` alias for compatibility with [R When `fireEvent` or `userEvent` doesn't call any handler, the test can silently do nothing, which can be confusing while debugging. When this option is enabled, a warning is logged in these cases: -- The handler is on a disabled element (e.g. a `Pressable` with `disabled={true}`). +- The handler is on a disabled element (e.g. a `Pressable` with `disabled={true}`, or a `TextInput` with `editable={false}`). - 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 is blocked on purpose (`pointerEvents="none"`, non-editable `TextInput`), or when it updates native state, e.g. `fireEvent.changeText` or `userEvent.type` on an uncontrolled `TextInput`. Defaults to `false`. Turn it on while debugging a test, or for the whole test suite in your Jest setup file: +No warning is logged when the event is blocked by `pointerEvents="none"`, or when it updates native state, e.g. `fireEvent.changeText` or `userEvent.type` on an uncontrolled `TextInput`. Defaults to `false`. Turn it on while debugging a test, or for the whole test suite in your Jest setup file: ```ts configure({ eventDiagnostics: true }); diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index f69967f19..ac099bb5a 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -927,12 +927,21 @@ describe('unhandled event warning', () => { expect(warnSpy).not.toHaveBeenCalled(); }); - test('does not warn when the handler is blocked by non-editable TextInput', async () => { + test('warns when the handler is blocked by non-editable TextInput', async () => { await render(); await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); - expect(warnSpy).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "Cannot fire the "changeText" event on a disabled element. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + " + `); }); test('does not warn when the event updates native state (uncontrolled TextInput)', async () => { diff --git a/src/events/index.ts b/src/events/index.ts index 55ca38a30..5297508e2 100644 --- a/src/events/index.ts +++ b/src/events/index.ts @@ -6,4 +6,4 @@ export * from './handler'; export { isPointerEventEnabled } from './is-enabled'; export * from './native-state'; export type { EventWarning } from './warnings'; -export { isWarnableDisabledTarget, logEventWarning } from './warnings'; +export { logEventWarning } from './warnings'; diff --git a/src/events/warnings.ts b/src/events/warnings.ts index aff4bb115..fbd82b1c3 100644 --- a/src/events/warnings.ts +++ b/src/events/warnings.ts @@ -4,9 +4,7 @@ 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'; @@ -54,16 +52,6 @@ export function logEventWarning({ message, elements }: EventWarning) { logger.warn([header, ...elementBlocks].join('\n\n')); } -export 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); -} - function getUnhandledEventWarning( instance: TestInstance, eventName: string, @@ -88,9 +76,9 @@ function getUnhandledEventWarning( }; } - // Only disabled elements are reported. Other rejections (`pointerEvents`, non-editable - // `TextInput`, responder declining the touch) are deliberate ways of blocking events. - const disabledTargets = skippedTargets.filter(isWarnableDisabledTarget); + // `computeAriaDisabled` also covers non-editable `TextInput`. Other rejections (`pointerEvents`, + // responder declining the touch) are deliberate ways of blocking events. + const disabledTargets = skippedTargets.filter(computeAriaDisabled); if (disabledTargets.length === 0) { return null; } diff --git a/src/user-event/__tests__/event-diagnostics.test.tsx b/src/user-event/__tests__/event-diagnostics.test.tsx index 23deb4257..ea89c152c 100644 --- a/src/user-event/__tests__/event-diagnostics.test.tsx +++ b/src/user-event/__tests__/event-diagnostics.test.tsx @@ -148,6 +148,53 @@ test('does not warn when the interaction updates native state (uncontrolled Text 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() did not call any event handlers. The element is disabled. + 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\(\) did not call any event handlers\. The element is disabled\./, + ); + expect(warnSpy.mock.calls[1][0]).toMatch( + /^paste\(\) did not call any event handlers\. The element is disabled\./, + ); +}); + +test('does not warn 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).not.toHaveBeenCalled(); +}); + test('does not warn when the press is blocked by pointerEvents="none"', async () => { await render( diff --git a/src/user-event/clear.ts b/src/user-event/clear.ts index dc8ff4be4..407c58359 100644 --- a/src/user-event/clear.ts +++ b/src/user-event/clear.ts @@ -22,12 +22,13 @@ 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; } - const interaction = new Interaction('clear', instance); - // 1. Enter instance await interaction.dispatchEvent('focus', buildFocusEvent()); diff --git a/src/user-event/paste.ts b/src/user-event/paste.ts index 80d30c3fc..49f63c022 100644 --- a/src/user-event/paste.ts +++ b/src/user-event/paste.ts @@ -28,12 +28,13 @@ export async function paste( ); } + const interaction = new Interaction('paste', instance); if (!isEditableTextInput(instance) || !isPointerEventEnabled(instance)) { + interaction.skippedTargets.push(instance); + warnAboutUnhandledInteraction(interaction); return; } - const interaction = new Interaction('paste', instance); - // 1. Enter instance await interaction.dispatchEvent('focus', buildFocusEvent()); diff --git a/src/user-event/type/type.ts b/src/user-event/type/type.ts index 7b76faa75..cd78d2be3 100644 --- a/src/user-event/type/type.ts +++ b/src/user-event/type/type.ts @@ -39,12 +39,13 @@ 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 interaction = new Interaction('type', instance); const keys = parseKeys(text); if (!options?.skipPress) { diff --git a/src/user-event/utils/warnings.ts b/src/user-event/utils/warnings.ts index 3a96e252f..8caa48547 100644 --- a/src/user-event/utils/warnings.ts +++ b/src/user-event/utils/warnings.ts @@ -1,6 +1,7 @@ import { getConfig } from '../../config'; import type { EventWarning } from '../../events'; -import { isWarnableDisabledTarget, logEventWarning } from '../../events'; +import { logEventWarning } from '../../events'; +import { computeAriaDisabled } from '../../helpers/accessibility'; import type { Interaction } from './interaction'; /** @@ -31,7 +32,8 @@ function getUnhandledInteractionWarning({ }: Interaction): EventWarning | null { const summary = `${name}() did not call any event handlers.`; - const disabledTargets = skippedTargets.filter(isWarnableDisabledTarget); + // `computeAriaDisabled` also covers non-editable `TextInput`. + const disabledTargets = skippedTargets.filter(computeAriaDisabled); if (disabledTargets.length > 0) { return { message: diff --git a/website/docs/14.x/docs/api/misc/config.mdx b/website/docs/14.x/docs/api/misc/config.mdx index c9393a3fd..5f6c12ade 100644 --- a/website/docs/14.x/docs/api/misc/config.mdx +++ b/website/docs/14.x/docs/api/misc/config.mdx @@ -39,12 +39,12 @@ This option is also available as `defaultHidden` alias for compatibility with [R When `fireEvent` or `userEvent` doesn't call any handler, the test can silently do nothing, which can be confusing while debugging. When this option is enabled, a warning is logged in these cases: -- The handler is on a disabled element (e.g. a `Pressable` with `disabled={true}`). +- The handler is on a disabled element (e.g. a `Pressable` with `disabled={true}`, or a `TextInput` with `editable={false}`). - 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 is blocked on purpose (`pointerEvents="none"`, non-editable `TextInput`), or when it updates native state, e.g. `fireEvent.changeText` or `userEvent.type` on an uncontrolled `TextInput`. Defaults to `false`. Turn it on while debugging a test, or for the whole test suite in your Jest setup file: +No warning is logged when the event is blocked by `pointerEvents="none"`, or when it updates native state, e.g. `fireEvent.changeText` or `userEvent.type` on an uncontrolled `TextInput`. Defaults to `false`. Turn it on while debugging a test, or for the whole test suite in your Jest setup file: ```ts configure({ eventDiagnostics: true }); From 479fa93d65487740ef806d8e084ce563463402ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Wed, 7 Oct 2026 12:06:07 +0200 Subject: [PATCH 14/22] diagnose blocking by pointe events --- contributing/event-dispatch.md | 2 +- docs/api/configuration.md | 3 +- src/config.ts | 4 +- src/events/__tests__/fire-event.test.tsx | 114 +++++++++++++++++- src/events/index.ts | 4 +- src/events/is-enabled.ts | 37 +++++- src/events/warnings.ts | 72 +++++++++-- .../__tests__/event-diagnostics.test.tsx | 94 ++++++++++++++- src/user-event/utils/warnings.ts | 15 ++- website/docs/14.x/docs/api/misc/config.mdx | 3 +- 10 files changed, 315 insertions(+), 33 deletions(-) diff --git a/contributing/event-dispatch.md b/contributing/event-dispatch.md index 95166d17e..057aa8836 100644 --- a/contributing/event-dispatch.md +++ b/contributing/event-dispatch.md @@ -35,7 +35,7 @@ For the `eventDiagnostics` warning, each action tracks itself with an `Interacti - 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 names only the disabled ones (`computeAriaDisabled()`, which includes non-editable `TextInput`), and skips the warning if every skipped element blocked the action on purpose. Text actions (`type()`, `clear()`, `paste()`) add the `TextInput` when it is non-editable or blocked by `pointerEvents`. +- 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`), 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 diff --git a/docs/api/configuration.md b/docs/api/configuration.md index bc0f8b348..0760a4527 100644 --- a/docs/api/configuration.md +++ b/docs/api/configuration.md @@ -40,11 +40,12 @@ This option is also available as `defaultHidden` alias for compatibility with [R When `fireEvent` or `userEvent` doesn't call any handler, the test can silently do nothing, which can be confusing while debugging. When this option is enabled, a warning is logged in these cases: - The handler is on a disabled element (e.g. a `Pressable` with `disabled={true}`, or a `TextInput` with `editable={false}`). +- 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 is blocked by `pointerEvents="none"`, or when it updates native state, e.g. `fireEvent.changeText` or `userEvent.type` on an uncontrolled `TextInput`. Defaults to `false`. Turn it on while debugging a test, or for the whole test suite in your Jest setup file: +No warning is logged when the event updates native state, e.g. `fireEvent.changeText` or `userEvent.type` on an uncontrolled `TextInput`. Defaults to `false`. Turn it on while debugging a test, or for the whole test suite in your Jest setup file: ```ts configure({ eventDiagnostics: true }); diff --git a/src/config.ts b/src/config.ts index b2d30dcfe..b19cb18cf 100644 --- a/src/config.ts +++ b/src/config.ts @@ -13,8 +13,8 @@ export type Config = { defaultIncludeHiddenElements: boolean; /** - * Warn when `fireEvent` or a `userEvent` interaction calls no handler, because the - * target is disabled or no element handles the event. Off by default. + * 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; diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index ac099bb5a..362992b49 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -580,13 +580,24 @@ describe('fireEvent.layout', () => { expect(onLayout).not.toHaveBeenCalled(); }); - 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(` " ▲ The element has no handler for the "layout" event. "layout" events do not bubble to ancestors. @@ -915,15 +926,112 @@ describe('unhandled event warning', () => { expect(warnSpy).not.toHaveBeenCalled(); }); - test('does not warn when the handler is blocked by pointerEvents="none"', async () => { + 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]).toMatch( + /Blocked by:\n\n {2}$/, + ); + expect(warnSpy.mock.calls[1][0]).toMatch( + /Blocked by:\n\n {2}$/, + ); + }); + + test('reports non-editable TextInput as disabled 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 disabled element\./, + ); + }); + + 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(); }); diff --git a/src/events/index.ts b/src/events/index.ts index 5297508e2..172b85056 100644 --- a/src/events/index.ts +++ b/src/events/index.ts @@ -3,7 +3,7 @@ export * from './builders/scroll'; export * from './builders/text'; export * from './dispatch'; export * from './handler'; -export { isPointerEventEnabled } from './is-enabled'; +export { getPointerEventsBlocker, isPointerEventEnabled } from './is-enabled'; export * from './native-state'; export type { EventWarning } from './warnings'; -export { logEventWarning } from './warnings'; +export { getPointerEventsWarning, logEventWarning } from './warnings'; diff --git a/src/events/is-enabled.ts b/src/events/is-enabled.ts index cb7d46475..a82abeeec 100644 --- a/src/events/is-enabled.ts +++ b/src/events/is-enabled.ts @@ -11,7 +11,19 @@ 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 => { +export function isPointerEventEnabled(instance: TestInstance): boolean { + return getPointerEventsBlocker(instance) == null; +} + +/** + * 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); +} + +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; @@ -19,15 +31,15 @@ export const isPointerEventEnabled = (instance: TestInstance, isParent?: boolean const parentCondition = isParent ? pointerEvents === 'box-only' : pointerEvents === 'box-none'; if (pointerEvents === 'none' || parentCondition) { - return false; + return instance; } if (!instance.parent) { - return true; + return null; } - return isPointerEventEnabled(instance.parent, true); -}; + return findPointerEventsBlocker(instance.parent, true); +} export function isTouchResponder(instance: TestInstance) { return Boolean(instance.props.onStartShouldSetResponder) || isHostTextInput(instance); @@ -38,6 +50,19 @@ export function isTouchResponder(instance: TestInstance) { */ const eventsAffectedByPointerEventsProp = new Set(['press']); +/** + * Like `getPointerEventsBlocker`, but only for events affected by `pointerEvents`. + * Expects event name without the `on*` prefix (see `normalizeEventName`). + */ +export function getPointerEventsBlockerForEvent( + instance: TestInstance, + eventName: string, +): TestInstance | null { + return eventsAffectedByPointerEventsProp.has(eventName) + ? getPointerEventsBlocker(instance) + : null; +} + /** * List of `TextInput` events not affected by `editable` prop. */ @@ -60,7 +85,7 @@ export function isEventEnabled( ); } - if (eventsAffectedByPointerEventsProp.has(eventName) && !isPointerEventEnabled(instance)) { + if (getPointerEventsBlockerForEvent(instance, eventName) != null) { return false; } diff --git a/src/events/warnings.ts b/src/events/warnings.ts index fbd82b1c3..3910b26dd 100644 --- a/src/events/warnings.ts +++ b/src/events/warnings.ts @@ -3,9 +3,10 @@ import type { TestInstance } from 'test-renderer'; import { getConfig } from '../config'; import { computeAriaDisabled } from '../helpers/accessibility'; -import { formatJson } from '../helpers/format-element'; +import { formatElement, formatJson } from '../helpers/format-element'; import { logger } from '../helpers/logger'; import { normalizeEventName } from './handler'; +import { getPointerEventsBlockerForEvent } from './is-enabled'; import { isDirectEvent } from './propagation'; type UnhandledEventInfo = { @@ -17,6 +18,8 @@ type UnhandledEventInfo = { export type EventWarning = { message: string; elements: TestInstance[]; + /** Elements whose `pointerEvents` blocked the event. Printed without their children. */ + pointerEventsBlockers?: TestInstance[]; }; /** @@ -39,9 +42,9 @@ export function warnAboutUnhandledEvent( } /** - * Logs the warning with the opt-out hint and the elements it is about. + * Logs the warning with the opt-out hint, the elements it is about and what blocked them. */ -export function logEventWarning({ message, elements }: EventWarning) { +export function logEventWarning({ message, elements, pointerEventsBlockers = [] }: EventWarning) { const header = `${message}\n` + 'If this is intentional, you can disable this warning via `configure({ eventDiagnostics: false })`.'; @@ -49,7 +52,41 @@ export function logEventWarning({ message, elements }: EventWarning) { .map((element) => element.toJSON()) .filter((json) => json != null) .map((json) => redent(formatJson(json), 2)); - logger.warn([header, ...elementBlocks].join('\n\n')); + // Blockers are often large containers, so they are printed without their children. + const blockerBlocks = [...new Set(pointerEventsBlockers)].map((blocker) => + redent(formatElement(blocker, { highlight: false }), 2), + ); + const blockerSection = blockerBlocks.length > 0 ? ['Blocked by:', ...blockerBlocks] : []; + logger.warn([header, ...elementBlocks, ...blockerSection].join('\n\n')); +} + +/** + * Builds the warning for elements that `pointerEvents` blocked, or returns `null` if it + * blocked none of them. + * + * @param targets Skipped elements, nearest first. + * @param getBlocker Returns the element whose `pointerEvents` blocked the target, if any. + * @param formatMessage Builds the message from the number of blocked elements. + */ +export function getPointerEventsWarning( + targets: TestInstance[], + getBlocker: (target: TestInstance) => TestInstance | null, + formatMessage: (count: number) => string, +): EventWarning | null { + const blocked = targets + .map((target) => ({ target, blocker: getBlocker(target) })) + .filter( + (entry): entry is { target: TestInstance; blocker: TestInstance } => entry.blocker != null, + ); + if (blocked.length === 0) { + return null; + } + + return { + message: formatMessage(blocked.length), + elements: blocked.map(({ target }) => target), + pointerEventsBlockers: blocked.map(({ blocker }) => blocker), + }; } function getUnhandledEventWarning( @@ -58,6 +95,11 @@ function getUnhandledEventWarning( { 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; + } + if (isDirectEvent(normalizeEventName(eventName))) { return { message: `The element has no handler for the "${eventName}" event. "${eventName}" events do not bubble to ancestors.`, @@ -65,19 +107,27 @@ function getUnhandledEventWarning( }; } - // The event still had an effect, e.g. `changeText` on an uncontrolled TextInput updates its value. - if (hasUpdatedNativeState) { - return null; - } - return { message: `The element and its ancestors have no handler for the "${eventName}" event.`, elements: [instance], }; } - // `computeAriaDisabled` also covers non-editable `TextInput`. Other rejections (`pointerEvents`, - // responder declining the touch) are deliberate ways of blocking events. + // `pointerEvents` is checked first: it blocks the event even if the element is enabled. + const pointerEventsWarning = getPointerEventsWarning( + skippedTargets, + (target) => getPointerEventsBlockerForEvent(target, normalizeEventName(eventName)), + (count) => + count === 1 + ? `Cannot fire the "${eventName}" event on an element blocked by pointerEvents.` + : `Cannot fire the "${eventName}" event on elements blocked by pointerEvents.`, + ); + if (pointerEventsWarning != null) { + return pointerEventsWarning; + } + + // `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; diff --git a/src/user-event/__tests__/event-diagnostics.test.tsx b/src/user-event/__tests__/event-diagnostics.test.tsx index ea89c152c..b0d278c7b 100644 --- a/src/user-event/__tests__/event-diagnostics.test.tsx +++ b/src/user-event/__tests__/event-diagnostics.test.tsx @@ -182,9 +182,9 @@ test('warns when clearing or pasting into a non-editable TextInput', async () => ); }); -test('does not warn when typing into a TextInput blocked by pointerEvents="none"', async () => { +test('warns when typing into a TextInput blocked by pointerEvents="none"', async () => { await render( - + , ); @@ -192,12 +192,27 @@ test('does not warn when typing into a TextInput blocked by pointerEvents="none" await user.type(screen.getByTestId('input'), 'Hello'); - expect(warnSpy).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "type() 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('does not warn when the press is blocked by pointerEvents="none"', async () => { +test('warns when the press is blocked by pointerEvents="none"', async () => { await render( - + Trigger @@ -207,6 +222,75 @@ test('does not warn when the press is blocked by pointerEvents="none"', async () await user.press(screen.getByText('Trigger')); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "press() 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\(\) did not call any event handlers\. The element is blocked by pointerEvents\./, + ); + expect(warnSpy.mock.calls[1][0]).toMatch( + /^type\(\) 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(); }); diff --git a/src/user-event/utils/warnings.ts b/src/user-event/utils/warnings.ts index 8caa48547..8cc246906 100644 --- a/src/user-event/utils/warnings.ts +++ b/src/user-event/utils/warnings.ts @@ -1,6 +1,6 @@ import { getConfig } from '../../config'; import type { EventWarning } from '../../events'; -import { logEventWarning } from '../../events'; +import { getPointerEventsBlocker, getPointerEventsWarning, logEventWarning } from '../../events'; import { computeAriaDisabled } from '../../helpers/accessibility'; import type { Interaction } from './interaction'; @@ -32,6 +32,19 @@ function getUnhandledInteractionWarning({ }: Interaction): EventWarning | null { const summary = `${name}() did not call any event handlers.`; + // `pointerEvents` is checked first: it blocks the interaction even if the element is enabled. + const pointerEventsWarning = getPointerEventsWarning( + skippedTargets, + getPointerEventsBlocker, + (count) => + count === 1 + ? `${summary} The element is blocked by pointerEvents.` + : `${summary} The elements are blocked by pointerEvents.`, + ); + if (pointerEventsWarning != null) { + return pointerEventsWarning; + } + // `computeAriaDisabled` also covers non-editable `TextInput`. const disabledTargets = skippedTargets.filter(computeAriaDisabled); if (disabledTargets.length > 0) { diff --git a/website/docs/14.x/docs/api/misc/config.mdx b/website/docs/14.x/docs/api/misc/config.mdx index 5f6c12ade..ff455d7e9 100644 --- a/website/docs/14.x/docs/api/misc/config.mdx +++ b/website/docs/14.x/docs/api/misc/config.mdx @@ -40,11 +40,12 @@ This option is also available as `defaultHidden` alias for compatibility with [R When `fireEvent` or `userEvent` doesn't call any handler, the test can silently do nothing, which can be confusing while debugging. When this option is enabled, a warning is logged in these cases: - The handler is on a disabled element (e.g. a `Pressable` with `disabled={true}`, or a `TextInput` with `editable={false}`). +- 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 is blocked by `pointerEvents="none"`, or when it updates native state, e.g. `fireEvent.changeText` or `userEvent.type` on an uncontrolled `TextInput`. Defaults to `false`. Turn it on while debugging a test, or for the whole test suite in your Jest setup file: +No warning is logged when the event updates native state, e.g. `fireEvent.changeText` or `userEvent.type` on an uncontrolled `TextInput`. Defaults to `false`. Turn it on while debugging a test, or for the whole test suite in your Jest setup file: ```ts configure({ eventDiagnostics: true }); From 33cf9b5a1a4c0bf62fdfe62595028320ef45d9a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Wed, 7 Oct 2026 12:08:34 +0200 Subject: [PATCH 15/22] tests --- src/events/__tests__/fire-event.test.tsx | 15 +++++++++++++++ .../__tests__/event-diagnostics.test.tsx | 14 ++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index 362992b49..a92ee6923 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -1052,6 +1052,21 @@ describe('unhandled event warning', () => { `); }); + 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 disabled element\\.`), + ); + }); + test('does not warn when the event updates native state (uncontrolled TextInput)', async () => { await render(); diff --git a/src/user-event/__tests__/event-diagnostics.test.tsx b/src/user-event/__tests__/event-diagnostics.test.tsx index b0d278c7b..91a6bbefb 100644 --- a/src/user-event/__tests__/event-diagnostics.test.tsx +++ b/src/user-event/__tests__/event-diagnostics.test.tsx @@ -182,6 +182,20 @@ test('warns when clearing or pasting into a non-editable TextInput', async () => ); }); +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\(\) did not call any event handlers\. The element is disabled\./, + ); +}); + test('warns when typing into a TextInput blocked by pointerEvents="none"', async () => { await render( From cfe3ea852bc556b64533fd32f799aebd9fa62420 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Wed, 7 Oct 2026 12:32:40 +0200 Subject: [PATCH 16/22] simplify --- contributing/event-dispatch.md | 2 +- docs/api/configuration.md | 9 ++-- src/events/__tests__/fire-event.test.tsx | 46 +++++++++++++++++-- src/events/index.ts | 2 +- src/events/warnings.ts | 24 +++++++--- .../__tests__/event-diagnostics.test.tsx | 10 ++-- src/user-event/utils/warnings.ts | 14 +++--- website/docs/14.x/docs/api/misc/config.mdx | 9 ++-- 8 files changed, 87 insertions(+), 29 deletions(-) diff --git a/contributing/event-dispatch.md b/contributing/event-dispatch.md index 057aa8836..7a0bb6981 100644 --- a/contributing/event-dispatch.md +++ b/contributing/event-dispatch.md @@ -35,7 +35,7 @@ For the `eventDiagnostics` warning, each action tracks itself with an `Interacti - 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`), 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`. +- 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 diff --git a/docs/api/configuration.md b/docs/api/configuration.md index 0760a4527..fe44194b5 100644 --- a/docs/api/configuration.md +++ b/docs/api/configuration.md @@ -37,15 +37,18 @@ This option is also available as `defaultHidden` alias for compatibility with [R ### `eventDiagnostics` option -When `fireEvent` or `userEvent` doesn't call any handler, the test can silently do nothing, which can be confusing while debugging. When this option is enabled, a warning is logged in these cases: +Logs a warning when `fireEvent` or `userEvent` doesn't call any handler, so a test doesn't silently do nothing. Defaults to `false`. -- The handler is on a disabled element (e.g. a `Pressable` with `disabled={true}`, or a `TextInput` with `editable={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`. Defaults to `false`. Turn it on while debugging a test, or for the whole test suite in your Jest setup file: +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 }); diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index a92ee6923..fe0024016 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -1009,7 +1009,7 @@ describe('unhandled event warning', () => { ); }); - test('reports non-editable TextInput as disabled for events not affected by pointerEvents', async () => { + test('reports non-editable TextInput for events not affected by pointerEvents', async () => { await render( @@ -1020,7 +1020,7 @@ describe('unhandled event warning', () => { expect(warnSpy).toHaveBeenCalledTimes(1); expect(warnSpy.mock.calls[0][0]).toMatch( - /^Cannot fire the "changeText" event on a disabled element\./, + /^Cannot fire the "changeText" event on a non-editable TextInput\./, ); }); @@ -1042,7 +1042,7 @@ describe('unhandled event warning', () => { expect(warnSpy).toHaveBeenCalledTimes(1); expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - "Cannot fire the "changeText" event on a disabled element. + "Cannot fire the "changeText" event on a non-editable TextInput. If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. { expect(warnSpy).toHaveBeenCalledTimes(1); expect(warnSpy.mock.calls[0][0]).toMatch( - new RegExp(`^Cannot fire the "${eventName}" event on a disabled element\\.`), + 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( + + + , + ); + + 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\./, ); }); diff --git a/src/events/index.ts b/src/events/index.ts index 172b85056..ab01aa6a4 100644 --- a/src/events/index.ts +++ b/src/events/index.ts @@ -6,4 +6,4 @@ export * from './handler'; export { getPointerEventsBlocker, isPointerEventEnabled } from './is-enabled'; export * from './native-state'; export type { EventWarning } from './warnings'; -export { getPointerEventsWarning, logEventWarning } from './warnings'; +export { formatDisabledTargets, getPointerEventsWarning, logEventWarning } from './warnings'; diff --git a/src/events/warnings.ts b/src/events/warnings.ts index 3910b26dd..6389edcd6 100644 --- a/src/events/warnings.ts +++ b/src/events/warnings.ts @@ -4,7 +4,9 @@ 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 { normalizeEventName } from './handler'; import { getPointerEventsBlockerForEvent } from './is-enabled'; import { isDirectEvent } from './propagation'; @@ -89,6 +91,18 @@ export function getPointerEventsWarning( }; } +/** + * 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, @@ -126,18 +140,16 @@ function getUnhandledEventWarning( return pointerEventsWarning; } - // `computeAriaDisabled` also covers non-editable `TextInput`. A responder declining the touch - // is a deliberate way of blocking events, so it doesn't warn. + // `computeAriaDisabled` also covers non-editable `TextInput`, which the message calls + // non-editable. 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: - disabledTargets.length === 1 - ? `Cannot fire the "${eventName}" event on a disabled element.` - : `Cannot fire the "${eventName}" event on disabled elements.`, + 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 index 91a6bbefb..601035c6e 100644 --- a/src/user-event/__tests__/event-diagnostics.test.tsx +++ b/src/user-event/__tests__/event-diagnostics.test.tsx @@ -28,7 +28,7 @@ test('warns when pressing a disabled element', async () => { expect(warnSpy).toHaveBeenCalledTimes(1); expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - "press() did not call any event handlers. The element is disabled. + "press() 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 })\`. { expect(warnSpy).toHaveBeenCalledTimes(1); expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - "type() did not call any event handlers. The element is disabled. + "type() 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 })\`. expect(warnSpy).toHaveBeenCalledTimes(2); expect(warnSpy.mock.calls[0][0]).toMatch( - /^clear\(\) did not call any event handlers\. The element is disabled\./, + /^clear\(\) did not call any event handlers\. Cannot interact with a non-editable TextInput\./, ); expect(warnSpy.mock.calls[1][0]).toMatch( - /^paste\(\) did not call any event handlers\. The element is disabled\./, + /^paste\(\) did not call any event handlers\. Cannot interact with a non-editable TextInput\./, ); }); @@ -192,7 +192,7 @@ test('warns when pressing a non-editable TextInput', async () => { expect(warnSpy).toHaveBeenCalledTimes(1); expect(warnSpy.mock.calls[0][0]).toMatch( - /^press\(\) did not call any event handlers\. The element is disabled\./, + /^press\(\) did not call any event handlers\. Cannot interact with a non-editable TextInput\./, ); }); diff --git a/src/user-event/utils/warnings.ts b/src/user-event/utils/warnings.ts index 8cc246906..e8992eace 100644 --- a/src/user-event/utils/warnings.ts +++ b/src/user-event/utils/warnings.ts @@ -1,6 +1,11 @@ import { getConfig } from '../../config'; import type { EventWarning } from '../../events'; -import { getPointerEventsBlocker, getPointerEventsWarning, logEventWarning } from '../../events'; +import { + formatDisabledTargets, + getPointerEventsBlocker, + getPointerEventsWarning, + logEventWarning, +} from '../../events'; import { computeAriaDisabled } from '../../helpers/accessibility'; import type { Interaction } from './interaction'; @@ -45,14 +50,11 @@ function getUnhandledInteractionWarning({ return pointerEventsWarning; } - // `computeAriaDisabled` also covers non-editable `TextInput`. + // `computeAriaDisabled` also covers non-editable `TextInput`, which the message calls non-editable. const disabledTargets = skippedTargets.filter(computeAriaDisabled); if (disabledTargets.length > 0) { return { - message: - disabledTargets.length === 1 - ? `${summary} The element is disabled.` - : `${summary} The elements are disabled.`, + message: `${summary} Cannot interact with ${formatDisabledTargets(disabledTargets)}.`, elements: disabledTargets, }; } diff --git a/website/docs/14.x/docs/api/misc/config.mdx b/website/docs/14.x/docs/api/misc/config.mdx index ff455d7e9..08e1d64ca 100644 --- a/website/docs/14.x/docs/api/misc/config.mdx +++ b/website/docs/14.x/docs/api/misc/config.mdx @@ -37,15 +37,18 @@ This option is also available as `defaultHidden` alias for compatibility with [R ### `eventDiagnostics` option -When `fireEvent` or `userEvent` doesn't call any handler, the test can silently do nothing, which can be confusing while debugging. When this option is enabled, a warning is logged in these cases: +Logs a warning when `fireEvent` or `userEvent` doesn't call any handler, so a test doesn't silently do nothing. Defaults to `false`. -- The handler is on a disabled element (e.g. a `Pressable` with `disabled={true}`, or a `TextInput` with `editable={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`. Defaults to `false`. Turn it on while debugging a test, or for the whole test suite in your Jest setup file: +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 }); From 5820d0a574cf28f241973d407d218ce09a542811 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Wed, 7 Oct 2026 12:34:35 +0200 Subject: [PATCH 17/22] trim comments --- src/events/warnings.ts | 14 +++----------- src/user-event/utils/interaction.ts | 3 --- src/user-event/utils/warnings.ts | 4 ++-- 3 files changed, 5 insertions(+), 16 deletions(-) diff --git a/src/events/warnings.ts b/src/events/warnings.ts index 6389edcd6..2307059c4 100644 --- a/src/events/warnings.ts +++ b/src/events/warnings.ts @@ -12,7 +12,6 @@ import { getPointerEventsBlockerForEvent } from './is-enabled'; import { isDirectEvent } from './propagation'; type UnhandledEventInfo = { - /** Elements whose handler was rejected by `isEventEnabled`, nearest first. */ skippedTargets: TestInstance[]; hasUpdatedNativeState: boolean; }; @@ -20,13 +19,12 @@ type UnhandledEventInfo = { export type EventWarning = { message: string; elements: TestInstance[]; - /** Elements whose `pointerEvents` blocked the event. Printed without their children. */ + /** Elements whose `pointerEvents` blocked the event. */ pointerEventsBlockers?: TestInstance[]; }; /** * Warns when no handler ran because the target is disabled or nothing handles the event. - * Enabled via `configure({ eventDiagnostics: true })`. */ export function warnAboutUnhandledEvent( instance: TestInstance, @@ -43,9 +41,6 @@ export function warnAboutUnhandledEvent( } } -/** - * Logs the warning with the opt-out hint, the elements it is about and what blocked them. - */ export function logEventWarning({ message, elements, pointerEventsBlockers = [] }: EventWarning) { const header = `${message}\n` + @@ -67,8 +62,6 @@ export function logEventWarning({ message, elements, pointerEventsBlockers = [] * blocked none of them. * * @param targets Skipped elements, nearest first. - * @param getBlocker Returns the element whose `pointerEvents` blocked the target, if any. - * @param formatMessage Builds the message from the number of blocked elements. */ export function getPointerEventsWarning( targets: TestInstance[], @@ -140,9 +133,8 @@ function getUnhandledEventWarning( return pointerEventsWarning; } - // `computeAriaDisabled` also covers non-editable `TextInput`, which the message calls - // non-editable. A responder declining the touch is a deliberate way of blocking events, so it - // doesn't warn. + // `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; diff --git a/src/user-event/utils/interaction.ts b/src/user-event/utils/interaction.ts index df9eeb48c..7ea2f7b3f 100644 --- a/src/user-event/utils/interaction.ts +++ b/src/user-event/utils/interaction.ts @@ -23,9 +23,6 @@ export class Interaction { public target: TestInstance, ) {} - /** - * Dispatches the event to `target` with `dispatchEvent` and records it. - */ async dispatchEvent(eventName: string, ...event: unknown[]) { const hasCalledHandler = await dispatchEvent(this.target, eventName, ...event); this.recordEvent(eventName, hasCalledHandler); diff --git a/src/user-event/utils/warnings.ts b/src/user-event/utils/warnings.ts index e8992eace..8481ee59a 100644 --- a/src/user-event/utils/warnings.ts +++ b/src/user-event/utils/warnings.ts @@ -11,7 +11,7 @@ import type { Interaction } from './interaction'; /** * Warns when none of the events of a `userEvent` interaction called a handler or updated - * native state. Enabled via `configure({ eventDiagnostics: true })`. + * native state. */ export function warnAboutUnhandledInteraction(interaction: Interaction) { if (!getConfig().eventDiagnostics) { @@ -50,7 +50,7 @@ function getUnhandledInteractionWarning({ return pointerEventsWarning; } - // `computeAriaDisabled` also covers non-editable `TextInput`, which the message calls non-editable. + // `computeAriaDisabled` also covers non-editable `TextInput`. const disabledTargets = skippedTargets.filter(computeAriaDisabled); if (disabledTargets.length > 0) { return { From 39eb4a650a18ac2b200618b4882afc9329acfb38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Wed, 7 Oct 2026 12:54:26 +0200 Subject: [PATCH 18/22] cleanuip --- PR.txt | 24 ------------------------ 1 file changed, 24 deletions(-) delete mode 100644 PR.txt diff --git a/PR.txt b/PR.txt deleted file mode 100644 index 1b3e5979b..000000000 --- a/PR.txt +++ /dev/null @@ -1,24 +0,0 @@ -### Summary - -When `fireEvent` doesn't call any handler, the test silently does nothing, which is hard to debug. This PR adds an opt-in `eventDiagnostics` option that logs a warning in these cases: - -- The handler is on a disabled element (e.g. `Pressable` with `disabled={true}`). The warning lists every disabled element the event skipped while bubbling, nearest first, rather than the fired element. -- Neither the element nor any of its ancestors has a handler for the event. For direct events (`layout`), which don't bubble, only the element itself is checked. This replaces the old warning for direct events that had no handler, which was always on. - -No warning is logged when the event is blocked on purpose (`pointerEvents="none"`, non-editable `TextInput`, a responder declining the touch), or when the event updates native state (e.g. `changeText` on an uncontrolled `TextInput`). - -The option is off by default, so enable it with `configure({ eventDiagnostics: true })`. Behavior change: firing `layout` on an element without `onLayout` used to always warn, and now it only warns when the option is on. - -Implementation: - -- `findEventHandler` now returns `{ handler, skippedTargets }`, so callers can tell a blocked handler apart from no handler at all. `skippedTargets` lists every element whose handler `isEventEnabled` rejected, nearest first. -- `updateNativeStateFromEvent` returns whether it updated native state. -- Removed the warning spies from tests that only used them to silence warnings. -- The warning logic lives in the new `src/events/warnings.ts`. -- Documented `eventDiagnostics` in the 14.x config docs, and added a file layout section to `contributing/code-style.md`. - -### Test plan - -- New `fireEvent` tests cover: a disabled element, nested disabled elements, no handler on the element or its ancestors, a direct event with no handler, no warning for intentional blocking or native state updates, and no warning when `eventDiagnostics` is off. The warning tests turn the option on in their setup. -- Ran locally: `yarn test` (86 suites, 841 tests passed), `yarn typecheck`, `yarn lint`, `yarn format:check`. All pass. -- Not run: `yarn build`, `yarn validate`. From e43d9b44827d6a9fc01037ae52d03f02e4202e1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Wed, 7 Oct 2026 13:14:56 +0200 Subject: [PATCH 19/22] simplify --- src/events/index.ts | 4 +-- src/events/is-enabled.ts | 48 +++++++++++++--------------- src/events/warnings.ts | 54 +++++++++++++++----------------- src/user-event/utils/warnings.ts | 22 ++++++------- 4 files changed, 58 insertions(+), 70 deletions(-) diff --git a/src/events/index.ts b/src/events/index.ts index ab01aa6a4..117425c85 100644 --- a/src/events/index.ts +++ b/src/events/index.ts @@ -3,7 +3,7 @@ export * from './builders/scroll'; export * from './builders/text'; export * from './dispatch'; export * from './handler'; -export { getPointerEventsBlocker, isPointerEventEnabled } from './is-enabled'; +export { isPointerEventEnabled } from './is-enabled'; export * from './native-state'; export type { EventWarning } from './warnings'; -export { formatDisabledTargets, getPointerEventsWarning, logEventWarning } from './warnings'; +export { formatDisabledTargets, getPointerEventsBlockedTargets, logEventWarning } from './warnings'; diff --git a/src/events/is-enabled.ts b/src/events/is-enabled.ts index a82abeeec..54d14497a 100644 --- a/src/events/is-enabled.ts +++ b/src/events/is-enabled.ts @@ -23,24 +23,6 @@ export function getPointerEventsBlocker(instance: TestInstance): TestInstance | return findPointerEventsBlocker(instance, false); } -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); -} - export function isTouchResponder(instance: TestInstance) { return Boolean(instance.props.onStartShouldSetResponder) || isHostTextInput(instance); } @@ -51,16 +33,10 @@ export function isTouchResponder(instance: TestInstance) { const eventsAffectedByPointerEventsProp = new Set(['press']); /** - * Like `getPointerEventsBlocker`, but only for events affected by `pointerEvents`. * Expects event name without the `on*` prefix (see `normalizeEventName`). */ -export function getPointerEventsBlockerForEvent( - instance: TestInstance, - eventName: string, -): TestInstance | null { - return eventsAffectedByPointerEventsProp.has(eventName) - ? getPointerEventsBlocker(instance) - : null; +export function isBlockableByPointerEvents(eventName: string): boolean { + return eventsAffectedByPointerEventsProp.has(eventName); } /** @@ -85,7 +61,7 @@ export function isEventEnabled( ); } - if (getPointerEventsBlockerForEvent(instance, eventName) != null) { + if (isBlockableByPointerEvents(eventName) && !isPointerEventEnabled(instance)) { return false; } @@ -97,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/warnings.ts b/src/events/warnings.ts index 2307059c4..ae5a78a42 100644 --- a/src/events/warnings.ts +++ b/src/events/warnings.ts @@ -8,7 +8,7 @@ import { isHostTextInput } from '../helpers/host-component-names'; import { logger } from '../helpers/logger'; import { isEditableTextInput } from '../helpers/text-input'; import { normalizeEventName } from './handler'; -import { getPointerEventsBlockerForEvent } from './is-enabled'; +import { getPointerEventsBlocker, isBlockableByPointerEvents } from './is-enabled'; import { isDirectEvent } from './propagation'; type UnhandledEventInfo = { @@ -58,30 +58,25 @@ export function logEventWarning({ message, elements, pointerEventsBlockers = [] } /** - * Builds the warning for elements that `pointerEvents` blocked, or returns `null` if it - * blocked none of them. + * 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 getPointerEventsWarning( +export function getPointerEventsBlockedTargets( targets: TestInstance[], - getBlocker: (target: TestInstance) => TestInstance | null, - formatMessage: (count: number) => string, -): EventWarning | null { - const blocked = targets - .map((target) => ({ target, blocker: getBlocker(target) })) - .filter( - (entry): entry is { target: TestInstance; blocker: TestInstance } => entry.blocker != null, - ); - if (blocked.length === 0) { - return null; +): 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 { - message: formatMessage(blocked.length), - elements: blocked.map(({ target }) => target), - pointerEventsBlockers: blocked.map(({ blocker }) => blocker), - }; + return elements.length > 0 ? { elements, pointerEventsBlockers } : null; } /** @@ -121,16 +116,17 @@ function getUnhandledEventWarning( } // `pointerEvents` is checked first: it blocks the event even if the element is enabled. - const pointerEventsWarning = getPointerEventsWarning( - skippedTargets, - (target) => getPointerEventsBlockerForEvent(target, normalizeEventName(eventName)), - (count) => - count === 1 - ? `Cannot fire the "${eventName}" event on an element blocked by pointerEvents.` - : `Cannot fire the "${eventName}" event on elements blocked by pointerEvents.`, - ); - if (pointerEventsWarning != null) { - return pointerEventsWarning; + const blocked = isBlockableByPointerEvents(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 diff --git a/src/user-event/utils/warnings.ts b/src/user-event/utils/warnings.ts index 8481ee59a..55edcd47e 100644 --- a/src/user-event/utils/warnings.ts +++ b/src/user-event/utils/warnings.ts @@ -2,8 +2,7 @@ import { getConfig } from '../../config'; import type { EventWarning } from '../../events'; import { formatDisabledTargets, - getPointerEventsBlocker, - getPointerEventsWarning, + getPointerEventsBlockedTargets, logEventWarning, } from '../../events'; import { computeAriaDisabled } from '../../helpers/accessibility'; @@ -38,16 +37,15 @@ function getUnhandledInteractionWarning({ const summary = `${name}() did not call any event handlers.`; // `pointerEvents` is checked first: it blocks the interaction even if the element is enabled. - const pointerEventsWarning = getPointerEventsWarning( - skippedTargets, - getPointerEventsBlocker, - (count) => - count === 1 - ? `${summary} The element is blocked by pointerEvents.` - : `${summary} The elements are blocked by pointerEvents.`, - ); - if (pointerEventsWarning != null) { - return pointerEventsWarning; + 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`. From 14ab28e44b9612a9bcab652b7137af2cb3157d6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Wed, 7 Oct 2026 14:13:56 +0200 Subject: [PATCH 20/22] tweaks --- src/events/__tests__/fire-event.test.tsx | 40 ++++++++++++++++++++---- src/events/is-enabled.ts | 4 +-- src/events/warnings.ts | 6 ++-- 3 files changed, 39 insertions(+), 11 deletions(-) diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index fe0024016..b07b15099 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -1001,12 +1001,40 @@ describe('unhandled event warning', () => { await fireEvent.press(screen.getByTestId('box-none')); expect(warnSpy).toHaveBeenCalledTimes(2); - expect(warnSpy.mock.calls[0][0]).toMatch( - /Blocked by:\n\n {2}$/, - ); - expect(warnSpy.mock.calls[1][0]).toMatch( - /Blocked by:\n\n {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 () => { diff --git a/src/events/is-enabled.ts b/src/events/is-enabled.ts index 54d14497a..5d6d220e9 100644 --- a/src/events/is-enabled.ts +++ b/src/events/is-enabled.ts @@ -35,7 +35,7 @@ const eventsAffectedByPointerEventsProp = new Set(['press']); /** * Expects event name without the `on*` prefix (see `normalizeEventName`). */ -export function isBlockableByPointerEvents(eventName: string): boolean { +export function isEventBlockableByPointerEvents(eventName: string): boolean { return eventsAffectedByPointerEventsProp.has(eventName); } @@ -61,7 +61,7 @@ export function isEventEnabled( ); } - if (isBlockableByPointerEvents(eventName) && !isPointerEventEnabled(instance)) { + if (isEventBlockableByPointerEvents(eventName) && !isPointerEventEnabled(instance)) { return false; } diff --git a/src/events/warnings.ts b/src/events/warnings.ts index ae5a78a42..c935d72d6 100644 --- a/src/events/warnings.ts +++ b/src/events/warnings.ts @@ -8,7 +8,7 @@ import { isHostTextInput } from '../helpers/host-component-names'; import { logger } from '../helpers/logger'; import { isEditableTextInput } from '../helpers/text-input'; import { normalizeEventName } from './handler'; -import { getPointerEventsBlocker, isBlockableByPointerEvents } from './is-enabled'; +import { getPointerEventsBlocker, isEventBlockableByPointerEvents } from './is-enabled'; import { isDirectEvent } from './propagation'; type UnhandledEventInfo = { @@ -51,7 +51,7 @@ export function logEventWarning({ message, elements, pointerEventsBlockers = [] .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, { highlight: false }), 2), + redent(formatElement(blocker), 2), ); const blockerSection = blockerBlocks.length > 0 ? ['Blocked by:', ...blockerBlocks] : []; logger.warn([header, ...elementBlocks, ...blockerSection].join('\n\n')); @@ -116,7 +116,7 @@ function getUnhandledEventWarning( } // `pointerEvents` is checked first: it blocks the event even if the element is enabled. - const blocked = isBlockableByPointerEvents(normalizeEventName(eventName)) + const blocked = isEventBlockableByPointerEvents(normalizeEventName(eventName)) ? getPointerEventsBlockedTargets(skippedTargets) : null; if (blocked != null) { From f53491ef74c2810efd0eb7d5fbe9dd830d7fb4c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Wed, 7 Oct 2026 14:16:33 +0200 Subject: [PATCH 21/22] tweaks --- src/events/__tests__/fire-event.test.tsx | 4 +-- src/events/handler.ts | 2 +- src/events/warnings.ts | 7 ++--- .../__tests__/event-diagnostics.test.tsx | 26 +++++++++---------- src/user-event/utils/warnings.ts | 2 +- 5 files changed, 21 insertions(+), 20 deletions(-) diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index b07b15099..edf6f3ae4 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -600,7 +600,7 @@ describe('fireEvent.layout', () => { expect(warnSpy).toHaveBeenCalledTimes(1); expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - " ▲ The element has no handler for the "layout" event. "layout" events do not bubble to ancestors. + " ▲ 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 })\`. { expect(warnSpy).toHaveBeenCalledTimes(1); expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - "The element and its ancestors have no handler for the "press" event. + "No "onPress" handler found on the element or its ancestors. If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. 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/warnings.ts b/src/events/warnings.ts index c935d72d6..ba1832cbe 100644 --- a/src/events/warnings.ts +++ b/src/events/warnings.ts @@ -7,7 +7,7 @@ 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 { normalizeEventName } from './handler'; +import { getEventHandlerName, normalizeEventName } from './handler'; import { getPointerEventsBlocker, isEventBlockableByPointerEvents } from './is-enabled'; import { isDirectEvent } from './propagation'; @@ -102,15 +102,16 @@ function getUnhandledEventWarning( return null; } + const handlerName = getEventHandlerName(eventName); if (isDirectEvent(normalizeEventName(eventName))) { return { - message: `The element has no handler for the "${eventName}" event. "${eventName}" events do not bubble to ancestors.`, + message: `No "${handlerName}" handler found on the element. "${eventName}" events do not bubble to ancestors.`, elements: [instance], }; } return { - message: `The element and its ancestors have no handler for the "${eventName}" event.`, + message: `No "${handlerName}" handler found on the element or its ancestors.`, elements: [instance], }; } diff --git a/src/user-event/__tests__/event-diagnostics.test.tsx b/src/user-event/__tests__/event-diagnostics.test.tsx index 601035c6e..b82e80638 100644 --- a/src/user-event/__tests__/event-diagnostics.test.tsx +++ b/src/user-event/__tests__/event-diagnostics.test.tsx @@ -28,7 +28,7 @@ test('warns when pressing a disabled element', async () => { expect(warnSpy).toHaveBeenCalledTimes(1); expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - "press() did not call any event handlers. Cannot interact with a disabled element. + "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 })\`. { expect(warnSpy).toHaveBeenCalledTimes(1); expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - "press() did not call any event handlers. The element and its ancestors have no handlers for this interaction. + "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 })\`. @@ -77,7 +77,7 @@ test('warns when none of the dispatched events has a handler', async () => { expect(warnSpy).toHaveBeenCalledTimes(1); expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - "longPress() did not call any event handlers. The element has no handler for the "pressIn", "longPress" or "pressOut" events. + "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 })\`. { expect(warnSpy).toHaveBeenCalledTimes(1); expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - "pullToRefresh() did not call any event handlers. The element has no handler for the "refresh" event. + "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 })\`. { expect(warnSpy).toHaveBeenCalledTimes(1); expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - "type() did not call any event handlers. Cannot interact with a non-editable TextInput. + "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 })\`. expect(warnSpy).toHaveBeenCalledTimes(2); expect(warnSpy.mock.calls[0][0]).toMatch( - /^clear\(\) did not call any event handlers\. Cannot interact with a non-editable TextInput\./, + /^clear\(\) interaction did not call any event handlers\. Cannot interact with a non-editable TextInput\./, ); expect(warnSpy.mock.calls[1][0]).toMatch( - /^paste\(\) did not call any event handlers\. Cannot interact with a non-editable TextInput\./, + /^paste\(\) interaction did not call any event handlers\. Cannot interact with a non-editable TextInput\./, ); }); @@ -192,7 +192,7 @@ test('warns when pressing a non-editable TextInput', async () => { expect(warnSpy).toHaveBeenCalledTimes(1); expect(warnSpy.mock.calls[0][0]).toMatch( - /^press\(\) did not call any event handlers\. Cannot interact with a non-editable TextInput\./, + /^press\(\) interaction did not call any event handlers\. Cannot interact with a non-editable TextInput\./, ); }); @@ -208,7 +208,7 @@ test('warns when typing into a TextInput blocked by pointerEvents="none"', async expect(warnSpy).toHaveBeenCalledTimes(1); expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - "type() did not call any event handlers. The element is blocked by pointerEvents. + "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 })\`. { expect(warnSpy).toHaveBeenCalledTimes(1); expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - "press() did not call any event handlers. The element is blocked by pointerEvents. + "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 })\`. Date: Wed, 7 Oct 2026 14:21:44 +0200 Subject: [PATCH 22/22] fix ts --- src/events/__tests__/fire-event.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index edf6f3ae4..3b9382ae6 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -1098,7 +1098,8 @@ describe('unhandled event warning', () => { 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. + , );