Skip to content

Commit b80a762

Browse files
feat: warn when firing events on disabled elements (#1934)
Co-authored-by: Maciej Jastrzębski <mdjastrzebski@gmail.com>
1 parent d9366a4 commit b80a762

26 files changed

Lines changed: 1258 additions & 135 deletions

‎contributing/code-style.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,10 @@ Tooling enforces the style. Run `yarn lint` and `yarn format:fix` before you pus
44

55
- **Formatting:** oxfmt with single quotes, trailing commas, and sorted imports.
66
- **Linting:** ESLint with `@callstack/eslint-config` and `typescript-eslint`. Notable rules: no `console`, and use `import type` for type-only imports.
7+
8+
## File Layout
9+
10+
Order each file top-down, so it reads from the public API to the details:
11+
12+
1. Exported functions (and their types) first.
13+
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.

‎contributing/event-dispatch.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Both are built on the shared event subsystem in `src/events/`, which also holds
1111
| `propagation.ts` | Bubbling vs direct events, walking up host and composite elements |
1212
| `is-enabled.ts` | Whether a device would deliver the event: `pointerEvents`, `editable`, touch responders |
1313
| `dispatch.ts` | `dispatchEvent()`: calls the target's own handler in `act()`, used by `userEvent` |
14+
| `warnings.ts` | `eventDiagnostics` warnings for `fireEvent`, and helpers shared with `userEvent` |
1415
| `builders/` | Event payloads, matching what React Native sends on a device |
1516
| `native-state.ts`, `update-native-state.ts` | [Native state](native-state.md) and how `fireEvent` updates it |
1617

@@ -30,6 +31,13 @@ Both are built on the shared event subsystem in `src/events/`, which also holds
3031

3132
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.
3233

34+
For the `eventDiagnostics` warning, each action tracks itself with an `Interaction` from `src/user-event/utils/interaction.ts`:
35+
36+
- 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).
37+
- Set `hasUpdatedNativeState` when the action writes to `nativeState`.
38+
- 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`.
39+
- 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.
40+
3341
## Guidelines
3442

3543
- To change which handler gets a single event, change `fireEvent`. To make an interaction more realistic, change the `userEvent` action.

‎docs/api/configuration.md‎

Lines changed: 22 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 `fireEvent` or a `userEvent` interaction calls no handler. Off by default. */
14+
eventDiagnostics: boolean;
15+
1316
/** Default options for `debug` helper. */
1417
defaultDebugOptions?: Partial<DebugOptions>;
1518
};
@@ -32,6 +35,25 @@ Default value for [includeHiddenElements](./queries.md#includehiddenelements-opt
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+
### `eventDiagnostics` option
39+
40+
Logs a warning when `fireEvent` or `userEvent` doesn't call any handler, so a test doesn't silently do nothing. Defaults to `false`.
41+
42+
A warning is logged in these cases:
43+
44+
- The handler is on a disabled element, e.g. a `Pressable` with `disabled={true}`.
45+
- 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`.
46+
- 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.
47+
- 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.
48+
49+
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.
50+
51+
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:
52+
53+
```ts
54+
configure({ eventDiagnostics: true });
55+
```
56+
3557
### `defaultDebugOptions` option
3658

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

‎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+
eventDiagnostics: false,
2526
});
2627
});
2728

‎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 `fireEvent` or a `userEvent` interaction calls no handler, because the target
17+
* is disabled, blocked by `pointerEvents`, or no element handles the event. Off by default.
18+
*/
19+
eventDiagnostics: 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+
eventDiagnostics: false,
2734
};
2835

2936
let config = { ...defaultConfig };
@@ -37,6 +44,7 @@ export function configure(options: Partial<Config & ConfigAliasOptions>) {
3744
defaultDebugOptions,
3845
defaultHidden,
3946
defaultIncludeHiddenElements,
47+
eventDiagnostics,
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+
eventDiagnostics: eventDiagnostics ?? config.eventDiagnostics,
5362
};
5463
}
5564

0 commit comments

Comments
 (0)