main
js 362 lines 9.98 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 *
7 * @jest-environment node
8 */
9
10 // sanity tests for act()
11
12 let React;
13 let ReactNoop;
14 let act;
15 let use;
16 let Suspense;
17 let DiscreteEventPriority;
18 let startTransition;
19 let waitForMicrotasks;
20 let Scheduler;
21 let assertLog;
22
23 describe('isomorphic act()', () => {
24 beforeEach(() => {
25 React = require('react');
26 Scheduler = require('scheduler');
27
28 ReactNoop = require('react-noop-renderer');
29 DiscreteEventPriority =
30 require('react-reconciler/constants').DiscreteEventPriority;
31 act = React.act;
32 use = React.use;
33 Suspense = React.Suspense;
34 startTransition = React.startTransition;
35
36 waitForMicrotasks = require('internal-test-utils').waitForMicrotasks;
37 assertLog = require('internal-test-utils').assertLog;
38 });
39
40 beforeEach(() => {
41 global.IS_REACT_ACT_ENVIRONMENT = true;
42 });
43
44 afterEach(() => {
45 jest.restoreAllMocks();
46 });
47
48 function Text({text}) {
49 Scheduler.log(text);
50 return text;
51 }
52
53 it('behavior in production', () => {
54 if (!__DEV__) {
55 if (gate('fb')) {
56 expect(() => act(() => {})).toThrow(
57 'act(...) is not supported in production builds of React',
58 );
59 } else {
60 expect(React).not.toHaveProperty('act');
61 }
62 }
63 });
64
65 // @gate __DEV__
66 it('bypasses queueMicrotask', async () => {
67 const root = ReactNoop.createRoot();
68
69 // First test what happens without wrapping in act. This update would
70 // normally be queued in a microtask.
71 global.IS_REACT_ACT_ENVIRONMENT = false;
72 ReactNoop.unstable_runWithPriority(DiscreteEventPriority, () => {
73 root.render('A');
74 });
75 // Nothing has rendered yet
76 expect(root).toMatchRenderedOutput(null);
77 // Flush the microtasks by awaiting
78 await waitForMicrotasks();
79 expect(root).toMatchRenderedOutput('A');
80
81 // Now do the same thing but wrap the update with `act`. No
82 // `await` necessary.
83 global.IS_REACT_ACT_ENVIRONMENT = true;
84 act(() => {
85 ReactNoop.unstable_runWithPriority(DiscreteEventPriority, () => {
86 root.render('B');
87 });
88 });
89 expect(root).toMatchRenderedOutput('B');
90 });
91
92 // @gate __DEV__
93 it('return value – sync callback', async () => {
94 expect(await act(() => 'hi')).toEqual('hi');
95 });
96
97 // @gate __DEV__
98 it('return value – sync callback, nested', async () => {
99 const returnValue = await act(() => {
100 return act(() => 'hi');
101 });
102 expect(returnValue).toEqual('hi');
103 });
104
105 // @gate __DEV__
106 it('return value – async callback', async () => {
107 const returnValue = await act(async () => {
108 return await Promise.resolve('hi');
109 });
110 expect(returnValue).toEqual('hi');
111 });
112
113 // @gate __DEV__
114 it('return value – async callback, nested', async () => {
115 const returnValue = await act(async () => {
116 return await act(async () => {
117 return await Promise.resolve('hi');
118 });
119 });
120 expect(returnValue).toEqual('hi');
121 });
122
123 // @gate __DEV__ && !disableLegacyMode
124 it('in legacy mode, updates are batched', () => {
125 const root = ReactNoop.createLegacyRoot();
126
127 // Outside of `act`, legacy updates are flushed completely synchronously
128 root.render('A');
129 expect(root).toMatchRenderedOutput('A');
130
131 // `act` will batch the updates and flush them at the end
132 act(() => {
133 root.render('B');
134 // Hasn't flushed yet
135 expect(root).toMatchRenderedOutput('A');
136
137 // Confirm that a nested `batchedUpdates` call won't cause the updates
138 // to flush early.
139 ReactNoop.batchedUpdates(() => {
140 root.render('C');
141 });
142
143 // Still hasn't flushed
144 expect(root).toMatchRenderedOutput('A');
145 });
146
147 // Now everything renders in a single batch.
148 expect(root).toMatchRenderedOutput('C');
149 });
150
151 // @gate __DEV__ && !disableLegacyMode
152 it('in legacy mode, in an async scope, updates are batched until the first `await`', async () => {
153 const root = ReactNoop.createLegacyRoot();
154
155 await act(async () => {
156 queueMicrotask(() => {
157 Scheduler.log('Current tree in microtask: ' + root.getChildrenAsJSX());
158 root.render(<Text text="C" />);
159 });
160 root.render(<Text text="A" />);
161 root.render(<Text text="B" />);
162
163 await null;
164 assertLog([
165 // A and B should render in a single batch _before_ the microtask queue
166 // has run. This replicates the behavior of the original `act`
167 // implementation, for compatibility.
168 'B',
169 'Current tree in microtask: B',
170
171 // C isn't scheduled until a microtask, so it's rendered separately.
172 'C',
173 ]);
174
175 // Subsequent updates should also render in separate batches.
176 root.render(<Text text="D" />);
177 root.render(<Text text="E" />);
178 assertLog(['D', 'E']);
179 });
180 });
181
182 // @gate __DEV__ && !disableLegacyMode
183 it('in legacy mode, in an async scope, updates are batched until the first `await` (regression test: batchedUpdates)', async () => {
184 const root = ReactNoop.createLegacyRoot();
185
186 await act(async () => {
187 queueMicrotask(() => {
188 Scheduler.log('Current tree in microtask: ' + root.getChildrenAsJSX());
189 root.render(<Text text="C" />);
190 });
191
192 // This is a regression test. The presence of `batchedUpdates` would cause
193 // these updates to not flush until a microtask. The correct behavior is
194 // that they flush before the microtask queue, regardless of whether
195 // they are wrapped with `batchedUpdates`.
196 ReactNoop.batchedUpdates(() => {
197 root.render(<Text text="A" />);
198 root.render(<Text text="B" />);
199 });
200
201 await null;
202 assertLog([
203 // A and B should render in a single batch _before_ the microtask queue
204 // has run. This replicates the behavior of the original `act`
205 // implementation, for compatibility.
206 'B',
207 'Current tree in microtask: B',
208
209 // C isn't scheduled until a microtask, so it's rendered separately.
210 'C',
211 ]);
212
213 // Subsequent updates should also render in separate batches.
214 root.render(<Text text="D" />);
215 root.render(<Text text="E" />);
216 assertLog(['D', 'E']);
217 });
218 });
219
220 // @gate __DEV__
221 it('unwraps promises by yielding to microtasks (async act scope)', async () => {
222 const promise = Promise.resolve('Async');
223
224 function Fallback() {
225 throw new Error('Fallback should never be rendered');
226 }
227
228 function App() {
229 return use(promise);
230 }
231
232 const root = ReactNoop.createRoot();
233 await act(async () => {
234 startTransition(() => {
235 root.render(
236 <Suspense fallback={<Fallback />}>
237 <App />
238 </Suspense>,
239 );
240 });
241 });
242 expect(root).toMatchRenderedOutput('Async');
243 });
244
245 // @gate __DEV__
246 it('unwraps promises by yielding to microtasks (non-async act scope)', async () => {
247 const promise = Promise.resolve('Async');
248
249 function Fallback() {
250 throw new Error('Fallback should never be rendered');
251 }
252
253 function App() {
254 return use(promise);
255 }
256
257 const root = ReactNoop.createRoot();
258
259 // Note that the scope function is not an async function
260 await act(() => {
261 startTransition(() => {
262 root.render(
263 <Suspense fallback={<Fallback />}>
264 <App />
265 </Suspense>,
266 );
267 });
268 });
269 expect(root).toMatchRenderedOutput('Async');
270 });
271
272 // @gate __DEV__
273 it('warns if a promise is used in a non-awaited `act` scope', async () => {
274 const promise = new Promise(() => {});
275
276 function Fallback() {
277 throw new Error('Fallback should never be rendered');
278 }
279
280 function App() {
281 return use(promise);
282 }
283
284 spyOnDev(console, 'error').mockImplementation(() => {});
285 const root = ReactNoop.createRoot();
286 act(() => {
287 startTransition(() => {
288 root.render(
289 <Suspense fallback={<Fallback />}>
290 <App />
291 </Suspense>,
292 );
293 });
294 });
295
296 // `act` warns after a few microtasks, instead of a macrotask, so that it's
297 // more likely to be attributed to the correct test case.
298 //
299 // The exact number of microtasks is an implementation detail; just needs
300 // to happen when the microtask queue is flushed.
301 await waitForMicrotasks();
302
303 expect(console.error).toHaveBeenCalledTimes(1);
304 expect(console.error.mock.calls[0][0]).toContain(
305 'A component suspended inside an `act` scope, but the `act` ' +
306 'call was not awaited. When testing React components that ' +
307 'depend on asynchronous data, you must await the result:\n\n' +
308 'await act(() => ...)',
309 );
310 });
311
312 // @gate __DEV__
313 it('does not warn when suspending via legacy `throw` API in non-awaited `act` scope', async () => {
314 let didResolve = false;
315 let resolvePromise;
316 const promise = new Promise(r => {
317 resolvePromise = () => {
318 didResolve = true;
319 r();
320 };
321 });
322
323 function Fallback() {
324 return 'Loading...';
325 }
326
327 function App() {
328 if (!didResolve) {
329 throw promise;
330 }
331 return 'Async';
332 }
333
334 spyOnDev(console, 'error').mockImplementation(() => {});
335 const root = ReactNoop.createRoot();
336 act(() => {
337 startTransition(() => {
338 root.render(
339 <Suspense fallback={<Fallback />}>
340 <App />
341 </Suspense>,
342 );
343 });
344 });
345 expect(root).toMatchRenderedOutput('Loading...');
346
347 // `act` warns after a few microtasks, instead of a macrotask, so that it's
348 // more likely to be attributed to the correct test case.
349 //
350 // The exact number of microtasks is an implementation detail; just needs
351 // to happen when the microtask queue is flushed.
352 await waitForMicrotasks();
353
354 expect(console.error).toHaveBeenCalledTimes(0);
355
356 // Finish loading the data
357 await act(async () => {
358 resolvePromise();
359 });
360 expect(root).toMatchRenderedOutput('Async');
361 });
362 });