main
js 1,295 lines 34.1 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 'use strict';
12
13 let React;
14 let ReactNoop;
15 let Scheduler;
16 let Suspense;
17 let useState;
18 let useLayoutEffect;
19 let useTransition;
20 let startTransition;
21 let act;
22 let getCacheForType;
23 let waitForAll;
24 let waitFor;
25 let waitForPaint;
26 let assertLog;
27
28 let caches;
29 let seededCache;
30
31 describe('ReactTransition', () => {
32 beforeEach(() => {
33 jest.resetModules();
34 React = require('react');
35 ReactNoop = require('react-noop-renderer');
36 Scheduler = require('scheduler');
37 useState = React.useState;
38 useLayoutEffect = React.useLayoutEffect;
39 useTransition = React.useTransition;
40 Suspense = React.Suspense;
41 startTransition = React.startTransition;
42 getCacheForType = React.unstable_getCacheForType;
43 act = require('internal-test-utils').act;
44
45 const InternalTestUtils = require('internal-test-utils');
46 waitForAll = InternalTestUtils.waitForAll;
47 waitFor = InternalTestUtils.waitFor;
48 waitForPaint = InternalTestUtils.waitForPaint;
49 assertLog = InternalTestUtils.assertLog;
50
51 caches = [];
52 seededCache = null;
53 });
54
55 function createTextCache() {
56 if (seededCache !== null) {
57 // Trick to seed a cache before it exists.
58 // TODO: Need a built-in API to seed data before the initial render (i.e.
59 // not a refresh because nothing has mounted yet).
60 const cache = seededCache;
61 seededCache = null;
62 return cache;
63 }
64
65 const data = new Map();
66 const version = caches.length + 1;
67 const cache = {
68 version,
69 data,
70 resolve(text) {
71 const record = data.get(text);
72 if (record === undefined) {
73 const newRecord = {
74 status: 'resolved',
75 value: text,
76 };
77 data.set(text, newRecord);
78 } else if (record.status === 'pending') {
79 const thenable = record.value;
80 record.status = 'resolved';
81 record.value = text;
82 thenable.pings.forEach(t => t());
83 }
84 },
85 reject(text, error) {
86 const record = data.get(text);
87 if (record === undefined) {
88 const newRecord = {
89 status: 'rejected',
90 value: error,
91 };
92 data.set(text, newRecord);
93 } else if (record.status === 'pending') {
94 const thenable = record.value;
95 record.status = 'rejected';
96 record.value = error;
97 thenable.pings.forEach(t => t());
98 }
99 },
100 };
101 caches.push(cache);
102 return cache;
103 }
104
105 function readText(text) {
106 const textCache = getCacheForType(createTextCache);
107 const record = textCache.data.get(text);
108 if (record !== undefined) {
109 switch (record.status) {
110 case 'pending':
111 Scheduler.log(`Suspend! [${text}]`);
112 throw record.value;
113 case 'rejected':
114 Scheduler.log(`Error! [${text}]`);
115 throw record.value;
116 case 'resolved':
117 return textCache.version;
118 }
119 } else {
120 Scheduler.log(`Suspend! [${text}]`);
121
122 const thenable = {
123 pings: [],
124 then(resolve) {
125 if (newRecord.status === 'pending') {
126 thenable.pings.push(resolve);
127 } else {
128 Promise.resolve().then(() => resolve(newRecord.value));
129 }
130 },
131 };
132
133 const newRecord = {
134 status: 'pending',
135 value: thenable,
136 };
137 textCache.data.set(text, newRecord);
138
139 throw thenable;
140 }
141 }
142
143 function Text({text}) {
144 Scheduler.log(text);
145 return text;
146 }
147
148 function AsyncText({text}) {
149 readText(text);
150 Scheduler.log(text);
151 return text;
152 }
153
154 function seedNextTextCache(text) {
155 if (seededCache === null) {
156 seededCache = createTextCache();
157 }
158 seededCache.resolve(text);
159 }
160
161 function resolveText(text) {
162 if (caches.length === 0) {
163 throw Error('Cache does not exist.');
164 } else {
165 // Resolve the most recently created cache. An older cache can by
166 // resolved with `caches[index].resolve(text)`.
167 caches[caches.length - 1].resolve(text);
168 }
169 }
170
171 // @gate enableLegacyCache
172 it('isPending works even if called from outside an input event', async () => {
173 let start;
174 function App() {
175 const [show, setShow] = useState(false);
176 const [isPending, _start] = useTransition();
177 start = () => _start(() => setShow(true));
178 return (
179 <Suspense fallback={<Text text="Loading..." />}>
180 {isPending ? <Text text="Pending..." /> : null}
181 {show ? <AsyncText text="Async" /> : <Text text="(empty)" />}
182 </Suspense>
183 );
184 }
185
186 const root = ReactNoop.createRoot();
187
188 await act(() => {
189 root.render(<App />);
190 });
191 assertLog(['(empty)']);
192 expect(root).toMatchRenderedOutput('(empty)');
193
194 await act(async () => {
195 start();
196
197 await waitForAll([
198 'Pending...',
199 '(empty)',
200 'Suspend! [Async]',
201 'Loading...',
202 ]);
203
204 expect(root).toMatchRenderedOutput('Pending...(empty)');
205
206 await resolveText('Async');
207 });
208 assertLog(['Async']);
209 expect(root).toMatchRenderedOutput('Async');
210 });
211
212 // @gate enableLegacyCache
213 it('when multiple transitions update different queues, they entangle', async () => {
214 let setA;
215 let startTransitionA;
216 let setB;
217 let startTransitionB;
218 function A() {
219 const [a, _setA] = useState(0);
220 const [isPending, _startTransitionA] = useTransition();
221 setA = _setA;
222 startTransitionA = _startTransitionA;
223
224 return (
225 <span>
226 {isPending && (
227 <span>
228 <Text text="Pending A..." />
229 </span>
230 )}
231 <AsyncText text={`A: ${a}`} />
232 </span>
233 );
234 }
235
236 function B() {
237 const [b, _setB] = useState(0);
238 const [isPending, _startTransitionB] = useTransition();
239 setB = _setB;
240 startTransitionB = _startTransitionB;
241
242 return (
243 <span>
244 {isPending && (
245 <span>
246 <Text text="Pending B..." />
247 </span>
248 )}
249 <AsyncText text={`B: ${b}`} />
250 </span>
251 );
252 }
253 function App() {
254 return (
255 <>
256 <Suspense fallback={<span>Loading A</span>}>
257 <A />
258 </Suspense>
259 <Suspense fallback={<span>Loading B</span>}>
260 <B />
261 </Suspense>
262 </>
263 );
264 }
265
266 // Initial render
267 const root = ReactNoop.createRoot();
268 await act(() => {
269 root.render(<App />);
270 });
271 assertLog([
272 'Suspend! [A: 0]',
273 'Suspend! [B: 0]',
274 'Suspend! [A: 0]',
275 'Suspend! [B: 0]',
276 ]);
277 expect(root).toMatchRenderedOutput(
278 <>
279 <span>Loading A</span>
280 <span>Loading B</span>
281 </>,
282 );
283
284 // Resolve
285 await act(() => {
286 resolveText('A: 0');
287 resolveText('B: 0');
288 });
289 assertLog(['A: 0', 'B: 0']);
290 expect(root).toMatchRenderedOutput(
291 <>
292 <span>A: 0</span>
293 <span>B: 0</span>
294 </>,
295 );
296
297 // Start transitioning A
298 await act(() => {
299 startTransitionA(() => {
300 setA(1);
301 });
302 });
303 assertLog(['Pending A...', 'A: 0', 'Suspend! [A: 1]']);
304 expect(root).toMatchRenderedOutput(
305 <>
306 <span>
307 <span>Pending A...</span>A: 0
308 </span>
309 <span>B: 0</span>
310 </>,
311 );
312
313 // Start transitioning B
314 await act(() => {
315 startTransitionB(() => {
316 setB(1);
317 });
318 });
319 assertLog(['Pending B...', 'B: 0', 'Suspend! [A: 1]', 'Suspend! [B: 1]']);
320 expect(root).toMatchRenderedOutput(
321 <>
322 <span>
323 <span>Pending A...</span>A: 0
324 </span>
325 <span>
326 <span>Pending B...</span>B: 0
327 </span>
328 </>,
329 );
330
331 // Resolve B
332 await act(() => {
333 resolveText('B: 1');
334 });
335 assertLog(
336 gate('enableParallelTransitions')
337 ? ['B: 1', 'Suspend! [A: 1]']
338 : ['Suspend! [A: 1]', 'B: 1'],
339 );
340 expect(root).toMatchRenderedOutput(
341 gate('enableParallelTransitions') ? (
342 <>
343 <span>
344 <span>Pending A...</span>A: 0
345 </span>
346 <span>B: 1</span>
347 </>
348 ) : (
349 <>
350 <span>
351 <span>Pending A...</span>A: 0
352 </span>
353 <span>
354 <span>Pending B...</span>B: 0
355 </span>
356 </>
357 ),
358 );
359
360 // Resolve A
361 await act(() => {
362 resolveText('A: 1');
363 });
364 assertLog(gate('enableParallelTransitions') ? ['A: 1'] : ['A: 1', 'B: 1']);
365 expect(root).toMatchRenderedOutput(
366 <>
367 <span>A: 1</span>
368 <span>B: 1</span>
369 </>,
370 );
371 });
372
373 // @gate enableLegacyCache
374 it('when multiple transitions update different queues, but suspend the same boundary, they do entangle', async () => {
375 let setA;
376 let startTransitionA;
377 let setB;
378 let startTransitionB;
379 function A() {
380 const [a, _setA] = useState(0);
381 const [isPending, _startTransitionA] = useTransition();
382 setA = _setA;
383 startTransitionA = _startTransitionA;
384
385 return (
386 <span>
387 {isPending && (
388 <span>
389 <Text text="Pending A..." />
390 </span>
391 )}
392 <AsyncText text={`A: ${a}`} />
393 </span>
394 );
395 }
396
397 function B() {
398 const [b, _setB] = useState(0);
399 const [isPending, _startTransitionB] = useTransition();
400 setB = _setB;
401 startTransitionB = _startTransitionB;
402
403 return (
404 <span>
405 {isPending && (
406 <span>
407 <Text text="Pending B..." />
408 </span>
409 )}
410 <AsyncText text={`B: ${b}`} />
411 </span>
412 );
413 }
414 function App() {
415 return (
416 <Suspense fallback={<span>Loading...</span>}>
417 <A />
418 <B />
419 </Suspense>
420 );
421 }
422
423 // Initial render
424 const root = ReactNoop.createRoot();
425 await act(() => {
426 root.render(<App />);
427 });
428 assertLog([
429 'Suspend! [A: 0]',
430 // pre-warming
431 'Suspend! [A: 0]',
432 'Suspend! [B: 0]',
433 ]);
434 expect(root).toMatchRenderedOutput(<span>Loading...</span>);
435
436 // Resolve
437 await act(() => {
438 resolveText('A: 0');
439 resolveText('B: 0');
440 });
441 assertLog(['A: 0', 'B: 0']);
442 expect(root).toMatchRenderedOutput(
443 <>
444 <span>A: 0</span>
445 <span>B: 0</span>
446 </>,
447 );
448
449 // Start transitioning A
450 await act(() => {
451 startTransitionA(() => {
452 setA(1);
453 });
454 });
455 assertLog(['Pending A...', 'A: 0', 'Suspend! [A: 1]']);
456 expect(root).toMatchRenderedOutput(
457 <>
458 <span>
459 <span>Pending A...</span>A: 0
460 </span>
461 <span>B: 0</span>
462 </>,
463 );
464
465 // Start transitioning B
466 await act(() => {
467 startTransitionB(() => {
468 setB(1);
469 });
470 });
471 assertLog(['Pending B...', 'B: 0', 'Suspend! [A: 1]', 'Suspend! [B: 1]']);
472 expect(root).toMatchRenderedOutput(
473 <>
474 <span>
475 <span>Pending A...</span>A: 0
476 </span>
477 <span>
478 <span>Pending B...</span>B: 0
479 </span>
480 </>,
481 );
482
483 // Resolve B
484 await act(() => {
485 resolveText('B: 1');
486 });
487 assertLog(
488 gate('enableParallelTransitions')
489 ? ['B: 1', 'Suspend! [A: 1]']
490 : ['Suspend! [A: 1]', 'B: 1'],
491 );
492 expect(root).toMatchRenderedOutput(
493 gate('enableParallelTransitions') ? (
494 <>
495 <span>
496 <span>Pending A...</span>A: 0
497 </span>
498 <span>B: 1</span>
499 </>
500 ) : (
501 <>
502 <span>
503 <span>Pending A...</span>A: 0
504 </span>
505 <span>
506 <span>Pending B...</span>B: 0
507 </span>
508 </>
509 ),
510 );
511
512 // Resolve A
513 await act(() => {
514 resolveText('A: 1');
515 });
516 assertLog(gate('enableParallelTransitions') ? ['A: 1'] : ['A: 1', 'B: 1']);
517 expect(root).toMatchRenderedOutput(
518 <>
519 <span>A: 1</span>
520 <span>B: 1</span>
521 </>,
522 );
523 });
524
525 // @gate enableLegacyCache
526 it(
527 'when multiple transitions update the same queue, only the most recent ' +
528 'one is allowed to finish (no intermediate states)',
529 async () => {
530 let update;
531 function App() {
532 const [isContentPending, startContentChange] = useTransition();
533 const [label, setLabel] = useState('A');
534 const [contents, setContents] = useState('A');
535 update = value => {
536 ReactNoop.discreteUpdates(() => {
537 setLabel(value);
538 startContentChange(() => {
539 setContents(value);
540 });
541 });
542 };
543 return (
544 <>
545 <Text
546 text={
547 label + ' label' + (isContentPending ? ' (loading...)' : '')
548 }
549 />
550 <div>
551 <Suspense fallback={<Text text="Loading..." />}>
552 <AsyncText text={contents + ' content'} />
553 </Suspense>
554 </div>
555 </>
556 );
557 }
558
559 // Initial render
560 const root = ReactNoop.createRoot();
561 await act(() => {
562 seedNextTextCache('A content');
563 root.render(<App />);
564 });
565 assertLog(['A label', 'A content']);
566 expect(root).toMatchRenderedOutput(
567 <>
568 A label<div>A content</div>
569 </>,
570 );
571
572 // Switch to B
573 await act(() => {
574 update('B');
575 });
576 assertLog([
577 // Commit pending state
578 'B label (loading...)',
579 'A content',
580
581 // Attempt to render B, but it suspends
582 'B label',
583 'Suspend! [B content]',
584 'Loading...',
585 ]);
586 // This is a refresh transition so it shouldn't show a fallback
587 expect(root).toMatchRenderedOutput(
588 <>
589 B label (loading...)<div>A content</div>
590 </>,
591 );
592
593 // Before B finishes loading, switch to C
594 await act(() => {
595 update('C');
596 });
597 assertLog([
598 // Commit pending state
599 'C label (loading...)',
600 'A content',
601
602 // Attempt to render C, but it suspends
603 'C label',
604 'Suspend! [C content]',
605 'Loading...',
606 ]);
607 expect(root).toMatchRenderedOutput(
608 <>
609 C label (loading...)<div>A content</div>
610 </>,
611 );
612
613 // Finish loading B. But we're not allowed to render B because it's
614 // entangled with C. So we're still pending.
615 await act(() => {
616 resolveText('B content');
617 });
618 assertLog([
619 // Attempt to render C, but it suspends
620 'C label',
621 'Suspend! [C content]',
622 'Loading...',
623 ]);
624 expect(root).toMatchRenderedOutput(
625 <>
626 C label (loading...)<div>A content</div>
627 </>,
628 );
629
630 // Now finish loading C. This is the terminal update, so it can finish.
631 await act(() => {
632 resolveText('C content');
633 });
634 assertLog(['C label', 'C content']);
635 expect(root).toMatchRenderedOutput(
636 <>
637 C label<div>C content</div>
638 </>,
639 );
640 },
641 );
642
643 // Same as previous test, but for class update queue.
644 // @gate enableLegacyCache
645 it(
646 'when multiple transitions update the same queue, only the most recent ' +
647 'one is allowed to finish (no intermediate states) (classes)',
648 async () => {
649 let update;
650 class App extends React.Component {
651 state = {
652 label: 'A',
653 contents: 'A',
654 };
655 render() {
656 update = value => {
657 ReactNoop.discreteUpdates(() => {
658 this.setState({label: value});
659 startTransition(() => {
660 this.setState({contents: value});
661 });
662 });
663 };
664 const label = this.state.label;
665 const contents = this.state.contents;
666 const isContentPending = label !== contents;
667 return (
668 <>
669 <Text
670 text={
671 label + ' label' + (isContentPending ? ' (loading...)' : '')
672 }
673 />
674 <div>
675 <Suspense fallback={<Text text="Loading..." />}>
676 <AsyncText text={contents + ' content'} />
677 </Suspense>
678 </div>
679 </>
680 );
681 }
682 }
683
684 // Initial render
685 const root = ReactNoop.createRoot();
686 await act(() => {
687 seedNextTextCache('A content');
688 root.render(<App />);
689 });
690 assertLog(['A label', 'A content']);
691 expect(root).toMatchRenderedOutput(
692 <>
693 A label<div>A content</div>
694 </>,
695 );
696
697 // Switch to B
698 await act(() => {
699 update('B');
700 });
701 assertLog([
702 // Commit pending state
703 'B label (loading...)',
704 'A content',
705
706 // Attempt to render B, but it suspends
707 'B label',
708 'Suspend! [B content]',
709 'Loading...',
710 ]);
711 // This is a refresh transition so it shouldn't show a fallback
712 expect(root).toMatchRenderedOutput(
713 <>
714 B label (loading...)<div>A content</div>
715 </>,
716 );
717
718 // Before B finishes loading, switch to C
719 await act(() => {
720 update('C');
721 });
722 assertLog([
723 // Commit pending state
724 'C label (loading...)',
725 'A content',
726
727 // Attempt to render C, but it suspends
728 'C label',
729 'Suspend! [C content]',
730 'Loading...',
731 ]);
732 expect(root).toMatchRenderedOutput(
733 <>
734 C label (loading...)<div>A content</div>
735 </>,
736 );
737
738 // Finish loading B. But we're not allowed to render B because it's
739 // entangled with C. So we're still pending.
740 await act(() => {
741 resolveText('B content');
742 });
743 assertLog([
744 // Attempt to render C, but it suspends
745 'C label',
746 'Suspend! [C content]',
747 'Loading...',
748 ]);
749 expect(root).toMatchRenderedOutput(
750 <>
751 C label (loading...)<div>A content</div>
752 </>,
753 );
754
755 // Now finish loading C. This is the terminal update, so it can finish.
756 await act(() => {
757 resolveText('C content');
758 });
759 assertLog(['C label', 'C content']);
760 expect(root).toMatchRenderedOutput(
761 <>
762 C label<div>C content</div>
763 </>,
764 );
765 },
766 );
767
768 // @gate enableLegacyCache
769 it(
770 'when multiple transitions update overlapping queues, all the transitions ' +
771 'across all the queues are entangled',
772 async () => {
773 let setShowA;
774 let setShowB;
775 let setShowC;
776 function App() {
777 const [showA, _setShowA] = useState(false);
778 const [showB, _setShowB] = useState(false);
779 const [showC, _setShowC] = useState(false);
780 setShowA = _setShowA;
781 setShowB = _setShowB;
782 setShowC = _setShowC;
783
784 // Only one of these children should be visible at a time. Except
785 // instead of being modeled as a single state, it's three separate
786 // states that are updated simultaneously. This may seem a bit
787 // contrived, but it's more common than you might think. Usually via
788 // a framework or indirection. For example, consider a tooltip manager
789 // that only shows a single tooltip at a time. Or a router that
790 // highlights links to the active route.
791 return (
792 <>
793 <Suspense fallback={<Text text="Loading..." />}>
794 {showA ? <AsyncText text="A" /> : null}
795 {showB ? <AsyncText text="B" /> : null}
796 {showC ? <AsyncText text="C" /> : null}
797 </Suspense>
798 </>
799 );
800 }
801
802 // Initial render. Start with all children hidden.
803 const root = ReactNoop.createRoot();
804 await act(() => {
805 root.render(<App />);
806 });
807 assertLog([]);
808 expect(root).toMatchRenderedOutput(null);
809
810 // Switch to A.
811 await act(() => {
812 startTransition(() => {
813 setShowA(true);
814 });
815 });
816 assertLog(['Suspend! [A]', 'Loading...']);
817 expect(root).toMatchRenderedOutput(null);
818
819 // Before A loads, switch to B. This should entangle A with B.
820 await act(() => {
821 startTransition(() => {
822 setShowA(false);
823 setShowB(true);
824 });
825 });
826 assertLog(['Suspend! [B]', 'Loading...']);
827 expect(root).toMatchRenderedOutput(null);
828
829 // Before A or B loads, switch to C. This should entangle C with B, and
830 // transitively entangle C with A.
831 await act(() => {
832 startTransition(() => {
833 setShowB(false);
834 setShowC(true);
835 });
836 });
837 assertLog(['Suspend! [C]', 'Loading...']);
838 expect(root).toMatchRenderedOutput(null);
839
840 // Now the data starts resolving out of order.
841
842 // First resolve B. This will attempt to render C, since everything is
843 // entangled.
844 await act(() => {
845 startTransition(() => {
846 resolveText('B');
847 });
848 });
849 assertLog(['Suspend! [C]', 'Loading...']);
850 expect(root).toMatchRenderedOutput(null);
851
852 // Now resolve A. Again, this will attempt to render C, since everything
853 // is entangled.
854 await act(() => {
855 startTransition(() => {
856 resolveText('A');
857 });
858 });
859 assertLog(['Suspend! [C]', 'Loading...']);
860 expect(root).toMatchRenderedOutput(null);
861
862 // Finally, resolve C. This time we can finish.
863 await act(() => {
864 startTransition(() => {
865 resolveText('C');
866 });
867 });
868 assertLog(['C']);
869 expect(root).toMatchRenderedOutput('C');
870 },
871 );
872
873 // @gate enableLegacyCache
874 it('interrupt a refresh transition if a new transition is scheduled', async () => {
875 const root = ReactNoop.createRoot();
876
877 await act(() => {
878 root.render(
879 <>
880 <Suspense fallback={<Text text="Loading..." />} />
881 <Text text="Initial" />
882 </>,
883 );
884 });
885 assertLog(['Initial']);
886 expect(root).toMatchRenderedOutput('Initial');
887
888 await act(async () => {
889 // Start a refresh transition
890 startTransition(() => {
891 root.render(
892 <>
893 <Suspense fallback={<Text text="Loading..." />}>
894 <AsyncText text="Async" />
895 </Suspense>
896 <Text text="After Suspense" />
897 <Text text="Sibling" />
898 </>,
899 );
900 });
901
902 // Partially render it.
903 await waitFor([
904 // Once we the update suspends, we know it's a refresh transition,
905 // because the Suspense boundary has already mounted.
906 'Suspend! [Async]',
907 'Loading...',
908 'After Suspense',
909 ]);
910
911 // Schedule a new transition
912 startTransition(async () => {
913 root.render(
914 <>
915 <Suspense fallback={<Text text="Loading..." />} />
916 <Text text="Updated" />
917 </>,
918 );
919 });
920 });
921
922 // Because the first one is going to suspend regardless, we should
923 // immediately switch to rendering the new transition.
924 assertLog(['Updated']);
925 expect(root).toMatchRenderedOutput('Updated');
926 });
927
928 // @gate enableLegacyCache
929 it(
930 "interrupt a refresh transition when something suspends and we've " +
931 'already bailed out on another transition in a parent',
932 async () => {
933 let setShouldSuspend;
934
935 function Parent({children}) {
936 const [shouldHideInParent, _setShouldHideInParent] = useState(false);
937 setShouldHideInParent = _setShouldHideInParent;
938 Scheduler.log('shouldHideInParent: ' + shouldHideInParent);
939 if (shouldHideInParent) {
940 return <Text text="(empty)" />;
941 }
942 return children;
943 }
944
945 let setShouldHideInParent;
946 function App() {
947 const [shouldSuspend, _setShouldSuspend] = useState(false);
948 setShouldSuspend = _setShouldSuspend;
949 return (
950 <>
951 <Text text="A" />
952 <Parent>
953 <Suspense fallback={<Text text="Loading..." />}>
954 {shouldSuspend ? <AsyncText text="Async" /> : null}
955 </Suspense>
956 </Parent>
957 <Text text="B" />
958 <Text text="C" />
959 </>
960 );
961 }
962
963 const root = ReactNoop.createRoot();
964
965 await act(async () => {
966 root.render(<App />);
967 await waitForAll(['A', 'shouldHideInParent: false', 'B', 'C']);
968 expect(root).toMatchRenderedOutput('ABC');
969
970 // Schedule an update
971 startTransition(() => {
972 setShouldSuspend(true);
973 });
974
975 // Now we need to trigger schedule another transition in a different
976 // lane from the first one. At the time this was written, all transitions are worked on
977 // simultaneously, unless a transition was already in progress when a
978 // new one was scheduled. So, partially render the first transition.
979 await waitFor(['A']);
980
981 // Now schedule a second transition. We won't interrupt the first one.
982 React.startTransition(() => {
983 setShouldHideInParent(true);
984 });
985 // Continue rendering the first transition.
986 await waitFor([
987 'shouldHideInParent: false',
988 'Suspend! [Async]',
989 'Loading...',
990 'B',
991 ]);
992 // Should not have committed loading state
993 expect(root).toMatchRenderedOutput('ABC');
994
995 // At this point, we've processed the parent update queue, so we know
996 // that it has a pending update from the second transition, even though
997 // we skipped it during this render. And we know this is a refresh
998 // transition, because we had to render a loading state. So the next
999 // time we re-enter the work loop (we don't interrupt immediately, we
1000 // just wait for the next time slice), we should throw out the
1001 // suspended first transition and try the second one.
1002 await waitForPaint(['shouldHideInParent: true', '(empty)']);
1003 expect(root).toMatchRenderedOutput('A(empty)BC');
1004
1005 // Since the two transitions are not entangled, we then later go back
1006 // and finish retry the first transition. Not really relevant to this
1007 // test but I'll assert the result anyway.
1008 await waitForAll([
1009 'A',
1010 'shouldHideInParent: true',
1011 '(empty)',
1012 'B',
1013 'C',
1014 ]);
1015 expect(root).toMatchRenderedOutput('A(empty)BC');
1016 });
1017 },
1018 );
1019
1020 // @gate enableLegacyCache
1021 it(
1022 'interrupt a refresh transition when something suspends and a parent ' +
1023 'component received an interleaved update after its queue was processed',
1024 async () => {
1025 // Title is confusing so I'll try to explain further: This is similar to
1026 // the previous test, except instead of skipped over a transition update
1027 // in a parent, the parent receives an interleaved update *after* its
1028 // begin phase has already finished.
1029
1030 function App({shouldSuspend, step}) {
1031 return (
1032 <>
1033 <Text text={`A${step}`} />
1034 <Suspense fallback={<Text text="Loading..." />}>
1035 {shouldSuspend ? <AsyncText text="Async" /> : null}
1036 </Suspense>
1037 <Text text={`B${step}`} />
1038 <Text text={`C${step}`} />
1039 </>
1040 );
1041 }
1042
1043 const root = ReactNoop.createRoot();
1044
1045 await act(() => {
1046 root.render(<App shouldSuspend={false} step={0} />);
1047 });
1048 assertLog(['A0', 'B0', 'C0']);
1049 expect(root).toMatchRenderedOutput('A0B0C0');
1050
1051 await act(async () => {
1052 // This update will suspend.
1053 startTransition(() => {
1054 root.render(<App shouldSuspend={true} step={1} />);
1055 });
1056 // Flush past the root, but stop before the async component.
1057 await waitFor(['A1']);
1058
1059 // Schedule another transition on the root, which already completed.
1060 startTransition(() => {
1061 root.render(<App shouldSuspend={false} step={2} />);
1062 });
1063 // We'll keep working on the first update.
1064 await waitFor([
1065 // Now the async component suspends
1066 'Suspend! [Async]',
1067 'Loading...',
1068 'B1',
1069 ]);
1070 // Should not have committed loading state
1071 expect(root).toMatchRenderedOutput('A0B0C0');
1072
1073 // After suspending, should abort the first update and switch to the
1074 // second update. So, C1 should not appear in the log.
1075 // TODO: This should work even if React does not yield to the main
1076 // thread. Should use same mechanism as selective hydration to interrupt
1077 // the render before the end of the current slice of work.
1078 await waitForAll(['A2', 'B2', 'C2']);
1079
1080 expect(root).toMatchRenderedOutput('A2B2C2');
1081 });
1082 },
1083 );
1084
1085 it('should render normal pri updates scheduled after transitions before transitions', async () => {
1086 let updateTransitionPri;
1087 let updateNormalPri;
1088 function App() {
1089 const [normalPri, setNormalPri] = useState(0);
1090 const [transitionPri, setTransitionPri] = useState(0);
1091 updateTransitionPri = () =>
1092 startTransition(() => setTransitionPri(n => n + 1));
1093 updateNormalPri = () => setNormalPri(n => n + 1);
1094
1095 useLayoutEffect(() => {
1096 Scheduler.log('Commit');
1097 });
1098
1099 return (
1100 <Suspense fallback={<Text text="Loading..." />}>
1101 <Text text={'Transition pri: ' + transitionPri} />
1102 {', '}
1103 <Text text={'Normal pri: ' + normalPri} />
1104 </Suspense>
1105 );
1106 }
1107
1108 const root = ReactNoop.createRoot();
1109 await act(() => {
1110 root.render(<App />);
1111 });
1112
1113 // Initial render.
1114 assertLog(['Transition pri: 0', 'Normal pri: 0', 'Commit']);
1115 expect(root).toMatchRenderedOutput('Transition pri: 0, Normal pri: 0');
1116
1117 await act(() => {
1118 updateTransitionPri();
1119 updateNormalPri();
1120 });
1121
1122 assertLog([
1123 // Normal update first.
1124 'Transition pri: 0',
1125 'Normal pri: 1',
1126 'Commit',
1127
1128 // Then transition update.
1129 'Transition pri: 1',
1130 'Normal pri: 1',
1131 'Commit',
1132 ]);
1133 expect(root).toMatchRenderedOutput('Transition pri: 1, Normal pri: 1');
1134 });
1135
1136 // @gate enableLegacyCache
1137 it('should render normal pri updates before transition suspense retries', async () => {
1138 let updateTransitionPri;
1139 let updateNormalPri;
1140 function App() {
1141 const [transitionPri, setTransitionPri] = useState(false);
1142 const [normalPri, setNormalPri] = useState(0);
1143
1144 updateTransitionPri = () => startTransition(() => setTransitionPri(true));
1145 updateNormalPri = () => setNormalPri(n => n + 1);
1146
1147 useLayoutEffect(() => {
1148 Scheduler.log('Commit');
1149 });
1150
1151 return (
1152 <Suspense fallback={<Text text="Loading..." />}>
1153 {transitionPri ? <AsyncText text="Async" /> : <Text text="(empty)" />}
1154 {', '}
1155 <Text text={'Normal pri: ' + normalPri} />
1156 </Suspense>
1157 );
1158 }
1159
1160 const root = ReactNoop.createRoot();
1161 await act(() => {
1162 root.render(<App />);
1163 });
1164
1165 // Initial render.
1166 assertLog(['(empty)', 'Normal pri: 0', 'Commit']);
1167 expect(root).toMatchRenderedOutput('(empty), Normal pri: 0');
1168
1169 await act(() => {
1170 updateTransitionPri();
1171 });
1172
1173 assertLog([
1174 // Suspend.
1175 'Suspend! [Async]',
1176 // pre-warming
1177 'Normal pri: 0',
1178 // end pre-warming
1179 'Loading...',
1180 ]);
1181 expect(root).toMatchRenderedOutput('(empty), Normal pri: 0');
1182
1183 await act(async () => {
1184 await resolveText('Async');
1185 updateNormalPri();
1186 });
1187
1188 assertLog([
1189 // Normal pri update.
1190 '(empty)',
1191 'Normal pri: 1',
1192 'Commit',
1193
1194 // Promise resolved, retry flushed.
1195 'Async',
1196 'Normal pri: 1',
1197 'Commit',
1198 ]);
1199 expect(root).toMatchRenderedOutput('Async, Normal pri: 1');
1200 });
1201
1202 it('should not interrupt transitions with normal pri updates', async () => {
1203 let updateNormalPri;
1204 let updateTransitionPri;
1205 function App() {
1206 const [transitionPri, setTransitionPri] = useState(0);
1207 const [normalPri, setNormalPri] = useState(0);
1208 updateTransitionPri = () =>
1209 startTransition(() => setTransitionPri(n => n + 1));
1210 updateNormalPri = () => setNormalPri(n => n + 1);
1211
1212 useLayoutEffect(() => {
1213 Scheduler.log('Commit');
1214 });
1215 return (
1216 <>
1217 <Text text={'Transition pri: ' + transitionPri} />
1218 {', '}
1219 <Text text={'Normal pri: ' + normalPri} />
1220 </>
1221 );
1222 }
1223
1224 const root = ReactNoop.createRoot();
1225 await act(() => {
1226 root.render(<App />);
1227 });
1228 assertLog(['Transition pri: 0', 'Normal pri: 0', 'Commit']);
1229 expect(root).toMatchRenderedOutput('Transition pri: 0, Normal pri: 0');
1230
1231 await act(async () => {
1232 updateTransitionPri();
1233
1234 await waitFor([
1235 // Start transition update.
1236 'Transition pri: 1',
1237 ]);
1238
1239 // Schedule normal pri update during transition update.
1240 // This should not interrupt.
1241 updateNormalPri();
1242 });
1243
1244 assertLog([
1245 'Normal pri: 0',
1246 'Commit',
1247
1248 // Normal pri update.
1249 'Transition pri: 1',
1250 'Normal pri: 1',
1251 'Commit',
1252 ]);
1253
1254 expect(root).toMatchRenderedOutput('Transition pri: 1, Normal pri: 1');
1255 });
1256
1257 it('tracks two pending flags for nested startTransition (#26226)', async () => {
1258 let update;
1259 function App() {
1260 const [isPendingA, startTransitionA] = useTransition();
1261 const [isPendingB, startTransitionB] = useTransition();
1262 const [state, setState] = useState(0);
1263
1264 update = function () {
1265 startTransitionA(() => {
1266 startTransitionB(() => {
1267 setState(1);
1268 });
1269 });
1270 };
1271
1272 return (
1273 <>
1274 <Text text={state} />
1275 {', '}
1276 <Text text={'A ' + isPendingA} />
1277 {', '}
1278 <Text text={'B ' + isPendingB} />
1279 </>
1280 );
1281 }
1282 const root = ReactNoop.createRoot();
1283 await act(async () => {
1284 root.render(<App />);
1285 });
1286 assertLog([0, 'A false', 'B false']);
1287 expect(root).toMatchRenderedOutput('0, A false, B false');
1288
1289 await act(async () => {
1290 update();
1291 });
1292 assertLog([0, 'A true', 'B true', 1, 'A false', 'B false']);
1293 expect(root).toMatchRenderedOutput('1, A false, B false');
1294 });
1295 });