main
js 4,424 lines 134 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 * @jest-environment node
9 */
10
11 /* eslint-disable no-func-assign */
12
13 'use strict';
14
15 let React;
16 let textCache;
17 let readText;
18 let resolveText;
19 let ReactNoop;
20 let Scheduler;
21 let Suspense;
22 let Activity;
23 let useState;
24 let useReducer;
25 let useEffect;
26 let useInsertionEffect;
27 let useLayoutEffect;
28 let useCallback;
29 let useMemo;
30 let useRef;
31 let useImperativeHandle;
32 let useTransition;
33 let useDeferredValue;
34 let forwardRef;
35 let memo;
36 let act;
37 let ContinuousEventPriority;
38 let SuspenseList;
39 let waitForAll;
40 let waitFor;
41 let waitForThrow;
42 let waitForPaint;
43 let assertLog;
44 let assertConsoleErrorDev;
45
46 describe('ReactHooksWithNoopRenderer', () => {
47 beforeEach(() => {
48 jest.resetModules();
49 jest.useFakeTimers();
50
51 React = require('react');
52 ReactNoop = require('react-noop-renderer');
53 Scheduler = require('scheduler');
54 act = require('internal-test-utils').act;
55 assertConsoleErrorDev =
56 require('internal-test-utils').assertConsoleErrorDev;
57 useState = React.useState;
58 useReducer = React.useReducer;
59 useEffect = React.useEffect;
60 useInsertionEffect = React.useInsertionEffect;
61 useLayoutEffect = React.useLayoutEffect;
62 useCallback = React.useCallback;
63 useMemo = React.useMemo;
64 useRef = React.useRef;
65 useImperativeHandle = React.useImperativeHandle;
66 forwardRef = React.forwardRef;
67 memo = React.memo;
68 useTransition = React.useTransition;
69 useDeferredValue = React.useDeferredValue;
70 Suspense = React.Suspense;
71 Activity = React.Activity;
72 ContinuousEventPriority =
73 require('react-reconciler/constants').ContinuousEventPriority;
74 if (gate(flags => flags.enableSuspenseList)) {
75 SuspenseList = React.unstable_SuspenseList;
76 }
77
78 const InternalTestUtils = require('internal-test-utils');
79 waitForAll = InternalTestUtils.waitForAll;
80 waitFor = InternalTestUtils.waitFor;
81 waitForThrow = InternalTestUtils.waitForThrow;
82 waitForPaint = InternalTestUtils.waitForPaint;
83 assertLog = InternalTestUtils.assertLog;
84
85 textCache = new Map();
86
87 readText = text => {
88 const record = textCache.get(text);
89 if (record !== undefined) {
90 switch (record.status) {
91 case 'pending':
92 throw record.promise;
93 case 'rejected':
94 throw Error('Failed to load: ' + text);
95 case 'resolved':
96 return text;
97 }
98 } else {
99 let ping;
100 const promise = new Promise(resolve => (ping = resolve));
101 const newRecord = {
102 status: 'pending',
103 ping: ping,
104 promise,
105 };
106 textCache.set(text, newRecord);
107 throw promise;
108 }
109 };
110
111 resolveText = text => {
112 const record = textCache.get(text);
113 if (record !== undefined) {
114 if (record.status === 'pending') {
115 Scheduler.log(`Promise resolved [${text}]`);
116 record.ping();
117 record.ping = null;
118 record.status = 'resolved';
119 clearTimeout(record.promise._timer);
120 record.promise = null;
121 }
122 } else {
123 const newRecord = {
124 ping: null,
125 status: 'resolved',
126 promise: null,
127 };
128 textCache.set(text, newRecord);
129 }
130 };
131 });
132
133 function Text(props) {
134 Scheduler.log(props.text);
135 return <span prop={props.text} />;
136 }
137
138 function AsyncText(props) {
139 const text = props.text;
140 try {
141 readText(text);
142 Scheduler.log(text);
143 return <span prop={text} />;
144 } catch (promise) {
145 if (typeof promise.then === 'function') {
146 Scheduler.log(`Suspend! [${text}]`);
147 if (typeof props.ms === 'number' && promise._timer === undefined) {
148 promise._timer = setTimeout(() => {
149 resolveText(text);
150 }, props.ms);
151 }
152 } else {
153 Scheduler.log(`Error! [${text}]`);
154 }
155 throw promise;
156 }
157 }
158
159 function advanceTimers(ms) {
160 // Note: This advances Jest's virtual time but not React's. Use
161 // ReactNoop.expire for that.
162 if (typeof ms !== 'number') {
163 throw new Error('Must specify ms');
164 }
165 jest.advanceTimersByTime(ms);
166 // Wait until the end of the current tick
167 // We cannot use a timer since we're faking them
168 return Promise.resolve().then(() => {});
169 }
170
171 it('resumes after an interruption', async () => {
172 function Counter(props, ref) {
173 const [count, updateCount] = useState(0);
174 useImperativeHandle(ref, () => ({updateCount}));
175 return <Text text={props.label + ': ' + count} />;
176 }
177 Counter = forwardRef(Counter);
178
179 // Initial mount
180 const counter = React.createRef(null);
181 ReactNoop.render(<Counter label="Count" ref={counter} />);
182 await waitForAll(['Count: 0']);
183 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
184
185 // Schedule some updates
186 await act(async () => {
187 React.startTransition(() => {
188 counter.current.updateCount(1);
189 counter.current.updateCount(count => count + 10);
190 });
191
192 // Partially flush without committing
193 await waitFor(['Count: 11']);
194 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
195
196 // Interrupt with a high priority update
197 ReactNoop.flushSync(() => {
198 ReactNoop.render(<Counter label="Total" />);
199 });
200 assertLog(['Total: 0']);
201
202 // Resume rendering
203 await waitForAll(['Total: 11']);
204 expect(ReactNoop).toMatchRenderedOutput(<span prop="Total: 11" />);
205 });
206 });
207
208 it('throws inside class components', async () => {
209 class BadCounter extends React.Component {
210 render() {
211 const [count] = useState(0);
212 return <Text text={this.props.label + ': ' + count} />;
213 }
214 }
215 ReactNoop.render(<BadCounter />);
216
217 await waitForThrow(
218 'Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for' +
219 ' one of the following reasons:\n' +
220 '1. You might have mismatching versions of React and the renderer (such as React DOM)\n' +
221 '2. You might be breaking the Rules of Hooks\n' +
222 '3. You might have more than one copy of React in the same app\n' +
223 'See https://react.dev/link/invalid-hook-call for tips about how to debug and fix this problem.',
224 );
225
226 // Confirm that a subsequent hook works properly.
227 function GoodCounter(props, ref) {
228 const [count] = useState(props.initialCount);
229 return <Text text={count} />;
230 }
231 ReactNoop.render(<GoodCounter initialCount={10} />);
232 await waitForAll([10]);
233 });
234
235 it('throws when called outside the render phase', async () => {
236 expect(() => useState(0)).toThrow(
237 "Cannot read properties of null (reading 'useState')",
238 );
239 assertConsoleErrorDev([
240 'Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for' +
241 ' one of the following reasons:\n' +
242 '1. You might have mismatching versions of React and the renderer (such as React DOM)\n' +
243 '2. You might be breaking the Rules of Hooks\n' +
244 '3. You might have more than one copy of React in the same app\n' +
245 'See https://react.dev/link/invalid-hook-call for tips about how to debug and fix this problem.',
246 ]);
247 });
248
249 describe('useState', () => {
250 it('simple mount and update', async () => {
251 function Counter(props, ref) {
252 const [count, updateCount] = useState(0);
253 useImperativeHandle(ref, () => ({updateCount}));
254 return <Text text={'Count: ' + count} />;
255 }
256 Counter = forwardRef(Counter);
257 const counter = React.createRef(null);
258 ReactNoop.render(<Counter ref={counter} />);
259 await waitForAll(['Count: 0']);
260 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
261
262 await act(() => counter.current.updateCount(1));
263 assertLog(['Count: 1']);
264 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);
265
266 await act(() => counter.current.updateCount(count => count + 10));
267 assertLog(['Count: 11']);
268 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 11" />);
269 });
270
271 it('lazy state initializer', async () => {
272 function Counter(props, ref) {
273 const [count, updateCount] = useState(() => {
274 Scheduler.log('getInitialState');
275 return props.initialState;
276 });
277 useImperativeHandle(ref, () => ({updateCount}));
278 return <Text text={'Count: ' + count} />;
279 }
280 Counter = forwardRef(Counter);
281 const counter = React.createRef(null);
282 ReactNoop.render(<Counter initialState={42} ref={counter} />);
283 await waitForAll(['getInitialState', 'Count: 42']);
284 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 42" />);
285
286 await act(() => counter.current.updateCount(7));
287 assertLog(['Count: 7']);
288 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 7" />);
289 });
290
291 it('multiple states', async () => {
292 function Counter(props, ref) {
293 const [count, updateCount] = useState(0);
294 const [label, updateLabel] = useState('Count');
295 useImperativeHandle(ref, () => ({updateCount, updateLabel}));
296 return <Text text={label + ': ' + count} />;
297 }
298 Counter = forwardRef(Counter);
299 const counter = React.createRef(null);
300 ReactNoop.render(<Counter ref={counter} />);
301 await waitForAll(['Count: 0']);
302 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
303
304 await act(() => counter.current.updateCount(7));
305 assertLog(['Count: 7']);
306
307 await act(() => counter.current.updateLabel('Total'));
308 assertLog(['Total: 7']);
309 });
310
311 it('returns the same updater function every time', async () => {
312 let updater = null;
313 function Counter() {
314 const [count, updateCount] = useState(0);
315 updater = updateCount;
316 return <Text text={'Count: ' + count} />;
317 }
318 ReactNoop.render(<Counter />);
319 await waitForAll(['Count: 0']);
320 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
321
322 const firstUpdater = updater;
323
324 await act(() => firstUpdater(1));
325 assertLog(['Count: 1']);
326 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);
327
328 const secondUpdater = updater;
329
330 await act(() => firstUpdater(count => count + 10));
331 assertLog(['Count: 11']);
332 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 11" />);
333
334 expect(firstUpdater).toBe(secondUpdater);
335 });
336
337 it('does not warn on set after unmount', async () => {
338 let _updateCount;
339 function Counter(props, ref) {
340 const [, updateCount] = useState(0);
341 _updateCount = updateCount;
342 return null;
343 }
344
345 ReactNoop.render(<Counter />);
346 await waitForAll([]);
347 ReactNoop.render(null);
348 await waitForAll([]);
349 await act(() => _updateCount(1));
350 });
351
352 it('works with memo', async () => {
353 let _updateCount;
354 function Counter(props) {
355 const [count, updateCount] = useState(0);
356 _updateCount = updateCount;
357 return <Text text={'Count: ' + count} />;
358 }
359 Counter = memo(Counter);
360
361 ReactNoop.render(<Counter />);
362 await waitForAll(['Count: 0']);
363 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
364
365 ReactNoop.render(<Counter />);
366 await waitForAll([]);
367 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
368
369 await act(() => _updateCount(1));
370 assertLog(['Count: 1']);
371 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);
372 });
373 });
374
375 describe('updates during the render phase', () => {
376 it('restarts the render function and applies the new updates on top', async () => {
377 function ScrollView({row: newRow}) {
378 const [isScrollingDown, setIsScrollingDown] = useState(false);
379 const [row, setRow] = useState(null);
380
381 if (row !== newRow) {
382 // Row changed since last render. Update isScrollingDown.
383 setIsScrollingDown(row !== null && newRow > row);
384 setRow(newRow);
385 }
386
387 return <Text text={`Scrolling down: ${isScrollingDown}`} />;
388 }
389
390 ReactNoop.render(<ScrollView row={1} />);
391 await waitForAll(['Scrolling down: false']);
392 expect(ReactNoop).toMatchRenderedOutput(
393 <span prop="Scrolling down: false" />,
394 );
395
396 ReactNoop.render(<ScrollView row={5} />);
397 await waitForAll(['Scrolling down: true']);
398 expect(ReactNoop).toMatchRenderedOutput(
399 <span prop="Scrolling down: true" />,
400 );
401
402 ReactNoop.render(<ScrollView row={5} />);
403 await waitForAll(['Scrolling down: true']);
404 expect(ReactNoop).toMatchRenderedOutput(
405 <span prop="Scrolling down: true" />,
406 );
407
408 ReactNoop.render(<ScrollView row={10} />);
409 await waitForAll(['Scrolling down: true']);
410 expect(ReactNoop).toMatchRenderedOutput(
411 <span prop="Scrolling down: true" />,
412 );
413
414 ReactNoop.render(<ScrollView row={2} />);
415 await waitForAll(['Scrolling down: false']);
416 expect(ReactNoop).toMatchRenderedOutput(
417 <span prop="Scrolling down: false" />,
418 );
419
420 ReactNoop.render(<ScrollView row={2} />);
421 await waitForAll(['Scrolling down: false']);
422 expect(ReactNoop).toMatchRenderedOutput(
423 <span prop="Scrolling down: false" />,
424 );
425 });
426
427 it('warns about render phase update on a different component', async () => {
428 let setStep;
429 function Foo() {
430 const [step, _setStep] = useState(0);
431 setStep = _setStep;
432 return <Text text={`Foo [${step}]`} />;
433 }
434
435 function Bar({triggerUpdate}) {
436 if (triggerUpdate) {
437 setStep(x => x + 1);
438 }
439 return <Text text="Bar" />;
440 }
441
442 const root = ReactNoop.createRoot();
443
444 await act(() => {
445 root.render(
446 <>
447 <Foo />
448 <Bar />
449 </>,
450 );
451 });
452 assertLog(['Foo [0]', 'Bar']);
453
454 // Bar will update Foo during its render phase. React should warn.
455 root.render(
456 <>
457 <Foo />
458 <Bar triggerUpdate={true} />
459 </>,
460 );
461 await waitForAll(['Foo [0]', 'Bar', 'Foo [1]']);
462 assertConsoleErrorDev([
463 'Cannot update a component (`Foo`) while rendering a different component (`Bar`). ' +
464 'To locate the bad setState() call inside `Bar`, ' +
465 'follow the stack trace as described in https://react.dev/link/setstate-in-render\n' +
466 ' in Bar (at **)',
467 ]);
468
469 // It should not warn again (deduplication).
470 await act(async () => {
471 root.render(
472 <>
473 <Foo />
474 <Bar triggerUpdate={true} />
475 </>,
476 );
477 await waitForAll(['Foo [1]', 'Bar', 'Foo [2]']);
478 });
479 });
480
481 it('keeps restarting until there are no more new updates', async () => {
482 function Counter({row: newRow}) {
483 const [count, setCount] = useState(0);
484 if (count < 3) {
485 setCount(count + 1);
486 }
487 Scheduler.log('Render: ' + count);
488 return <Text text={count} />;
489 }
490
491 ReactNoop.render(<Counter />);
492 await waitForAll(['Render: 0', 'Render: 1', 'Render: 2', 'Render: 3', 3]);
493 expect(ReactNoop).toMatchRenderedOutput(<span prop={3} />);
494 });
495
496 it('updates multiple times within same render function', async () => {
497 function Counter({row: newRow}) {
498 const [count, setCount] = useState(0);
499 if (count < 12) {
500 setCount(c => c + 1);
501 setCount(c => c + 1);
502 setCount(c => c + 1);
503 }
504 Scheduler.log('Render: ' + count);
505 return <Text text={count} />;
506 }
507
508 ReactNoop.render(<Counter />);
509 await waitForAll([
510 // Should increase by three each time
511 'Render: 0',
512 'Render: 3',
513 'Render: 6',
514 'Render: 9',
515 'Render: 12',
516 12,
517 ]);
518 expect(ReactNoop).toMatchRenderedOutput(<span prop={12} />);
519 });
520
521 it('throws after too many iterations', async () => {
522 function Counter({row: newRow}) {
523 const [count, setCount] = useState(0);
524 setCount(count + 1);
525 Scheduler.log('Render: ' + count);
526 return <Text text={count} />;
527 }
528 ReactNoop.render(<Counter />);
529 await waitForThrow(
530 'Too many re-renders. React limits the number of renders to prevent ' +
531 'an infinite loop.',
532 );
533 });
534
535 it('works with useReducer', async () => {
536 function reducer(state, action) {
537 return action === 'increment' ? state + 1 : state;
538 }
539 function Counter({row: newRow}) {
540 const [count, dispatch] = useReducer(reducer, 0);
541 if (count < 3) {
542 dispatch('increment');
543 }
544 Scheduler.log('Render: ' + count);
545 return <Text text={count} />;
546 }
547
548 ReactNoop.render(<Counter />);
549 await waitForAll(['Render: 0', 'Render: 1', 'Render: 2', 'Render: 3', 3]);
550 expect(ReactNoop).toMatchRenderedOutput(<span prop={3} />);
551 });
552
553 it('uses reducer passed at time of render, not time of dispatch', async () => {
554 // This test is a bit contrived but it demonstrates a subtle edge case.
555
556 // Reducer A increments by 1. Reducer B increments by 10.
557 function reducerA(state, action) {
558 switch (action) {
559 case 'increment':
560 return state + 1;
561 case 'reset':
562 return 0;
563 }
564 }
565 function reducerB(state, action) {
566 switch (action) {
567 case 'increment':
568 return state + 10;
569 case 'reset':
570 return 0;
571 }
572 }
573
574 function Counter({row: newRow}, ref) {
575 const [reducer, setReducer] = useState(() => reducerA);
576 const [count, dispatch] = useReducer(reducer, 0);
577 useImperativeHandle(ref, () => ({dispatch}));
578 if (count < 20) {
579 dispatch('increment');
580 // Swap reducers each time we increment
581 if (reducer === reducerA) {
582 setReducer(() => reducerB);
583 } else {
584 setReducer(() => reducerA);
585 }
586 }
587 Scheduler.log('Render: ' + count);
588 return <Text text={count} />;
589 }
590 Counter = forwardRef(Counter);
591 const counter = React.createRef(null);
592 ReactNoop.render(<Counter ref={counter} />);
593 await waitForAll([
594 // The count should increase by alternating amounts of 10 and 1
595 // until we reach 21.
596 'Render: 0',
597 'Render: 10',
598 'Render: 11',
599 'Render: 21',
600 21,
601 ]);
602 expect(ReactNoop).toMatchRenderedOutput(<span prop={21} />);
603
604 // Test that it works on update, too. This time the log is a bit different
605 // because we started with reducerB instead of reducerA.
606 await act(() => {
607 counter.current.dispatch('reset');
608 });
609 ReactNoop.render(<Counter ref={counter} />);
610 assertLog([
611 'Render: 0',
612 'Render: 1',
613 'Render: 11',
614 'Render: 12',
615 'Render: 22',
616 22,
617 ]);
618 expect(ReactNoop).toMatchRenderedOutput(<span prop={22} />);
619 });
620
621 it('discards render phase updates if something suspends', async () => {
622 const thenable = {then() {}};
623 function Foo({signal}) {
624 return (
625 <Suspense fallback="Loading...">
626 <Bar signal={signal} />
627 </Suspense>
628 );
629 }
630
631 function Bar({signal: newSignal}) {
632 const [counter, setCounter] = useState(0);
633 const [signal, setSignal] = useState(true);
634
635 // Increment a counter every time the signal changes
636 if (signal !== newSignal) {
637 setCounter(c => c + 1);
638 setSignal(newSignal);
639 if (counter === 0) {
640 // We're suspending during a render that includes render phase
641 // updates. Those updates should not persist to the next render.
642 Scheduler.log('Suspend!');
643 throw thenable;
644 }
645 }
646
647 return <Text text={counter} />;
648 }
649
650 const root = ReactNoop.createRoot();
651 root.render(<Foo signal={true} />);
652
653 await waitForAll([0]);
654 expect(root).toMatchRenderedOutput(<span prop={0} />);
655
656 React.startTransition(() => {
657 root.render(<Foo signal={false} />);
658 });
659 await waitForAll(['Suspend!']);
660 expect(root).toMatchRenderedOutput(<span prop={0} />);
661
662 // Rendering again should suspend again.
663 React.startTransition(() => {
664 root.render(<Foo signal={false} />);
665 });
666 await waitForAll(['Suspend!']);
667 });
668
669 it('discards render phase updates if something suspends, but not other updates in the same component', async () => {
670 const thenable = {then() {}};
671 function Foo({signal}) {
672 return (
673 <Suspense fallback="Loading...">
674 <Bar signal={signal} />
675 </Suspense>
676 );
677 }
678
679 let setLabel;
680 function Bar({signal: newSignal}) {
681 const [counter, setCounter] = useState(0);
682
683 if (counter === 1) {
684 // We're suspending during a render that includes render phase
685 // updates. Those updates should not persist to the next render.
686 Scheduler.log('Suspend!');
687 throw thenable;
688 }
689
690 const [signal, setSignal] = useState(true);
691
692 // Increment a counter every time the signal changes
693 if (signal !== newSignal) {
694 setCounter(c => c + 1);
695 setSignal(newSignal);
696 }
697
698 const [label, _setLabel] = useState('A');
699 setLabel = _setLabel;
700
701 return <Text text={`${label}:${counter}`} />;
702 }
703
704 const root = ReactNoop.createRoot();
705 root.render(<Foo signal={true} />);
706
707 await waitForAll(['A:0']);
708 expect(root).toMatchRenderedOutput(<span prop="A:0" />);
709
710 await act(async () => {
711 React.startTransition(() => {
712 root.render(<Foo signal={false} />);
713 setLabel('B');
714 });
715
716 await waitForAll(['Suspend!']);
717 expect(root).toMatchRenderedOutput(<span prop="A:0" />);
718
719 // Rendering again should suspend again.
720 React.startTransition(() => {
721 root.render(<Foo signal={false} />);
722 });
723 await waitForAll(['Suspend!']);
724
725 // Flip the signal back to "cancel" the update. However, the update to
726 // label should still proceed. It shouldn't have been dropped.
727 React.startTransition(() => {
728 root.render(<Foo signal={true} />);
729 });
730 await waitForAll(['B:0']);
731 expect(root).toMatchRenderedOutput(<span prop="B:0" />);
732 });
733 });
734
735 it(
736 'preserves pending updates on later hooks that were not processed ' +
737 'before unwind',
738 async () => {
739 const thenable = {then() {}};
740
741 let setLabel;
742 function Foo({suspend}) {
743 return (
744 <Suspense fallback="Loading...">
745 <Bar suspend={suspend} />
746 </Suspense>
747 );
748 }
749
750 function Bar({suspend}) {
751 const [counter, setCounter] = useState(0);
752
753 if (suspend) {
754 setCounter(c => c + 1);
755 Scheduler.log('Suspend!');
756 throw thenable;
757 }
758
759 const [label, _setLabel] = useState('A');
760 setLabel = _setLabel;
761
762 return <Text text={`${label}:${counter}`} />;
763 }
764
765 const root = ReactNoop.createRoot();
766 root.render(<Foo suspend={false} />);
767
768 await waitForAll(['A:0']);
769 expect(root).toMatchRenderedOutput(<span prop="A:0" />);
770
771 await act(async () => {
772 React.startTransition(() => {
773 root.render(<Foo suspend={true} />);
774 setLabel('B');
775 });
776
777 await waitForAll(['Suspend!']);
778 expect(root).toMatchRenderedOutput(<span prop="A:0" />);
779
780 React.startTransition(() => {
781 root.render(<Foo suspend={false} />);
782 });
783
784 await waitForAll(['B:0']);
785 expect(root).toMatchRenderedOutput(<span prop="B:0" />);
786 });
787 },
788 );
789
790 it('regression: render phase updates cause lower pri work to be dropped', async () => {
791 let setRow;
792 function ScrollView() {
793 const [row, _setRow] = useState(10);
794 setRow = _setRow;
795
796 const [scrollDirection, setScrollDirection] = useState('Up');
797 const [prevRow, setPrevRow] = useState(null);
798
799 if (prevRow !== row) {
800 setScrollDirection(prevRow !== null && row > prevRow ? 'Down' : 'Up');
801 setPrevRow(row);
802 }
803
804 return <Text text={scrollDirection} />;
805 }
806
807 const root = ReactNoop.createRoot();
808
809 await act(() => {
810 root.render(<ScrollView row={10} />);
811 });
812 assertLog(['Up']);
813 expect(root).toMatchRenderedOutput(<span prop="Up" />);
814
815 await act(() => {
816 ReactNoop.discreteUpdates(() => {
817 setRow(5);
818 });
819 React.startTransition(() => {
820 setRow(20);
821 });
822 });
823 assertLog(['Up', 'Down']);
824 expect(root).toMatchRenderedOutput(<span prop="Down" />);
825 });
826
827 // TODO: This should probably warn
828 it('calling startTransition inside render phase', async () => {
829 function App() {
830 const [counter, setCounter] = useState(0);
831
832 if (counter === 0) {
833 React.startTransition(() => {
834 setCounter(c => c + 1);
835 });
836 }
837
838 return <Text text={counter} />;
839 }
840
841 const root = ReactNoop.createRoot();
842 root.render(<App />);
843 await waitForAll([1]);
844 expect(root).toMatchRenderedOutput(<span prop={1} />);
845 });
846 });
847
848 describe('useReducer', () => {
849 it('simple mount and update', async () => {
850 const INCREMENT = 'INCREMENT';
851 const DECREMENT = 'DECREMENT';
852
853 function reducer(state, action) {
854 switch (action) {
855 case 'INCREMENT':
856 return state + 1;
857 case 'DECREMENT':
858 return state - 1;
859 default:
860 return state;
861 }
862 }
863
864 function Counter(props, ref) {
865 const [count, dispatch] = useReducer(reducer, 0);
866 useImperativeHandle(ref, () => ({dispatch}));
867 return <Text text={'Count: ' + count} />;
868 }
869 Counter = forwardRef(Counter);
870 const counter = React.createRef(null);
871 ReactNoop.render(<Counter ref={counter} />);
872 await waitForAll(['Count: 0']);
873 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
874
875 await act(() => counter.current.dispatch(INCREMENT));
876 assertLog(['Count: 1']);
877 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);
878 await act(() => {
879 counter.current.dispatch(DECREMENT);
880 counter.current.dispatch(DECREMENT);
881 counter.current.dispatch(DECREMENT);
882 });
883
884 assertLog(['Count: -2']);
885 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: -2" />);
886 });
887
888 it('lazy init', async () => {
889 const INCREMENT = 'INCREMENT';
890 const DECREMENT = 'DECREMENT';
891
892 function reducer(state, action) {
893 switch (action) {
894 case 'INCREMENT':
895 return state + 1;
896 case 'DECREMENT':
897 return state - 1;
898 default:
899 return state;
900 }
901 }
902
903 function Counter(props, ref) {
904 const [count, dispatch] = useReducer(reducer, props, p => {
905 Scheduler.log('Init');
906 return p.initialCount;
907 });
908 useImperativeHandle(ref, () => ({dispatch}));
909 return <Text text={'Count: ' + count} />;
910 }
911 Counter = forwardRef(Counter);
912 const counter = React.createRef(null);
913 ReactNoop.render(<Counter initialCount={10} ref={counter} />);
914 await waitForAll(['Init', 'Count: 10']);
915 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 10" />);
916
917 await act(() => counter.current.dispatch(INCREMENT));
918 assertLog(['Count: 11']);
919 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 11" />);
920
921 await act(() => {
922 counter.current.dispatch(DECREMENT);
923 counter.current.dispatch(DECREMENT);
924 counter.current.dispatch(DECREMENT);
925 });
926
927 assertLog(['Count: 8']);
928 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 8" />);
929 });
930
931 // Regression test for https://github.com/facebook/react/issues/14360
932 it('handles dispatches with mixed priorities', async () => {
933 const INCREMENT = 'INCREMENT';
934
935 function reducer(state, action) {
936 return action === INCREMENT ? state + 1 : state;
937 }
938
939 function Counter(props, ref) {
940 const [count, dispatch] = useReducer(reducer, 0);
941 useImperativeHandle(ref, () => ({dispatch}));
942 return <Text text={'Count: ' + count} />;
943 }
944
945 Counter = forwardRef(Counter);
946 const counter = React.createRef(null);
947 ReactNoop.render(<Counter ref={counter} />);
948
949 await waitForAll(['Count: 0']);
950 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
951
952 ReactNoop.batchedUpdates(() => {
953 counter.current.dispatch(INCREMENT);
954 counter.current.dispatch(INCREMENT);
955 counter.current.dispatch(INCREMENT);
956 });
957
958 ReactNoop.flushSync(() => {
959 counter.current.dispatch(INCREMENT);
960 });
961 assertLog(['Count: 4']);
962 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 4" />);
963 });
964 });
965
966 describe('useEffect', () => {
967 it('simple mount and update', async () => {
968 function Counter(props) {
969 useEffect(() => {
970 Scheduler.log(`Passive effect [${props.count}]`);
971 });
972 return <Text text={'Count: ' + props.count} />;
973 }
974 await act(async () => {
975 ReactNoop.render(<Counter count={0} />, () =>
976 Scheduler.log('Sync effect'),
977 );
978 await waitFor(['Count: 0', 'Sync effect']);
979 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
980 // Effects are deferred until after the commit
981 await waitForAll(['Passive effect [0]']);
982 });
983
984 await act(async () => {
985 ReactNoop.render(<Counter count={1} />, () =>
986 Scheduler.log('Sync effect'),
987 );
988 await waitFor(['Count: 1', 'Sync effect']);
989 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);
990 // Effects are deferred until after the commit
991 await waitForAll(['Passive effect [1]']);
992 });
993 });
994
995 it('flushes passive effects even with sibling deletions', async () => {
996 function LayoutEffect(props) {
997 useLayoutEffect(() => {
998 Scheduler.log(`Layout effect`);
999 });
1000 return <Text text="Layout" />;
1001 }
1002 function PassiveEffect(props) {
1003 useEffect(() => {
1004 Scheduler.log(`Passive effect`);
1005 }, []);
1006 return <Text text="Passive" />;
1007 }
1008 const passive = <PassiveEffect key="p" />;
1009 await act(async () => {
1010 ReactNoop.render([<LayoutEffect key="l" />, passive]);
1011 await waitFor(['Layout', 'Passive', 'Layout effect']);
1012 expect(ReactNoop).toMatchRenderedOutput(
1013 <>
1014 <span prop="Layout" />
1015 <span prop="Passive" />
1016 </>,
1017 );
1018 // Destroying the first child shouldn't prevent the passive effect from
1019 // being executed
1020 ReactNoop.render([passive]);
1021 await waitForAll(['Passive effect']);
1022 expect(ReactNoop).toMatchRenderedOutput(<span prop="Passive" />);
1023 });
1024 // exiting act calls flushPassiveEffects(), but there are none left to flush.
1025 assertLog([]);
1026 });
1027
1028 it('flushes passive effects even if siblings schedule an update', async () => {
1029 function PassiveEffect(props) {
1030 useEffect(() => {
1031 Scheduler.log('Passive effect');
1032 });
1033 return <Text text="Passive" />;
1034 }
1035 function LayoutEffect(props) {
1036 const [count, setCount] = useState(0);
1037 useLayoutEffect(() => {
1038 // Scheduling work shouldn't interfere with the queued passive effect
1039 if (count === 0) {
1040 setCount(1);
1041 }
1042 Scheduler.log('Layout effect ' + count);
1043 });
1044 return <Text text="Layout" />;
1045 }
1046
1047 ReactNoop.render([<PassiveEffect key="p" />, <LayoutEffect key="l" />]);
1048
1049 await act(async () => {
1050 await waitForAll([
1051 'Passive',
1052 'Layout',
1053 'Layout effect 0',
1054 'Passive effect',
1055 'Layout',
1056 'Layout effect 1',
1057 ]);
1058 });
1059
1060 expect(ReactNoop).toMatchRenderedOutput(
1061 <>
1062 <span prop="Passive" />
1063 <span prop="Layout" />
1064 </>,
1065 );
1066 });
1067
1068 it('flushes passive effects even if siblings schedule a new root', async () => {
1069 function PassiveEffect(props) {
1070 useEffect(() => {
1071 Scheduler.log('Passive effect');
1072 }, []);
1073 return <Text text="Passive" />;
1074 }
1075 function LayoutEffect(props) {
1076 useLayoutEffect(() => {
1077 Scheduler.log('Layout effect');
1078 // Scheduling work shouldn't interfere with the queued passive effect
1079 ReactNoop.renderToRootWithID(<Text text="New Root" />, 'root2');
1080 });
1081 return <Text text="Layout" />;
1082 }
1083 await act(async () => {
1084 ReactNoop.render([<PassiveEffect key="p" />, <LayoutEffect key="l" />]);
1085 await waitForAll([
1086 'Passive',
1087 'Layout',
1088 'Layout effect',
1089 'Passive effect',
1090 'New Root',
1091 ]);
1092 expect(ReactNoop).toMatchRenderedOutput(
1093 <>
1094 <span prop="Passive" />
1095 <span prop="Layout" />
1096 </>,
1097 );
1098 });
1099 });
1100
1101 it(
1102 'flushes effects serially by flushing old effects before flushing ' +
1103 "new ones, if they haven't already fired",
1104 async () => {
1105 function getCommittedText() {
1106 const children = ReactNoop.getChildrenAsJSX();
1107 if (children === null) {
1108 return null;
1109 }
1110 return children.props.prop;
1111 }
1112
1113 function Counter(props) {
1114 useEffect(() => {
1115 Scheduler.log(
1116 `Committed state when effect was fired: ${getCommittedText()}`,
1117 );
1118 });
1119 return <Text text={props.count} />;
1120 }
1121 await act(async () => {
1122 ReactNoop.render(<Counter count={0} />, () =>
1123 Scheduler.log('Sync effect'),
1124 );
1125 await waitFor([0, 'Sync effect']);
1126 expect(ReactNoop).toMatchRenderedOutput(<span prop={0} />);
1127 // Before the effects have a chance to flush, schedule another update
1128 ReactNoop.render(<Counter count={1} />, () =>
1129 Scheduler.log('Sync effect'),
1130 );
1131 await waitFor([
1132 // The previous effect flushes before the reconciliation
1133 'Committed state when effect was fired: 0',
1134 1,
1135 'Sync effect',
1136 ]);
1137 expect(ReactNoop).toMatchRenderedOutput(<span prop={1} />);
1138 });
1139
1140 assertLog(['Committed state when effect was fired: 1']);
1141 },
1142 );
1143
1144 it('defers passive effect destroy functions during unmount', async () => {
1145 function Child({bar, foo}) {
1146 React.useEffect(() => {
1147 Scheduler.log('passive bar create');
1148 return () => {
1149 Scheduler.log('passive bar destroy');
1150 };
1151 }, [bar]);
1152 React.useLayoutEffect(() => {
1153 Scheduler.log('layout bar create');
1154 return () => {
1155 Scheduler.log('layout bar destroy');
1156 };
1157 }, [bar]);
1158 React.useEffect(() => {
1159 Scheduler.log('passive foo create');
1160 return () => {
1161 Scheduler.log('passive foo destroy');
1162 };
1163 }, [foo]);
1164 React.useLayoutEffect(() => {
1165 Scheduler.log('layout foo create');
1166 return () => {
1167 Scheduler.log('layout foo destroy');
1168 };
1169 }, [foo]);
1170 Scheduler.log('render');
1171 return null;
1172 }
1173
1174 await act(async () => {
1175 ReactNoop.render(<Child bar={1} foo={1} />, () =>
1176 Scheduler.log('Sync effect'),
1177 );
1178 await waitFor([
1179 'render',
1180 'layout bar create',
1181 'layout foo create',
1182 'Sync effect',
1183 ]);
1184 // Effects are deferred until after the commit
1185 await waitForAll(['passive bar create', 'passive foo create']);
1186 });
1187
1188 // This update exists to test an internal implementation detail:
1189 // Effects without updating dependencies lose their layout/passive tag during an update.
1190 await act(async () => {
1191 ReactNoop.render(<Child bar={1} foo={2} />, () =>
1192 Scheduler.log('Sync effect'),
1193 );
1194 await waitFor([
1195 'render',
1196 'layout foo destroy',
1197 'layout foo create',
1198 'Sync effect',
1199 ]);
1200 // Effects are deferred until after the commit
1201 await waitForAll(['passive foo destroy', 'passive foo create']);
1202 });
1203
1204 // Unmount the component and verify that passive destroy functions are deferred until post-commit.
1205 await act(async () => {
1206 ReactNoop.render(null, () => Scheduler.log('Sync effect'));
1207 await waitFor([
1208 'layout bar destroy',
1209 'layout foo destroy',
1210 'Sync effect',
1211 ]);
1212 // Effects are deferred until after the commit
1213 await waitForAll(['passive bar destroy', 'passive foo destroy']);
1214 });
1215 });
1216
1217 it('does not warn about state updates for unmounted components with pending passive unmounts', async () => {
1218 let completePendingRequest = null;
1219 function Component() {
1220 Scheduler.log('Component');
1221 const [didLoad, setDidLoad] = React.useState(false);
1222 React.useLayoutEffect(() => {
1223 Scheduler.log('layout create');
1224 return () => {
1225 Scheduler.log('layout destroy');
1226 };
1227 }, []);
1228 React.useEffect(() => {
1229 Scheduler.log('passive create');
1230 // Mimic an XHR request with a complete handler that updates state.
1231 completePendingRequest = () => setDidLoad(true);
1232 return () => {
1233 Scheduler.log('passive destroy');
1234 };
1235 }, []);
1236 return didLoad;
1237 }
1238
1239 await act(async () => {
1240 ReactNoop.renderToRootWithID(<Component />, 'root', () =>
1241 Scheduler.log('Sync effect'),
1242 );
1243 await waitFor(['Component', 'layout create', 'Sync effect']);
1244 ReactNoop.flushPassiveEffects();
1245 assertLog(['passive create']);
1246
1247 // Unmount but don't process pending passive destroy function
1248 ReactNoop.unmountRootWithID('root');
1249 await waitFor(['layout destroy']);
1250
1251 // Simulate an XHR completing, which will cause a state update-
1252 // but should not log a warning.
1253 completePendingRequest();
1254
1255 ReactNoop.flushPassiveEffects();
1256 assertLog(['passive destroy']);
1257 });
1258 });
1259
1260 it('does not warn about state updates for unmounted components with pending passive unmounts for alternates', async () => {
1261 let setParentState = null;
1262 const setChildStates = [];
1263
1264 function Parent() {
1265 const [state, setState] = useState(true);
1266 setParentState = setState;
1267 Scheduler.log(`Parent ${state} render`);
1268 useLayoutEffect(() => {
1269 Scheduler.log(`Parent ${state} commit`);
1270 });
1271 if (state) {
1272 return (
1273 <>
1274 <Child label="one" />
1275 <Child label="two" />
1276 </>
1277 );
1278 } else {
1279 return null;
1280 }
1281 }
1282
1283 function Child({label}) {
1284 const [state, setState] = useState(0);
1285 useLayoutEffect(() => {
1286 Scheduler.log(`Child ${label} commit`);
1287 });
1288 useEffect(() => {
1289 setChildStates.push(setState);
1290 Scheduler.log(`Child ${label} passive create`);
1291 return () => {
1292 Scheduler.log(`Child ${label} passive destroy`);
1293 };
1294 }, []);
1295 Scheduler.log(`Child ${label} render`);
1296 return state;
1297 }
1298
1299 // Schedule debounced state update for child (prob a no-op for this test)
1300 // later tick: schedule unmount for parent
1301 // start process unmount (but don't flush passive effectS)
1302 // State update on child
1303 await act(async () => {
1304 ReactNoop.render(<Parent />);
1305 await waitFor([
1306 'Parent true render',
1307 'Child one render',
1308 'Child two render',
1309 'Child one commit',
1310 'Child two commit',
1311 'Parent true commit',
1312 'Child one passive create',
1313 'Child two passive create',
1314 ]);
1315
1316 // Update children.
1317 setChildStates.forEach(setChildState => setChildState(1));
1318 await waitFor([
1319 'Child one render',
1320 'Child two render',
1321 'Child one commit',
1322 'Child two commit',
1323 ]);
1324
1325 // Schedule another update for children, and partially process it.
1326 React.startTransition(() => {
1327 setChildStates.forEach(setChildState => setChildState(2));
1328 });
1329 await waitFor(['Child one render']);
1330
1331 // Schedule unmount for the parent that unmounts children with pending update.
1332 ReactNoop.unstable_runWithPriority(ContinuousEventPriority, () => {
1333 setParentState(false);
1334 });
1335 await waitForPaint(['Parent false render', 'Parent false commit']);
1336
1337 // Schedule updates for children too (which should be ignored)
1338 setChildStates.forEach(setChildState => setChildState(2));
1339 await waitForAll([
1340 'Child one passive destroy',
1341 'Child two passive destroy',
1342 ]);
1343 });
1344 });
1345
1346 it('does not warn about state updates for unmounted components with no pending passive unmounts', async () => {
1347 let completePendingRequest = null;
1348 function Component() {
1349 Scheduler.log('Component');
1350 const [didLoad, setDidLoad] = React.useState(false);
1351 React.useLayoutEffect(() => {
1352 Scheduler.log('layout create');
1353 // Mimic an XHR request with a complete handler that updates state.
1354 completePendingRequest = () => setDidLoad(true);
1355 return () => {
1356 Scheduler.log('layout destroy');
1357 };
1358 }, []);
1359 return didLoad;
1360 }
1361
1362 await act(async () => {
1363 ReactNoop.renderToRootWithID(<Component />, 'root', () =>
1364 Scheduler.log('Sync effect'),
1365 );
1366 await waitFor(['Component', 'layout create', 'Sync effect']);
1367
1368 // Unmount but don't process pending passive destroy function
1369 ReactNoop.unmountRootWithID('root');
1370 await waitFor(['layout destroy']);
1371
1372 // Simulate an XHR completing.
1373 completePendingRequest();
1374 });
1375 });
1376
1377 it('does not warn if there are pending passive unmount effects but not for the current fiber', async () => {
1378 let completePendingRequest = null;
1379 function ComponentWithXHR() {
1380 Scheduler.log('Component');
1381 const [didLoad, setDidLoad] = React.useState(false);
1382 React.useLayoutEffect(() => {
1383 Scheduler.log('a:layout create');
1384 return () => {
1385 Scheduler.log('a:layout destroy');
1386 };
1387 }, []);
1388 React.useEffect(() => {
1389 Scheduler.log('a:passive create');
1390 // Mimic an XHR request with a complete handler that updates state.
1391 completePendingRequest = () => setDidLoad(true);
1392 }, []);
1393 return didLoad;
1394 }
1395
1396 function ComponentWithPendingPassiveUnmount() {
1397 React.useEffect(() => {
1398 Scheduler.log('b:passive create');
1399 return () => {
1400 Scheduler.log('b:passive destroy');
1401 };
1402 }, []);
1403 return null;
1404 }
1405
1406 await act(async () => {
1407 ReactNoop.renderToRootWithID(
1408 <>
1409 <ComponentWithXHR />
1410 <ComponentWithPendingPassiveUnmount />
1411 </>,
1412 'root',
1413 () => Scheduler.log('Sync effect'),
1414 );
1415 await waitFor(['Component', 'a:layout create', 'Sync effect']);
1416 ReactNoop.flushPassiveEffects();
1417 assertLog(['a:passive create', 'b:passive create']);
1418
1419 // Unmount but don't process pending passive destroy function
1420 ReactNoop.unmountRootWithID('root');
1421 await waitFor(['a:layout destroy']);
1422
1423 // Simulate an XHR completing in the component without a pending passive effect..
1424 completePendingRequest();
1425 });
1426 });
1427
1428 it('does not warn if there are updates after pending passive unmount effects have been flushed', async () => {
1429 let updaterFunction;
1430
1431 function Component() {
1432 Scheduler.log('Component');
1433 const [state, setState] = React.useState(false);
1434 updaterFunction = setState;
1435 React.useEffect(() => {
1436 Scheduler.log('passive create');
1437 return () => {
1438 Scheduler.log('passive destroy');
1439 };
1440 }, []);
1441 return state;
1442 }
1443
1444 await act(() => {
1445 ReactNoop.renderToRootWithID(<Component />, 'root', () =>
1446 Scheduler.log('Sync effect'),
1447 );
1448 });
1449 assertLog(['Component', 'Sync effect', 'passive create']);
1450
1451 ReactNoop.unmountRootWithID('root');
1452 await waitForAll(['passive destroy']);
1453
1454 await act(() => {
1455 updaterFunction(true);
1456 });
1457 });
1458
1459 it('does not show a warning when a component updates its own state from within passive unmount function', async () => {
1460 function Component() {
1461 Scheduler.log('Component');
1462 const [didLoad, setDidLoad] = React.useState(false);
1463 React.useEffect(() => {
1464 Scheduler.log('passive create');
1465 return () => {
1466 setDidLoad(true);
1467 Scheduler.log('passive destroy');
1468 };
1469 }, []);
1470 return didLoad;
1471 }
1472
1473 await act(async () => {
1474 ReactNoop.renderToRootWithID(<Component />, 'root', () =>
1475 Scheduler.log('Sync effect'),
1476 );
1477 await waitFor(['Component', 'Sync effect', 'passive create']);
1478
1479 // Unmount but don't process pending passive destroy function
1480 ReactNoop.unmountRootWithID('root');
1481 await waitForAll(['passive destroy']);
1482 });
1483 });
1484
1485 it('does not show a warning when a component updates a child state from within passive unmount function', async () => {
1486 function Parent() {
1487 Scheduler.log('Parent');
1488 const updaterRef = useRef(null);
1489 React.useEffect(() => {
1490 Scheduler.log('Parent passive create');
1491 return () => {
1492 updaterRef.current(true);
1493 Scheduler.log('Parent passive destroy');
1494 };
1495 }, []);
1496 return <Child updaterRef={updaterRef} />;
1497 }
1498
1499 function Child({updaterRef}) {
1500 Scheduler.log('Child');
1501 const [state, setState] = React.useState(false);
1502 React.useEffect(() => {
1503 Scheduler.log('Child passive create');
1504 updaterRef.current = setState;
1505 }, []);
1506 return state;
1507 }
1508
1509 await act(async () => {
1510 ReactNoop.renderToRootWithID(<Parent />, 'root');
1511 await waitFor([
1512 'Parent',
1513 'Child',
1514 'Child passive create',
1515 'Parent passive create',
1516 ]);
1517
1518 // Unmount but don't process pending passive destroy function
1519 ReactNoop.unmountRootWithID('root');
1520 await waitForAll(['Parent passive destroy']);
1521 });
1522 });
1523
1524 it('does not show a warning when a component updates a parents state from within passive unmount function', async () => {
1525 function Parent() {
1526 const [state, setState] = React.useState(false);
1527 Scheduler.log('Parent');
1528 return <Child setState={setState} state={state} />;
1529 }
1530
1531 function Child({setState, state}) {
1532 Scheduler.log('Child');
1533 React.useEffect(() => {
1534 Scheduler.log('Child passive create');
1535 return () => {
1536 Scheduler.log('Child passive destroy');
1537 setState(true);
1538 };
1539 }, []);
1540 return state;
1541 }
1542
1543 await act(async () => {
1544 ReactNoop.renderToRootWithID(<Parent />, 'root');
1545 await waitFor(['Parent', 'Child', 'Child passive create']);
1546
1547 // Unmount but don't process pending passive destroy function
1548 ReactNoop.unmountRootWithID('root');
1549 await waitForAll(['Child passive destroy']);
1550 });
1551 });
1552
1553 it('updates have async priority', async () => {
1554 function Counter(props) {
1555 const [count, updateCount] = useState('(empty)');
1556 useEffect(() => {
1557 Scheduler.log(`Schedule update [${props.count}]`);
1558 updateCount(props.count);
1559 }, [props.count]);
1560 return <Text text={'Count: ' + count} />;
1561 }
1562 await act(async () => {
1563 ReactNoop.render(<Counter count={0} />, () =>
1564 Scheduler.log('Sync effect'),
1565 );
1566 await waitFor(['Count: (empty)', 'Sync effect']);
1567 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: (empty)" />);
1568 ReactNoop.flushPassiveEffects();
1569 assertLog(['Schedule update [0]']);
1570 await waitForAll(['Count: 0']);
1571 });
1572
1573 await act(async () => {
1574 ReactNoop.render(<Counter count={1} />, () =>
1575 Scheduler.log('Sync effect'),
1576 );
1577 await waitFor(['Count: 0', 'Sync effect']);
1578 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
1579 ReactNoop.flushPassiveEffects();
1580 assertLog(['Schedule update [1]']);
1581 await waitForAll(['Count: 1']);
1582 });
1583 });
1584
1585 it('updates have async priority even if effects are flushed early', async () => {
1586 function Counter(props) {
1587 const [count, updateCount] = useState('(empty)');
1588 useEffect(() => {
1589 Scheduler.log(`Schedule update [${props.count}]`);
1590 updateCount(props.count);
1591 }, [props.count]);
1592 return <Text text={'Count: ' + count} />;
1593 }
1594 await act(async () => {
1595 ReactNoop.render(<Counter count={0} />, () =>
1596 Scheduler.log('Sync effect'),
1597 );
1598 await waitFor(['Count: (empty)', 'Sync effect']);
1599 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: (empty)" />);
1600
1601 // Rendering again should flush the previous commit's effects
1602 React.startTransition(() => {
1603 ReactNoop.render(<Counter count={1} />, () =>
1604 Scheduler.log('Sync effect'),
1605 );
1606 });
1607
1608 await waitFor(['Schedule update [0]', 'Count: 0']);
1609
1610 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
1611 await waitFor([
1612 'Count: 0',
1613 'Sync effect',
1614 'Schedule update [1]',
1615 'Count: 1',
1616 ]);
1617
1618 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);
1619 });
1620 });
1621
1622 it('does not flush non-discrete passive effects when flushing sync', async () => {
1623 let _updateCount;
1624 function Counter(props) {
1625 const [count, updateCount] = useState(0);
1626 _updateCount = updateCount;
1627 useEffect(() => {
1628 Scheduler.log(`Will set count to 1`);
1629 updateCount(1);
1630 }, []);
1631 return <Text text={'Count: ' + count} />;
1632 }
1633
1634 ReactNoop.render(<Counter count={0} />, () =>
1635 Scheduler.log('Sync effect'),
1636 );
1637 await waitFor(['Count: 0', 'Sync effect']);
1638 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
1639 // A flush sync doesn't cause the passive effects to fire.
1640 // So we haven't added the other update yet.
1641 await act(() => {
1642 ReactNoop.flushSync(() => {
1643 _updateCount(2);
1644 });
1645 });
1646
1647 // As a result we, somewhat surprisingly, commit them in the opposite order.
1648 // This should be fine because any non-discrete set of work doesn't guarantee order
1649 // and easily could've happened slightly later too.
1650 assertLog(['Will set count to 1', 'Count: 1']);
1651
1652 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);
1653 });
1654
1655 // @gate !disableLegacyMode
1656 it(
1657 'in legacy mode, useEffect is deferred and updates finish synchronously ' +
1658 '(in a single batch)',
1659 async () => {
1660 function Counter(props) {
1661 const [count, updateCount] = useState('(empty)');
1662 useEffect(() => {
1663 // Update multiple times. These should all be batched together in
1664 // a single render.
1665 updateCount(props.count);
1666 updateCount(props.count);
1667 updateCount(props.count);
1668 updateCount(props.count);
1669 updateCount(props.count);
1670 updateCount(props.count);
1671 }, [props.count]);
1672 return <Text text={'Count: ' + count} />;
1673 }
1674 await act(() => {
1675 ReactNoop.flushSync(() => {
1676 ReactNoop.renderLegacySyncRoot(<Counter count={0} />);
1677 });
1678
1679 // Even in legacy mode, effects are deferred until after paint
1680 assertLog(['Count: (empty)']);
1681 expect(ReactNoop).toMatchRenderedOutput(
1682 <span prop="Count: (empty)" />,
1683 );
1684 });
1685
1686 // effects get forced on exiting act()
1687 // There were multiple updates, but there should only be a
1688 // single render
1689 assertLog(['Count: 0']);
1690 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
1691 },
1692 );
1693
1694 it('flushSync is not allowed', async () => {
1695 function Counter(props) {
1696 const [count, updateCount] = useState('(empty)');
1697 useEffect(() => {
1698 Scheduler.log(`Schedule update [${props.count}]`);
1699 ReactNoop.flushSync(() => {
1700 updateCount(props.count);
1701 });
1702 assertLog([`Schedule update [${props.count}]`]);
1703 assertConsoleErrorDev([
1704 'flushSync was called from inside a lifecycle method. ' +
1705 'React cannot flush when React is already rendering. ' +
1706 'Consider moving this call to a scheduler task or micro task.\n' +
1707 ' in Counter (at **)',
1708 ]);
1709 // This shouldn't flush synchronously.
1710 expect(ReactNoop).not.toMatchRenderedOutput(
1711 <span prop={`Count: ${props.count}`} />,
1712 );
1713 }, [props.count]);
1714 return <Text text={'Count: ' + count} />;
1715 }
1716 await act(async () => {
1717 ReactNoop.render(<Counter count={0} />, () =>
1718 Scheduler.log('Sync effect'),
1719 );
1720 await waitFor(['Count: (empty)', 'Sync effect']);
1721 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: (empty)" />);
1722 });
1723
1724 assertLog([`Count: 0`]);
1725 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
1726 });
1727
1728 it('unmounts previous effect', async () => {
1729 function Counter(props) {
1730 useEffect(() => {
1731 Scheduler.log(`Did create [${props.count}]`);
1732 return () => {
1733 Scheduler.log(`Did destroy [${props.count}]`);
1734 };
1735 });
1736 return <Text text={'Count: ' + props.count} />;
1737 }
1738 await act(async () => {
1739 ReactNoop.render(<Counter count={0} />, () =>
1740 Scheduler.log('Sync effect'),
1741 );
1742 await waitFor(['Count: 0', 'Sync effect']);
1743 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
1744 });
1745
1746 assertLog(['Did create [0]']);
1747
1748 await act(async () => {
1749 ReactNoop.render(<Counter count={1} />, () =>
1750 Scheduler.log('Sync effect'),
1751 );
1752 await waitFor(['Count: 1', 'Sync effect']);
1753 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);
1754 });
1755
1756 assertLog(['Did destroy [0]', 'Did create [1]']);
1757 });
1758
1759 it('unmounts on deletion', async () => {
1760 function Counter(props) {
1761 useEffect(() => {
1762 Scheduler.log(`Did create [${props.count}]`);
1763 return () => {
1764 Scheduler.log(`Did destroy [${props.count}]`);
1765 };
1766 });
1767 return <Text text={'Count: ' + props.count} />;
1768 }
1769 await act(async () => {
1770 ReactNoop.render(<Counter count={0} />, () =>
1771 Scheduler.log('Sync effect'),
1772 );
1773 await waitFor(['Count: 0', 'Sync effect']);
1774 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
1775 });
1776
1777 assertLog(['Did create [0]']);
1778
1779 ReactNoop.render(null);
1780 await waitForAll(['Did destroy [0]']);
1781 expect(ReactNoop).toMatchRenderedOutput(null);
1782 });
1783
1784 it('unmounts on deletion after skipped effect', async () => {
1785 function Counter(props) {
1786 useEffect(() => {
1787 Scheduler.log(`Did create [${props.count}]`);
1788 return () => {
1789 Scheduler.log(`Did destroy [${props.count}]`);
1790 };
1791 }, []);
1792 return <Text text={'Count: ' + props.count} />;
1793 }
1794 await act(async () => {
1795 ReactNoop.render(<Counter count={0} />, () =>
1796 Scheduler.log('Sync effect'),
1797 );
1798 await waitFor(['Count: 0', 'Sync effect']);
1799 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
1800 });
1801
1802 assertLog(['Did create [0]']);
1803
1804 await act(async () => {
1805 ReactNoop.render(<Counter count={1} />, () =>
1806 Scheduler.log('Sync effect'),
1807 );
1808 await waitFor(['Count: 1', 'Sync effect']);
1809 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);
1810 });
1811
1812 assertLog([]);
1813
1814 ReactNoop.render(null);
1815 await waitForAll(['Did destroy [0]']);
1816 expect(ReactNoop).toMatchRenderedOutput(null);
1817 });
1818
1819 it('always fires effects if no dependencies are provided', async () => {
1820 function effect() {
1821 Scheduler.log(`Did create`);
1822 return () => {
1823 Scheduler.log(`Did destroy`);
1824 };
1825 }
1826 function Counter(props) {
1827 useEffect(effect);
1828 return <Text text={'Count: ' + props.count} />;
1829 }
1830 await act(async () => {
1831 ReactNoop.render(<Counter count={0} />, () =>
1832 Scheduler.log('Sync effect'),
1833 );
1834 await waitFor(['Count: 0', 'Sync effect']);
1835 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
1836 });
1837
1838 assertLog(['Did create']);
1839
1840 await act(async () => {
1841 ReactNoop.render(<Counter count={1} />, () =>
1842 Scheduler.log('Sync effect'),
1843 );
1844 await waitFor(['Count: 1', 'Sync effect']);
1845 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);
1846 });
1847
1848 assertLog(['Did destroy', 'Did create']);
1849
1850 ReactNoop.render(null);
1851 await waitForAll(['Did destroy']);
1852 expect(ReactNoop).toMatchRenderedOutput(null);
1853 });
1854
1855 it('skips effect if inputs have not changed', async () => {
1856 function Counter(props) {
1857 const text = `${props.label}: ${props.count}`;
1858 useEffect(() => {
1859 Scheduler.log(`Did create [${text}]`);
1860 return () => {
1861 Scheduler.log(`Did destroy [${text}]`);
1862 };
1863 }, [props.label, props.count]);
1864 return <Text text={text} />;
1865 }
1866 await act(async () => {
1867 ReactNoop.render(<Counter label="Count" count={0} />, () =>
1868 Scheduler.log('Sync effect'),
1869 );
1870 await waitFor(['Count: 0', 'Sync effect']);
1871 });
1872
1873 assertLog(['Did create [Count: 0]']);
1874 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
1875
1876 await act(async () => {
1877 ReactNoop.render(<Counter label="Count" count={1} />, () =>
1878 Scheduler.log('Sync effect'),
1879 );
1880 // Count changed
1881 await waitFor(['Count: 1', 'Sync effect']);
1882 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);
1883 });
1884
1885 assertLog(['Did destroy [Count: 0]', 'Did create [Count: 1]']);
1886
1887 await act(async () => {
1888 ReactNoop.render(<Counter label="Count" count={1} />, () =>
1889 Scheduler.log('Sync effect'),
1890 );
1891 // Nothing changed, so no effect should have fired
1892 await waitFor(['Count: 1', 'Sync effect']);
1893 });
1894
1895 assertLog([]);
1896 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);
1897
1898 await act(async () => {
1899 ReactNoop.render(<Counter label="Total" count={1} />, () =>
1900 Scheduler.log('Sync effect'),
1901 );
1902 // Label changed
1903 await waitFor(['Total: 1', 'Sync effect']);
1904 expect(ReactNoop).toMatchRenderedOutput(<span prop="Total: 1" />);
1905 });
1906
1907 assertLog(['Did destroy [Count: 1]', 'Did create [Total: 1]']);
1908 });
1909
1910 it('multiple effects', async () => {
1911 function Counter(props) {
1912 useEffect(() => {
1913 Scheduler.log(`Did commit 1 [${props.count}]`);
1914 });
1915 useEffect(() => {
1916 Scheduler.log(`Did commit 2 [${props.count}]`);
1917 });
1918 return <Text text={'Count: ' + props.count} />;
1919 }
1920 await act(async () => {
1921 ReactNoop.render(<Counter count={0} />, () =>
1922 Scheduler.log('Sync effect'),
1923 );
1924 await waitFor(['Count: 0', 'Sync effect']);
1925 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
1926 });
1927
1928 assertLog(['Did commit 1 [0]', 'Did commit 2 [0]']);
1929
1930 await act(async () => {
1931 ReactNoop.render(<Counter count={1} />, () =>
1932 Scheduler.log('Sync effect'),
1933 );
1934 await waitFor(['Count: 1', 'Sync effect']);
1935 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);
1936 });
1937 assertLog(['Did commit 1 [1]', 'Did commit 2 [1]']);
1938 });
1939
1940 it('unmounts all previous effects before creating any new ones', async () => {
1941 function Counter(props) {
1942 useEffect(() => {
1943 Scheduler.log(`Mount A [${props.count}]`);
1944 return () => {
1945 Scheduler.log(`Unmount A [${props.count}]`);
1946 };
1947 });
1948 useEffect(() => {
1949 Scheduler.log(`Mount B [${props.count}]`);
1950 return () => {
1951 Scheduler.log(`Unmount B [${props.count}]`);
1952 };
1953 });
1954 return <Text text={'Count: ' + props.count} />;
1955 }
1956 await act(async () => {
1957 ReactNoop.render(<Counter count={0} />, () =>
1958 Scheduler.log('Sync effect'),
1959 );
1960 await waitFor(['Count: 0', 'Sync effect']);
1961 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
1962 });
1963
1964 assertLog(['Mount A [0]', 'Mount B [0]']);
1965
1966 await act(async () => {
1967 ReactNoop.render(<Counter count={1} />, () =>
1968 Scheduler.log('Sync effect'),
1969 );
1970 await waitFor(['Count: 1', 'Sync effect']);
1971 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);
1972 });
1973 assertLog([
1974 'Unmount A [0]',
1975 'Unmount B [0]',
1976 'Mount A [1]',
1977 'Mount B [1]',
1978 ]);
1979 });
1980
1981 it('unmounts all previous effects between siblings before creating any new ones', async () => {
1982 function Counter({count, label}) {
1983 useEffect(() => {
1984 Scheduler.log(`Mount ${label} [${count}]`);
1985 return () => {
1986 Scheduler.log(`Unmount ${label} [${count}]`);
1987 };
1988 });
1989 return <Text text={`${label} ${count}`} />;
1990 }
1991 await act(async () => {
1992 ReactNoop.render(
1993 <>
1994 <Counter label="A" count={0} />
1995 <Counter label="B" count={0} />
1996 </>,
1997 () => Scheduler.log('Sync effect'),
1998 );
1999 await waitFor(['A 0', 'B 0', 'Sync effect']);
2000 expect(ReactNoop).toMatchRenderedOutput(
2001 <>
2002 <span prop="A 0" />
2003 <span prop="B 0" />
2004 </>,
2005 );
2006 });
2007
2008 assertLog(['Mount A [0]', 'Mount B [0]']);
2009
2010 await act(async () => {
2011 ReactNoop.render(
2012 <>
2013 <Counter label="A" count={1} />
2014 <Counter label="B" count={1} />
2015 </>,
2016 () => Scheduler.log('Sync effect'),
2017 );
2018 await waitFor(['A 1', 'B 1', 'Sync effect']);
2019 expect(ReactNoop).toMatchRenderedOutput(
2020 <>
2021 <span prop="A 1" />
2022 <span prop="B 1" />
2023 </>,
2024 );
2025 });
2026 assertLog([
2027 'Unmount A [0]',
2028 'Unmount B [0]',
2029 'Mount A [1]',
2030 'Mount B [1]',
2031 ]);
2032
2033 await act(async () => {
2034 ReactNoop.render(
2035 <>
2036 <Counter label="B" count={2} />
2037 <Counter label="C" count={0} />
2038 </>,
2039 () => Scheduler.log('Sync effect'),
2040 );
2041 await waitFor(['B 2', 'C 0', 'Sync effect']);
2042 expect(ReactNoop).toMatchRenderedOutput(
2043 <>
2044 <span prop="B 2" />
2045 <span prop="C 0" />
2046 </>,
2047 );
2048 });
2049 assertLog([
2050 'Unmount A [1]',
2051 'Unmount B [1]',
2052 'Mount B [2]',
2053 'Mount C [0]',
2054 ]);
2055 });
2056
2057 it('handles errors in create on mount', async () => {
2058 function Counter(props) {
2059 useEffect(() => {
2060 Scheduler.log(`Mount A [${props.count}]`);
2061 return () => {
2062 Scheduler.log(`Unmount A [${props.count}]`);
2063 };
2064 });
2065 useEffect(() => {
2066 Scheduler.log('Oops!');
2067 throw new Error('Oops!');
2068 // eslint-disable-next-line no-unreachable
2069 Scheduler.log(`Mount B [${props.count}]`);
2070 return () => {
2071 Scheduler.log(`Unmount B [${props.count}]`);
2072 };
2073 });
2074 return <Text text={'Count: ' + props.count} />;
2075 }
2076 await expect(async () => {
2077 await act(async () => {
2078 ReactNoop.render(<Counter count={0} />, () =>
2079 Scheduler.log('Sync effect'),
2080 );
2081 await waitFor(['Count: 0', 'Sync effect']);
2082 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
2083 });
2084 }).rejects.toThrow('Oops');
2085
2086 assertLog([
2087 'Mount A [0]',
2088 'Oops!',
2089 // Clean up effect A. There's no effect B to clean-up, because it
2090 // never mounted.
2091 'Unmount A [0]',
2092 ]);
2093 expect(ReactNoop).toMatchRenderedOutput(null);
2094 });
2095
2096 it('handles errors in create on update', async () => {
2097 function Counter(props) {
2098 useEffect(() => {
2099 Scheduler.log(`Mount A [${props.count}]`);
2100 return () => {
2101 Scheduler.log(`Unmount A [${props.count}]`);
2102 };
2103 });
2104 useEffect(() => {
2105 if (props.count === 1) {
2106 Scheduler.log('Oops!');
2107 throw new Error('Oops error!');
2108 }
2109 Scheduler.log(`Mount B [${props.count}]`);
2110 return () => {
2111 Scheduler.log(`Unmount B [${props.count}]`);
2112 };
2113 });
2114 return <Text text={'Count: ' + props.count} />;
2115 }
2116 await act(async () => {
2117 ReactNoop.render(<Counter count={0} />, () =>
2118 Scheduler.log('Sync effect'),
2119 );
2120 await waitFor(['Count: 0', 'Sync effect']);
2121 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
2122 ReactNoop.flushPassiveEffects();
2123 assertLog(['Mount A [0]', 'Mount B [0]']);
2124 });
2125
2126 await expect(async () => {
2127 await act(async () => {
2128 // This update will trigger an error
2129 ReactNoop.render(<Counter count={1} />, () =>
2130 Scheduler.log('Sync effect'),
2131 );
2132 await waitFor(['Count: 1', 'Sync effect']);
2133 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);
2134 ReactNoop.flushPassiveEffects();
2135 assertLog([
2136 'Unmount A [0]',
2137 'Unmount B [0]',
2138 'Mount A [1]',
2139 'Oops!',
2140 // Clean up effect A runs passively on unmount.
2141 // There's no effect B to clean-up, because it never mounted.
2142 'Unmount A [1]',
2143 ]);
2144 expect(ReactNoop).toMatchRenderedOutput(null);
2145 });
2146 }).rejects.toThrow('Oops error!');
2147 });
2148
2149 it('handles errors in destroy on update', async () => {
2150 function Counter(props) {
2151 useEffect(() => {
2152 Scheduler.log(`Mount A [${props.count}]`);
2153 return () => {
2154 Scheduler.log('Oops!');
2155 if (props.count === 0) {
2156 throw new Error('Oops error!');
2157 }
2158 };
2159 });
2160 useEffect(() => {
2161 Scheduler.log(`Mount B [${props.count}]`);
2162 return () => {
2163 Scheduler.log(`Unmount B [${props.count}]`);
2164 };
2165 });
2166 return <Text text={'Count: ' + props.count} />;
2167 }
2168
2169 await act(async () => {
2170 ReactNoop.render(<Counter count={0} />, () =>
2171 Scheduler.log('Sync effect'),
2172 );
2173 await waitFor(['Count: 0', 'Sync effect']);
2174 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
2175 ReactNoop.flushPassiveEffects();
2176 assertLog(['Mount A [0]', 'Mount B [0]']);
2177 });
2178
2179 await expect(async () => {
2180 await act(async () => {
2181 // This update will trigger an error during passive effect unmount
2182 ReactNoop.render(<Counter count={1} />, () =>
2183 Scheduler.log('Sync effect'),
2184 );
2185 await waitFor(['Count: 1', 'Sync effect']);
2186 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);
2187 ReactNoop.flushPassiveEffects();
2188
2189 // This branch enables a feature flag that flushes all passive destroys in a
2190 // separate pass before flushing any passive creates.
2191 // A result of this two-pass flush is that an error thrown from unmount does
2192 // not block the subsequent create functions from being run.
2193 assertLog([
2194 'Oops!',
2195 'Unmount B [0]',
2196 'Mount A [1]',
2197 'Mount B [1]',
2198 // <Counter> gets unmounted because an error is thrown above.
2199 // The remaining destroy functions are run later on unmount, since they're passive.
2200 // In this case, one of them throws again (because of how the test is written).
2201 'Oops!',
2202 'Unmount B [1]',
2203 ]);
2204 });
2205 }).rejects.toThrow('Oops error!');
2206
2207 expect(ReactNoop).toMatchRenderedOutput(null);
2208 });
2209
2210 it('works with memo', async () => {
2211 function Counter({count}) {
2212 useLayoutEffect(() => {
2213 Scheduler.log('Mount: ' + count);
2214 return () => Scheduler.log('Unmount: ' + count);
2215 });
2216 return <Text text={'Count: ' + count} />;
2217 }
2218 Counter = memo(Counter);
2219
2220 ReactNoop.render(<Counter count={0} />, () =>
2221 Scheduler.log('Sync effect'),
2222 );
2223 await waitFor(['Count: 0', 'Mount: 0', 'Sync effect']);
2224 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
2225
2226 ReactNoop.render(<Counter count={1} />, () =>
2227 Scheduler.log('Sync effect'),
2228 );
2229 await waitFor(['Count: 1', 'Unmount: 0', 'Mount: 1', 'Sync effect']);
2230 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);
2231
2232 ReactNoop.render(null);
2233 await waitFor(['Unmount: 1']);
2234 expect(ReactNoop).toMatchRenderedOutput(null);
2235 });
2236
2237 describe('errors thrown in passive destroy function within unmounted trees', () => {
2238 let BrokenUseEffectCleanup;
2239 let ErrorBoundary;
2240 let LogOnlyErrorBoundary;
2241
2242 beforeEach(() => {
2243 BrokenUseEffectCleanup = function () {
2244 useEffect(() => {
2245 Scheduler.log('BrokenUseEffectCleanup useEffect');
2246 return () => {
2247 Scheduler.log('BrokenUseEffectCleanup useEffect destroy');
2248 throw new Error('Expected error');
2249 };
2250 }, []);
2251
2252 return 'inner child';
2253 };
2254
2255 ErrorBoundary = class extends React.Component {
2256 state = {error: null};
2257 static getDerivedStateFromError(error) {
2258 Scheduler.log(`ErrorBoundary static getDerivedStateFromError`);
2259 return {error};
2260 }
2261 componentDidCatch(error, info) {
2262 Scheduler.log(`ErrorBoundary componentDidCatch`);
2263 }
2264 render() {
2265 if (this.state.error) {
2266 Scheduler.log('ErrorBoundary render error');
2267 return <span prop="ErrorBoundary fallback" />;
2268 }
2269 Scheduler.log('ErrorBoundary render success');
2270 return this.props.children || null;
2271 }
2272 };
2273
2274 LogOnlyErrorBoundary = class extends React.Component {
2275 componentDidCatch(error, info) {
2276 Scheduler.log(`LogOnlyErrorBoundary componentDidCatch`);
2277 }
2278 render() {
2279 Scheduler.log(`LogOnlyErrorBoundary render`);
2280 return this.props.children || null;
2281 }
2282 };
2283 });
2284
2285 it('should use the nearest still-mounted boundary if there are no unmounted boundaries', async () => {
2286 await act(() => {
2287 ReactNoop.render(
2288 <LogOnlyErrorBoundary>
2289 <BrokenUseEffectCleanup />
2290 </LogOnlyErrorBoundary>,
2291 );
2292 });
2293
2294 assertLog([
2295 'LogOnlyErrorBoundary render',
2296 'BrokenUseEffectCleanup useEffect',
2297 ]);
2298
2299 await act(() => {
2300 ReactNoop.render(<LogOnlyErrorBoundary />);
2301 });
2302
2303 assertLog([
2304 'LogOnlyErrorBoundary render',
2305 'BrokenUseEffectCleanup useEffect destroy',
2306 'LogOnlyErrorBoundary componentDidCatch',
2307 ]);
2308 });
2309
2310 it('should skip unmounted boundaries and use the nearest still-mounted boundary', async () => {
2311 function Conditional({showChildren}) {
2312 if (showChildren) {
2313 return (
2314 <ErrorBoundary>
2315 <BrokenUseEffectCleanup />
2316 </ErrorBoundary>
2317 );
2318 } else {
2319 return null;
2320 }
2321 }
2322
2323 await act(() => {
2324 ReactNoop.render(
2325 <LogOnlyErrorBoundary>
2326 <Conditional showChildren={true} />
2327 </LogOnlyErrorBoundary>,
2328 );
2329 });
2330
2331 assertLog([
2332 'LogOnlyErrorBoundary render',
2333 'ErrorBoundary render success',
2334 'BrokenUseEffectCleanup useEffect',
2335 ]);
2336
2337 await act(() => {
2338 ReactNoop.render(
2339 <LogOnlyErrorBoundary>
2340 <Conditional showChildren={false} />
2341 </LogOnlyErrorBoundary>,
2342 );
2343 });
2344
2345 assertLog([
2346 'LogOnlyErrorBoundary render',
2347 'BrokenUseEffectCleanup useEffect destroy',
2348 'LogOnlyErrorBoundary componentDidCatch',
2349 ]);
2350 });
2351
2352 it('should call getDerivedStateFromError in the nearest still-mounted boundary', async () => {
2353 function Conditional({showChildren}) {
2354 if (showChildren) {
2355 return <BrokenUseEffectCleanup />;
2356 } else {
2357 return null;
2358 }
2359 }
2360
2361 await act(() => {
2362 ReactNoop.render(
2363 <ErrorBoundary>
2364 <Conditional showChildren={true} />
2365 </ErrorBoundary>,
2366 );
2367 });
2368
2369 assertLog([
2370 'ErrorBoundary render success',
2371 'BrokenUseEffectCleanup useEffect',
2372 ]);
2373
2374 await act(() => {
2375 ReactNoop.render(
2376 <ErrorBoundary>
2377 <Conditional showChildren={false} />
2378 </ErrorBoundary>,
2379 );
2380 });
2381
2382 assertLog([
2383 'ErrorBoundary render success',
2384 'BrokenUseEffectCleanup useEffect destroy',
2385 'ErrorBoundary static getDerivedStateFromError',
2386 'ErrorBoundary render error',
2387 'ErrorBoundary componentDidCatch',
2388 ]);
2389
2390 expect(ReactNoop).toMatchRenderedOutput(
2391 <span prop="ErrorBoundary fallback" />,
2392 );
2393 });
2394
2395 it('should rethrow error if there are no still-mounted boundaries', async () => {
2396 function Conditional({showChildren}) {
2397 if (showChildren) {
2398 return (
2399 <ErrorBoundary>
2400 <BrokenUseEffectCleanup />
2401 </ErrorBoundary>
2402 );
2403 } else {
2404 return null;
2405 }
2406 }
2407
2408 await act(() => {
2409 ReactNoop.render(<Conditional showChildren={true} />);
2410 });
2411
2412 assertLog([
2413 'ErrorBoundary render success',
2414 'BrokenUseEffectCleanup useEffect',
2415 ]);
2416
2417 await act(async () => {
2418 ReactNoop.render(<Conditional showChildren={false} />);
2419 await waitForThrow('Expected error');
2420 });
2421
2422 assertLog(['BrokenUseEffectCleanup useEffect destroy']);
2423
2424 expect(ReactNoop).toMatchRenderedOutput(null);
2425 });
2426 });
2427
2428 it('calls passive effect destroy functions for memoized components', async () => {
2429 const Wrapper = ({children}) => children;
2430 function Child() {
2431 React.useEffect(() => {
2432 Scheduler.log('passive create');
2433 return () => {
2434 Scheduler.log('passive destroy');
2435 };
2436 }, []);
2437 React.useLayoutEffect(() => {
2438 Scheduler.log('layout create');
2439 return () => {
2440 Scheduler.log('layout destroy');
2441 };
2442 }, []);
2443 Scheduler.log('render');
2444 return null;
2445 }
2446
2447 const isEqual = (prevProps, nextProps) =>
2448 prevProps.prop === nextProps.prop;
2449 const MemoizedChild = React.memo(Child, isEqual);
2450
2451 await act(() => {
2452 ReactNoop.render(
2453 <Wrapper>
2454 <MemoizedChild key={1} />
2455 </Wrapper>,
2456 );
2457 });
2458 assertLog(['render', 'layout create', 'passive create']);
2459
2460 // Include at least one no-op (memoized) update to trigger original bug.
2461 await act(() => {
2462 ReactNoop.render(
2463 <Wrapper>
2464 <MemoizedChild key={1} />
2465 </Wrapper>,
2466 );
2467 });
2468 assertLog([]);
2469
2470 await act(() => {
2471 ReactNoop.render(
2472 <Wrapper>
2473 <MemoizedChild key={2} />
2474 </Wrapper>,
2475 );
2476 });
2477 assertLog([
2478 'render',
2479 'layout destroy',
2480 'layout create',
2481 'passive destroy',
2482 'passive create',
2483 ]);
2484
2485 await act(() => {
2486 ReactNoop.render(null);
2487 });
2488 assertLog(['layout destroy', 'passive destroy']);
2489 });
2490
2491 it('calls passive effect destroy functions for descendants of memoized components', async () => {
2492 const Wrapper = ({children}) => children;
2493 function Child() {
2494 return <Grandchild />;
2495 }
2496
2497 function Grandchild() {
2498 React.useEffect(() => {
2499 Scheduler.log('passive create');
2500 return () => {
2501 Scheduler.log('passive destroy');
2502 };
2503 }, []);
2504 React.useLayoutEffect(() => {
2505 Scheduler.log('layout create');
2506 return () => {
2507 Scheduler.log('layout destroy');
2508 };
2509 }, []);
2510 Scheduler.log('render');
2511 return null;
2512 }
2513
2514 const isEqual = (prevProps, nextProps) =>
2515 prevProps.prop === nextProps.prop;
2516 const MemoizedChild = React.memo(Child, isEqual);
2517
2518 await act(() => {
2519 ReactNoop.render(
2520 <Wrapper>
2521 <MemoizedChild key={1} />
2522 </Wrapper>,
2523 );
2524 });
2525 assertLog(['render', 'layout create', 'passive create']);
2526
2527 // Include at least one no-op (memoized) update to trigger original bug.
2528 await act(() => {
2529 ReactNoop.render(
2530 <Wrapper>
2531 <MemoizedChild key={1} />
2532 </Wrapper>,
2533 );
2534 });
2535 assertLog([]);
2536
2537 await act(() => {
2538 ReactNoop.render(
2539 <Wrapper>
2540 <MemoizedChild key={2} />
2541 </Wrapper>,
2542 );
2543 });
2544 assertLog([
2545 'render',
2546 'layout destroy',
2547 'layout create',
2548 'passive destroy',
2549 'passive create',
2550 ]);
2551
2552 await act(() => {
2553 ReactNoop.render(null);
2554 });
2555 assertLog(['layout destroy', 'passive destroy']);
2556 });
2557
2558 it('assumes passive effect destroy function is either a function or undefined', async () => {
2559 function App(props) {
2560 useEffect(() => {
2561 return props.return;
2562 });
2563 return null;
2564 }
2565
2566 const root1 = ReactNoop.createRoot();
2567 await act(() => {
2568 root1.render(<App return={17} />);
2569 });
2570 assertConsoleErrorDev([
2571 'useEffect must not return anything besides a function, ' +
2572 'which is used for clean-up. You returned: 17\n' +
2573 ' in App (at **)',
2574 ]);
2575
2576 const root2 = ReactNoop.createRoot();
2577 await act(() => {
2578 root2.render(<App return={null} />);
2579 });
2580 assertConsoleErrorDev([
2581 'useEffect must not return anything besides a function, ' +
2582 'which is used for clean-up. You returned null. ' +
2583 'If your effect does not require clean up, return undefined (or nothing).\n' +
2584 ' in App (at **)',
2585 ]);
2586
2587 const root3 = ReactNoop.createRoot();
2588 await act(() => {
2589 root3.render(<App return={Promise.resolve()} />);
2590 });
2591 assertConsoleErrorDev([
2592 'useEffect must not return anything besides a function, which is used for clean-up.\n' +
2593 '\n' +
2594 'It looks like you wrote useEffect(async () => ...) or returned a Promise. ' +
2595 'Instead, write the async function inside your effect and call it immediately:\n' +
2596 '\n' +
2597 'useEffect(() => {\n' +
2598 ' async function fetchData() {\n' +
2599 ' // You can await here\n' +
2600 ' const response = await MyAPI.getData(someId);\n' +
2601 ' // ...\n' +
2602 ' }\n' +
2603 ' fetchData();\n' +
2604 "}, [someId]); // Or [] if effect doesn't need props or state\n" +
2605 '\n' +
2606 'Learn more about data fetching with Hooks: https://react.dev/link/hooks-data-fetching\n' +
2607 ' in App (at **)',
2608 ]);
2609
2610 // Error on unmount because React assumes the value is a function
2611 await act(async () => {
2612 root3.render(null);
2613 await waitForThrow('is not a function');
2614 });
2615 });
2616 });
2617
2618 describe('useInsertionEffect', () => {
2619 it('fires insertion effects after snapshots on update', async () => {
2620 function CounterA(props) {
2621 useInsertionEffect(() => {
2622 Scheduler.log(`Create insertion`);
2623 return () => {
2624 Scheduler.log(`Destroy insertion`);
2625 };
2626 });
2627 return null;
2628 }
2629
2630 class CounterB extends React.Component {
2631 getSnapshotBeforeUpdate(prevProps, prevState) {
2632 Scheduler.log(`Get Snapshot`);
2633 return null;
2634 }
2635
2636 componentDidUpdate() {}
2637
2638 render() {
2639 return null;
2640 }
2641 }
2642
2643 await act(async () => {
2644 ReactNoop.render(
2645 <>
2646 <CounterA />
2647 <CounterB />
2648 </>,
2649 );
2650
2651 await waitForAll(['Create insertion']);
2652 });
2653
2654 // Update
2655 await act(async () => {
2656 ReactNoop.render(
2657 <>
2658 <CounterA />
2659 <CounterB />
2660 </>,
2661 );
2662
2663 await waitForAll([
2664 'Get Snapshot',
2665 'Destroy insertion',
2666 'Create insertion',
2667 ]);
2668 });
2669
2670 // Unmount everything
2671 await act(async () => {
2672 ReactNoop.render(null);
2673
2674 await waitForAll(['Destroy insertion']);
2675 });
2676 });
2677
2678 it('fires insertion effects before layout effects', async () => {
2679 let committedText = '(empty)';
2680
2681 function Counter(props) {
2682 useInsertionEffect(() => {
2683 Scheduler.log(`Create insertion [current: ${committedText}]`);
2684 committedText = String(props.count);
2685 return () => {
2686 Scheduler.log(`Destroy insertion [current: ${committedText}]`);
2687 };
2688 });
2689 useLayoutEffect(() => {
2690 Scheduler.log(`Create layout [current: ${committedText}]`);
2691 return () => {
2692 Scheduler.log(`Destroy layout [current: ${committedText}]`);
2693 };
2694 });
2695 useEffect(() => {
2696 Scheduler.log(`Create passive [current: ${committedText}]`);
2697 return () => {
2698 Scheduler.log(`Destroy passive [current: ${committedText}]`);
2699 };
2700 });
2701 return null;
2702 }
2703 await act(async () => {
2704 ReactNoop.render(<Counter count={0} />);
2705
2706 await waitForPaint([
2707 'Create insertion [current: (empty)]',
2708 'Create layout [current: 0]',
2709 ]);
2710 expect(committedText).toEqual('0');
2711 });
2712
2713 assertLog(['Create passive [current: 0]']);
2714
2715 // Unmount everything
2716 await act(async () => {
2717 ReactNoop.render(null);
2718
2719 await waitForPaint([
2720 'Destroy insertion [current: 0]',
2721 'Destroy layout [current: 0]',
2722 ]);
2723 });
2724
2725 assertLog(['Destroy passive [current: 0]']);
2726 });
2727
2728 it('force flushes passive effects before firing new insertion effects', async () => {
2729 let committedText = '(empty)';
2730
2731 function Counter(props) {
2732 useInsertionEffect(() => {
2733 Scheduler.log(`Create insertion [current: ${committedText}]`);
2734 committedText = String(props.count);
2735 return () => {
2736 Scheduler.log(`Destroy insertion [current: ${committedText}]`);
2737 };
2738 });
2739 useLayoutEffect(() => {
2740 Scheduler.log(`Create layout [current: ${committedText}]`);
2741 committedText = String(props.count);
2742 return () => {
2743 Scheduler.log(`Destroy layout [current: ${committedText}]`);
2744 };
2745 });
2746 useEffect(() => {
2747 Scheduler.log(`Create passive [current: ${committedText}]`);
2748 return () => {
2749 Scheduler.log(`Destroy passive [current: ${committedText}]`);
2750 };
2751 });
2752 return null;
2753 }
2754
2755 await act(async () => {
2756 React.startTransition(() => {
2757 ReactNoop.render(<Counter count={0} />);
2758 });
2759 await waitForPaint([
2760 'Create insertion [current: (empty)]',
2761 'Create layout [current: 0]',
2762 ]);
2763 expect(committedText).toEqual('0');
2764
2765 React.startTransition(() => {
2766 ReactNoop.render(<Counter count={1} />);
2767 });
2768 if (gate(flags => flags.enableYieldingBeforePassive)) {
2769 await waitForPaint(['Create passive [current: 0]']);
2770 await waitForPaint([
2771 'Destroy insertion [current: 0]',
2772 'Create insertion [current: 0]',
2773 'Destroy layout [current: 1]',
2774 'Create layout [current: 1]',
2775 ]);
2776 } else {
2777 await waitForPaint([
2778 'Create passive [current: 0]',
2779 'Destroy insertion [current: 0]',
2780 'Create insertion [current: 0]',
2781 'Destroy layout [current: 1]',
2782 'Create layout [current: 1]',
2783 ]);
2784 }
2785 expect(committedText).toEqual('1');
2786 });
2787 assertLog([
2788 'Destroy passive [current: 1]',
2789 'Create passive [current: 1]',
2790 ]);
2791 });
2792
2793 it('fires all insertion effects (interleaved) before firing any layout effects', async () => {
2794 let committedA = '(empty)';
2795 let committedB = '(empty)';
2796
2797 function CounterA(props) {
2798 useInsertionEffect(() => {
2799 Scheduler.log(
2800 `Create Insertion 1 for Component A [A: ${committedA}, B: ${committedB}]`,
2801 );
2802 committedA = String(props.count);
2803 return () => {
2804 Scheduler.log(
2805 `Destroy Insertion 1 for Component A [A: ${committedA}, B: ${committedB}]`,
2806 );
2807 };
2808 });
2809 useInsertionEffect(() => {
2810 Scheduler.log(
2811 `Create Insertion 2 for Component A [A: ${committedA}, B: ${committedB}]`,
2812 );
2813 committedA = String(props.count);
2814 return () => {
2815 Scheduler.log(
2816 `Destroy Insertion 2 for Component A [A: ${committedA}, B: ${committedB}]`,
2817 );
2818 };
2819 });
2820
2821 useLayoutEffect(() => {
2822 Scheduler.log(
2823 `Create Layout 1 for Component A [A: ${committedA}, B: ${committedB}]`,
2824 );
2825 return () => {
2826 Scheduler.log(
2827 `Destroy Layout 1 for Component A [A: ${committedA}, B: ${committedB}]`,
2828 );
2829 };
2830 });
2831
2832 useLayoutEffect(() => {
2833 Scheduler.log(
2834 `Create Layout 2 for Component A [A: ${committedA}, B: ${committedB}]`,
2835 );
2836 return () => {
2837 Scheduler.log(
2838 `Destroy Layout 2 for Component A [A: ${committedA}, B: ${committedB}]`,
2839 );
2840 };
2841 });
2842 return null;
2843 }
2844
2845 function CounterB(props) {
2846 useInsertionEffect(() => {
2847 Scheduler.log(
2848 `Create Insertion 1 for Component B [A: ${committedA}, B: ${committedB}]`,
2849 );
2850 committedB = String(props.count);
2851 return () => {
2852 Scheduler.log(
2853 `Destroy Insertion 1 for Component B [A: ${committedA}, B: ${committedB}]`,
2854 );
2855 };
2856 });
2857 useInsertionEffect(() => {
2858 Scheduler.log(
2859 `Create Insertion 2 for Component B [A: ${committedA}, B: ${committedB}]`,
2860 );
2861 committedB = String(props.count);
2862 return () => {
2863 Scheduler.log(
2864 `Destroy Insertion 2 for Component B [A: ${committedA}, B: ${committedB}]`,
2865 );
2866 };
2867 });
2868
2869 useLayoutEffect(() => {
2870 Scheduler.log(
2871 `Create Layout 1 for Component B [A: ${committedA}, B: ${committedB}]`,
2872 );
2873 return () => {
2874 Scheduler.log(
2875 `Destroy Layout 1 for Component B [A: ${committedA}, B: ${committedB}]`,
2876 );
2877 };
2878 });
2879
2880 useLayoutEffect(() => {
2881 Scheduler.log(
2882 `Create Layout 2 for Component B [A: ${committedA}, B: ${committedB}]`,
2883 );
2884 return () => {
2885 Scheduler.log(
2886 `Destroy Layout 2 for Component B [A: ${committedA}, B: ${committedB}]`,
2887 );
2888 };
2889 });
2890 return null;
2891 }
2892
2893 await act(async () => {
2894 ReactNoop.render(
2895 <React.Fragment>
2896 <CounterA count={0} />
2897 <CounterB count={0} />
2898 </React.Fragment>,
2899 );
2900 await waitForAll([
2901 // All insertion effects fire before all layout effects
2902 'Create Insertion 1 for Component A [A: (empty), B: (empty)]',
2903 'Create Insertion 2 for Component A [A: 0, B: (empty)]',
2904 'Create Insertion 1 for Component B [A: 0, B: (empty)]',
2905 'Create Insertion 2 for Component B [A: 0, B: 0]',
2906 'Create Layout 1 for Component A [A: 0, B: 0]',
2907 'Create Layout 2 for Component A [A: 0, B: 0]',
2908 'Create Layout 1 for Component B [A: 0, B: 0]',
2909 'Create Layout 2 for Component B [A: 0, B: 0]',
2910 ]);
2911 expect([committedA, committedB]).toEqual(['0', '0']);
2912 });
2913
2914 await act(async () => {
2915 ReactNoop.render(
2916 <React.Fragment>
2917 <CounterA count={1} />
2918 <CounterB count={1} />
2919 </React.Fragment>,
2920 );
2921 await waitForAll([
2922 'Destroy Insertion 1 for Component A [A: 0, B: 0]',
2923 'Destroy Insertion 2 for Component A [A: 0, B: 0]',
2924 'Create Insertion 1 for Component A [A: 0, B: 0]',
2925 'Create Insertion 2 for Component A [A: 1, B: 0]',
2926 'Destroy Layout 1 for Component A [A: 1, B: 0]',
2927 'Destroy Layout 2 for Component A [A: 1, B: 0]',
2928 'Destroy Insertion 1 for Component B [A: 1, B: 0]',
2929 'Destroy Insertion 2 for Component B [A: 1, B: 0]',
2930 'Create Insertion 1 for Component B [A: 1, B: 0]',
2931 'Create Insertion 2 for Component B [A: 1, B: 1]',
2932 'Destroy Layout 1 for Component B [A: 1, B: 1]',
2933 'Destroy Layout 2 for Component B [A: 1, B: 1]',
2934 'Create Layout 1 for Component A [A: 1, B: 1]',
2935 'Create Layout 2 for Component A [A: 1, B: 1]',
2936 'Create Layout 1 for Component B [A: 1, B: 1]',
2937 'Create Layout 2 for Component B [A: 1, B: 1]',
2938 ]);
2939 expect([committedA, committedB]).toEqual(['1', '1']);
2940
2941 // Unmount everything
2942 await act(async () => {
2943 ReactNoop.render(null);
2944
2945 await waitForAll([
2946 'Destroy Insertion 1 for Component A [A: 1, B: 1]',
2947 'Destroy Insertion 2 for Component A [A: 1, B: 1]',
2948 'Destroy Layout 1 for Component A [A: 1, B: 1]',
2949 'Destroy Layout 2 for Component A [A: 1, B: 1]',
2950 'Destroy Insertion 1 for Component B [A: 1, B: 1]',
2951 'Destroy Insertion 2 for Component B [A: 1, B: 1]',
2952 'Destroy Layout 1 for Component B [A: 1, B: 1]',
2953 'Destroy Layout 2 for Component B [A: 1, B: 1]',
2954 ]);
2955 });
2956 });
2957 });
2958
2959 it('assumes insertion effect destroy function is either a function or undefined', async () => {
2960 function App(props) {
2961 useInsertionEffect(() => {
2962 return props.return;
2963 });
2964 return null;
2965 }
2966
2967 const root1 = ReactNoop.createRoot();
2968 await act(() => {
2969 root1.render(<App return={17} />);
2970 });
2971 assertConsoleErrorDev([
2972 'useInsertionEffect must not return anything besides a function, ' +
2973 'which is used for clean-up. You returned: 17\n' +
2974 ' in App (at **)',
2975 ]);
2976
2977 const root2 = ReactNoop.createRoot();
2978 await act(() => {
2979 root2.render(<App return={null} />);
2980 });
2981 assertConsoleErrorDev([
2982 'useInsertionEffect must not return anything besides a function, ' +
2983 'which is used for clean-up. You returned null. ' +
2984 'If your effect does not require clean up, return undefined (or nothing).\n' +
2985 ' in App (at **)',
2986 ]);
2987
2988 const root3 = ReactNoop.createRoot();
2989 await act(() => {
2990 root3.render(<App return={Promise.resolve()} />);
2991 });
2992 assertConsoleErrorDev([
2993 'useInsertionEffect must not return anything besides a ' +
2994 'function, which is used for clean-up.\n' +
2995 '\n' +
2996 'It looks like you wrote useInsertionEffect(async () => ...) or returned a Promise. ' +
2997 'Instead, write the async function inside your effect and call it immediately:\n' +
2998 '\n' +
2999 'useInsertionEffect(() => {\n' +
3000 ' async function fetchData() {\n' +
3001 ' // You can await here\n' +
3002 ' const response = await MyAPI.getData(someId);\n' +
3003 ' // ...\n' +
3004 ' }\n' +
3005 ' fetchData();\n' +
3006 "}, [someId]); // Or [] if effect doesn't need props or state\n" +
3007 '\n' +
3008 'Learn more about data fetching with Hooks: https://react.dev/link/hooks-data-fetching\n' +
3009 ' in App (at **)',
3010 ]);
3011
3012 // Error on unmount because React assumes the value is a function
3013 await act(async () => {
3014 root3.render(null);
3015 await waitForThrow('is not a function');
3016 });
3017 });
3018
3019 it('warns when setState is called from insertion effect setup', async () => {
3020 function App(props) {
3021 const [, setX] = useState(0);
3022 useInsertionEffect(() => {
3023 setX(1);
3024 if (props.throw) {
3025 throw Error('No');
3026 }
3027 }, [props.throw]);
3028 return null;
3029 }
3030
3031 const root = ReactNoop.createRoot();
3032 await act(() => {
3033 root.render(<App />);
3034 });
3035 assertConsoleErrorDev([
3036 'useInsertionEffect must not schedule updates.\n' +
3037 ' in App (at **)',
3038 ]);
3039
3040 await act(async () => {
3041 root.render(<App throw={true} />);
3042 await waitForThrow('No');
3043 });
3044
3045 // Should not warn for regular effects after throw.
3046 function NotInsertion() {
3047 const [, setX] = useState(0);
3048 useEffect(() => {
3049 setX(1);
3050 }, []);
3051 return null;
3052 }
3053 await act(() => {
3054 root.render(<NotInsertion />);
3055 });
3056 });
3057
3058 it('warns when setState is called from insertion effect cleanup', async () => {
3059 function App(props) {
3060 const [, setX] = useState(0);
3061 useInsertionEffect(() => {
3062 if (props.throw) {
3063 throw Error('No');
3064 }
3065 return () => {
3066 setX(1);
3067 };
3068 }, [props.throw, props.foo]);
3069 return null;
3070 }
3071
3072 const root = ReactNoop.createRoot();
3073 await act(() => {
3074 root.render(<App foo="hello" />);
3075 });
3076 await act(() => {
3077 root.render(<App foo="goodbye" />);
3078 });
3079 assertConsoleErrorDev([
3080 'useInsertionEffect must not schedule updates.\n' +
3081 ' in App (at **)',
3082 ]);
3083
3084 await act(async () => {
3085 root.render(<App throw={true} />);
3086 await waitForThrow('No');
3087 });
3088
3089 // Should not warn for regular effects after throw.
3090 function NotInsertion() {
3091 const [, setX] = useState(0);
3092 useEffect(() => {
3093 setX(1);
3094 }, []);
3095 return null;
3096 }
3097 await act(() => {
3098 root.render(<NotInsertion />);
3099 });
3100 });
3101
3102 it('warns when setState is called from offscreen deleted insertion effect cleanup', async () => {
3103 function App(props) {
3104 const [, setX] = useState(0);
3105 useInsertionEffect(() => {
3106 if (props.throw) {
3107 throw Error('No');
3108 }
3109 return () => {
3110 setX(1);
3111 };
3112 }, [props.throw, props.foo]);
3113 return null;
3114 }
3115
3116 const root = ReactNoop.createRoot();
3117 await act(() => {
3118 root.render(
3119 <Activity mode="hidden">
3120 <App foo="hello" />
3121 </Activity>,
3122 );
3123 });
3124
3125 await act(() => {
3126 root.render(<Activity mode="hidden" />);
3127 });
3128 assertConsoleErrorDev([
3129 'useInsertionEffect must not schedule updates.\n' +
3130 ' in App (at **)',
3131 ]);
3132
3133 // Should not warn for regular effects after throw.
3134 function NotInsertion() {
3135 const [, setX] = useState(0);
3136 useEffect(() => {
3137 setX(1);
3138 }, []);
3139 return null;
3140 }
3141 await act(() => {
3142 root.render(<NotInsertion />);
3143 });
3144 });
3145 });
3146
3147 describe('useLayoutEffect', () => {
3148 it('fires layout effects after the host has been mutated', async () => {
3149 function getCommittedText() {
3150 const yields = Scheduler.unstable_clearLog();
3151 const children = ReactNoop.getChildrenAsJSX();
3152 Scheduler.log(yields);
3153 if (children === null) {
3154 return null;
3155 }
3156 return children.props.prop;
3157 }
3158
3159 function Counter(props) {
3160 useLayoutEffect(() => {
3161 Scheduler.log(`Current: ${getCommittedText()}`);
3162 });
3163 return <Text text={props.count} />;
3164 }
3165
3166 ReactNoop.render(<Counter count={0} />, () =>
3167 Scheduler.log('Sync effect'),
3168 );
3169 await waitFor([[0], 'Current: 0', 'Sync effect']);
3170 expect(ReactNoop).toMatchRenderedOutput(<span prop={0} />);
3171
3172 ReactNoop.render(<Counter count={1} />, () =>
3173 Scheduler.log('Sync effect'),
3174 );
3175 await waitFor([[1], 'Current: 1', 'Sync effect']);
3176 expect(ReactNoop).toMatchRenderedOutput(<span prop={1} />);
3177 });
3178
3179 it('force flushes passive effects before firing new layout effects', async () => {
3180 let committedText = '(empty)';
3181
3182 function Counter(props) {
3183 useLayoutEffect(() => {
3184 // Normally this would go in a mutation effect, but this test
3185 // intentionally omits a mutation effect.
3186 committedText = String(props.count);
3187
3188 Scheduler.log(`Mount layout [current: ${committedText}]`);
3189 return () => {
3190 Scheduler.log(`Unmount layout [current: ${committedText}]`);
3191 };
3192 });
3193 useEffect(() => {
3194 Scheduler.log(`Mount normal [current: ${committedText}]`);
3195 return () => {
3196 Scheduler.log(`Unmount normal [current: ${committedText}]`);
3197 };
3198 });
3199 return null;
3200 }
3201
3202 await act(async () => {
3203 ReactNoop.render(<Counter count={0} />, () =>
3204 Scheduler.log('Sync effect'),
3205 );
3206 await waitFor(['Mount layout [current: 0]', 'Sync effect']);
3207 expect(committedText).toEqual('0');
3208 ReactNoop.render(<Counter count={1} />, () =>
3209 Scheduler.log('Sync effect'),
3210 );
3211 await waitFor([
3212 'Mount normal [current: 0]',
3213 'Unmount layout [current: 0]',
3214 'Mount layout [current: 1]',
3215 'Sync effect',
3216 ]);
3217 expect(committedText).toEqual('1');
3218 });
3219
3220 assertLog(['Unmount normal [current: 1]', 'Mount normal [current: 1]']);
3221 });
3222
3223 it('catches errors thrown in useLayoutEffect', async () => {
3224 class ErrorBoundary extends React.Component {
3225 state = {error: null};
3226 static getDerivedStateFromError(error) {
3227 Scheduler.log(`ErrorBoundary static getDerivedStateFromError`);
3228 return {error};
3229 }
3230 render() {
3231 const {children, id, fallbackID} = this.props;
3232 const {error} = this.state;
3233 if (error) {
3234 Scheduler.log(`${id} render error`);
3235 return <Component id={fallbackID} />;
3236 }
3237 Scheduler.log(`${id} render success`);
3238 return children || null;
3239 }
3240 }
3241
3242 function Component({id}) {
3243 Scheduler.log('Component render ' + id);
3244 return <span prop={id} />;
3245 }
3246
3247 function BrokenLayoutEffectDestroy() {
3248 useLayoutEffect(() => {
3249 return () => {
3250 Scheduler.log('BrokenLayoutEffectDestroy useLayoutEffect destroy');
3251 throw Error('Expected');
3252 };
3253 }, []);
3254
3255 Scheduler.log('BrokenLayoutEffectDestroy render');
3256 return <span prop="broken" />;
3257 }
3258
3259 ReactNoop.render(
3260 <ErrorBoundary id="OuterBoundary" fallbackID="OuterFallback">
3261 <Component id="sibling" />
3262 <ErrorBoundary id="InnerBoundary" fallbackID="InnerFallback">
3263 <BrokenLayoutEffectDestroy />
3264 </ErrorBoundary>
3265 </ErrorBoundary>,
3266 );
3267
3268 await waitForAll([
3269 'OuterBoundary render success',
3270 'Component render sibling',
3271 'InnerBoundary render success',
3272 'BrokenLayoutEffectDestroy render',
3273 ]);
3274 expect(ReactNoop).toMatchRenderedOutput(
3275 <>
3276 <span prop="sibling" />
3277 <span prop="broken" />
3278 </>,
3279 );
3280
3281 ReactNoop.render(
3282 <ErrorBoundary id="OuterBoundary" fallbackID="OuterFallback">
3283 <Component id="sibling" />
3284 </ErrorBoundary>,
3285 );
3286
3287 // React should skip over the unmounting boundary and find the nearest still-mounted boundary.
3288 await waitForAll([
3289 'OuterBoundary render success',
3290 'Component render sibling',
3291 'BrokenLayoutEffectDestroy useLayoutEffect destroy',
3292 'ErrorBoundary static getDerivedStateFromError',
3293 'OuterBoundary render error',
3294 'Component render OuterFallback',
3295 ]);
3296 expect(ReactNoop).toMatchRenderedOutput(<span prop="OuterFallback" />);
3297 });
3298
3299 it('assumes layout effect destroy function is either a function or undefined', async () => {
3300 function App(props) {
3301 useLayoutEffect(() => {
3302 return props.return;
3303 });
3304 return null;
3305 }
3306
3307 const root1 = ReactNoop.createRoot();
3308 await act(() => {
3309 root1.render(<App return={17} />);
3310 });
3311 assertConsoleErrorDev([
3312 'useLayoutEffect must not return anything besides a ' +
3313 'function, which is used for clean-up. You returned: 17\n' +
3314 ' in App (at **)',
3315 ]);
3316
3317 const root2 = ReactNoop.createRoot();
3318 await act(() => {
3319 root2.render(<App return={null} />);
3320 });
3321 assertConsoleErrorDev([
3322 'useLayoutEffect must not return anything besides a ' +
3323 'function, which is used for clean-up. You returned null. If your ' +
3324 'effect does not require clean up, return undefined (or nothing).\n' +
3325 ' in App (at **)',
3326 ]);
3327
3328 const root3 = ReactNoop.createRoot();
3329 await act(() => {
3330 root3.render(<App return={Promise.resolve()} />);
3331 });
3332 assertConsoleErrorDev([
3333 'useLayoutEffect must not return anything besides a ' +
3334 'function, which is used for clean-up.\n\n' +
3335 'It looks like you wrote useLayoutEffect(async () => ...) or returned a Promise. ' +
3336 'Instead, write the async function inside your effect and call it immediately:\n' +
3337 '\n' +
3338 'useLayoutEffect(() => {\n' +
3339 ' async function fetchData() {\n' +
3340 ' // You can await here\n' +
3341 ' const response = await MyAPI.getData(someId);\n' +
3342 ' // ...\n' +
3343 ' }\n' +
3344 ' fetchData();\n' +
3345 "}, [someId]); // Or [] if effect doesn't need props or state\n" +
3346 '\n' +
3347 'Learn more about data fetching with Hooks: https://react.dev/link/hooks-data-fetching\n' +
3348 ' in App (at **)',
3349 ]);
3350
3351 // Error on unmount because React assumes the value is a function
3352 await act(async () => {
3353 root3.render(null);
3354 await waitForThrow('is not a function');
3355 });
3356 });
3357 });
3358
3359 describe('useCallback', () => {
3360 it('memoizes callback by comparing inputs', async () => {
3361 class IncrementButton extends React.PureComponent {
3362 increment = () => {
3363 this.props.increment();
3364 };
3365 render() {
3366 return <Text text="Increment" />;
3367 }
3368 }
3369
3370 function Counter({incrementBy}) {
3371 const [count, updateCount] = useState(0);
3372 const increment = useCallback(
3373 () => updateCount(c => c + incrementBy),
3374 [incrementBy],
3375 );
3376 return (
3377 <>
3378 <IncrementButton increment={increment} ref={button} />
3379 <Text text={'Count: ' + count} />
3380 </>
3381 );
3382 }
3383
3384 const button = React.createRef(null);
3385 ReactNoop.render(<Counter incrementBy={1} />);
3386 await waitForAll(['Increment', 'Count: 0']);
3387 expect(ReactNoop).toMatchRenderedOutput(
3388 <>
3389 <span prop="Increment" />
3390 <span prop="Count: 0" />
3391 </>,
3392 );
3393
3394 await act(() => button.current.increment());
3395 assertLog([
3396 // Button should not re-render, because its props haven't changed
3397 // 'Increment',
3398 'Count: 1',
3399 ]);
3400 expect(ReactNoop).toMatchRenderedOutput(
3401 <>
3402 <span prop="Increment" />
3403 <span prop="Count: 1" />
3404 </>,
3405 );
3406
3407 // Increase the increment amount
3408 ReactNoop.render(<Counter incrementBy={10} />);
3409 await waitForAll([
3410 // Inputs did change this time
3411 'Increment',
3412 'Count: 1',
3413 ]);
3414 expect(ReactNoop).toMatchRenderedOutput(
3415 <>
3416 <span prop="Increment" />
3417 <span prop="Count: 1" />
3418 </>,
3419 );
3420
3421 // Callback should have updated
3422 await act(() => button.current.increment());
3423 assertLog(['Count: 11']);
3424 expect(ReactNoop).toMatchRenderedOutput(
3425 <>
3426 <span prop="Increment" />
3427 <span prop="Count: 11" />
3428 </>,
3429 );
3430 });
3431 });
3432
3433 describe('useMemo', () => {
3434 it('memoizes value by comparing to previous inputs', async () => {
3435 function CapitalizedText(props) {
3436 const text = props.text;
3437 const capitalizedText = useMemo(() => {
3438 Scheduler.log(`Capitalize '${text}'`);
3439 return text.toUpperCase();
3440 }, [text]);
3441 return <Text text={capitalizedText} />;
3442 }
3443
3444 ReactNoop.render(<CapitalizedText text="hello" />);
3445 await waitForAll(["Capitalize 'hello'", 'HELLO']);
3446 expect(ReactNoop).toMatchRenderedOutput(<span prop="HELLO" />);
3447
3448 ReactNoop.render(<CapitalizedText text="hi" />);
3449 await waitForAll(["Capitalize 'hi'", 'HI']);
3450 expect(ReactNoop).toMatchRenderedOutput(<span prop="HI" />);
3451
3452 ReactNoop.render(<CapitalizedText text="hi" />);
3453 await waitForAll(['HI']);
3454 expect(ReactNoop).toMatchRenderedOutput(<span prop="HI" />);
3455
3456 ReactNoop.render(<CapitalizedText text="goodbye" />);
3457 await waitForAll(["Capitalize 'goodbye'", 'GOODBYE']);
3458 expect(ReactNoop).toMatchRenderedOutput(<span prop="GOODBYE" />);
3459 });
3460
3461 it('always re-computes if no inputs are provided', async () => {
3462 function LazyCompute(props) {
3463 const computed = useMemo(props.compute);
3464 return <Text text={computed} />;
3465 }
3466
3467 function computeA() {
3468 Scheduler.log('compute A');
3469 return 'A';
3470 }
3471
3472 function computeB() {
3473 Scheduler.log('compute B');
3474 return 'B';
3475 }
3476
3477 ReactNoop.render(<LazyCompute compute={computeA} />);
3478 await waitForAll(['compute A', 'A']);
3479
3480 ReactNoop.render(<LazyCompute compute={computeA} />);
3481 await waitForAll(['compute A', 'A']);
3482
3483 ReactNoop.render(<LazyCompute compute={computeA} />);
3484 await waitForAll(['compute A', 'A']);
3485
3486 ReactNoop.render(<LazyCompute compute={computeB} />);
3487 await waitForAll(['compute B', 'B']);
3488 });
3489
3490 it('should not invoke memoized function during re-renders unless inputs change', async () => {
3491 function LazyCompute(props) {
3492 const computed = useMemo(
3493 () => props.compute(props.input),
3494 [props.input],
3495 );
3496 const [count, setCount] = useState(0);
3497 if (count < 3) {
3498 setCount(count + 1);
3499 }
3500 return <Text text={computed} />;
3501 }
3502
3503 function compute(val) {
3504 Scheduler.log('compute ' + val);
3505 return val;
3506 }
3507
3508 ReactNoop.render(<LazyCompute compute={compute} input="A" />);
3509 await waitForAll(['compute A', 'A']);
3510
3511 ReactNoop.render(<LazyCompute compute={compute} input="A" />);
3512 await waitForAll(['A']);
3513
3514 ReactNoop.render(<LazyCompute compute={compute} input="B" />);
3515 await waitForAll(['compute B', 'B']);
3516 });
3517 });
3518
3519 describe('useImperativeHandle', () => {
3520 it('does not update when deps are the same', async () => {
3521 const INCREMENT = 'INCREMENT';
3522
3523 function reducer(state, action) {
3524 return action === INCREMENT ? state + 1 : state;
3525 }
3526
3527 function Counter(props, ref) {
3528 const [count, dispatch] = useReducer(reducer, 0);
3529 useImperativeHandle(ref, () => ({count, dispatch}), []);
3530 return <Text text={'Count: ' + count} />;
3531 }
3532
3533 Counter = forwardRef(Counter);
3534 const counter = React.createRef(null);
3535 ReactNoop.render(<Counter ref={counter} />);
3536 await waitForAll(['Count: 0']);
3537 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
3538 expect(counter.current.count).toBe(0);
3539
3540 await act(() => {
3541 counter.current.dispatch(INCREMENT);
3542 });
3543 assertLog(['Count: 1']);
3544 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);
3545 // Intentionally not updated because of [] deps:
3546 expect(counter.current.count).toBe(0);
3547 });
3548
3549 // Regression test for https://github.com/facebook/react/issues/14782
3550 it('automatically updates when deps are not specified', async () => {
3551 const INCREMENT = 'INCREMENT';
3552
3553 function reducer(state, action) {
3554 return action === INCREMENT ? state + 1 : state;
3555 }
3556
3557 function Counter(props, ref) {
3558 const [count, dispatch] = useReducer(reducer, 0);
3559 useImperativeHandle(ref, () => ({count, dispatch}));
3560 return <Text text={'Count: ' + count} />;
3561 }
3562
3563 Counter = forwardRef(Counter);
3564 const counter = React.createRef(null);
3565 ReactNoop.render(<Counter ref={counter} />);
3566 await waitForAll(['Count: 0']);
3567 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
3568 expect(counter.current.count).toBe(0);
3569
3570 await act(() => {
3571 counter.current.dispatch(INCREMENT);
3572 });
3573 assertLog(['Count: 1']);
3574 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);
3575 expect(counter.current.count).toBe(1);
3576 });
3577
3578 it('updates when deps are different', async () => {
3579 const INCREMENT = 'INCREMENT';
3580
3581 function reducer(state, action) {
3582 return action === INCREMENT ? state + 1 : state;
3583 }
3584
3585 let totalRefUpdates = 0;
3586 function Counter(props, ref) {
3587 const [count, dispatch] = useReducer(reducer, 0);
3588 useImperativeHandle(ref, () => {
3589 totalRefUpdates++;
3590 return {count, dispatch};
3591 }, [count]);
3592 return <Text text={'Count: ' + count} />;
3593 }
3594
3595 Counter = forwardRef(Counter);
3596 const counter = React.createRef(null);
3597 ReactNoop.render(<Counter ref={counter} />);
3598 await waitForAll(['Count: 0']);
3599 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
3600 expect(counter.current.count).toBe(0);
3601 expect(totalRefUpdates).toBe(1);
3602
3603 await act(() => {
3604 counter.current.dispatch(INCREMENT);
3605 });
3606 assertLog(['Count: 1']);
3607 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);
3608 expect(counter.current.count).toBe(1);
3609 expect(totalRefUpdates).toBe(2);
3610
3611 // Update that doesn't change the ref dependencies
3612 ReactNoop.render(<Counter ref={counter} />);
3613 await waitForAll(['Count: 1']);
3614 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);
3615 expect(counter.current.count).toBe(1);
3616 expect(totalRefUpdates).toBe(2); // Should not increase since last time
3617 });
3618 });
3619
3620 describe('useTransition', () => {
3621 it('delays showing loading state until after timeout', async () => {
3622 let transition;
3623 function App() {
3624 const [show, setShow] = useState(false);
3625 const [isPending, startTransition] = useTransition();
3626 transition = () => {
3627 startTransition(() => {
3628 setShow(true);
3629 });
3630 };
3631 return (
3632 <Suspense
3633 fallback={<Text text={`Loading... Pending: ${isPending}`} />}>
3634 {show ? (
3635 <AsyncText text={`After... Pending: ${isPending}`} />
3636 ) : (
3637 <Text text={`Before... Pending: ${isPending}`} />
3638 )}
3639 </Suspense>
3640 );
3641 }
3642 ReactNoop.render(<App />);
3643 await waitForAll(['Before... Pending: false']);
3644 expect(ReactNoop).toMatchRenderedOutput(
3645 <span prop="Before... Pending: false" />,
3646 );
3647
3648 await act(async () => {
3649 transition();
3650
3651 await waitForAll([
3652 'Before... Pending: true',
3653 'Suspend! [After... Pending: false]',
3654 'Loading... Pending: false',
3655 ]);
3656 expect(ReactNoop).toMatchRenderedOutput(
3657 <span prop="Before... Pending: true" />,
3658 );
3659 Scheduler.unstable_advanceTime(500);
3660 await advanceTimers(500);
3661
3662 // Even after a long amount of time, we still don't show a placeholder.
3663 Scheduler.unstable_advanceTime(100000);
3664 await advanceTimers(100000);
3665 expect(ReactNoop).toMatchRenderedOutput(
3666 <span prop="Before... Pending: true" />,
3667 );
3668
3669 await resolveText('After... Pending: false');
3670 assertLog(['Promise resolved [After... Pending: false]']);
3671 await waitForAll(['After... Pending: false']);
3672 expect(ReactNoop).toMatchRenderedOutput(
3673 <span prop="After... Pending: false" />,
3674 );
3675 });
3676 });
3677 });
3678
3679 describe('useDeferredValue', () => {
3680 it('defers text value', async () => {
3681 function TextBox({text}) {
3682 return <AsyncText text={text} />;
3683 }
3684
3685 let _setText;
3686 function App() {
3687 const [text, setText] = useState('A');
3688 const deferredText = useDeferredValue(text);
3689 _setText = setText;
3690 return (
3691 <>
3692 <Text text={text} />
3693 <Suspense fallback={<Text text={'Loading'} />}>
3694 <TextBox text={deferredText} />
3695 </Suspense>
3696 </>
3697 );
3698 }
3699
3700 await act(() => {
3701 ReactNoop.render(<App />);
3702 });
3703
3704 assertLog([
3705 'A',
3706 'Suspend! [A]',
3707 'Loading',
3708 // pre-warming
3709 'Suspend! [A]',
3710 ]);
3711 expect(ReactNoop).toMatchRenderedOutput(
3712 <>
3713 <span prop="A" />
3714 <span prop="Loading" />
3715 </>,
3716 );
3717
3718 await act(() => resolveText('A'));
3719 assertLog(['Promise resolved [A]', 'A']);
3720 expect(ReactNoop).toMatchRenderedOutput(
3721 <>
3722 <span prop="A" />
3723 <span prop="A" />
3724 </>,
3725 );
3726
3727 await act(async () => {
3728 _setText('B');
3729 await waitForAll(['B', 'A', 'B', 'Suspend! [B]', 'Loading']);
3730 await waitForAll([]);
3731 expect(ReactNoop).toMatchRenderedOutput(
3732 <>
3733 <span prop="B" />
3734 <span prop="A" />
3735 </>,
3736 );
3737 });
3738
3739 await act(async () => {
3740 Scheduler.unstable_advanceTime(250);
3741 await advanceTimers(250);
3742 });
3743 assertLog([]);
3744 expect(ReactNoop).toMatchRenderedOutput(
3745 <>
3746 <span prop="B" />
3747 <span prop="A" />
3748 </>,
3749 );
3750
3751 // Even after a long amount of time, we don't show a fallback
3752 Scheduler.unstable_advanceTime(100000);
3753 await advanceTimers(100000);
3754 await waitForAll([]);
3755 expect(ReactNoop).toMatchRenderedOutput(
3756 <>
3757 <span prop="B" />
3758 <span prop="A" />
3759 </>,
3760 );
3761
3762 await act(async () => {
3763 await resolveText('B');
3764 });
3765 assertLog(['Promise resolved [B]', 'B', 'B']);
3766 expect(ReactNoop).toMatchRenderedOutput(
3767 <>
3768 <span prop="B" />
3769 <span prop="B" />
3770 </>,
3771 );
3772 });
3773 });
3774
3775 describe('progressive enhancement (not supported)', () => {
3776 it('mount additional state', async () => {
3777 let updateA;
3778 let updateB;
3779 // let updateC;
3780
3781 function App(props) {
3782 const [A, _updateA] = useState(0);
3783 const [B, _updateB] = useState(0);
3784 updateA = _updateA;
3785 updateB = _updateB;
3786
3787 let C;
3788 if (props.loadC) {
3789 useState(0);
3790 } else {
3791 C = '[not loaded]';
3792 }
3793
3794 return <Text text={`A: ${A}, B: ${B}, C: ${C}`} />;
3795 }
3796
3797 ReactNoop.render(<App loadC={false} />);
3798 await waitForAll(['A: 0, B: 0, C: [not loaded]']);
3799 expect(ReactNoop).toMatchRenderedOutput(
3800 <span prop="A: 0, B: 0, C: [not loaded]" />,
3801 );
3802
3803 await act(() => {
3804 updateA(2);
3805 updateB(3);
3806 });
3807
3808 assertLog(['A: 2, B: 3, C: [not loaded]']);
3809 expect(ReactNoop).toMatchRenderedOutput(
3810 <span prop="A: 2, B: 3, C: [not loaded]" />,
3811 );
3812
3813 ReactNoop.render(<App loadC={true} />);
3814 await waitForThrow(
3815 'Rendered more hooks than during the previous render.',
3816 );
3817 assertLog([]);
3818 assertConsoleErrorDev([
3819 'React has detected a change in the order of Hooks called by App. ' +
3820 'This will lead to bugs and errors if not fixed. For more information, ' +
3821 'read the Rules of Hooks: https://react.dev/link/rules-of-hooks\n' +
3822 '\n' +
3823 ' Previous render Next render\n' +
3824 ' ------------------------------------------------------\n' +
3825 '1. useState useState\n' +
3826 '2. useState useState\n' +
3827 '3. undefined useState\n' +
3828 ' ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n' +
3829 '\n' +
3830 ' in App (at **)',
3831 ]);
3832
3833 // Uncomment if/when we support this again
3834 // expect(ReactNoop).toMatchRenderedOutput(<span prop="A: 2, B: 3, C: 0" />]);
3835
3836 // updateC(4);
3837 // expect(Scheduler).toFlushAndYield(['A: 2, B: 3, C: 4']);
3838 // expect(ReactNoop).toMatchRenderedOutput(<span prop="A: 2, B: 3, C: 4" />]);
3839 });
3840
3841 it('unmount state', async () => {
3842 let updateA;
3843 let updateB;
3844 let updateC;
3845
3846 function App(props) {
3847 const [A, _updateA] = useState(0);
3848 const [B, _updateB] = useState(0);
3849 updateA = _updateA;
3850 updateB = _updateB;
3851
3852 let C;
3853 if (props.loadC) {
3854 const [_C, _updateC] = useState(0);
3855 C = _C;
3856 updateC = _updateC;
3857 } else {
3858 C = '[not loaded]';
3859 }
3860
3861 return <Text text={`A: ${A}, B: ${B}, C: ${C}`} />;
3862 }
3863
3864 ReactNoop.render(<App loadC={true} />);
3865 await waitForAll(['A: 0, B: 0, C: 0']);
3866 expect(ReactNoop).toMatchRenderedOutput(<span prop="A: 0, B: 0, C: 0" />);
3867 await act(() => {
3868 updateA(2);
3869 updateB(3);
3870 updateC(4);
3871 });
3872 assertLog(['A: 2, B: 3, C: 4']);
3873 expect(ReactNoop).toMatchRenderedOutput(<span prop="A: 2, B: 3, C: 4" />);
3874 ReactNoop.render(<App loadC={false} />);
3875 await waitForThrow(
3876 'Rendered fewer hooks than expected. This may be caused by an ' +
3877 'accidental early return statement.',
3878 );
3879 });
3880
3881 it('unmount effects', async () => {
3882 function App(props) {
3883 useEffect(() => {
3884 Scheduler.log('Mount A');
3885 return () => {
3886 Scheduler.log('Unmount A');
3887 };
3888 }, []);
3889
3890 if (props.showMore) {
3891 useEffect(() => {
3892 Scheduler.log('Mount B');
3893 return () => {
3894 Scheduler.log('Unmount B');
3895 };
3896 }, []);
3897 }
3898
3899 return null;
3900 }
3901
3902 await act(async () => {
3903 ReactNoop.render(<App showMore={false} />, () =>
3904 Scheduler.log('Sync effect'),
3905 );
3906 await waitFor(['Sync effect']);
3907 });
3908
3909 assertLog(['Mount A']);
3910
3911 await act(async () => {
3912 ReactNoop.render(<App showMore={true} />);
3913 await waitForThrow(
3914 'Rendered more hooks than during the previous render.',
3915 );
3916 assertLog(['Unmount A']);
3917 assertConsoleErrorDev([
3918 'React has detected a change in the order of Hooks called by App. ' +
3919 'This will lead to bugs and errors if not fixed. For more information, ' +
3920 'read the Rules of Hooks: https://react.dev/link/rules-of-hooks\n' +
3921 '\n' +
3922 ' Previous render Next render\n' +
3923 ' ------------------------------------------------------\n' +
3924 '1. useEffect useEffect\n' +
3925 '2. undefined useEffect\n' +
3926 ' ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n' +
3927 '\n' +
3928 ' in App (at **)',
3929 ]);
3930 });
3931
3932 // Uncomment if/when we support this again
3933 // ReactNoop.flushPassiveEffects();
3934 // expect(Scheduler).toHaveYielded(['Mount B']);
3935
3936 // ReactNoop.render(<App showMore={false} />);
3937 // expect(Scheduler).toFlushAndThrow(
3938 // 'Rendered fewer hooks than expected. This may be caused by an ' +
3939 // 'accidental early return statement.',
3940 // );
3941 });
3942 });
3943
3944 it('useReducer does not eagerly bail out of state updates', async () => {
3945 // Edge case based on a bug report
3946 let setCounter;
3947 function App() {
3948 const [counter, _setCounter] = useState(1);
3949 setCounter = _setCounter;
3950 return <Component count={counter} />;
3951 }
3952
3953 function Component({count}) {
3954 const [state, dispatch] = useReducer(() => {
3955 // This reducer closes over a value from props. If the reducer is not
3956 // properly updated, the eager reducer will compare to an old value
3957 // and bail out incorrectly.
3958 Scheduler.log('Reducer: ' + count);
3959 return count;
3960 }, -1);
3961 useEffect(() => {
3962 Scheduler.log('Effect: ' + count);
3963 dispatch();
3964 }, [count]);
3965 Scheduler.log('Render: ' + state);
3966 return count;
3967 }
3968
3969 await act(async () => {
3970 ReactNoop.render(<App />);
3971 await waitForAll(['Render: -1', 'Effect: 1', 'Reducer: 1', 'Render: 1']);
3972 expect(ReactNoop).toMatchRenderedOutput('1');
3973 });
3974
3975 await act(() => {
3976 setCounter(2);
3977 });
3978 assertLog(['Render: 1', 'Effect: 2', 'Reducer: 2', 'Render: 2']);
3979 expect(ReactNoop).toMatchRenderedOutput('2');
3980 });
3981
3982 it('useReducer does not replay previous no-op actions when other state changes', async () => {
3983 let increment;
3984 let setDisabled;
3985
3986 function Counter() {
3987 const [disabled, _setDisabled] = useState(true);
3988 const [count, dispatch] = useReducer((state, action) => {
3989 if (disabled) {
3990 return state;
3991 }
3992 if (action.type === 'increment') {
3993 return state + 1;
3994 }
3995 return state;
3996 }, 0);
3997
3998 increment = () => dispatch({type: 'increment'});
3999 setDisabled = _setDisabled;
4000
4001 Scheduler.log('Render disabled: ' + disabled);
4002 Scheduler.log('Render count: ' + count);
4003 return count;
4004 }
4005
4006 ReactNoop.render(<Counter />);
4007 await waitForAll(['Render disabled: true', 'Render count: 0']);
4008 expect(ReactNoop).toMatchRenderedOutput('0');
4009
4010 await act(() => {
4011 // These increments should have no effect, since disabled=true
4012 increment();
4013 increment();
4014 increment();
4015 });
4016 assertLog(['Render disabled: true', 'Render count: 0']);
4017 expect(ReactNoop).toMatchRenderedOutput('0');
4018
4019 await act(() => {
4020 // Enabling the updater should *not* replay the previous increment() actions
4021 setDisabled(false);
4022 });
4023 assertLog(['Render disabled: false', 'Render count: 0']);
4024 expect(ReactNoop).toMatchRenderedOutput('0');
4025 });
4026
4027 it('useReducer does not replay previous no-op actions when props change', async () => {
4028 let setDisabled;
4029 let increment;
4030
4031 function Counter({disabled}) {
4032 const [count, dispatch] = useReducer((state, action) => {
4033 if (disabled) {
4034 return state;
4035 }
4036 if (action.type === 'increment') {
4037 return state + 1;
4038 }
4039 return state;
4040 }, 0);
4041
4042 increment = () => dispatch({type: 'increment'});
4043
4044 Scheduler.log('Render count: ' + count);
4045 return count;
4046 }
4047
4048 function App() {
4049 const [disabled, _setDisabled] = useState(true);
4050 setDisabled = _setDisabled;
4051 Scheduler.log('Render disabled: ' + disabled);
4052 return <Counter disabled={disabled} />;
4053 }
4054
4055 ReactNoop.render(<App />);
4056 await waitForAll(['Render disabled: true', 'Render count: 0']);
4057 expect(ReactNoop).toMatchRenderedOutput('0');
4058
4059 await act(() => {
4060 // These increments should have no effect, since disabled=true
4061 increment();
4062 increment();
4063 increment();
4064 });
4065 assertLog(['Render count: 0']);
4066 expect(ReactNoop).toMatchRenderedOutput('0');
4067
4068 await act(() => {
4069 // Enabling the updater should *not* replay the previous increment() actions
4070 setDisabled(false);
4071 });
4072 assertLog(['Render disabled: false', 'Render count: 0']);
4073 expect(ReactNoop).toMatchRenderedOutput('0');
4074 });
4075
4076 it('useReducer applies potential no-op changes if made relevant by other updates in the batch', async () => {
4077 let setDisabled;
4078 let increment;
4079
4080 function Counter({disabled}) {
4081 const [count, dispatch] = useReducer((state, action) => {
4082 if (disabled) {
4083 return state;
4084 }
4085 if (action.type === 'increment') {
4086 return state + 1;
4087 }
4088 return state;
4089 }, 0);
4090
4091 increment = () => dispatch({type: 'increment'});
4092
4093 Scheduler.log('Render count: ' + count);
4094 return count;
4095 }
4096
4097 function App() {
4098 const [disabled, _setDisabled] = useState(true);
4099 setDisabled = _setDisabled;
4100 Scheduler.log('Render disabled: ' + disabled);
4101 return <Counter disabled={disabled} />;
4102 }
4103
4104 ReactNoop.render(<App />);
4105 await waitForAll(['Render disabled: true', 'Render count: 0']);
4106 expect(ReactNoop).toMatchRenderedOutput('0');
4107
4108 await act(() => {
4109 // Although the increment happens first (and would seem to do nothing since disabled=true),
4110 // because these calls are in a batch the parent updates first. This should cause the child
4111 // to re-render with disabled=false and *then* process the increment action, which now
4112 // increments the count and causes the component output to change.
4113 increment();
4114 setDisabled(false);
4115 });
4116 assertLog(['Render disabled: false', 'Render count: 1']);
4117 expect(ReactNoop).toMatchRenderedOutput('1');
4118 });
4119
4120 // Regression test. Covers a case where an internal state variable
4121 // (`didReceiveUpdate`) is not reset properly.
4122 it('state bail out edge case (#16359)', async () => {
4123 let setCounterA;
4124 let setCounterB;
4125
4126 function CounterA() {
4127 const [counter, setCounter] = useState(0);
4128 setCounterA = setCounter;
4129 Scheduler.log('Render A: ' + counter);
4130 useEffect(() => {
4131 Scheduler.log('Commit A: ' + counter);
4132 });
4133 return counter;
4134 }
4135
4136 function CounterB() {
4137 const [counter, setCounter] = useState(0);
4138 setCounterB = setCounter;
4139 Scheduler.log('Render B: ' + counter);
4140 useEffect(() => {
4141 Scheduler.log('Commit B: ' + counter);
4142 });
4143 return counter;
4144 }
4145
4146 const root = ReactNoop.createRoot(null);
4147 await act(() => {
4148 root.render(
4149 <>
4150 <CounterA />
4151 <CounterB />
4152 </>,
4153 );
4154 });
4155 assertLog(['Render A: 0', 'Render B: 0', 'Commit A: 0', 'Commit B: 0']);
4156
4157 await act(() => {
4158 setCounterA(1);
4159
4160 // In the same batch, update B twice. To trigger the condition we're
4161 // testing, the first update is necessary to bypass the early
4162 // bailout optimization.
4163 setCounterB(1);
4164 setCounterB(0);
4165 });
4166 assertLog([
4167 'Render A: 1',
4168 'Render B: 0',
4169 'Commit A: 1',
4170 // B should not fire an effect because the update bailed out
4171 // 'Commit B: 0',
4172 ]);
4173 });
4174
4175 it('should update latest rendered reducer when a preceding state receives a render phase update', async () => {
4176 // Similar to previous test, except using a preceding render phase update
4177 // instead of new props.
4178 let dispatch;
4179 function App() {
4180 const [step, setStep] = useState(0);
4181 const [shadow, _dispatch] = useReducer(() => step, step);
4182 dispatch = _dispatch;
4183
4184 if (step < 5) {
4185 setStep(step + 1);
4186 }
4187
4188 Scheduler.log(`Step: ${step}, Shadow: ${shadow}`);
4189 return shadow;
4190 }
4191
4192 ReactNoop.render(<App />);
4193 await waitForAll([
4194 'Step: 0, Shadow: 0',
4195 'Step: 1, Shadow: 0',
4196 'Step: 2, Shadow: 0',
4197 'Step: 3, Shadow: 0',
4198 'Step: 4, Shadow: 0',
4199 'Step: 5, Shadow: 0',
4200 ]);
4201 expect(ReactNoop).toMatchRenderedOutput('0');
4202
4203 await act(() => dispatch());
4204 assertLog(['Step: 5, Shadow: 5']);
4205 expect(ReactNoop).toMatchRenderedOutput('5');
4206 });
4207
4208 it('should process the rest pending updates after a render phase update', async () => {
4209 // Similar to previous test, except using a preceding render phase update
4210 // instead of new props.
4211 let updateA;
4212 let updateC;
4213 function App() {
4214 const [a, setA] = useState(false);
4215 const [b, setB] = useState(false);
4216 if (a !== b) {
4217 setB(a);
4218 }
4219 // Even though we called setB above,
4220 // we should still apply the changes to C,
4221 // during this render pass.
4222 const [c, setC] = useState(false);
4223 updateA = setA;
4224 updateC = setC;
4225 return `${a ? 'A' : 'a'}${b ? 'B' : 'b'}${c ? 'C' : 'c'}`;
4226 }
4227
4228 await act(() => ReactNoop.render(<App />));
4229 expect(ReactNoop).toMatchRenderedOutput('abc');
4230
4231 await act(() => {
4232 updateA(true);
4233 // This update should not get dropped.
4234 updateC(true);
4235 });
4236 expect(ReactNoop).toMatchRenderedOutput('ABC');
4237 });
4238
4239 it("regression test: don't unmount effects on siblings of deleted nodes", async () => {
4240 const root = ReactNoop.createRoot();
4241
4242 function Child({label}) {
4243 useLayoutEffect(() => {
4244 Scheduler.log('Mount layout ' + label);
4245 return () => {
4246 Scheduler.log('Unmount layout ' + label);
4247 };
4248 }, [label]);
4249 useEffect(() => {
4250 Scheduler.log('Mount passive ' + label);
4251 return () => {
4252 Scheduler.log('Unmount passive ' + label);
4253 };
4254 }, [label]);
4255 return label;
4256 }
4257
4258 await act(() => {
4259 root.render(
4260 <>
4261 <Child key="A" label="A" />
4262 <Child key="B" label="B" />
4263 </>,
4264 );
4265 });
4266 assertLog([
4267 'Mount layout A',
4268 'Mount layout B',
4269 'Mount passive A',
4270 'Mount passive B',
4271 ]);
4272
4273 // Delete A. This should only unmount the effect on A. In the regression,
4274 // B's effect would also unmount.
4275 await act(() => {
4276 root.render(
4277 <>
4278 <Child key="B" label="B" />
4279 </>,
4280 );
4281 });
4282 assertLog(['Unmount layout A', 'Unmount passive A']);
4283
4284 // Now delete and unmount B.
4285 await act(() => {
4286 root.render(null);
4287 });
4288 assertLog(['Unmount layout B', 'Unmount passive B']);
4289 });
4290
4291 it('regression: deleting a tree and unmounting its effects after a reorder', async () => {
4292 const root = ReactNoop.createRoot();
4293
4294 function Child({label}) {
4295 useEffect(() => {
4296 Scheduler.log('Mount ' + label);
4297 return () => {
4298 Scheduler.log('Unmount ' + label);
4299 };
4300 }, [label]);
4301 return label;
4302 }
4303
4304 await act(() => {
4305 root.render(
4306 <>
4307 <Child key="A" label="A" />
4308 <Child key="B" label="B" />
4309 </>,
4310 );
4311 });
4312 assertLog(['Mount A', 'Mount B']);
4313
4314 await act(() => {
4315 root.render(
4316 <>
4317 <Child key="B" label="B" />
4318 <Child key="A" label="A" />
4319 </>,
4320 );
4321 });
4322 assertLog([]);
4323
4324 await act(() => {
4325 root.render(null);
4326 });
4327
4328 assertLog([
4329 'Unmount B',
4330 // In the regression, the reorder would cause Child A to "forget" that it
4331 // contains passive effects. Then when we deleted the tree, A's unmount
4332 // effect would not fire.
4333 'Unmount A',
4334 ]);
4335 });
4336
4337 // @gate enableSuspenseList
4338 it('regression: SuspenseList causes unmounts to be dropped on deletion', async () => {
4339 function Row({label}) {
4340 useEffect(() => {
4341 Scheduler.log('Mount ' + label);
4342 return () => {
4343 Scheduler.log('Unmount ' + label);
4344 };
4345 }, [label]);
4346 return (
4347 <Suspense fallback="Loading...">
4348 <AsyncText text={label} />
4349 </Suspense>
4350 );
4351 }
4352
4353 function App() {
4354 return (
4355 <SuspenseList revealOrder="together">
4356 <Row label="A" />
4357 <Row label="B" />
4358 </SuspenseList>
4359 );
4360 }
4361
4362 const root = ReactNoop.createRoot();
4363 await act(() => {
4364 root.render(<App />);
4365 });
4366 assertLog(['Suspend! [A]', 'Suspend! [B]', 'Mount A', 'Mount B']);
4367
4368 await act(async () => {
4369 await resolveText('A');
4370 });
4371 assertLog(['Promise resolved [A]', 'A', 'Suspend! [B]']);
4372
4373 await act(() => {
4374 root.render(null);
4375 });
4376 // In the regression, SuspenseList would cause the children to "forget" that
4377 // it contains passive effects. Then when we deleted the tree, these unmount
4378 // effects would not fire.
4379 assertLog(['Unmount A', 'Unmount B']);
4380 });
4381
4382 it('effect dependencies are persisted after a render phase update', async () => {
4383 let handleClick;
4384 function Test() {
4385 const [count, setCount] = useState(0);
4386
4387 useEffect(() => {
4388 Scheduler.log(`Effect: ${count}`);
4389 }, [count]);
4390
4391 if (count > 0) {
4392 setCount(0);
4393 }
4394
4395 handleClick = () => setCount(2);
4396
4397 return <Text text={`Render: ${count}`} />;
4398 }
4399
4400 await act(() => {
4401 ReactNoop.render(<Test />);
4402 });
4403
4404 assertLog(['Render: 0', 'Effect: 0']);
4405
4406 await act(() => {
4407 handleClick();
4408 });
4409
4410 assertLog(['Render: 0']);
4411
4412 await act(() => {
4413 handleClick();
4414 });
4415
4416 assertLog(['Render: 0']);
4417
4418 await act(() => {
4419 handleClick();
4420 });
4421
4422 assertLog(['Render: 0']);
4423 });
4424 });