Skip to content

Commit

Permalink
Fix: switch IS_REACT_ACT_ENVIRONMENT in userEvent (#1491)
Browse files Browse the repository at this point in the history
* fix: use act in wait util

* refactor: remove useless usage of act in press implem

* refactor: make test fail by checking console.error is not called

* refactor: extract async wrapper from waitFor implem

* feat: use asyncWrapper for userEvent to prevent act warnings

* refactor: add comment making test on act environment more explicit

* refactor: move asyncWrapper to helper folder cause its not direclty tied to userevent

* refactor: add comment in asyncWrapper

* refactor: move UE `act` test to UE `press` tests

* refactor: tweaks

* refactor: naming tweaks

* chore: revert unnecessary filename change

* chore: exclude code cov for React <= 17

* chore: disable codecov for wrap-async

---------

Co-authored-by: pierrezimmermann <pierrez@nam.tech>
Co-authored-by: Maciej Jastrzebski <mdjastrzebski@gmail.com>
  • Loading branch information
3 people committed Oct 23, 2023
1 parent 3be9f3b commit 328466d
Show file tree
Hide file tree
Showing 6 changed files with 138 additions and 67 deletions.
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!;
}
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));
}

0 comments on commit 328466d

Please sign in to comment.