Skip to content

Commit 328466d

Browse files
pierrezimmermannbampierrezimmermannmdjastrzebski
authored
Fix: switch IS_REACT_ACT_ENVIRONMENT in userEvent (#1491)
* 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 <[email protected]> Co-authored-by: Maciej Jastrzebski <[email protected]>
1 parent 3be9f3b commit 328466d

File tree

6 files changed

+138
-67
lines changed

6 files changed

+138
-67
lines changed
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import * as React from "react";
2-
import { render, screen, fireEvent } from "@testing-library/react";
3-
import userEvent from "@testing-library/user-event";
1+
import * as React from 'react';
2+
import { render, screen, fireEvent } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
44

5-
test("userEvent.click()", async () => {
5+
test('userEvent.click()', async () => {
66
const handleClick = jest.fn();
77

88
render(
@@ -11,12 +11,12 @@ test("userEvent.click()", async () => {
1111
</button>
1212
);
1313

14-
const button = screen.getByText("Click");
14+
const button = screen.getByText('Click');
1515
await userEvent.click(button);
1616
expect(handleClick).toHaveBeenCalledTimes(1);
1717
});
1818

19-
test("fireEvent.click()", () => {
19+
test('fireEvent.click()', () => {
2020
const handleClick = jest.fn();
2121

2222
render(
@@ -25,7 +25,7 @@ test("fireEvent.click()", () => {
2525
</button>
2626
);
2727

28-
const button = screen.getByText("Click");
28+
const button = screen.getByText('Click');
2929
fireEvent.click(button);
3030
expect(handleClick).toHaveBeenCalledTimes(1);
3131
});

src/helpers/wrap-async.ts

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/* istanbul ignore file */
2+
3+
import { act } from 'react-test-renderer';
4+
import { getIsReactActEnvironment, setReactActEnvironment } from '../act';
5+
import { flushMicroTasksLegacy } from '../flush-micro-tasks';
6+
import { checkReactVersionAtLeast } from '../react-versions';
7+
8+
/**
9+
* Run given async callback with temporarily disabled `act` environment and flushes microtasks queue.
10+
*
11+
* @param callback Async callback to run
12+
* @returns Result of the callback
13+
*/
14+
export async function wrapAsync<Result>(
15+
callback: () => Promise<Result>
16+
): Promise<Result> {
17+
if (checkReactVersionAtLeast(18, 0)) {
18+
const previousActEnvironment = getIsReactActEnvironment();
19+
setReactActEnvironment(false);
20+
21+
try {
22+
const result = await callback();
23+
// Flush the microtask queue before restoring the `act` environment
24+
await flushMicroTasksLegacy();
25+
return result;
26+
} finally {
27+
setReactActEnvironment(previousActEnvironment);
28+
}
29+
}
30+
31+
if (!checkReactVersionAtLeast(16, 9)) {
32+
return callback();
33+
}
34+
35+
// Wrapping with act for react version 16.9 to 17.x
36+
let result: Result;
37+
await act(async () => {
38+
result = await callback();
39+
});
40+
41+
// Either we have result or `callback` threw error
42+
return result!;
43+
}

src/user-event/press/__tests__/press.test.tsx

+28
Original file line numberDiff line numberDiff line change
@@ -454,4 +454,32 @@ describe('userEvent.press with fake timers', () => {
454454

455455
expect(mockOnPress).toHaveBeenCalled();
456456
});
457+
458+
test('disables act environmennt', async () => {
459+
// In this test there is state update during await when typing
460+
// Since wait is not wrapped by act there would be a warning
461+
// if act environment was not disabled.
462+
const consoleErrorSpy = jest.spyOn(console, 'error');
463+
jest.useFakeTimers();
464+
465+
const TestComponent = () => {
466+
const [showText, setShowText] = React.useState(false);
467+
468+
React.useEffect(() => {
469+
setTimeout(() => setShowText(true), 100);
470+
}, []);
471+
472+
return (
473+
<>
474+
<Pressable testID="pressable" />
475+
{showText && <Text />}
476+
</>
477+
);
478+
};
479+
480+
render(<TestComponent />);
481+
await userEvent.press(screen.getByTestId('pressable'));
482+
483+
expect(consoleErrorSpy).not.toHaveBeenCalled();
484+
});
457485
});

src/user-event/press/press.ts

+20-23
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { ReactTestInstance } from 'react-test-renderer';
2-
import act from '../../act';
32
import { getHostParent } from '../../helpers/component-tree';
43
import { isTextInputEditable } from '../../helpers/text-input';
54
import { isPointerEventEnabled } from '../../helpers/pointer-events';
@@ -83,28 +82,26 @@ const emitPressablePressEvents = async (
8382

8483
await wait(config);
8584

86-
await act(async () => {
87-
dispatchEvent(
88-
element,
89-
'responderGrant',
90-
EventBuilder.Common.responderGrant()
91-
);
92-
93-
await wait(config, options.duration);
94-
95-
dispatchEvent(
96-
element,
97-
'responderRelease',
98-
EventBuilder.Common.responderRelease()
99-
);
100-
101-
// React Native will wait for minimal delay of DEFAULT_MIN_PRESS_DURATION
102-
// before emitting the `pressOut` event. We need to wait here, so that
103-
// `press()` function does not return before that.
104-
if (DEFAULT_MIN_PRESS_DURATION - options.duration > 0) {
105-
await wait(config, DEFAULT_MIN_PRESS_DURATION - options.duration);
106-
}
107-
});
85+
dispatchEvent(
86+
element,
87+
'responderGrant',
88+
EventBuilder.Common.responderGrant()
89+
);
90+
91+
await wait(config, options.duration);
92+
93+
dispatchEvent(
94+
element,
95+
'responderRelease',
96+
EventBuilder.Common.responderRelease()
97+
);
98+
99+
// React Native will wait for minimal delay of DEFAULT_MIN_PRESS_DURATION
100+
// before emitting the `pressOut` event. We need to wait here, so that
101+
// `press()` function does not return before that.
102+
if (DEFAULT_MIN_PRESS_DURATION - options.duration > 0) {
103+
await wait(config, DEFAULT_MIN_PRESS_DURATION - options.duration);
104+
}
108105
};
109106

110107
const isEnabledTouchResponder = (element: ReactTestInstance) => {

src/user-event/setup/setup.ts

+37-8
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { ReactTestInstance } from 'react-test-renderer';
22
import { jestFakeTimersAreEnabled } from '../../helpers/timers';
3-
import { PressOptions, press, longPress } from '../press';
4-
import { TypeOptions, type } from '../type';
3+
import { wrapAsync } from '../../helpers/wrap-async';
54
import { clear } from '../clear';
5+
import { PressOptions, press, longPress } from '../press';
66
import { ScrollToOptions, scrollTo } from '../scroll';
7+
import { TypeOptions, type } from '../type';
8+
import { wait } from '../utils';
79

810
export interface UserEventSetupOptions {
911
/**
@@ -141,15 +143,42 @@ function createInstance(config: UserEventConfig): UserEventInstance {
141143
config,
142144
} as UserEventInstance;
143145

144-
// We need to bind these functions, as they access the config through 'this.config'.
146+
// Bind interactions to given User Event instance.
145147
const api = {
146-
press: press.bind(instance),
147-
longPress: longPress.bind(instance),
148-
type: type.bind(instance),
149-
clear: clear.bind(instance),
150-
scrollTo: scrollTo.bind(instance),
148+
press: wrapAndBindImpl(instance, press),
149+
longPress: wrapAndBindImpl(instance, longPress),
150+
type: wrapAndBindImpl(instance, type),
151+
clear: wrapAndBindImpl(instance, clear),
152+
scrollTo: wrapAndBindImpl(instance, scrollTo),
151153
};
152154

153155
Object.assign(instance, api);
154156
return instance;
155157
}
158+
159+
/**
160+
* Wraps user interaction with `wrapAsync` (temporarily disable `act` environment while
161+
* calling & resolving the async callback, then flush the microtask queue)
162+
*
163+
* This implementation is sourced from `testing-library/user-event`
164+
* @see https://github.com/testing-library/user-event/blob/7a305dee9ab833d6f338d567fc2e862b4838b76a/src/setup/setup.ts#L121
165+
*/
166+
function wrapAndBindImpl<
167+
Args extends any[],
168+
Impl extends (this: UserEventInstance, ...args: Args) => Promise<unknown>
169+
>(instance: UserEventInstance, impl: Impl) {
170+
function method(...args: Args) {
171+
return wrapAsync(() =>
172+
// eslint-disable-next-line promise/prefer-await-to-then
173+
impl.apply(instance, args).then(async (result) => {
174+
await wait(instance.config);
175+
return result;
176+
})
177+
);
178+
}
179+
180+
// Copy implementation name to the returned function
181+
Object.defineProperty(method, 'name', { get: () => impl.name });
182+
183+
return method as Impl;
184+
}

src/waitFor.ts

+3-29
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
/* globals jest */
2-
import act, { setReactActEnvironment, getIsReactActEnvironment } from './act';
32
import { getConfig } from './config';
4-
import { flushMicroTasks, flushMicroTasksLegacy } from './flush-micro-tasks';
3+
import { flushMicroTasks } from './flush-micro-tasks';
54
import { ErrorWithStack, copyStackTrace } from './helpers/errors';
65
import {
76
setTimeout,
87
clearTimeout,
98
jestFakeTimersAreEnabled,
109
} from './helpers/timers';
11-
import { checkReactVersionAtLeast } from './react-versions';
10+
import { wrapAsync } from './helpers/wrap-async';
1211

1312
const DEFAULT_INTERVAL = 50;
1413

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

202-
if (checkReactVersionAtLeast(18, 0)) {
203-
const previousActEnvironment = getIsReactActEnvironment();
204-
setReactActEnvironment(false);
205-
206-
try {
207-
const result = await waitForInternal(expectation, optionsWithStackTrace);
208-
// Flush the microtask queue before restoring the `act` environment
209-
await flushMicroTasksLegacy();
210-
return result;
211-
} finally {
212-
setReactActEnvironment(previousActEnvironment);
213-
}
214-
}
215-
216-
if (!checkReactVersionAtLeast(16, 9)) {
217-
return waitForInternal(expectation, optionsWithStackTrace);
218-
}
219-
220-
let result: T;
221-
222-
await act(async () => {
223-
result = await waitForInternal(expectation, optionsWithStackTrace);
224-
});
225-
226-
// Either we have result or `waitFor` threw error
227-
return result!;
201+
return wrapAsync(() => waitForInternal(expectation, optionsWithStackTrace));
228202
}

0 commit comments

Comments
 (0)