main
js 783 lines 21.7 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 * @emails react-core
8 */
9
10 let React;
11 let ReactDOMClient;
12 let Scheduler;
13 let act;
14 let container;
15 let assertLog;
16 let assertConsoleErrorDev;
17
18 jest.useRealTimers();
19
20 global.IS_REACT_ACT_ENVIRONMENT = true;
21
22 function sleep(period) {
23 return new Promise(resolve => {
24 setTimeout(() => {
25 resolve(true);
26 }, period);
27 });
28 }
29
30 describe('React.act()', () => {
31 afterEach(() => {
32 jest.restoreAllMocks();
33 });
34
35 let root = null;
36 const renderConcurrent = (el, dom) => {
37 root = ReactDOMClient.createRoot(dom);
38 if (__DEV__) {
39 act(() => root.render(el));
40 } else {
41 root.render(el);
42 }
43 };
44
45 const unmountConcurrent = _dom => {
46 if (__DEV__) {
47 act(() => {
48 if (root !== null) {
49 root.unmount();
50 root = null;
51 }
52 });
53 } else {
54 if (root !== null) {
55 root.unmount();
56 root = null;
57 }
58 }
59 };
60
61 const rerenderConcurrent = el => {
62 act(() => root.render(el));
63 };
64
65 runActTests(renderConcurrent, unmountConcurrent, rerenderConcurrent);
66
67 describe('unacted effects', () => {
68 function App() {
69 React.useEffect(() => {}, []);
70 return null;
71 }
72
73 // @gate __DEV__
74 it('does not warn', () => {
75 root = ReactDOMClient.createRoot(document.createElement('div'));
76 act(() => root.render(<App />));
77 });
78 });
79 });
80
81 function runActTests(render, unmount, rerender) {
82 describe('concurrent render', () => {
83 beforeEach(() => {
84 jest.resetModules();
85 React = require('react');
86 ReactDOMClient = require('react-dom/client');
87 Scheduler = require('scheduler');
88 act = React.act;
89
90 const InternalTestUtils = require('internal-test-utils');
91 assertLog = InternalTestUtils.assertLog;
92 assertConsoleErrorDev = InternalTestUtils.assertConsoleErrorDev;
93
94 container = document.createElement('div');
95 document.body.appendChild(container);
96 });
97
98 afterEach(() => {
99 unmount(container);
100 document.body.removeChild(container);
101 });
102
103 describe('sync', () => {
104 // @gate __DEV__
105 it('can use act to flush effects', () => {
106 function App() {
107 React.useEffect(() => {
108 Scheduler.log(100);
109 });
110 return null;
111 }
112
113 act(() => {
114 render(<App />, container);
115 });
116
117 assertLog([100]);
118 });
119
120 // @gate __DEV__
121 it('flushes effects on every call', async () => {
122 function App() {
123 const [ctr, setCtr] = React.useState(0);
124 React.useEffect(() => {
125 Scheduler.log(ctr);
126 });
127 return (
128 <button id="button" onClick={() => setCtr(x => x + 1)}>
129 {ctr}
130 </button>
131 );
132 }
133
134 act(() => {
135 render(<App />, container);
136 });
137 assertLog([0]);
138 const button = container.querySelector('#button');
139 function click() {
140 button.dispatchEvent(new MouseEvent('click', {bubbles: true}));
141 }
142
143 await act(async () => {
144 click();
145 click();
146 click();
147 });
148 // it consolidates the 3 updates, then fires the effect
149 assertLog([3]);
150 await act(async () => click());
151 assertLog([4]);
152 await act(async () => click());
153 assertLog([5]);
154 expect(button.innerHTML).toBe('5');
155 });
156
157 // @gate __DEV__
158 it("should keep flushing effects until they're done", () => {
159 function App() {
160 const [ctr, setCtr] = React.useState(0);
161 React.useEffect(() => {
162 if (ctr < 5) {
163 setCtr(x => x + 1);
164 }
165 });
166 return ctr;
167 }
168
169 act(() => {
170 render(<App />, container);
171 });
172
173 expect(container.innerHTML).toBe('5');
174 });
175
176 // @gate __DEV__
177 it('should flush effects only on exiting the outermost act', () => {
178 function App() {
179 React.useEffect(() => {
180 Scheduler.log(0);
181 });
182 return null;
183 }
184 // let's nest a couple of act() calls
185 act(() => {
186 act(() => {
187 render(<App />, container);
188 });
189 // the effect wouldn't have yielded yet because
190 // we're still inside an act() scope
191 assertLog([]);
192 });
193 // but after exiting the last one, effects get flushed
194 assertLog([0]);
195 });
196
197 // @gate __DEV__
198 it('warns if a setState is called outside of act(...)', () => {
199 let setValue = null;
200 function App() {
201 const [value, _setValue] = React.useState(0);
202 setValue = _setValue;
203 return value;
204 }
205
206 act(() => {
207 render(<App />, container);
208 });
209
210 setValue(1);
211 assertConsoleErrorDev([
212 'An update to App inside a test was not wrapped in act(...).\n' +
213 '\n' +
214 'When testing, code that causes React state updates should be wrapped into act(...):\n' +
215 '\n' +
216 'act(() => {\n' +
217 ' /* fire events that update state */\n' +
218 '});\n' +
219 '/* assert on the output */\n' +
220 '\n' +
221 "This ensures that you're testing the behavior the user would see in the browser. " +
222 'Learn more at https://react.dev/link/wrap-tests-with-act\n' +
223 ' in App (at **)',
224 ]);
225 });
226
227 // @gate __DEV__
228 it('warns if a setState is called outside of act(...) after a component threw', () => {
229 let setValue = null;
230 function App({defaultValue}) {
231 if (defaultValue === undefined) {
232 throw new Error('some error');
233 }
234 const [value, _setValue] = React.useState(defaultValue);
235 setValue = _setValue;
236 return value;
237 }
238
239 expect(() => {
240 act(() => {
241 render(<App defaultValue={undefined} />, container);
242 });
243 }).toThrow('some error');
244
245 act(() => {
246 rerender(<App defaultValue={0} />, container);
247 });
248
249 setValue(1);
250 assertConsoleErrorDev([
251 'An update to App inside a test was not wrapped in act(...).\n' +
252 '\n' +
253 'When testing, code that causes React state updates should be wrapped into act(...):\n' +
254 '\n' +
255 'act(() => {\n' +
256 ' /* fire events that update state */\n' +
257 '});\n' +
258 '/* assert on the output */\n' +
259 '\n' +
260 "This ensures that you're testing the behavior the user would see in the browser. " +
261 'Learn more at https://react.dev/link/wrap-tests-with-act\n' +
262 ' in App (at **)',
263 ]);
264 });
265
266 // @gate __DEV__
267 it('does not warn if IS_REACT_ACT_ENVIRONMENT is set to false', () => {
268 let setState;
269 function App() {
270 const [state, _setState] = React.useState(0);
271 setState = _setState;
272 return state;
273 }
274
275 act(() => {
276 render(<App />, container);
277 });
278
279 // First show that it does warn
280 setState(1);
281 assertConsoleErrorDev([
282 'An update to App inside a test was not wrapped in act(...).\n' +
283 '\n' +
284 'When testing, code that causes React state updates should be wrapped into act(...):\n' +
285 '\n' +
286 'act(() => {\n' +
287 ' /* fire events that update state */\n' +
288 '});\n' +
289 '/* assert on the output */\n' +
290 '\n' +
291 "This ensures that you're testing the behavior the user would see in the browser. " +
292 'Learn more at https://react.dev/link/wrap-tests-with-act\n' +
293 ' in App (at **)',
294 ]);
295
296 // Now do the same thing again, but disable with the environment flag
297 const prevIsActEnvironment = global.IS_REACT_ACT_ENVIRONMENT;
298 global.IS_REACT_ACT_ENVIRONMENT = false;
299 try {
300 setState(2);
301 } finally {
302 global.IS_REACT_ACT_ENVIRONMENT = prevIsActEnvironment;
303 }
304
305 // When the flag is restored to its previous value, it should start
306 // warning again. This shows that React reads the flag each time.
307 setState(3);
308 assertConsoleErrorDev([
309 'An update to App inside a test was not wrapped in act(...).\n' +
310 '\n' +
311 'When testing, code that causes React state updates should be wrapped into act(...):\n' +
312 '\n' +
313 'act(() => {\n' +
314 ' /* fire events that update state */\n' +
315 '});\n' +
316 '/* assert on the output */\n' +
317 '\n' +
318 "This ensures that you're testing the behavior the user would see in the browser. " +
319 'Learn more at https://react.dev/link/wrap-tests-with-act\n' +
320 ' in App (at **)',
321 ]);
322 });
323
324 describe('fake timers', () => {
325 beforeEach(() => {
326 jest.useFakeTimers();
327 });
328
329 afterEach(() => {
330 jest.useRealTimers();
331 });
332
333 // @gate __DEV__
334 it('lets a ticker update', () => {
335 function App() {
336 const [toggle, setToggle] = React.useState(0);
337 React.useEffect(() => {
338 const timeout = setTimeout(() => {
339 setToggle(1);
340 }, 200);
341 return () => clearTimeout(timeout);
342 }, []);
343 return toggle;
344 }
345
346 act(() => {
347 render(<App />, container);
348 });
349 act(() => {
350 jest.runAllTimers();
351 });
352
353 expect(container.innerHTML).toBe('1');
354 });
355
356 // @gate __DEV__
357 it('can use the async version to catch microtasks', async () => {
358 function App() {
359 const [toggle, setToggle] = React.useState(0);
360 React.useEffect(() => {
361 // just like the previous test, except we
362 // use a promise and schedule the update
363 // after it resolves
364 sleep(200).then(() => setToggle(1));
365 }, []);
366 return toggle;
367 }
368
369 act(() => {
370 render(<App />, container);
371 });
372 await act(async () => {
373 jest.runAllTimers();
374 });
375
376 expect(container.innerHTML).toBe('1');
377 });
378
379 // @gate __DEV__
380 it('can handle cascading promises with fake timers', async () => {
381 // this component triggers an effect, that waits a tick,
382 // then sets state. repeats this 5 times.
383 function App() {
384 const [state, setState] = React.useState(0);
385 async function ticker() {
386 await null;
387 setState(x => x + 1);
388 }
389 React.useEffect(() => {
390 ticker();
391 }, [Math.min(state, 4)]);
392 return state;
393 }
394
395 await act(async () => {
396 render(<App />, container);
397 });
398
399 // all 5 ticks present and accounted for
400 expect(container.innerHTML).toBe('5');
401 });
402
403 // @gate __DEV__
404 it('flushes immediate re-renders with act', () => {
405 function App() {
406 const [ctr, setCtr] = React.useState(0);
407 React.useEffect(() => {
408 if (ctr === 0) {
409 setCtr(1);
410 }
411 const timeout = setTimeout(() => setCtr(2), 1000);
412 return () => clearTimeout(timeout);
413 });
414 return ctr;
415 }
416
417 act(() => {
418 render(<App />, container);
419 // Since effects haven't been flushed yet, this does not advance the timer
420 jest.runAllTimers();
421 });
422
423 expect(container.innerHTML).toBe('1');
424
425 act(() => {
426 jest.runAllTimers();
427 });
428
429 expect(container.innerHTML).toBe('2');
430 });
431 });
432 });
433
434 describe('asynchronous tests', () => {
435 // @gate __DEV__
436 it('works with timeouts', async () => {
437 function App() {
438 const [ctr, setCtr] = React.useState(0);
439 function doSomething() {
440 setTimeout(() => {
441 setCtr(1);
442 }, 50);
443 }
444
445 React.useEffect(() => {
446 doSomething();
447 }, []);
448 return ctr;
449 }
450
451 await act(async () => {
452 render(<App />, container);
453 });
454 expect(container.innerHTML).toBe('0');
455 // Flush the pending timers
456 await act(async () => {
457 await sleep(100);
458 });
459 expect(container.innerHTML).toBe('1');
460 });
461
462 // @gate __DEV__
463 it('flushes microtasks before exiting (async function)', async () => {
464 function App() {
465 const [ctr, setCtr] = React.useState(0);
466 async function someAsyncFunction() {
467 // queue a bunch of promises to be sure they all flush
468 await null;
469 await null;
470 await null;
471 setCtr(1);
472 }
473 React.useEffect(() => {
474 someAsyncFunction();
475 }, []);
476 return ctr;
477 }
478
479 await act(async () => {
480 render(<App />, container);
481 });
482 expect(container.innerHTML).toEqual('1');
483 });
484
485 // @gate __DEV__
486 it('flushes microtasks before exiting (sync function)', async () => {
487 // Same as previous test, but the callback passed to `act` is not itself
488 // an async function.
489 function App() {
490 const [ctr, setCtr] = React.useState(0);
491 async function someAsyncFunction() {
492 // queue a bunch of promises to be sure they all flush
493 await null;
494 await null;
495 await null;
496 setCtr(1);
497 }
498 React.useEffect(() => {
499 someAsyncFunction();
500 }, []);
501 return ctr;
502 }
503
504 await act(() => {
505 render(<App />, container);
506 });
507 expect(container.innerHTML).toEqual('1');
508 });
509
510 // @gate __DEV__
511 it('warns if you do not await an act call', async () => {
512 spyOnDevAndProd(console, 'error').mockImplementation(() => {});
513 act(async () => {});
514 // it's annoying that we have to wait a tick before this warning comes in
515 await sleep(0);
516 if (__DEV__) {
517 expect(console.error).toHaveBeenCalledTimes(1);
518 expect(console.error.mock.calls[0][0]).toMatch(
519 'You called act(async () => ...) without await.',
520 );
521 }
522 });
523
524 // @gate __DEV__
525 it('warns if you try to interleave multiple act calls', async () => {
526 spyOnDevAndProd(console, 'error').mockImplementation(() => {});
527
528 await Promise.all([
529 act(async () => {
530 await sleep(50);
531 }),
532 act(async () => {
533 await sleep(100);
534 }),
535 ]);
536
537 await sleep(150);
538 if (__DEV__) {
539 expect(console.error).toHaveBeenCalledTimes(2);
540 expect(console.error.mock.calls[0][0]).toMatch(
541 'You seem to have overlapping act() calls',
542 );
543 expect(console.error.mock.calls[1][0]).toMatch(
544 'You seem to have overlapping act() calls',
545 );
546 }
547 });
548
549 // @gate __DEV__
550 it('async commits and effects are guaranteed to be flushed', async () => {
551 function App() {
552 const [state, setState] = React.useState(0);
553 async function something() {
554 await null;
555 setState(1);
556 }
557 React.useEffect(() => {
558 something();
559 }, []);
560 React.useEffect(() => {
561 Scheduler.log(state);
562 });
563 return state;
564 }
565
566 await act(async () => {
567 render(<App />, container);
568 });
569 // exiting act() drains effects and microtasks
570
571 assertLog([0, 1]);
572 expect(container.innerHTML).toBe('1');
573 });
574
575 // @gate __DEV__
576 it('can handle cascading promises', async () => {
577 // this component triggers an effect, that waits a tick,
578 // then sets state. repeats this 5 times.
579 function App() {
580 const [state, setState] = React.useState(0);
581 async function ticker() {
582 await null;
583 setState(x => x + 1);
584 }
585 React.useEffect(() => {
586 Scheduler.log(state);
587 ticker();
588 }, [Math.min(state, 4)]);
589 return state;
590 }
591
592 await act(async () => {
593 render(<App />, container);
594 });
595 // all 5 ticks present and accounted for
596 assertLog([0, 1, 2, 3, 4]);
597 expect(container.innerHTML).toBe('5');
598 });
599 });
600
601 describe('error propagation', () => {
602 // @gate __DEV__
603 it('propagates errors - sync', () => {
604 let err;
605 try {
606 act(() => {
607 throw new Error('some error');
608 });
609 } catch (_err) {
610 err = _err;
611 } finally {
612 expect(err instanceof Error).toBe(true);
613 expect(err.message).toBe('some error');
614 }
615 });
616
617 // @gate __DEV__
618 it('should propagate errors from effects - sync', () => {
619 function App() {
620 React.useEffect(() => {
621 throw new Error('oh no');
622 });
623 return null;
624 }
625 let error;
626
627 try {
628 act(() => {
629 render(<App />, container);
630 });
631 } catch (_error) {
632 error = _error;
633 } finally {
634 expect(error instanceof Error).toBe(true);
635 expect(error.message).toBe('oh no');
636 }
637 });
638
639 // @gate __DEV__
640 it('propagates errors - async', async () => {
641 let err;
642 try {
643 await act(async () => {
644 await sleep(100);
645 throw new Error('some error');
646 });
647 } catch (_err) {
648 err = _err;
649 } finally {
650 expect(err instanceof Error).toBe(true);
651 expect(err.message).toBe('some error');
652 }
653 });
654
655 // @gate __DEV__
656 it('should cleanup after errors - sync', () => {
657 function App() {
658 React.useEffect(() => {
659 Scheduler.log('oh yes');
660 });
661 return null;
662 }
663 let error;
664 try {
665 act(() => {
666 throw new Error('oh no');
667 });
668 } catch (_error) {
669 error = _error;
670 } finally {
671 expect(error instanceof Error).toBe(true);
672 expect(error.message).toBe('oh no');
673 // should be able to render components after this tho
674 act(() => {
675 render(<App />, container);
676 });
677 assertLog(['oh yes']);
678 }
679 });
680
681 // @gate __DEV__
682 it('should cleanup after errors - async', async () => {
683 function App() {
684 async function somethingAsync() {
685 await null;
686 Scheduler.log('oh yes');
687 }
688 React.useEffect(() => {
689 somethingAsync();
690 });
691 return null;
692 }
693 let error;
694 try {
695 await act(async () => {
696 await sleep(100);
697 throw new Error('oh no');
698 });
699 } catch (_error) {
700 error = _error;
701 } finally {
702 expect(error instanceof Error).toBe(true);
703 expect(error.message).toBe('oh no');
704 // should be able to render components after this tho
705 await act(async () => {
706 render(<App />, container);
707 });
708 assertLog(['oh yes']);
709 }
710 });
711 });
712
713 describe('suspense', () => {
714 if (__DEV__ && __EXPERIMENTAL__) {
715 // todo - remove __DEV__ check once we start using testing builds
716
717 // @gate __DEV__
718 it('triggers fallbacks if available', async () => {
719 let resolved = false;
720 let resolve;
721 const promise = new Promise(_resolve => {
722 resolve = _resolve;
723 });
724
725 function Suspends() {
726 if (resolved) {
727 return 'was suspended';
728 }
729 throw promise;
730 }
731
732 function App(props) {
733 return (
734 <React.Suspense
735 fallback={<span data-test-id="spinner">loading...</span>}>
736 {props.suspend ? <Suspends /> : 'content'}
737 </React.Suspense>
738 );
739 }
740
741 // render something so there's content
742 act(() => {
743 render(<App suspend={false} />, container);
744 });
745
746 // trigger a suspendy update
747 act(() => {
748 rerender(<App suspend={true} />);
749 });
750 expect(
751 document.querySelector('[data-test-id=spinner]'),
752 ).not.toBeNull();
753
754 // now render regular content again
755 act(() => {
756 rerender(<App suspend={false} />);
757 });
758 expect(document.querySelector('[data-test-id=spinner]')).toBeNull();
759
760 // trigger a suspendy update with a delay
761 React.startTransition(() => {
762 act(() => {
763 rerender(<App suspend={true} />);
764 });
765 });
766
767 // In Concurrent Mode, refresh transitions delay indefinitely.
768 expect(document.querySelector('[data-test-id=spinner]')).toBeNull();
769
770 // resolve the promise
771 await act(async () => {
772 resolved = true;
773 resolve();
774 });
775
776 // spinner gone, content showing
777 expect(document.querySelector('[data-test-id=spinner]')).toBeNull();
778 expect(container.textContent).toBe('was suspended');
779 });
780 }
781 });
782 });
783 }