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/__tests__/fire-event.test.tsx b/src/__tests__/fire-event.test.tsx index ea39223ac..becf3ec0f 100644 --- a/src/__tests__/fire-event.test.tsx +++ b/src/__tests__/fire-event.test.tsx @@ -14,6 +14,8 @@ import { } from 'react-native'; import { fireEvent, render, screen } from '..'; +import { configure, resetToDefaults } from '../config'; +import { logger } from '../helpers/logger'; import { nativeState } from '../native-state'; const layoutEvent = { nativeEvent: { layout: { width: 100, height: 100 } } }; @@ -560,6 +562,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( @@ -623,6 +636,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', () => { @@ -831,6 +899,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/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/fire-event.ts b/src/fire-event.ts index 4b64b652c..b76e6f9b1 100644 --- a/src/fire-event.ts +++ b/src/fire-event.ts @@ -8,12 +8,15 @@ import type { import type { Fiber, TestInstance } from 'test-renderer'; import { act } from './act'; +import { getConfig } from './config'; import type { LayoutRectangle } from './event-builder'; import { buildLayoutEvent, buildScrollEvent, buildTouchEvent } from './event-builder'; import type { EventHandler } from './event-handler'; import { getEventHandlerFromProps } from './event-handler'; +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 { isPointerEventEnabled } from './helpers/pointer-events'; import { isEditableTextInput } from './helpers/text-input'; import { nativeState } from './native-state'; @@ -113,6 +116,51 @@ function findEventHandlerFromFiber(fiber: Fiber | null, eventName: string): Even return findEventHandlerFromFiber(fiber.return, eventName); } +/** + * 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 })`.', + ); +} + // String union type of keys of T that start with on, stripped of 'on' type EventNameExtractor = keyof { [K in keyof T as K extends `on${infer Rest}` ? Uncapitalize : never]: T[K]; @@ -135,6 +183,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()`.