Skip to content

Commit 0949859

Browse files
committed
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).
1 parent fc59e55 commit 0949859

5 files changed

Lines changed: 140 additions & 0 deletions

File tree

src/__tests__/config.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ test('configure() overrides existing config values', () => {
2222
asyncUtilTimeout: 5000,
2323
defaultDebugOptions: { message: 'debug message' },
2424
defaultIncludeHiddenElements: false,
25+
disabledEventWarning: true,
2526
});
2627
});
2728

src/__tests__/fire-event.test.tsx

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import {
1414
} from 'react-native';
1515

1616
import { fireEvent, render, screen } from '..';
17+
import { configure, resetToDefaults } from '../config';
18+
import { logger } from '../helpers/logger';
1719
import { nativeState } from '../native-state';
1820

1921
const layoutEvent = { nativeEvent: { layout: { width: 100, height: 100 } } };
@@ -560,6 +562,17 @@ test('fireEvent handles handler that throws gracefully', async () => {
560562
});
561563

562564
describe('disabled elements', () => {
565+
let warnSpy: jest.SpyInstance;
566+
567+
beforeEach(() => {
568+
warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => {});
569+
});
570+
571+
afterEach(() => {
572+
warnSpy.mockRestore();
573+
resetToDefaults();
574+
});
575+
563576
test('does not fire on disabled Pressable', async () => {
564577
const onPress = jest.fn();
565578
await render(
@@ -623,6 +636,61 @@ describe('disabled elements', () => {
623636
await fireEvent.press(screen.getByText('Trigger Test'));
624637
expect(handlePress).toHaveBeenCalledTimes(1);
625638
});
639+
640+
test('warns when firing an event on a disabled element', async () => {
641+
await render(
642+
<Pressable onPress={jest.fn()} disabled={true}>
643+
<Text>Trigger</Text>
644+
</Pressable>,
645+
);
646+
647+
await fireEvent.press(screen.getByText('Trigger'));
648+
649+
expect(warnSpy).toHaveBeenCalledTimes(1);
650+
expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(`
651+
"Tried to fire the "press" event on a disabled element, so no handler was called.
652+
If this is intentional, you can disable this warning via \`configure({ disabledEventWarning: false })\`."
653+
`);
654+
});
655+
656+
test('does not warn when the event bubbles to an enabled parent', async () => {
657+
await render(
658+
<Pressable onPress={jest.fn()}>
659+
<Pressable onPress={jest.fn()} disabled={true}>
660+
<Text>Inner Trigger</Text>
661+
</Pressable>
662+
</Pressable>,
663+
);
664+
665+
await fireEvent.press(screen.getByText('Inner Trigger'));
666+
667+
expect(warnSpy).not.toHaveBeenCalled();
668+
});
669+
670+
test('does not warn when the element is not disabled (e.g. pointerEvents="none")', async () => {
671+
await render(
672+
<View pointerEvents="none">
673+
<Pressable testID="btn" onPress={jest.fn()} />
674+
</View>,
675+
);
676+
677+
await fireEvent.press(screen.getByTestId('btn'));
678+
679+
expect(warnSpy).not.toHaveBeenCalled();
680+
});
681+
682+
test('does not warn when disabledEventWarning is turned off', async () => {
683+
configure({ disabledEventWarning: false });
684+
await render(
685+
<Pressable onPress={jest.fn()} disabled={true}>
686+
<Text>Trigger</Text>
687+
</Pressable>,
688+
);
689+
690+
await fireEvent.press(screen.getByText('Trigger'));
691+
692+
expect(warnSpy).not.toHaveBeenCalled();
693+
});
626694
});
627695

628696
describe('pointerEvents prop', () => {
@@ -832,6 +900,7 @@ describe('non-editable TextInput', () => {
832900

833901
describe('responder system', () => {
834902
test('respects disabled prop through composite wrappers', async () => {
903+
const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => {});
835904
function TestChildTouchableComponent({
836905
onPress,
837906
someProp,
@@ -855,6 +924,7 @@ describe('responder system', () => {
855924
);
856925
await fireEvent.press(screen.getByText('Trigger'));
857926
expect(handlePress).not.toHaveBeenCalled();
927+
warnSpy.mockRestore();
858928
});
859929

860930
test('fires responderMove on PanResponder component', async () => {

src/config.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ export type Config = {
1212
/** Default value for `includeHiddenElements` query option. */
1313
defaultIncludeHiddenElements: boolean;
1414

15+
/**
16+
* Warn when an event is fired on a disabled element and no handler is
17+
* triggered as a result. Set to `false` to opt out.
18+
*/
19+
disabledEventWarning: boolean;
20+
1521
/** Default options for `debug` helper. */
1622
defaultDebugOptions?: Partial<DebugOptions>;
1723
};
@@ -24,6 +30,7 @@ export type ConfigAliasOptions = {
2430
const defaultConfig: Config = {
2531
asyncUtilTimeout: 1000,
2632
defaultIncludeHiddenElements: false,
33+
disabledEventWarning: true,
2734
};
2835

2936
let config = { ...defaultConfig };
@@ -37,6 +44,7 @@ export function configure(options: Partial<Config & ConfigAliasOptions>) {
3744
defaultDebugOptions,
3845
defaultHidden,
3946
defaultIncludeHiddenElements,
47+
disabledEventWarning,
4048
...rest
4149
} = options;
4250

@@ -50,6 +58,7 @@ export function configure(options: Partial<Config & ConfigAliasOptions>) {
5058
asyncUtilTimeout: asyncUtilTimeout ?? config.asyncUtilTimeout,
5159
defaultDebugOptions,
5260
defaultIncludeHiddenElements: resolvedDefaultIncludeHiddenElements,
61+
disabledEventWarning: disabledEventWarning ?? config.disabledEventWarning,
5362
};
5463
}
5564

src/fire-event.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,15 @@ import type {
88
import type { Fiber, TestInstance } from 'test-renderer';
99

1010
import { act } from './act';
11+
import { getConfig } from './config';
1112
import type { LayoutRectangle } from './event-builder';
1213
import { buildLayoutEvent, buildScrollEvent, buildTouchEvent } from './event-builder';
1314
import type { EventHandler } from './event-handler';
1415
import { getEventHandlerFromProps } from './event-handler';
16+
import { computeAriaDisabled } from './helpers/accessibility';
1517
import { isInstanceMounted } from './helpers/component-tree';
1618
import { isHostScrollView, isHostTextInput } from './helpers/host-component-names';
19+
import { logger } from './helpers/logger';
1720
import { isPointerEventEnabled } from './helpers/pointer-events';
1821
import { isEditableTextInput } from './helpers/text-input';
1922
import { nativeState } from './native-state';
@@ -113,6 +116,51 @@ function findEventHandlerFromFiber(fiber: Fiber | null, eventName: string): Even
113116
return findEventHandlerFromFiber(fiber.return, eventName);
114117
}
115118

119+
/**
120+
* Walks up from the target to the nearest element that can respond to touches
121+
* (a touch responder or a host `TextInput`), mirroring `findEventHandler`.
122+
*/
123+
function getNearestTouchResponder(instance: TestInstance): TestInstance | null {
124+
let current: TestInstance | null = instance;
125+
while (current != null) {
126+
if (isTouchResponder(current)) {
127+
return current;
128+
}
129+
130+
current = current.parent;
131+
}
132+
133+
return null;
134+
}
135+
136+
/**
137+
* Warns when an event did not trigger any handler because the responding
138+
* element is disabled. Helps debug tests that silently do nothing.
139+
* Can be opted out via `configure({ disabledEventWarning: false })`.
140+
*/
141+
function warnAboutDisabledEventTarget(instance: TestInstance, eventName: string) {
142+
if (!getConfig().disabledEventWarning) {
143+
return;
144+
}
145+
146+
const target = getNearestTouchResponder(instance) ?? instance;
147+
148+
// `TextInput` editability (`editable={false}`) is a separate concern from
149+
// disabled state, so we don't warn about it here to avoid false positives.
150+
if (isHostTextInput(target)) {
151+
return;
152+
}
153+
154+
if (!computeAriaDisabled(target)) {
155+
return;
156+
}
157+
158+
logger.warn(
159+
`Tried to fire the "${eventName}" event on a disabled element, so no handler was called.\n` +
160+
'If this is intentional, you can disable this warning via `configure({ disabledEventWarning: false })`.',
161+
);
162+
}
163+
116164
// String union type of keys of T that start with on, stripped of 'on'
117165
type EventNameExtractor<T> = keyof {
118166
[K in keyof T as K extends `on${infer Rest}` ? Uncapitalize<Rest> : never]: T[K];
@@ -135,6 +183,7 @@ async function fireEvent(instance: TestInstance, eventName: EventName, ...data:
135183

136184
const handler = findEventHandler(instance, eventName);
137185
if (!handler) {
186+
warnAboutDisabledEventTarget(instance, eventName);
138187
return;
139188
}
140189

website/docs/14.x/docs/api/misc/config.mdx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ type Config = {
1010
/** Default value for `includeHiddenElements` query option. */
1111
defaultIncludeHiddenElements: boolean;
1212

13+
/** Warn when firing an event on a disabled element triggers no handler. */
14+
disabledEventWarning: boolean;
15+
1316
/** Default options for `debug` helper. */
1417
defaultDebugOptions?: Partial<DebugOptions>;
1518
};
@@ -32,6 +35,14 @@ Default value for [includeHiddenElements](/docs/api/queries#includehiddenelement
3235

3336
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).
3437

38+
### `disabledEventWarning` option
39+
40+
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:
41+
42+
```ts
43+
configure({ disabledEventWarning: false });
44+
```
45+
3546
### `defaultDebugOptions` option
3647

3748
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()`.

0 commit comments

Comments
 (0)