Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix: switch IS_REACT_ACT_ENVIRONMENT in userEvent #1491

Merged
merged 14 commits into from
Oct 23, 2023
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
14 changes: 7 additions & 7 deletions experiments-rtl/src/app/__tests__/click.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import * as React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import * as React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

test("userEvent.click()", async () => {
test('userEvent.click()', async () => {
const handleClick = jest.fn();

render(
Expand All @@ -11,12 +11,12 @@ test("userEvent.click()", async () => {
</button>
);

const button = screen.getByText("Click");
const button = screen.getByText('Click');
await userEvent.click(button);
expect(handleClick).toHaveBeenCalledTimes(1);
});

test("fireEvent.click()", () => {
test('fireEvent.click()', () => {
const handleClick = jest.fn();

render(
Expand All @@ -25,7 +25,7 @@ test("fireEvent.click()", () => {
</button>
);

const button = screen.getByText("Click");
const button = screen.getByText('Click');
fireEvent.click(button);
expect(handleClick).toHaveBeenCalledTimes(1);
});
43 changes: 43 additions & 0 deletions src/helpers/wrap-async.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/* istanbul ignore file */

import { act } from 'react-test-renderer';
import { getIsReactActEnvironment, setReactActEnvironment } from '../act';
import { flushMicroTasksLegacy } from '../flush-micro-tasks';
import { checkReactVersionAtLeast } from '../react-versions';

/**
* Run given async callback with temporarily disabled `act` environment and flushes microtasks queue.
*
* @param callback Async callback to run
* @returns Result of the callback
*/
export async function wrapAsync<Result>(
callback: () => Promise<Result>
): Promise<Result> {
if (checkReactVersionAtLeast(18, 0)) {
const previousActEnvironment = getIsReactActEnvironment();
setReactActEnvironment(false);

try {
const result = await callback();
// Flush the microtask queue before restoring the `act` environment
await flushMicroTasksLegacy();
return result;
} finally {
setReactActEnvironment(previousActEnvironment);
}
}

if (!checkReactVersionAtLeast(16, 9)) {
return callback();
}

// Wrapping with act for react version 16.9 to 17.x
let result: Result;
await act(async () => {
result = await callback();
});

// Either we have result or `callback` threw error
return result!;
}

Check warning on line 43 in src/helpers/wrap-async.ts

View check run for this annotation

Codecov / codecov/patch

src/helpers/wrap-async.ts#L30-L43

Added lines #L30 - L43 were not covered by tests
28 changes: 28 additions & 0 deletions src/user-event/press/__tests__/press.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -454,4 +454,32 @@ describe('userEvent.press with fake timers', () => {

expect(mockOnPress).toHaveBeenCalled();
});

test('disables act environmennt', async () => {
// In this test there is state update during await when typing
// Since wait is not wrapped by act there would be a warning
// if act environment was not disabled.
const consoleErrorSpy = jest.spyOn(console, 'error');
jest.useFakeTimers();

const TestComponent = () => {
const [showText, setShowText] = React.useState(false);

React.useEffect(() => {
setTimeout(() => setShowText(true), 100);
}, []);

return (
<>
<Pressable testID="pressable" />
{showText && <Text />}
</>
);
};

render(<TestComponent />);
await userEvent.press(screen.getByTestId('pressable'));

expect(consoleErrorSpy).not.toHaveBeenCalled();
});
});
43 changes: 20 additions & 23 deletions src/user-event/press/press.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { ReactTestInstance } from 'react-test-renderer';
import act from '../../act';
import { getHostParent } from '../../helpers/component-tree';
import { isTextInputEditable } from '../../helpers/text-input';
import { isPointerEventEnabled } from '../../helpers/pointer-events';
Expand Down Expand Up @@ -83,28 +82,26 @@ const emitPressablePressEvents = async (

await wait(config);

await act(async () => {
dispatchEvent(
element,
'responderGrant',
EventBuilder.Common.responderGrant()
);

await wait(config, options.duration);

dispatchEvent(
element,
'responderRelease',
EventBuilder.Common.responderRelease()
);

// React Native will wait for minimal delay of DEFAULT_MIN_PRESS_DURATION
// before emitting the `pressOut` event. We need to wait here, so that
// `press()` function does not return before that.
if (DEFAULT_MIN_PRESS_DURATION - options.duration > 0) {
await wait(config, DEFAULT_MIN_PRESS_DURATION - options.duration);
}
});
dispatchEvent(
element,
'responderGrant',
EventBuilder.Common.responderGrant()
);

await wait(config, options.duration);

dispatchEvent(
element,
'responderRelease',
EventBuilder.Common.responderRelease()
);

// React Native will wait for minimal delay of DEFAULT_MIN_PRESS_DURATION
// before emitting the `pressOut` event. We need to wait here, so that
// `press()` function does not return before that.
if (DEFAULT_MIN_PRESS_DURATION - options.duration > 0) {
await wait(config, DEFAULT_MIN_PRESS_DURATION - options.duration);
}
};

const isEnabledTouchResponder = (element: ReactTestInstance) => {
Expand Down
45 changes: 37 additions & 8 deletions src/user-event/setup/setup.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { ReactTestInstance } from 'react-test-renderer';
import { jestFakeTimersAreEnabled } from '../../helpers/timers';
import { PressOptions, press, longPress } from '../press';
import { TypeOptions, type } from '../type';
import { wrapAsync } from '../../helpers/wrap-async';
import { clear } from '../clear';
import { PressOptions, press, longPress } from '../press';
import { ScrollToOptions, scrollTo } from '../scroll';
import { TypeOptions, type } from '../type';
import { wait } from '../utils';

export interface UserEventSetupOptions {
/**
Expand Down Expand Up @@ -141,15 +143,42 @@ function createInstance(config: UserEventConfig): UserEventInstance {
config,
} as UserEventInstance;

// We need to bind these functions, as they access the config through 'this.config'.
// Bind interactions to given User Event instance.
const api = {
press: press.bind(instance),
longPress: longPress.bind(instance),
type: type.bind(instance),
clear: clear.bind(instance),
scrollTo: scrollTo.bind(instance),
press: wrapAndBindImpl(instance, press),
longPress: wrapAndBindImpl(instance, longPress),
type: wrapAndBindImpl(instance, type),
clear: wrapAndBindImpl(instance, clear),
scrollTo: wrapAndBindImpl(instance, scrollTo),
};

Object.assign(instance, api);
return instance;
}

/**
* Wraps user interaction with `wrapAsync` (temporarily disable `act` environment while
* calling & resolving the async callback, then flush the microtask queue)
*
* This implementation is sourced from `testing-library/user-event`
* @see https://github.com/testing-library/user-event/blob/7a305dee9ab833d6f338d567fc2e862b4838b76a/src/setup/setup.ts#L121
*/
function wrapAndBindImpl<
Args extends any[],
Impl extends (this: UserEventInstance, ...args: Args) => Promise<unknown>
>(instance: UserEventInstance, impl: Impl) {
function method(...args: Args) {
return wrapAsync(() =>
// eslint-disable-next-line promise/prefer-await-to-then
impl.apply(instance, args).then(async (result) => {
await wait(instance.config);
return result;
})
);
}

// Copy implementation name to the returned function
Object.defineProperty(method, 'name', { get: () => impl.name });

return method as Impl;
}
32 changes: 3 additions & 29 deletions src/waitFor.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
/* globals jest */
import act, { setReactActEnvironment, getIsReactActEnvironment } from './act';
import { getConfig } from './config';
import { flushMicroTasks, flushMicroTasksLegacy } from './flush-micro-tasks';
import { flushMicroTasks } from './flush-micro-tasks';
import { ErrorWithStack, copyStackTrace } from './helpers/errors';
import {
setTimeout,
clearTimeout,
jestFakeTimersAreEnabled,
} from './helpers/timers';
import { checkReactVersionAtLeast } from './react-versions';
import { wrapAsync } from './helpers/wrap-async';

const DEFAULT_INTERVAL = 50;

Expand Down Expand Up @@ -199,30 +198,5 @@ export default async function waitFor<T>(
const stackTraceError = new ErrorWithStack('STACK_TRACE_ERROR', waitFor);
const optionsWithStackTrace = { stackTraceError, ...options };

if (checkReactVersionAtLeast(18, 0)) {
const previousActEnvironment = getIsReactActEnvironment();
setReactActEnvironment(false);

try {
const result = await waitForInternal(expectation, optionsWithStackTrace);
// Flush the microtask queue before restoring the `act` environment
await flushMicroTasksLegacy();
return result;
} finally {
setReactActEnvironment(previousActEnvironment);
}
}

if (!checkReactVersionAtLeast(16, 9)) {
return waitForInternal(expectation, optionsWithStackTrace);
}

let result: T;

await act(async () => {
result = await waitForInternal(expectation, optionsWithStackTrace);
});

// Either we have result or `waitFor` threw error
return result!;
return wrapAsync(() => waitForInternal(expectation, optionsWithStackTrace));
}