Skip to content
Open
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
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,
disabledEventWarning: true,
});
});

Expand Down
78 changes: 78 additions & 0 deletions src/__tests__/fire-event.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 } } };
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
<Pressable onPress={jest.fn()} disabled={true}>
<Text>Trigger</Text>
</Pressable>,
);

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(
<Pressable onPress={jest.fn()}>
<Pressable onPress={jest.fn()} disabled={true}>
<Text>Inner Trigger</Text>
</Pressable>
</Pressable>,
);

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(
<View pointerEvents="none">
<Pressable testID="btn" onPress={jest.fn()} />
</View>,
);

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(
<Pressable onPress={jest.fn()} disabled={true}>
<Text>Trigger</Text>
</Pressable>,
);

await fireEvent.press(screen.getByText('Trigger'));

expect(warnSpy).not.toHaveBeenCalled();
});
});

describe('pointerEvents prop', () => {
Expand Down Expand Up @@ -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,
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 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<DebugOptions>;
};
Expand All @@ -24,6 +30,7 @@ export type ConfigAliasOptions = {
const defaultConfig: Config = {
asyncUtilTimeout: 1000,
defaultIncludeHiddenElements: false,
disabledEventWarning: true,
};

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

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

Expand Down
49 changes: 49 additions & 0 deletions src/fire-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}
Comment on lines +148 to +152

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<T> = keyof {
[K in keyof T as K extends `on${infer Rest}` ? Uncapitalize<Rest> : never]: T[K];
Expand All @@ -135,6 +183,7 @@ async function fireEvent(instance: TestInstance, eventName: EventName, ...data:

const handler = findEventHandler(instance, eventName);
if (!handler) {
warnAboutDisabledEventTarget(instance, eventName);
return;
}

Expand Down
11 changes: 11 additions & 0 deletions website/docs/14.x/docs/api/misc/config.mdx
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 firing an event on a disabled element triggers no handler. */
disabledEventWarning: boolean;

/** Default options for `debug` helper. */
defaultDebugOptions?: Partial<DebugOptions>;
};
Expand All @@ -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()`.
Expand Down