Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions contributing/code-style.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
8 changes: 8 additions & 0 deletions contributing/event-dispatch.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand All @@ -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). 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`; 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

- To change which handler gets a single event, change `fireEvent`. To make an interaction more realistic, change the `userEvent` action.
Expand Down
22 changes: 22 additions & 0 deletions docs/api/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<DebugOptions>;
};
Expand All @@ -32,6 +35,25 @@ 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

Logs a warning when `fireEvent` or `userEvent` doesn't call any handler, so a test doesn't silently do nothing. Defaults to `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`. 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()`.
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ test('configure() overrides existing config values', () => {
asyncUtilTimeout: 5000,
defaultDebugOptions: { message: 'debug message' },
defaultIncludeHiddenElements: false,
eventDiagnostics: false,
});
});

Expand Down
9 changes: 9 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ export type Config = {
/** Default value for `includeHiddenElements` query option. */
defaultIncludeHiddenElements: boolean;

/**
* 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;

/** Default options for `debug` helper. */
defaultDebugOptions?: Partial<DebugOptions>;
};
Expand All @@ -24,6 +30,7 @@ export type ConfigAliasOptions = {
const defaultConfig: Config = {
asyncUtilTimeout: 1000,
defaultIncludeHiddenElements: false,
eventDiagnostics: false,
};

let config = { ...defaultConfig };
Expand All @@ -37,6 +44,7 @@ export function configure(options: Partial<Config & ConfigAliasOptions>) {
defaultDebugOptions,
defaultHidden,
defaultIncludeHiddenElements,
eventDiagnostics,
...rest
} = options;

Expand All @@ -50,6 +58,7 @@ export function configure(options: Partial<Config & ConfigAliasOptions>) {
asyncUtilTimeout: asyncUtilTimeout ?? config.asyncUtilTimeout,
defaultDebugOptions,
defaultIncludeHiddenElements: resolvedDefaultIncludeHiddenElements,
eventDiagnostics: eventDiagnostics ?? config.eventDiagnostics,
};
}

Expand Down
Loading
Loading