main
js 4,177 lines 113 KB
Raw
1 let React;
2 let Fragment;
3 let ReactNoop;
4 let Scheduler;
5 let act;
6 let waitFor;
7 let waitForAll;
8 let waitForMicrotasks;
9 let assertLog;
10 let waitForPaint;
11 let Suspense;
12 let startTransition;
13 let getCacheForType;
14
15 let caches;
16 let seededCache;
17
18 describe('ReactSuspenseWithNoopRenderer', () => {
19 beforeEach(() => {
20 jest.resetModules();
21
22 React = require('react');
23 Fragment = React.Fragment;
24 ReactNoop = require('react-noop-renderer');
25 Scheduler = require('scheduler');
26 act = require('internal-test-utils').act;
27 Suspense = React.Suspense;
28 startTransition = React.startTransition;
29 const InternalTestUtils = require('internal-test-utils');
30 waitFor = InternalTestUtils.waitFor;
31 waitForAll = InternalTestUtils.waitForAll;
32 waitForPaint = InternalTestUtils.waitForPaint;
33 waitForMicrotasks = InternalTestUtils.waitForMicrotasks;
34 assertLog = InternalTestUtils.assertLog;
35
36 getCacheForType = React.unstable_getCacheForType;
37
38 caches = [];
39 seededCache = null;
40 });
41
42 function createTextCache() {
43 if (seededCache !== null) {
44 // Trick to seed a cache before it exists.
45 // TODO: Need a built-in API to seed data before the initial render (i.e.
46 // not a refresh because nothing has mounted yet).
47 const cache = seededCache;
48 seededCache = null;
49 return cache;
50 }
51
52 const data = new Map();
53 const version = caches.length + 1;
54 const cache = {
55 version,
56 data,
57 resolve(text) {
58 const record = data.get(text);
59 if (record === undefined) {
60 const newRecord = {
61 status: 'resolved',
62 value: text,
63 };
64 data.set(text, newRecord);
65 } else if (record.status === 'pending') {
66 const thenable = record.value;
67 record.status = 'resolved';
68 record.value = text;
69 thenable.pings.forEach(t => t());
70 }
71 },
72 reject(text, error) {
73 const record = data.get(text);
74 if (record === undefined) {
75 const newRecord = {
76 status: 'rejected',
77 value: error,
78 };
79 data.set(text, newRecord);
80 } else if (record.status === 'pending') {
81 const thenable = record.value;
82 record.status = 'rejected';
83 record.value = error;
84 thenable.pings.forEach(t => t());
85 }
86 },
87 };
88 caches.push(cache);
89 return cache;
90 }
91
92 function readText(text) {
93 const textCache = getCacheForType(createTextCache);
94 const record = textCache.data.get(text);
95 if (record !== undefined) {
96 switch (record.status) {
97 case 'pending':
98 Scheduler.log(`Suspend! [${text}]`);
99 throw record.value;
100 case 'rejected':
101 Scheduler.log(`Error! [${text}]`);
102 throw record.value;
103 case 'resolved':
104 return textCache.version;
105 }
106 } else {
107 Scheduler.log(`Suspend! [${text}]`);
108
109 const thenable = {
110 pings: [],
111 then(resolve) {
112 if (newRecord.status === 'pending') {
113 thenable.pings.push(resolve);
114 } else {
115 Promise.resolve().then(() => resolve(newRecord.value));
116 }
117 },
118 };
119
120 const newRecord = {
121 status: 'pending',
122 value: thenable,
123 };
124 textCache.data.set(text, newRecord);
125
126 throw thenable;
127 }
128 }
129
130 function Text({text}) {
131 Scheduler.log(text);
132 return <span prop={text} />;
133 }
134
135 function AsyncText({text, showVersion}) {
136 const version = readText(text);
137 const fullText = showVersion ? `${text} [v${version}]` : text;
138 Scheduler.log(fullText);
139 return <span prop={fullText} />;
140 }
141
142 function seedNextTextCache(text) {
143 if (seededCache === null) {
144 seededCache = createTextCache();
145 }
146 seededCache.resolve(text);
147 }
148
149 function resolveMostRecentTextCache(text) {
150 if (caches.length === 0) {
151 throw Error('Cache does not exist.');
152 } else {
153 // Resolve the most recently created cache. An older cache can by
154 // resolved with `caches[index].resolve(text)`.
155 caches[caches.length - 1].resolve(text);
156 }
157 }
158
159 const resolveText = resolveMostRecentTextCache;
160
161 function rejectMostRecentTextCache(text, error) {
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].reject(text, error)`.
167 caches[caches.length - 1].reject(text, error);
168 }
169 }
170
171 const rejectText = rejectMostRecentTextCache;
172
173 function advanceTimers(ms) {
174 // Note: This advances Jest's virtual time but not React's. Use
175 // ReactNoop.expire for that.
176 if (typeof ms !== 'number') {
177 throw new Error('Must specify ms');
178 }
179 jest.advanceTimersByTime(ms);
180 // Wait until the end of the current tick
181 // We cannot use a timer since we're faking them
182 return Promise.resolve().then(() => {});
183 }
184
185 // Note: This is based on a similar component we use in www. We can delete
186 // once the extra div wrapper is no longer necessary.
187 function LegacyHiddenDiv({children, mode}) {
188 return (
189 <div hidden={mode === 'hidden'}>
190 <React.unstable_LegacyHidden
191 mode={mode === 'hidden' ? 'unstable-defer-without-hiding' : mode}>
192 {children}
193 </React.unstable_LegacyHidden>
194 </div>
195 );
196 }
197
198 // @gate enableLegacyCache
199 it("does not restart if there's a ping during initial render", async () => {
200 function Bar(props) {
201 Scheduler.log('Bar');
202 return props.children;
203 }
204
205 function Foo() {
206 Scheduler.log('Foo');
207 return (
208 <>
209 <Suspense fallback={<Text text="Loading..." />}>
210 <Bar>
211 <AsyncText text="A" />
212 <Text text="B" />
213 </Bar>
214 </Suspense>
215 <Text text="C" />
216 <Text text="D" />
217 </>
218 );
219 }
220
221 React.startTransition(() => {
222 ReactNoop.render(<Foo />);
223 });
224 await waitFor([
225 'Foo',
226 'Bar',
227 // A suspends
228 'Suspend! [A]',
229 // We immediately unwind and switch to a fallback without
230 // rendering siblings.
231 'Loading...',
232 'C',
233 // Yield before rendering D
234 ]);
235 expect(ReactNoop).toMatchRenderedOutput(null);
236
237 // Flush the promise completely
238 await act(async () => {
239 await resolveText('A');
240 // Even though the promise has resolved, we should now flush
241 // and commit the in progress render instead of restarting.
242 await waitForPaint(['D']);
243 expect(ReactNoop).toMatchRenderedOutput(
244 <>
245 <span prop="Loading..." />
246 <span prop="C" />
247 <span prop="D" />
248 </>,
249 );
250 // Next, we'll flush the complete content.
251 await waitForAll(['Bar', 'A', 'B']);
252 });
253
254 expect(ReactNoop).toMatchRenderedOutput(
255 <>
256 <span prop="A" />
257 <span prop="B" />
258 <span prop="C" />
259 <span prop="D" />
260 </>,
261 );
262 });
263
264 // @gate enableLegacyCache
265 it('suspends rendering and continues later', async () => {
266 function Bar(props) {
267 Scheduler.log('Bar');
268 return props.children;
269 }
270
271 function Foo({renderBar}) {
272 Scheduler.log('Foo');
273 return (
274 <Suspense fallback={<Text text="Loading..." />}>
275 {renderBar ? (
276 <Bar>
277 <AsyncText text="A" />
278 <Text text="B" />
279 </Bar>
280 ) : null}
281 </Suspense>
282 );
283 }
284
285 // Render empty shell.
286 ReactNoop.render(<Foo />);
287 await waitForAll(['Foo']);
288
289 // The update will suspend.
290 React.startTransition(() => {
291 ReactNoop.render(<Foo renderBar={true} />);
292 });
293 await waitForAll([
294 'Foo',
295 'Bar',
296 // A suspends
297 'Suspend! [A]',
298
299 // pre-warming
300 'B',
301 // end pre-warming
302
303 // We immediately unwind and switch to a fallback without
304 // rendering siblings.
305 'Loading...',
306 ]);
307 expect(ReactNoop).toMatchRenderedOutput(null);
308
309 // Resolve the data
310 await resolveText('A');
311 // Renders successfully
312 await waitForAll(['Foo', 'Bar', 'A', 'B']);
313 expect(ReactNoop).toMatchRenderedOutput(
314 <>
315 <span prop="A" />
316 <span prop="B" />
317 </>,
318 );
319 });
320
321 // @gate enableLegacyCache
322 it('suspends siblings and later recovers each independently', async () => {
323 // Render two sibling Suspense components
324 ReactNoop.render(
325 <Fragment>
326 <Suspense fallback={<Text text="Loading A..." />}>
327 <AsyncText text="A" />
328 </Suspense>
329 <Suspense fallback={<Text text="Loading B..." />}>
330 <AsyncText text="B" />
331 </Suspense>
332 </Fragment>,
333 );
334 await waitForAll([
335 'Suspend! [A]',
336 'Loading A...',
337 'Suspend! [B]',
338 'Loading B...',
339 // pre-warming
340 'Suspend! [A]',
341 'Suspend! [B]',
342 ]);
343 expect(ReactNoop).toMatchRenderedOutput(
344 <>
345 <span prop="Loading A..." />
346 <span prop="Loading B..." />
347 </>,
348 );
349
350 // Resolve first Suspense's promise so that it switches switches back to the
351 // normal view. The second Suspense should still show the placeholder.
352 await act(() => resolveText('A'));
353 assertLog([
354 'A',
355 ...(gate('alwaysThrottleRetries')
356 ? ['Suspend! [B]', 'Suspend! [B]']
357 : []),
358 ]);
359 expect(ReactNoop).toMatchRenderedOutput(
360 <>
361 <span prop="A" />
362 <span prop="Loading B..." />
363 </>,
364 );
365
366 // Resolve the second Suspense's promise so that it switches back to the
367 // normal view.
368 await act(() => resolveText('B'));
369 assertLog(['B']);
370 expect(ReactNoop).toMatchRenderedOutput(
371 <>
372 <span prop="A" />
373 <span prop="B" />
374 </>,
375 );
376 });
377
378 // @gate enableLegacyCache
379 it('when something suspends, unwinds immediately without rendering siblings', async () => {
380 // A shell is needed. The update cause it to suspend.
381 ReactNoop.render(<Suspense fallback={<Text text="Loading..." />} />);
382 await waitForAll([]);
383 React.startTransition(() => {
384 ReactNoop.render(
385 <Suspense fallback={<Text text="Loading..." />}>
386 <Text text="A" />
387 <AsyncText text="B" />
388 <Text text="C" />
389 <Text text="D" />
390 </Suspense>,
391 );
392 });
393
394 // B suspends. Render a fallback
395 await waitForAll([
396 'A',
397 'Suspend! [B]',
398 // pre-warming
399 'C',
400 'D',
401 // end pre-warming
402 'Loading...',
403 ]);
404 // Did not commit yet.
405 expect(ReactNoop).toMatchRenderedOutput(null);
406
407 // Wait for data to resolve
408 await resolveText('B');
409 await waitForAll(['A', 'B', 'C', 'D']);
410 // Renders successfully
411 expect(ReactNoop).toMatchRenderedOutput(
412 <>
413 <span prop="A" />
414 <span prop="B" />
415 <span prop="C" />
416 <span prop="D" />
417 </>,
418 );
419 });
420
421 // Second condition is redundant but guarantees that the test runs in prod.
422 // @gate enableLegacyCache
423 it('retries on error', async () => {
424 class ErrorBoundary extends React.Component {
425 state = {error: null};
426 componentDidCatch(error) {
427 this.setState({error});
428 }
429 reset() {
430 this.setState({error: null});
431 }
432 render() {
433 if (this.state.error !== null) {
434 return <Text text={'Caught error: ' + this.state.error.message} />;
435 }
436 return this.props.children;
437 }
438 }
439
440 const errorBoundary = React.createRef();
441 function App({renderContent}) {
442 return (
443 <Suspense fallback={<Text text="Loading..." />}>
444 {renderContent ? (
445 <ErrorBoundary ref={errorBoundary}>
446 <AsyncText text="Result" />
447 </ErrorBoundary>
448 ) : null}
449 </Suspense>
450 );
451 }
452
453 ReactNoop.render(<App />);
454 await waitForAll([]);
455 expect(ReactNoop).toMatchRenderedOutput(null);
456
457 React.startTransition(() => {
458 ReactNoop.render(<App renderContent={true} />);
459 });
460 await waitForAll(['Suspend! [Result]', 'Loading...']);
461 expect(ReactNoop).toMatchRenderedOutput(null);
462
463 await rejectText('Result', new Error('Failed to load: Result'));
464
465 await waitForAll([
466 'Error! [Result]',
467
468 // React retries one more time
469 'Error! [Result]',
470
471 // Errored again on retry. Now handle it.
472 'Caught error: Failed to load: Result',
473 ]);
474 expect(ReactNoop).toMatchRenderedOutput(
475 <span prop="Caught error: Failed to load: Result" />,
476 );
477 });
478
479 // Second condition is redundant but guarantees that the test runs in prod.
480 // @gate enableLegacyCache
481 it('retries on error after falling back to a placeholder', async () => {
482 class ErrorBoundary extends React.Component {
483 state = {error: null};
484 componentDidCatch(error) {
485 this.setState({error});
486 }
487 reset() {
488 this.setState({error: null});
489 }
490 render() {
491 if (this.state.error !== null) {
492 return <Text text={'Caught error: ' + this.state.error.message} />;
493 }
494 return this.props.children;
495 }
496 }
497
498 const errorBoundary = React.createRef();
499 function App() {
500 return (
501 <Suspense fallback={<Text text="Loading..." />}>
502 <ErrorBoundary ref={errorBoundary}>
503 <AsyncText text="Result" />
504 </ErrorBoundary>
505 </Suspense>
506 );
507 }
508
509 ReactNoop.render(<App />);
510 await waitForAll([
511 'Suspend! [Result]',
512 'Loading...',
513 // pre-warming
514 'Suspend! [Result]',
515 ]);
516 expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
517
518 await act(() => rejectText('Result', new Error('Failed to load: Result')));
519 assertLog([
520 'Error! [Result]',
521
522 // React retries one more time
523 'Error! [Result]',
524
525 // Errored again on retry. Now handle it.
526 'Caught error: Failed to load: Result',
527 ]);
528 expect(ReactNoop).toMatchRenderedOutput(
529 <span prop="Caught error: Failed to load: Result" />,
530 );
531 });
532
533 // @gate enableLegacyCache
534 it('can update at a higher priority while in a suspended state', async () => {
535 let setHighPri;
536 function HighPri() {
537 const [text, setText] = React.useState('A');
538 setHighPri = setText;
539 return <Text text={text} />;
540 }
541
542 let setLowPri;
543 function LowPri() {
544 const [text, setText] = React.useState('1');
545 setLowPri = setText;
546 return <AsyncText text={text} />;
547 }
548
549 function App() {
550 return (
551 <>
552 <HighPri />
553 <Suspense fallback={<Text text="Loading..." />}>
554 <LowPri />
555 </Suspense>
556 </>
557 );
558 }
559
560 // Initial mount
561 await act(() => ReactNoop.render(<App />));
562 assertLog([
563 'A',
564 'Suspend! [1]',
565 'Loading...',
566 // pre-warming
567 'Suspend! [1]',
568 ]);
569
570 await act(() => resolveText('1'));
571 assertLog(['1']);
572 expect(ReactNoop).toMatchRenderedOutput(
573 <>
574 <span prop="A" />
575 <span prop="1" />
576 </>,
577 );
578
579 // Update the low-pri text
580 await act(() => startTransition(() => setLowPri('2')));
581 // Suspends
582 assertLog(['Suspend! [2]', 'Loading...']);
583
584 // While we're still waiting for the low-pri update to complete, update the
585 // high-pri text at high priority.
586 ReactNoop.flushSync(() => {
587 setHighPri('B');
588 });
589 assertLog(['B']);
590 expect(ReactNoop).toMatchRenderedOutput(
591 <>
592 <span prop="B" />
593 <span prop="1" />
594 </>,
595 );
596
597 // Unblock the low-pri text and finish. Nothing in the UI changes because
598 // the update was overriden
599 await act(() => resolveText('2'));
600 assertLog(['2']);
601 expect(ReactNoop).toMatchRenderedOutput(
602 <>
603 <span prop="B" />
604 <span prop="2" />
605 </>,
606 );
607 });
608
609 // @gate enableLegacyCache
610 it('keeps working on lower priority work after being pinged', async () => {
611 function App(props) {
612 return (
613 <Suspense fallback={<Text text="Loading..." />}>
614 {props.showA && <AsyncText text="A" />}
615 {props.showB && <Text text="B" />}
616 </Suspense>
617 );
618 }
619
620 ReactNoop.render(<App showA={false} showB={false} />);
621 await waitForAll([]);
622 expect(ReactNoop).toMatchRenderedOutput(null);
623
624 React.startTransition(() => {
625 ReactNoop.render(<App showA={true} showB={false} />);
626 });
627 await waitForAll(['Suspend! [A]', 'Loading...']);
628 expect(ReactNoop).toMatchRenderedOutput(null);
629
630 React.startTransition(() => {
631 ReactNoop.render(<App showA={true} showB={true} />);
632 });
633 await waitForAll([
634 'Suspend! [A]',
635 // pre-warming
636 'B',
637 // end pre-warming
638 'Loading...',
639 ]);
640 expect(ReactNoop).toMatchRenderedOutput(null);
641
642 await resolveText('A');
643 await waitForAll(['A', 'B']);
644 expect(ReactNoop).toMatchRenderedOutput(
645 <>
646 <span prop="A" />
647 <span prop="B" />
648 </>,
649 );
650 });
651
652 // @gate enableLegacyCache
653 it('tries rendering a lower priority pending update even if a higher priority one suspends', async () => {
654 function App(props) {
655 if (props.hide) {
656 return <Text text="(empty)" />;
657 }
658 return (
659 <Suspense fallback="Loading...">
660 <AsyncText text="Async" />
661 </Suspense>
662 );
663 }
664
665 // Schedule a default pri update and a low pri update, without rendering in between.
666 // Default pri
667 ReactNoop.render(<App />);
668 // Low pri
669 React.startTransition(() => {
670 ReactNoop.render(<App hide={true} />);
671 });
672
673 await waitForAll([
674 // The first update suspends
675 'Suspend! [Async]',
676 // but we have another pending update that we can work on
677 '(empty)',
678 ]);
679 expect(ReactNoop).toMatchRenderedOutput(<span prop="(empty)" />);
680 });
681
682 // Note: This test was written to test a heuristic used in the expiration
683 // times model. Might not make sense in the new model.
684 // TODO: This test doesn't over what it was originally designed to test.
685 // Either rewrite or delete.
686 it('tries each subsequent level after suspending', async () => {
687 const root = ReactNoop.createRoot();
688
689 function App({step, shouldSuspend}) {
690 return (
691 <Suspense fallback="Loading...">
692 <Text text="Sibling" />
693 {shouldSuspend ? (
694 <AsyncText text={'Step ' + step} />
695 ) : (
696 <Text text={'Step ' + step} />
697 )}
698 </Suspense>
699 );
700 }
701
702 function interrupt() {
703 // React has a heuristic to batch all updates that occur within the same
704 // event. This is a trick to circumvent that heuristic.
705 ReactNoop.flushSync(() => {
706 ReactNoop.renderToRootWithID(null, 'other-root');
707 });
708 }
709
710 // Mount the Suspense boundary without suspending, so that the subsequent
711 // updates suspend with a delay.
712 await act(() => {
713 root.render(<App step={0} shouldSuspend={false} />);
714 });
715 await advanceTimers(1000);
716 assertLog(['Sibling', 'Step 0']);
717
718 // Schedule an update at several distinct expiration times
719 await act(async () => {
720 React.startTransition(() => {
721 root.render(<App step={1} shouldSuspend={true} />);
722 });
723 Scheduler.unstable_advanceTime(1000);
724 await waitFor(['Sibling']);
725 interrupt();
726
727 React.startTransition(() => {
728 root.render(<App step={2} shouldSuspend={true} />);
729 });
730 Scheduler.unstable_advanceTime(1000);
731 await waitFor(['Sibling']);
732 interrupt();
733
734 React.startTransition(() => {
735 root.render(<App step={3} shouldSuspend={true} />);
736 });
737 Scheduler.unstable_advanceTime(1000);
738 await waitFor(['Sibling']);
739 interrupt();
740
741 root.render(<App step={4} shouldSuspend={false} />);
742 });
743
744 assertLog(['Sibling', 'Step 4']);
745 });
746
747 // @gate enableLegacyCache
748 it('switches to an inner fallback after suspending for a while', async () => {
749 // Advance the virtual time so that we're closer to the edge of a bucket.
750 ReactNoop.expire(200);
751
752 ReactNoop.render(
753 <Fragment>
754 <Text text="Sync" />
755 <Suspense fallback={<Text text="Loading outer..." />}>
756 <AsyncText text="Outer content" />
757 <Suspense fallback={<Text text="Loading inner..." />}>
758 <AsyncText text="Inner content" />
759 </Suspense>
760 </Suspense>
761 </Fragment>,
762 );
763
764 await waitForAll([
765 'Sync',
766 // The async content suspends
767 'Suspend! [Outer content]',
768 'Loading outer...',
769 // pre-warming
770 'Suspend! [Outer content]',
771 'Suspend! [Inner content]',
772 'Loading inner...',
773 ]);
774 // The outer loading state finishes immediately.
775 expect(ReactNoop).toMatchRenderedOutput(
776 <>
777 <span prop="Sync" />
778 <span prop="Loading outer..." />
779 </>,
780 );
781
782 // Resolve the outer promise.
783 await resolveText('Outer content');
784 await waitForAll([
785 'Outer content',
786 'Suspend! [Inner content]',
787 'Loading inner...',
788 ]);
789 // Don't commit the inner placeholder yet.
790 expect(ReactNoop).toMatchRenderedOutput(
791 <>
792 <span prop="Sync" />
793 <span prop="Loading outer..." />
794 </>,
795 );
796
797 // Expire the inner timeout.
798 ReactNoop.expire(500);
799 await advanceTimers(500);
800 // Now that 750ms have elapsed since the outer placeholder timed out,
801 // we can timeout the inner placeholder.
802 expect(ReactNoop).toMatchRenderedOutput(
803 <>
804 <span prop="Sync" />
805 <span prop="Outer content" />
806 <span prop="Loading inner..." />
807 </>,
808 );
809
810 // Finally, flush the inner promise. We should see the complete screen.
811 await act(() => resolveText('Inner content'));
812 assertLog(['Inner content']);
813 expect(ReactNoop).toMatchRenderedOutput(
814 <>
815 <span prop="Sync" />
816 <span prop="Outer content" />
817 <span prop="Inner content" />
818 </>,
819 );
820 });
821
822 // @gate enableLegacyCache
823 it('renders an Suspense boundary synchronously', async () => {
824 spyOnDev(console, 'error');
825 // Synchronously render a tree that suspends
826 ReactNoop.flushSync(() =>
827 ReactNoop.render(
828 <Fragment>
829 <Suspense fallback={<Text text="Loading..." />}>
830 <AsyncText text="Async" />
831 </Suspense>
832 <Text text="Sync" />
833 </Fragment>,
834 ),
835 );
836 assertLog([
837 // The async child suspends
838 'Suspend! [Async]',
839 // We immediately render the fallback UI
840 'Loading...',
841 // Continue on the sibling
842 'Sync',
843 ]);
844 // The tree commits synchronously
845 expect(ReactNoop).toMatchRenderedOutput(
846 <>
847 <span prop="Loading..." />
848 <span prop="Sync" />
849 </>,
850 );
851
852 // Once the promise resolves, we render the suspended view
853 await act(() => resolveText('Async'));
854 assertLog(['Async']);
855 expect(ReactNoop).toMatchRenderedOutput(
856 <>
857 <span prop="Async" />
858 <span prop="Sync" />
859 </>,
860 );
861 });
862
863 // @gate enableLegacyCache
864 it('suspending inside an expired expiration boundary will bubble to the next one', async () => {
865 ReactNoop.flushSync(() =>
866 ReactNoop.render(
867 <Fragment>
868 <Suspense fallback={<Text text="Loading (outer)..." />}>
869 <Suspense fallback={<AsyncText text="Loading (inner)..." />}>
870 <AsyncText text="Async" />
871 </Suspense>
872 <Text text="Sync" />
873 </Suspense>
874 </Fragment>,
875 ),
876 );
877 assertLog([
878 'Suspend! [Async]',
879 'Suspend! [Loading (inner)...]',
880 'Loading (outer)...',
881 ]);
882 // The tree commits synchronously
883 expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading (outer)..." />);
884 });
885
886 // @gate enableLegacyCache
887 it('resolves successfully even if fallback render is pending', async () => {
888 const root = ReactNoop.createRoot();
889 root.render(
890 <>
891 <Suspense fallback={<Text text="Loading..." />} />
892 </>,
893 );
894 await waitForAll([]);
895 expect(root).toMatchRenderedOutput(null);
896 React.startTransition(() => {
897 root.render(
898 <>
899 <Suspense fallback={<Text text="Loading..." />}>
900 <AsyncText text="Async" />
901 <Text text="Sibling" />
902 </Suspense>
903 </>,
904 );
905 });
906 await waitFor(['Suspend! [Async]']);
907
908 await resolveText('Async');
909
910 // Because we're already showing a fallback, interrupt the current render
911 // and restart immediately.
912 await waitForAll(['Async', 'Sibling']);
913 expect(root).toMatchRenderedOutput(
914 <>
915 <span prop="Async" />
916 <span prop="Sibling" />
917 </>,
918 );
919 });
920
921 // @gate enableLegacyCache
922 it('in concurrent mode, does not error when an update suspends without a Suspense boundary during a sync update', () => {
923 // NOTE: We may change this to be a warning in the future.
924 expect(() => {
925 ReactNoop.flushSync(() => {
926 ReactNoop.render(<AsyncText text="Async" />);
927 });
928 }).not.toThrow();
929 });
930
931 // @gate enableLegacyCache && !disableLegacyMode
932 it('in legacy mode, errors when an update suspends without a Suspense boundary during a sync update', async () => {
933 const root = ReactNoop.createLegacyRoot();
934 await expect(async () => {
935 await act(() => root.render(<AsyncText text="Async" />));
936 }).rejects.toThrow(
937 'A component suspended while responding to synchronous input.',
938 );
939 });
940
941 // @gate enableLegacyCache
942 it('a Suspense component correctly handles more than one suspended child', async () => {
943 ReactNoop.render(
944 <Suspense fallback={<Text text="Loading..." />}>
945 <AsyncText text="A" />
946 <AsyncText text="B" />
947 </Suspense>,
948 );
949 await waitForAll([
950 'Suspend! [A]',
951 'Loading...',
952 // pre-warming
953 'Suspend! [A]',
954 'Suspend! [B]',
955 ]);
956 expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
957
958 await act(() => {
959 resolveText('A');
960 resolveText('B');
961 });
962 assertLog(['A', 'B']);
963 expect(ReactNoop).toMatchRenderedOutput(
964 <>
965 <span prop="A" />
966 <span prop="B" />
967 </>,
968 );
969 });
970
971 // @gate enableLegacyCache
972 it('can resume rendering earlier than a timeout', async () => {
973 ReactNoop.render(<Suspense fallback={<Text text="Loading..." />} />);
974 await waitForAll([]);
975
976 React.startTransition(() => {
977 ReactNoop.render(
978 <Suspense fallback={<Text text="Loading..." />}>
979 <AsyncText text="Async" />
980 </Suspense>,
981 );
982 });
983 await waitForAll(['Suspend! [Async]', 'Loading...']);
984 expect(ReactNoop).toMatchRenderedOutput(null);
985
986 // Resolve the promise
987 await resolveText('Async');
988 // We can now resume rendering
989 await waitForAll(['Async']);
990 expect(ReactNoop).toMatchRenderedOutput(<span prop="Async" />);
991 });
992
993 // @gate enableLegacyCache
994 it('starts working on an update even if its priority falls between two suspended levels', async () => {
995 function App(props) {
996 return (
997 <Suspense fallback={<Text text="Loading..." />}>
998 {props.text === 'C' || props.text === 'S' ? (
999 <Text text={props.text} />
1000 ) : (
1001 <AsyncText text={props.text} />
1002 )}
1003 </Suspense>
1004 );
1005 }
1006
1007 // First mount without suspending. This ensures we already have content
1008 // showing so that subsequent updates will suspend.
1009 ReactNoop.render(<App text="S" />);
1010 await waitForAll(['S']);
1011
1012 // Schedule an update, and suspend for up to 5 seconds.
1013 React.startTransition(() => ReactNoop.render(<App text="A" />));
1014 // The update should suspend.
1015 await waitForAll(['Suspend! [A]', 'Loading...']);
1016 expect(ReactNoop).toMatchRenderedOutput(<span prop="S" />);
1017
1018 // Advance time until right before it expires.
1019 await advanceTimers(4999);
1020 ReactNoop.expire(4999);
1021 await waitForAll([]);
1022 expect(ReactNoop).toMatchRenderedOutput(<span prop="S" />);
1023
1024 // Schedule another low priority update.
1025 React.startTransition(() => ReactNoop.render(<App text="B" />));
1026 // This update should also suspend.
1027 await waitForAll(['Suspend! [B]', 'Loading...']);
1028 expect(ReactNoop).toMatchRenderedOutput(<span prop="S" />);
1029
1030 // Schedule a regular update. Its expiration time will fall between
1031 // the expiration times of the previous two updates.
1032 ReactNoop.render(<App text="C" />);
1033 await waitForAll(['C']);
1034 expect(ReactNoop).toMatchRenderedOutput(<span prop="C" />);
1035
1036 // Flush the remaining work.
1037 await resolveText('A');
1038 await resolveText('B');
1039 // Nothing else to render.
1040 await waitForAll([]);
1041 expect(ReactNoop).toMatchRenderedOutput(<span prop="C" />);
1042 });
1043
1044 // @gate enableLegacyCache
1045 it('a suspended update that expires', async () => {
1046 // Regression test. This test used to fall into an infinite loop.
1047 function ExpensiveText({text}) {
1048 // This causes the update to expire.
1049 Scheduler.unstable_advanceTime(10000);
1050 // Then something suspends.
1051 return <AsyncText text={text} />;
1052 }
1053
1054 function App() {
1055 return (
1056 <Suspense fallback="Loading...">
1057 <ExpensiveText text="A" />
1058 <ExpensiveText text="B" />
1059 <ExpensiveText text="C" />
1060 </Suspense>
1061 );
1062 }
1063
1064 ReactNoop.render(<App />);
1065 await waitForAll([
1066 'Suspend! [A]',
1067 // pre-warming
1068 'Suspend! [A]',
1069 'Suspend! [B]',
1070 'Suspend! [C]',
1071 ]);
1072 expect(ReactNoop).toMatchRenderedOutput('Loading...');
1073
1074 await resolveText('A');
1075 await resolveText('B');
1076 await resolveText('C');
1077
1078 await waitForAll(['A', 'B', 'C']);
1079 expect(ReactNoop).toMatchRenderedOutput(
1080 <>
1081 <span prop="A" />
1082 <span prop="B" />
1083 <span prop="C" />
1084 </>,
1085 );
1086 });
1087
1088 describe('legacy mode mode', () => {
1089 // @gate enableLegacyCache && !disableLegacyMode
1090 it('times out immediately', async () => {
1091 function App() {
1092 return (
1093 <Suspense fallback={<Text text="Loading..." />}>
1094 <AsyncText text="Result" />
1095 </Suspense>
1096 );
1097 }
1098
1099 // Times out immediately, ignoring the specified threshold.
1100 ReactNoop.renderLegacySyncRoot(<App />);
1101 assertLog(['Suspend! [Result]', 'Loading...']);
1102 expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
1103
1104 await act(() => {
1105 resolveText('Result');
1106 });
1107
1108 assertLog(['Result']);
1109 expect(ReactNoop).toMatchRenderedOutput(<span prop="Result" />);
1110 });
1111
1112 // @gate enableLegacyCache && !disableLegacyMode
1113 it('times out immediately when Suspense is in legacy mode', async () => {
1114 class UpdatingText extends React.Component {
1115 state = {step: 1};
1116 render() {
1117 return <AsyncText text={`Step: ${this.state.step}`} />;
1118 }
1119 }
1120
1121 function Spinner() {
1122 return (
1123 <Fragment>
1124 <Text text="Loading (1)" />
1125 <Text text="Loading (2)" />
1126 <Text text="Loading (3)" />
1127 </Fragment>
1128 );
1129 }
1130
1131 const text = React.createRef(null);
1132 function App() {
1133 return (
1134 <Suspense fallback={<Spinner />}>
1135 <UpdatingText ref={text} />
1136 <Text text="Sibling" />
1137 </Suspense>
1138 );
1139 }
1140
1141 // Initial mount.
1142 await seedNextTextCache('Step: 1');
1143 ReactNoop.renderLegacySyncRoot(<App />);
1144 assertLog(['Step: 1', 'Sibling']);
1145 expect(ReactNoop).toMatchRenderedOutput(
1146 <>
1147 <span prop="Step: 1" />
1148 <span prop="Sibling" />
1149 </>,
1150 );
1151
1152 // Update.
1153 text.current.setState({step: 2}, () =>
1154 Scheduler.log('Update did commit'),
1155 );
1156
1157 expect(ReactNoop.flushNextYield()).toEqual([
1158 'Suspend! [Step: 2]',
1159 'Loading (1)',
1160 'Loading (2)',
1161 'Loading (3)',
1162 'Update did commit',
1163 ]);
1164 expect(ReactNoop).toMatchRenderedOutput(
1165 <>
1166 <span hidden={true} prop="Step: 1" />
1167 <span hidden={true} prop="Sibling" />
1168 <span prop="Loading (1)" />
1169 <span prop="Loading (2)" />
1170 <span prop="Loading (3)" />
1171 </>,
1172 );
1173
1174 await act(() => {
1175 resolveText('Step: 2');
1176 });
1177 assertLog(['Step: 2']);
1178 expect(ReactNoop).toMatchRenderedOutput(
1179 <>
1180 <span prop="Step: 2" />
1181 <span prop="Sibling" />
1182 </>,
1183 );
1184 });
1185
1186 // @gate enableLegacyCache && !disableLegacyMode
1187 it('does not re-render siblings in loose mode', async () => {
1188 class TextWithLifecycle extends React.Component {
1189 componentDidMount() {
1190 Scheduler.log(`Mount [${this.props.text}]`);
1191 }
1192 componentDidUpdate() {
1193 Scheduler.log(`Update [${this.props.text}]`);
1194 }
1195 render() {
1196 return <Text {...this.props} />;
1197 }
1198 }
1199
1200 class AsyncTextWithLifecycle extends React.Component {
1201 componentDidMount() {
1202 Scheduler.log(`Mount [${this.props.text}]`);
1203 }
1204 componentDidUpdate() {
1205 Scheduler.log(`Update [${this.props.text}]`);
1206 }
1207 render() {
1208 return <AsyncText {...this.props} />;
1209 }
1210 }
1211
1212 function App() {
1213 return (
1214 <Suspense fallback={<TextWithLifecycle text="Loading..." />}>
1215 <TextWithLifecycle text="A" />
1216 <AsyncTextWithLifecycle text="B" />
1217 <TextWithLifecycle text="C" />
1218 </Suspense>
1219 );
1220 }
1221
1222 ReactNoop.renderLegacySyncRoot(<App />, () =>
1223 Scheduler.log('Commit root'),
1224 );
1225 assertLog([
1226 'A',
1227 'Suspend! [B]',
1228 'C',
1229
1230 'Loading...',
1231 'Mount [A]',
1232 'Mount [B]',
1233 'Mount [C]',
1234 // This should be a mount, not an update.
1235 'Mount [Loading...]',
1236 'Commit root',
1237 ]);
1238 expect(ReactNoop).toMatchRenderedOutput(
1239 <>
1240 <span hidden={true} prop="A" />
1241 <span hidden={true} prop="C" />
1242
1243 <span prop="Loading..." />
1244 </>,
1245 );
1246
1247 await act(() => {
1248 resolveText('B');
1249 });
1250
1251 assertLog(['B']);
1252 expect(ReactNoop).toMatchRenderedOutput(
1253 <>
1254 <span prop="A" />
1255 <span prop="B" />
1256 <span prop="C" />
1257 </>,
1258 );
1259 });
1260
1261 // @gate enableLegacyCache && !disableLegacyMode
1262 it('suspends inside constructor', async () => {
1263 class AsyncTextInConstructor extends React.Component {
1264 constructor(props) {
1265 super(props);
1266 const text = props.text;
1267 Scheduler.log('constructor');
1268 readText(text);
1269 this.state = {text};
1270 }
1271 componentDidMount() {
1272 Scheduler.log('componentDidMount');
1273 }
1274 render() {
1275 Scheduler.log(this.state.text);
1276 return <span prop={this.state.text} />;
1277 }
1278 }
1279
1280 ReactNoop.renderLegacySyncRoot(
1281 <Suspense fallback={<Text text="Loading..." />}>
1282 <AsyncTextInConstructor text="Hi" />
1283 </Suspense>,
1284 );
1285
1286 assertLog(['constructor', 'Suspend! [Hi]', 'Loading...']);
1287 expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
1288
1289 await act(() => {
1290 resolveText('Hi');
1291 });
1292
1293 assertLog(['constructor', 'Hi', 'componentDidMount']);
1294 expect(ReactNoop).toMatchRenderedOutput(<span prop="Hi" />);
1295 });
1296
1297 // @gate enableLegacyCache && !disableLegacyMode
1298 it('does not infinite loop if fallback contains lifecycle method', async () => {
1299 class Fallback extends React.Component {
1300 state = {
1301 name: 'foo',
1302 };
1303 componentDidMount() {
1304 this.setState({
1305 name: 'bar',
1306 });
1307 }
1308 render() {
1309 return <Text text="Loading..." />;
1310 }
1311 }
1312
1313 class Demo extends React.Component {
1314 render() {
1315 return (
1316 <Suspense fallback={<Fallback />}>
1317 <AsyncText text="Hi" />
1318 </Suspense>
1319 );
1320 }
1321 }
1322
1323 ReactNoop.renderLegacySyncRoot(<Demo />);
1324
1325 assertLog([
1326 'Suspend! [Hi]',
1327 'Loading...',
1328 // Re-render due to lifecycle update
1329 'Loading...',
1330 ]);
1331 expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
1332 await act(() => {
1333 resolveText('Hi');
1334 });
1335 assertLog(['Hi']);
1336 expect(ReactNoop).toMatchRenderedOutput(<span prop="Hi" />);
1337 });
1338
1339 if (global.__PERSISTENT__) {
1340 // @gate enableLegacyCache && !disableLegacyMode
1341 it('hides/unhides suspended children before layout effects fire (persistent)', async () => {
1342 const {useRef, useLayoutEffect} = React;
1343
1344 function Parent() {
1345 const child = useRef(null);
1346
1347 useLayoutEffect(() => {
1348 Scheduler.log(ReactNoop.getPendingChildrenAsJSX());
1349 });
1350
1351 return (
1352 <span ref={child} hidden={false}>
1353 <AsyncText text="Hi" />
1354 </span>
1355 );
1356 }
1357
1358 function App(props) {
1359 return (
1360 <Suspense fallback={<Text text="Loading..." />}>
1361 <Parent />
1362 </Suspense>
1363 );
1364 }
1365
1366 ReactNoop.renderLegacySyncRoot(<App middleText="B" />);
1367
1368 assertLog([
1369 'Suspend! [Hi]',
1370 'Loading...',
1371 // The child should have already been hidden
1372 <>
1373 <span hidden={true} />
1374 <span prop="Loading..." />
1375 </>,
1376 ]);
1377
1378 await act(() => {
1379 resolveText('Hi');
1380 });
1381 assertLog(['Hi']);
1382 });
1383 } else {
1384 // @gate enableLegacyCache && !disableLegacyMode
1385 it('hides/unhides suspended children before layout effects fire (mutation)', async () => {
1386 const {useRef, useLayoutEffect} = React;
1387
1388 function Parent() {
1389 const child = useRef(null);
1390
1391 useLayoutEffect(() => {
1392 Scheduler.log('Child is hidden: ' + child.current.hidden);
1393 });
1394
1395 return (
1396 <span ref={child} hidden={false}>
1397 <AsyncText text="Hi" />
1398 </span>
1399 );
1400 }
1401
1402 function App(props) {
1403 return (
1404 <Suspense fallback={<Text text="Loading..." />}>
1405 <Parent />
1406 </Suspense>
1407 );
1408 }
1409
1410 ReactNoop.renderLegacySyncRoot(<App middleText="B" />);
1411
1412 assertLog([
1413 'Suspend! [Hi]',
1414 'Loading...',
1415 // The child should have already been hidden
1416 'Child is hidden: true',
1417 ]);
1418
1419 await act(() => {
1420 resolveText('Hi');
1421 });
1422
1423 assertLog(['Hi']);
1424 });
1425 }
1426
1427 // @gate enableLegacyCache && !disableLegacyMode
1428 it('handles errors in the return path of a component that suspends', async () => {
1429 // Covers an edge case where an error is thrown inside the complete phase
1430 // of a component that is in the return path of a component that suspends.
1431 // The second error should also be handled (i.e. able to be captured by
1432 // an error boundary.
1433 class ErrorBoundary extends React.Component {
1434 state = {error: null};
1435 static getDerivedStateFromError(error, errorInfo) {
1436 return {error};
1437 }
1438 render() {
1439 if (this.state.error) {
1440 return `Caught an error: ${this.state.error.message}`;
1441 }
1442 return this.props.children;
1443 }
1444 }
1445
1446 ReactNoop.renderLegacySyncRoot(
1447 <ErrorBoundary>
1448 <Suspense fallback="Loading...">
1449 <errorInCompletePhase>
1450 <AsyncText text="Async" />
1451 </errorInCompletePhase>
1452 </Suspense>
1453 </ErrorBoundary>,
1454 );
1455
1456 assertLog(['Suspend! [Async]']);
1457 expect(ReactNoop).toMatchRenderedOutput(
1458 'Caught an error: Error in host config.',
1459 );
1460 });
1461
1462 // @gate !disableLegacyMode
1463 it('does not drop mounted effects', async () => {
1464 const never = {then() {}};
1465
1466 let setShouldSuspend;
1467 function App() {
1468 const [shouldSuspend, _setShouldSuspend] = React.useState(0);
1469 setShouldSuspend = _setShouldSuspend;
1470 return (
1471 <Suspense fallback="Loading...">
1472 <Child shouldSuspend={shouldSuspend} />
1473 </Suspense>
1474 );
1475 }
1476
1477 function Child({shouldSuspend}) {
1478 if (shouldSuspend) {
1479 throw never;
1480 }
1481
1482 React.useEffect(() => {
1483 Scheduler.log('Mount');
1484 return () => {
1485 Scheduler.log('Unmount');
1486 };
1487 }, []);
1488
1489 return 'Child';
1490 }
1491
1492 const root = ReactNoop.createLegacyRoot(null);
1493 await act(() => {
1494 root.render(<App />);
1495 });
1496 assertLog(['Mount']);
1497 expect(root).toMatchRenderedOutput('Child');
1498
1499 // Suspend the child. This puts it into an inconsistent state.
1500 await act(() => {
1501 setShouldSuspend(true);
1502 });
1503 expect(root).toMatchRenderedOutput('Loading...');
1504
1505 // Unmount everything
1506 await act(() => {
1507 root.render(null);
1508 });
1509 assertLog(['Unmount']);
1510 });
1511 });
1512
1513 // @gate enableLegacyCache && !disableLegacyMode
1514 it('does not call lifecycles of a suspended component', async () => {
1515 class TextWithLifecycle extends React.Component {
1516 componentDidMount() {
1517 Scheduler.log(`Mount [${this.props.text}]`);
1518 }
1519 componentDidUpdate() {
1520 Scheduler.log(`Update [${this.props.text}]`);
1521 }
1522 componentWillUnmount() {
1523 Scheduler.log(`Unmount [${this.props.text}]`);
1524 }
1525 render() {
1526 return <Text {...this.props} />;
1527 }
1528 }
1529
1530 class AsyncTextWithLifecycle extends React.Component {
1531 componentDidMount() {
1532 Scheduler.log(`Mount [${this.props.text}]`);
1533 }
1534 componentDidUpdate() {
1535 Scheduler.log(`Update [${this.props.text}]`);
1536 }
1537 componentWillUnmount() {
1538 Scheduler.log(`Unmount [${this.props.text}]`);
1539 }
1540 render() {
1541 const text = this.props.text;
1542 readText(text);
1543 Scheduler.log(text);
1544 return <span prop={text} />;
1545 }
1546 }
1547
1548 function App() {
1549 return (
1550 <Suspense fallback={<TextWithLifecycle text="Loading..." />}>
1551 <TextWithLifecycle text="A" />
1552 <AsyncTextWithLifecycle text="B" />
1553 <TextWithLifecycle text="C" />
1554 </Suspense>
1555 );
1556 }
1557
1558 ReactNoop.renderLegacySyncRoot(<App />, () => Scheduler.log('Commit root'));
1559 assertLog([
1560 'A',
1561 'Suspend! [B]',
1562 'C',
1563 'Loading...',
1564
1565 'Mount [A]',
1566 // B's lifecycle should not fire because it suspended
1567 // 'Mount [B]',
1568 'Mount [C]',
1569 'Mount [Loading...]',
1570 'Commit root',
1571 ]);
1572 expect(ReactNoop).toMatchRenderedOutput(
1573 <>
1574 <span hidden={true} prop="A" />
1575 <span hidden={true} prop="C" />
1576 <span prop="Loading..." />
1577 </>,
1578 );
1579 });
1580
1581 // @gate enableLegacyCache && !disableLegacyMode
1582 it('does not call lifecycles of a suspended component (hooks)', async () => {
1583 function TextWithLifecycle(props) {
1584 React.useLayoutEffect(() => {
1585 Scheduler.log(`Layout Effect [${props.text}]`);
1586 return () => {
1587 Scheduler.log(`Destroy Layout Effect [${props.text}]`);
1588 };
1589 }, [props.text]);
1590 React.useEffect(() => {
1591 Scheduler.log(`Effect [${props.text}]`);
1592 return () => {
1593 Scheduler.log(`Destroy Effect [${props.text}]`);
1594 };
1595 }, [props.text]);
1596 return <Text {...props} />;
1597 }
1598
1599 function AsyncTextWithLifecycle(props) {
1600 React.useLayoutEffect(() => {
1601 Scheduler.log(`Layout Effect [${props.text}]`);
1602 return () => {
1603 Scheduler.log(`Destroy Layout Effect [${props.text}]`);
1604 };
1605 }, [props.text]);
1606 React.useEffect(() => {
1607 Scheduler.log(`Effect [${props.text}]`);
1608 return () => {
1609 Scheduler.log(`Destroy Effect [${props.text}]`);
1610 };
1611 }, [props.text]);
1612 const text = props.text;
1613 readText(text);
1614 Scheduler.log(text);
1615 return <span prop={text} />;
1616 }
1617
1618 function App({text}) {
1619 return (
1620 <Suspense fallback={<TextWithLifecycle text="Loading..." />}>
1621 <TextWithLifecycle text="A" />
1622 <AsyncTextWithLifecycle text={text} />
1623 <TextWithLifecycle text="C" />
1624 </Suspense>
1625 );
1626 }
1627
1628 ReactNoop.renderLegacySyncRoot(<App text="B" />, () =>
1629 Scheduler.log('Commit root'),
1630 );
1631 assertLog([
1632 'A',
1633 'Suspend! [B]',
1634 'C',
1635 'Loading...',
1636
1637 'Layout Effect [A]',
1638 // B's effect should not fire because it suspended
1639 // 'Layout Effect [B]',
1640 'Layout Effect [C]',
1641 'Layout Effect [Loading...]',
1642 'Commit root',
1643 ]);
1644
1645 // Flush passive effects.
1646 await waitForAll([
1647 'Effect [A]',
1648 // B's effect should not fire because it suspended
1649 // 'Effect [B]',
1650 'Effect [C]',
1651 'Effect [Loading...]',
1652 ]);
1653
1654 expect(ReactNoop).toMatchRenderedOutput(
1655 <>
1656 <span hidden={true} prop="A" />
1657 <span hidden={true} prop="C" />
1658 <span prop="Loading..." />
1659 </>,
1660 );
1661
1662 await act(() => {
1663 resolveText('B');
1664 });
1665
1666 assertLog([
1667 'B',
1668 'Destroy Layout Effect [Loading...]',
1669 'Layout Effect [B]',
1670 'Destroy Effect [Loading...]',
1671 'Effect [B]',
1672 ]);
1673
1674 // Update
1675 ReactNoop.renderLegacySyncRoot(<App text="B2" />, () =>
1676 Scheduler.log('Commit root'),
1677 );
1678
1679 assertLog([
1680 'A',
1681 'Suspend! [B2]',
1682 'C',
1683 'Loading...',
1684
1685 // B2's effect should not fire because it suspended
1686 // 'Layout Effect [B2]',
1687 'Layout Effect [Loading...]',
1688 'Commit root',
1689 ]);
1690
1691 // Flush passive effects.
1692 await waitForAll([
1693 // B2's effect should not fire because it suspended
1694 // 'Effect [B2]',
1695 'Effect [Loading...]',
1696 ]);
1697
1698 await act(() => {
1699 resolveText('B2');
1700 });
1701
1702 assertLog([
1703 'B2',
1704 'Destroy Layout Effect [Loading...]',
1705 'Destroy Layout Effect [B]',
1706 'Layout Effect [B2]',
1707 'Destroy Effect [Loading...]',
1708 'Destroy Effect [B]',
1709 'Effect [B2]',
1710 ]);
1711 });
1712
1713 // @gate enableLegacyCache
1714 it('does not suspends if a fallback has been shown for a long time', async () => {
1715 function Foo() {
1716 Scheduler.log('Foo');
1717 return (
1718 <Suspense fallback={<Text text="Loading..." />}>
1719 <AsyncText text="A" />
1720 <Suspense fallback={<Text text="Loading more..." />}>
1721 <AsyncText text="B" />
1722 </Suspense>
1723 </Suspense>
1724 );
1725 }
1726
1727 ReactNoop.render(<Foo />);
1728 // Start rendering
1729 await waitForAll([
1730 'Foo',
1731 // A suspends
1732 'Suspend! [A]',
1733 'Loading...',
1734 // pre-warming
1735 'Suspend! [A]',
1736 'Suspend! [B]',
1737 'Loading more...',
1738 ]);
1739 expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
1740
1741 // Wait a long time.
1742 Scheduler.unstable_advanceTime(5000);
1743 await advanceTimers(5000);
1744
1745 // Retry with the new content.
1746 await resolveText('A');
1747 await waitForAll([
1748 'A',
1749 // B suspends
1750 'Suspend! [B]',
1751 'Loading more...',
1752 // pre-warming
1753 'Suspend! [B]',
1754 ]);
1755
1756 // Because we've already been waiting for so long we've exceeded
1757 // our threshold and we show the next level immediately.
1758 expect(ReactNoop).toMatchRenderedOutput(
1759 <>
1760 <span prop="A" />
1761 <span prop="Loading more..." />
1762 </>,
1763 );
1764
1765 // Flush the last promise completely
1766 await act(() => resolveText('B'));
1767 // Renders successfully
1768 assertLog(['B']);
1769 expect(ReactNoop).toMatchRenderedOutput(
1770 <>
1771 <span prop="A" />
1772 <span prop="B" />
1773 </>,
1774 );
1775 });
1776
1777 // @gate enableLegacyCache
1778 it('throttles content from appearing if a fallback was shown recently', async () => {
1779 function Foo() {
1780 Scheduler.log('Foo');
1781 return (
1782 <Suspense fallback={<Text text="Loading..." />}>
1783 <AsyncText text="A" />
1784 <Suspense fallback={<Text text="Loading more..." />}>
1785 <AsyncText text="B" />
1786 </Suspense>
1787 </Suspense>
1788 );
1789 }
1790
1791 ReactNoop.render(<Foo />);
1792 // Start rendering
1793 await waitForAll([
1794 'Foo',
1795 // A suspends
1796 'Suspend! [A]',
1797 'Loading...',
1798 // pre-warming
1799 'Suspend! [A]',
1800 'Suspend! [B]',
1801 'Loading more...',
1802 ]);
1803 expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
1804
1805 await act(async () => {
1806 await resolveText('A');
1807
1808 // Retry with the new content.
1809 await waitForAll([
1810 'A',
1811 // B suspends
1812 'Suspend! [B]',
1813 'Loading more...',
1814 ]);
1815 // Because we've already been waiting for so long we can
1816 // wait a bit longer. Still nothing...
1817 expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
1818
1819 // Before we commit another Promise resolves.
1820 // We're still showing the first loading state.
1821 await resolveText('B');
1822 expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
1823
1824 // Restart and render the complete content.
1825 await waitForAll(['A', 'B']);
1826
1827 if (gate(flags => flags.alwaysThrottleRetries)) {
1828 // Correct behavior:
1829 //
1830 // The tree will finish but we won't commit the result yet because the fallback appeared recently.
1831 expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
1832 } else {
1833 // Old behavior, gated until this rolls out at Meta:
1834 //
1835 // TODO: Because this render was the result of a retry, and a fallback
1836 // was shown recently, we should suspend and remain on the fallback for
1837 // little bit longer. We currently only do this if there's still
1838 // remaining fallbacks in the tree, but we should do it for all retries.
1839 expect(ReactNoop).toMatchRenderedOutput(
1840 <>
1841 <span prop="A" />
1842 <span prop="B" />
1843 </>,
1844 );
1845 }
1846 });
1847 assertLog([]);
1848 expect(ReactNoop).toMatchRenderedOutput(
1849 <>
1850 <span prop="A" />
1851 <span prop="B" />
1852 </>,
1853 );
1854 });
1855
1856 // @gate enableLegacyCache
1857 it('throttles content from appearing if a fallback was filled in recently', async () => {
1858 function Foo() {
1859 Scheduler.log('Foo');
1860 return (
1861 <>
1862 <Suspense fallback={<Text text="Loading A..." />}>
1863 <AsyncText text="A" />
1864 </Suspense>
1865 <Suspense fallback={<Text text="Loading B..." />}>
1866 <AsyncText text="B" />
1867 </Suspense>
1868 </>
1869 );
1870 }
1871
1872 ReactNoop.render(<Foo />);
1873 // Start rendering
1874 await waitForAll([
1875 'Foo',
1876 'Suspend! [A]',
1877 'Loading A...',
1878 'Suspend! [B]',
1879 'Loading B...',
1880 // pre-warming
1881 'Suspend! [A]',
1882 'Suspend! [B]',
1883 ]);
1884 expect(ReactNoop).toMatchRenderedOutput(
1885 <>
1886 <span prop="Loading A..." />
1887 <span prop="Loading B..." />
1888 </>,
1889 );
1890
1891 // Resolve only A. B will still be loading.
1892 await act(async () => {
1893 await resolveText('A');
1894
1895 // If we didn't advance the time here, A would not commit; it would
1896 // be throttled because the fallback would have appeared too recently.
1897 Scheduler.unstable_advanceTime(10000);
1898 jest.advanceTimersByTime(10000);
1899 if (gate(flags => flags.enableYieldingBeforePassive)) {
1900 // Passive effects.
1901 await waitForPaint([]);
1902 }
1903 await waitForPaint(['A']);
1904 expect(ReactNoop).toMatchRenderedOutput(
1905 <>
1906 <span prop="A" />
1907 <span prop="Loading B..." />
1908 </>,
1909 );
1910 });
1911
1912 // Advance by a small amount of time. For testing purposes, this is meant
1913 // to be just under the throttling interval. It's a heurstic, though, so
1914 // if we adjust the heuristic we might have to update this test, too.
1915 Scheduler.unstable_advanceTime(200);
1916 jest.advanceTimersByTime(200);
1917
1918 // Now resolve B.
1919 await act(async () => {
1920 await resolveText('B');
1921 await waitForPaint(['B']);
1922
1923 if (gate(flags => flags.alwaysThrottleRetries)) {
1924 // B should not commit yet. Even though it's been a long time since its
1925 // fallback was shown, it hasn't been long since A appeared. So B's
1926 // appearance is throttled to reduce jank.
1927 expect(ReactNoop).toMatchRenderedOutput(
1928 <>
1929 <span prop="A" />
1930 <span prop="Loading B..." />
1931 </>,
1932 );
1933
1934 // Advance time a little bit more. Now it commits because enough time
1935 // has passed.
1936 Scheduler.unstable_advanceTime(100);
1937 jest.advanceTimersByTime(100);
1938 await waitForAll([]);
1939 expect(ReactNoop).toMatchRenderedOutput(
1940 <>
1941 <span prop="A" />
1942 <span prop="B" />
1943 </>,
1944 );
1945 } else {
1946 // Old behavior, gated until this rolls out at Meta:
1947 //
1948 // B appears immediately, without being throttled.
1949 expect(ReactNoop).toMatchRenderedOutput(
1950 <>
1951 <span prop="A" />
1952 <span prop="B" />
1953 </>,
1954 );
1955 }
1956 });
1957 });
1958
1959 // TODO: flip to "warns" when this is implemented again.
1960 // @gate enableLegacyCache
1961 it('does not warn when a low priority update suspends inside a high priority update for functional components', async () => {
1962 let _setShow;
1963 function App() {
1964 const [show, setShow] = React.useState(false);
1965 _setShow = setShow;
1966 return (
1967 <Suspense fallback="Loading...">
1968 {show && <AsyncText text="A" />}
1969 </Suspense>
1970 );
1971 }
1972
1973 await act(() => {
1974 ReactNoop.render(<App />);
1975 });
1976
1977 // TODO: assertConsoleErrorDev() when the warning is implemented again.
1978 await act(() => {
1979 ReactNoop.flushSync(() => _setShow(true));
1980 });
1981 });
1982
1983 // TODO: flip to "warns" when this is implemented again.
1984 // @gate enableLegacyCache
1985 it('does not warn when a low priority update suspends inside a high priority update for class components', async () => {
1986 let show;
1987 class App extends React.Component {
1988 state = {show: false};
1989
1990 render() {
1991 show = () => this.setState({show: true});
1992 return (
1993 <Suspense fallback="Loading...">
1994 {this.state.show && <AsyncText text="A" />}
1995 </Suspense>
1996 );
1997 }
1998 }
1999
2000 await act(() => {
2001 ReactNoop.render(<App />);
2002 });
2003
2004 // TODO: assertConsoleErrorDev() when the warning is implemented again.
2005 await act(() => {
2006 ReactNoop.flushSync(() => show());
2007 });
2008 });
2009
2010 // @gate enableLegacyCache
2011 it('does not warn about wrong Suspense priority if no new fallbacks are shown', async () => {
2012 let showB;
2013 class App extends React.Component {
2014 state = {showB: false};
2015
2016 render() {
2017 showB = () => this.setState({showB: true});
2018 return (
2019 <Suspense fallback="Loading...">
2020 {<AsyncText text="A" />}
2021 {this.state.showB && <AsyncText text="B" />}
2022 </Suspense>
2023 );
2024 }
2025 }
2026
2027 await act(() => {
2028 ReactNoop.render(<App />);
2029 });
2030
2031 assertLog([
2032 'Suspend! [A]',
2033 // pre-warming
2034 'Suspend! [A]',
2035 ]);
2036 expect(ReactNoop).toMatchRenderedOutput('Loading...');
2037
2038 await act(() => {
2039 ReactNoop.flushSync(() => showB());
2040 });
2041
2042 assertLog([
2043 'Suspend! [A]',
2044 // pre-warming
2045 'Suspend! [A]',
2046 'Suspend! [B]',
2047 ]);
2048 });
2049
2050 // TODO: flip to "warns" when this is implemented again.
2051 // @gate enableLegacyCache
2052 it(
2053 'does not warn when component that triggered user-blocking update is between Suspense boundary ' +
2054 'and component that suspended',
2055 async () => {
2056 let _setShow;
2057 function A() {
2058 const [show, setShow] = React.useState(false);
2059 _setShow = setShow;
2060 return show && <AsyncText text="A" />;
2061 }
2062 function App() {
2063 return (
2064 <Suspense fallback="Loading...">
2065 <A />
2066 </Suspense>
2067 );
2068 }
2069 await act(() => {
2070 ReactNoop.render(<App />);
2071 });
2072
2073 // TODO: assertConsoleErrorDev() when the warning is implemented again.
2074 await act(() => {
2075 ReactNoop.flushSync(() => _setShow(true));
2076 });
2077 },
2078 );
2079
2080 // @gate enableLegacyCache
2081 it('normal priority updates suspending do not warn for class components', async () => {
2082 let show;
2083 class App extends React.Component {
2084 state = {show: false};
2085
2086 render() {
2087 show = () => this.setState({show: true});
2088 return (
2089 <Suspense fallback="Loading...">
2090 {this.state.show && <AsyncText text="A" />}
2091 </Suspense>
2092 );
2093 }
2094 }
2095
2096 await act(() => {
2097 ReactNoop.render(<App />);
2098 });
2099
2100 // also make sure lowpriority is okay
2101 await act(() => show(true));
2102
2103 assertLog([
2104 'Suspend! [A]',
2105 // pre-warming
2106 'Suspend! [A]',
2107 ]);
2108 await resolveText('A');
2109
2110 expect(ReactNoop).toMatchRenderedOutput('Loading...');
2111 });
2112
2113 // @gate enableLegacyCache
2114 it('normal priority updates suspending do not warn for functional components', async () => {
2115 let _setShow;
2116 function App() {
2117 const [show, setShow] = React.useState(false);
2118 _setShow = setShow;
2119 return (
2120 <Suspense fallback="Loading...">
2121 {show && <AsyncText text="A" />}
2122 </Suspense>
2123 );
2124 }
2125
2126 await act(() => {
2127 ReactNoop.render(<App />);
2128 });
2129
2130 // also make sure lowpriority is okay
2131 await act(() => _setShow(true));
2132
2133 assertLog([
2134 'Suspend! [A]',
2135 // pre-warming
2136 'Suspend! [A]',
2137 ]);
2138 await resolveText('A');
2139
2140 expect(ReactNoop).toMatchRenderedOutput('Loading...');
2141 });
2142
2143 // @gate enableLegacyCache && enableSuspenseAvoidThisFallback
2144 it('shows the parent fallback if the inner fallback should be avoided', async () => {
2145 function Foo({showC}) {
2146 Scheduler.log('Foo');
2147 return (
2148 <Suspense fallback={<Text text="Initial load..." />}>
2149 <Suspense
2150 unstable_avoidThisFallback={true}
2151 fallback={<Text text="Updating..." />}>
2152 <AsyncText text="A" />
2153 {showC ? <AsyncText text="C" /> : null}
2154 </Suspense>
2155 <Text text="B" />
2156 </Suspense>
2157 );
2158 }
2159
2160 ReactNoop.render(<Foo />);
2161 await waitForAll([
2162 'Foo',
2163 'Suspend! [A]',
2164 'Initial load...',
2165 // pre-warming
2166 'Suspend! [A]',
2167 'B',
2168 ]);
2169 expect(ReactNoop).toMatchRenderedOutput(<span prop="Initial load..." />);
2170
2171 // Eventually we resolve and show the data.
2172 await act(() => resolveText('A'));
2173 assertLog(['A', 'B']);
2174 expect(ReactNoop).toMatchRenderedOutput(
2175 <>
2176 <span prop="A" />
2177 <span prop="B" />
2178 </>,
2179 );
2180
2181 // Update to show C
2182 ReactNoop.render(<Foo showC={true} />);
2183 await waitForAll([
2184 'Foo',
2185 'A',
2186 'Suspend! [C]',
2187 'Updating...',
2188 'B',
2189 // pre-warming
2190 'A',
2191 'Suspend! [C]',
2192 ]);
2193 // Flush to skip suspended time.
2194 Scheduler.unstable_advanceTime(600);
2195 await advanceTimers(600);
2196 // Since the optional suspense boundary is already showing its content,
2197 // we have to use the inner fallback instead.
2198 expect(ReactNoop).toMatchRenderedOutput(
2199 <>
2200 <span prop="A" hidden={true} />
2201 <span prop="Updating..." />
2202 <span prop="B" />
2203 </>,
2204 );
2205
2206 // Later we load the data.
2207 await act(() => resolveText('C'));
2208 assertLog(['A', 'C']);
2209 expect(ReactNoop).toMatchRenderedOutput(
2210 <>
2211 <span prop="A" />
2212 <span prop="C" />
2213 <span prop="B" />
2214 </>,
2215 );
2216 });
2217
2218 // @gate enableLegacyCache
2219 it('does not show the parent fallback if the inner fallback is not defined', async () => {
2220 function Foo({showC}) {
2221 Scheduler.log('Foo');
2222 return (
2223 <Suspense fallback={<Text text="Initial load..." />}>
2224 <Suspense>
2225 <AsyncText text="A" />
2226 {showC ? <AsyncText text="C" /> : null}
2227 </Suspense>
2228 <Text text="B" />
2229 </Suspense>
2230 );
2231 }
2232
2233 ReactNoop.render(<Foo />);
2234 await waitForAll([
2235 'Foo',
2236 'Suspend! [A]',
2237 'B',
2238 // null
2239 // pre-warming
2240 'Suspend! [A]',
2241 ]);
2242 expect(ReactNoop).toMatchRenderedOutput(<span prop="B" />);
2243
2244 // Eventually we resolve and show the data.
2245 await act(() => resolveText('A'));
2246 assertLog(['A']);
2247 expect(ReactNoop).toMatchRenderedOutput(
2248 <>
2249 <span prop="A" />
2250 <span prop="B" />
2251 </>,
2252 );
2253
2254 // Update to show C
2255 ReactNoop.render(<Foo showC={true} />);
2256 await waitForAll([
2257 'Foo',
2258 'A',
2259 'Suspend! [C]',
2260 // null
2261 'B',
2262 // pre-warming
2263 'A',
2264 'Suspend! [C]',
2265 ]);
2266 // Flush to skip suspended time.
2267 Scheduler.unstable_advanceTime(600);
2268 await advanceTimers(600);
2269 expect(ReactNoop).toMatchRenderedOutput(
2270 <>
2271 <span prop="A" hidden={true} />
2272 <span prop="B" />
2273 </>,
2274 );
2275
2276 // Later we load the data.
2277 await act(() => resolveText('C'));
2278 assertLog(['A', 'C']);
2279 expect(ReactNoop).toMatchRenderedOutput(
2280 <>
2281 <span prop="A" />
2282 <span prop="C" />
2283 <span prop="B" />
2284 </>,
2285 );
2286 });
2287
2288 // @gate enableLegacyCache
2289 it('favors showing the inner fallback for nested top level avoided fallback', async () => {
2290 function Foo({showB}) {
2291 Scheduler.log('Foo');
2292 return (
2293 <Suspense
2294 unstable_avoidThisFallback={true}
2295 fallback={<Text text="Loading A..." />}>
2296 <Text text="A" />
2297 <Suspense
2298 unstable_avoidThisFallback={true}
2299 fallback={<Text text="Loading B..." />}>
2300 <AsyncText text="B" />
2301 </Suspense>
2302 </Suspense>
2303 );
2304 }
2305
2306 ReactNoop.render(<Foo />);
2307 await waitForAll([
2308 'Foo',
2309 'A',
2310 'Suspend! [B]',
2311 'Loading B...',
2312 // pre-warming
2313 'Suspend! [B]',
2314 ]);
2315 // Flush to skip suspended time.
2316 Scheduler.unstable_advanceTime(600);
2317 await advanceTimers(600);
2318
2319 expect(ReactNoop).toMatchRenderedOutput(
2320 <>
2321 <span prop="A" />
2322 <span prop="Loading B..." />
2323 </>,
2324 );
2325 });
2326
2327 // @gate enableLegacyCache && enableSuspenseAvoidThisFallback
2328 it('keeps showing an avoided parent fallback if it is already showing', async () => {
2329 function Foo({showB}) {
2330 Scheduler.log('Foo');
2331 return (
2332 <Suspense fallback={<Text text="Initial load..." />}>
2333 <Suspense
2334 unstable_avoidThisFallback={true}
2335 fallback={<Text text="Loading A..." />}>
2336 <Text text="A" />
2337 {showB ? (
2338 <Suspense
2339 unstable_avoidThisFallback={true}
2340 fallback={<Text text="Loading B..." />}>
2341 <AsyncText text="B" />
2342 </Suspense>
2343 ) : null}
2344 </Suspense>
2345 </Suspense>
2346 );
2347 }
2348
2349 ReactNoop.render(<Foo />);
2350 await waitForAll(['Foo', 'A']);
2351 expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
2352
2353 React.startTransition(() => {
2354 ReactNoop.render(<Foo showB={true} />);
2355 });
2356
2357 await waitForAll(['Foo', 'A', 'Suspend! [B]', 'Loading B...']);
2358
2359 // Transitions never fall back.
2360 expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
2361 });
2362
2363 // @gate enableLegacyCache
2364 it('keeps showing an undefined fallback if it is already showing', async () => {
2365 function Foo({showB}) {
2366 Scheduler.log('Foo');
2367 return (
2368 <Suspense fallback={<Text text="Initial load..." />}>
2369 <Suspense fallback={undefined}>
2370 <Text text="A" />
2371 {showB ? (
2372 <Suspense fallback={undefined}>
2373 <AsyncText text="B" />
2374 </Suspense>
2375 ) : null}
2376 </Suspense>
2377 </Suspense>
2378 );
2379 }
2380
2381 ReactNoop.render(<Foo />);
2382 await waitForAll(['Foo', 'A']);
2383 expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
2384
2385 React.startTransition(() => {
2386 ReactNoop.render(<Foo showB={true} />);
2387 });
2388
2389 await waitForAll([
2390 'Foo',
2391 'A',
2392 'Suspend! [B]',
2393 // Null
2394 // pre-warming
2395 'Suspend! [B]',
2396 ]);
2397 // Still suspended.
2398 expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
2399
2400 // Flush to skip suspended time.
2401 Scheduler.unstable_advanceTime(600);
2402 await advanceTimers(600);
2403
2404 expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
2405 });
2406
2407 describe('startTransition', () => {
2408 // @gate enableLegacyCache
2409 it('top level render', async () => {
2410 function App({page}) {
2411 return (
2412 <Suspense fallback={<Text text="Loading..." />}>
2413 <AsyncText text={page} />
2414 </Suspense>
2415 );
2416 }
2417
2418 // Initial render.
2419 React.startTransition(() => ReactNoop.render(<App page="A" />));
2420
2421 await waitForAll([
2422 'Suspend! [A]',
2423 'Loading...',
2424 // pre-warming
2425 'Suspend! [A]',
2426 ]);
2427 // Only a short time is needed to unsuspend the initial loading state.
2428 Scheduler.unstable_advanceTime(400);
2429 await advanceTimers(400);
2430 expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
2431
2432 // Later we load the data.
2433 await act(() => resolveText('A'));
2434 assertLog(['A']);
2435 expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
2436
2437 // Start transition.
2438 React.startTransition(() => ReactNoop.render(<App page="B" />));
2439
2440 await waitForAll(['Suspend! [B]', 'Loading...']);
2441 Scheduler.unstable_advanceTime(100000);
2442 await advanceTimers(100000);
2443 // Even after lots of time has passed, we have still not yet flushed the
2444 // loading state.
2445 expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
2446 // Later we load the data.
2447 await act(() => resolveText('B'));
2448 assertLog(['B']);
2449 expect(ReactNoop).toMatchRenderedOutput(<span prop="B" />);
2450 });
2451
2452 // @gate enableLegacyCache
2453 it('hooks', async () => {
2454 let transitionToPage;
2455 function App() {
2456 const [page, setPage] = React.useState('none');
2457 transitionToPage = setPage;
2458 if (page === 'none') {
2459 return null;
2460 }
2461 return (
2462 <Suspense fallback={<Text text="Loading..." />}>
2463 <AsyncText text={page} />
2464 </Suspense>
2465 );
2466 }
2467
2468 ReactNoop.render(<App />);
2469 await waitForAll([]);
2470
2471 // Initial render.
2472 await act(async () => {
2473 React.startTransition(() => transitionToPage('A'));
2474
2475 await waitForAll([
2476 'Suspend! [A]',
2477 'Loading...',
2478 // pre-warming
2479 'Suspend! [A]',
2480 ]);
2481 // Only a short time is needed to unsuspend the initial loading state.
2482 Scheduler.unstable_advanceTime(400);
2483 await advanceTimers(400);
2484 expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
2485 });
2486
2487 // Later we load the data.
2488 await act(() => resolveText('A'));
2489 assertLog(['A']);
2490 expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
2491
2492 // Start transition.
2493 await act(async () => {
2494 React.startTransition(() => transitionToPage('B'));
2495
2496 await waitForAll(['Suspend! [B]', 'Loading...']);
2497 Scheduler.unstable_advanceTime(100000);
2498 await advanceTimers(100000);
2499 // Even after lots of time has passed, we have still not yet flushed the
2500 // loading state.
2501 expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
2502 });
2503 // Later we load the data.
2504 await act(() => resolveText('B'));
2505 assertLog(['B']);
2506 expect(ReactNoop).toMatchRenderedOutput(<span prop="B" />);
2507 });
2508
2509 // @gate enableLegacyCache
2510 it('classes', async () => {
2511 let transitionToPage;
2512 class App extends React.Component {
2513 state = {page: 'none'};
2514 render() {
2515 transitionToPage = page => this.setState({page});
2516 const page = this.state.page;
2517 if (page === 'none') {
2518 return null;
2519 }
2520 return (
2521 <Suspense fallback={<Text text="Loading..." />}>
2522 <AsyncText text={page} />
2523 </Suspense>
2524 );
2525 }
2526 }
2527
2528 ReactNoop.render(<App />);
2529 await waitForAll([]);
2530
2531 // Initial render.
2532 await act(async () => {
2533 React.startTransition(() => transitionToPage('A'));
2534
2535 await waitForAll([
2536 'Suspend! [A]',
2537 'Loading...',
2538 // pre-warming
2539 'Suspend! [A]',
2540 ]);
2541 // Only a short time is needed to unsuspend the initial loading state.
2542 Scheduler.unstable_advanceTime(400);
2543 await advanceTimers(400);
2544 expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
2545 });
2546
2547 // Later we load the data.
2548 await act(() => resolveText('A'));
2549 assertLog(['A']);
2550 expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
2551
2552 // Start transition.
2553 await act(async () => {
2554 React.startTransition(() => transitionToPage('B'));
2555
2556 await waitForAll(['Suspend! [B]', 'Loading...']);
2557 Scheduler.unstable_advanceTime(100000);
2558 await advanceTimers(100000);
2559 // Even after lots of time has passed, we have still not yet flushed the
2560 // loading state.
2561 expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
2562 });
2563 // Later we load the data.
2564 await act(() => resolveText('B'));
2565 assertLog(['B']);
2566 expect(ReactNoop).toMatchRenderedOutput(<span prop="B" />);
2567 });
2568 });
2569
2570 describe('delays transitions when using React.startTransition', () => {
2571 // @gate enableLegacyCache
2572 it('top level render', async () => {
2573 function App({page}) {
2574 return (
2575 <Suspense fallback={<Text text="Loading..." />}>
2576 <AsyncText text={page} />
2577 </Suspense>
2578 );
2579 }
2580
2581 // Initial render.
2582 React.startTransition(() => ReactNoop.render(<App page="A" />));
2583
2584 await waitForAll([
2585 'Suspend! [A]',
2586 'Loading...',
2587 // pre-warming
2588 'Suspend! [A]',
2589 ]);
2590 // Only a short time is needed to unsuspend the initial loading state.
2591 Scheduler.unstable_advanceTime(400);
2592 await advanceTimers(400);
2593 expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
2594
2595 // Later we load the data.
2596 await act(() => resolveText('A'));
2597 assertLog(['A']);
2598 expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
2599
2600 // Start transition.
2601 React.startTransition(() => ReactNoop.render(<App page="B" />));
2602
2603 await waitForAll(['Suspend! [B]', 'Loading...']);
2604 Scheduler.unstable_advanceTime(2999);
2605 await advanceTimers(2999);
2606 // Since the timeout is infinite (or effectively infinite),
2607 // we have still not yet flushed the loading state.
2608 expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
2609
2610 // Later we load the data.
2611 await act(() => resolveText('B'));
2612 assertLog(['B']);
2613 expect(ReactNoop).toMatchRenderedOutput(<span prop="B" />);
2614
2615 // Start a long (infinite) transition.
2616 React.startTransition(() => ReactNoop.render(<App page="C" />));
2617 await waitForAll(['Suspend! [C]', 'Loading...']);
2618
2619 // Even after lots of time has passed, we have still not yet flushed the
2620 // loading state.
2621 Scheduler.unstable_advanceTime(100000);
2622 await advanceTimers(100000);
2623 expect(ReactNoop).toMatchRenderedOutput(<span prop="B" />);
2624 });
2625
2626 // @gate enableLegacyCache
2627 it('hooks', async () => {
2628 let transitionToPage;
2629 function App() {
2630 const [page, setPage] = React.useState('none');
2631 transitionToPage = setPage;
2632 if (page === 'none') {
2633 return null;
2634 }
2635 return (
2636 <Suspense fallback={<Text text="Loading..." />}>
2637 <AsyncText text={page} />
2638 </Suspense>
2639 );
2640 }
2641
2642 ReactNoop.render(<App />);
2643 await waitForAll([]);
2644
2645 // Initial render.
2646 await act(async () => {
2647 React.startTransition(() => transitionToPage('A'));
2648
2649 await waitForAll([
2650 'Suspend! [A]',
2651 'Loading...',
2652 // pre-warming
2653 'Suspend! [A]',
2654 ]);
2655 // Only a short time is needed to unsuspend the initial loading state.
2656 Scheduler.unstable_advanceTime(400);
2657 await advanceTimers(400);
2658 expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
2659 });
2660
2661 // Later we load the data.
2662 await act(() => resolveText('A'));
2663 assertLog(['A']);
2664 expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
2665
2666 // Start transition.
2667 await act(async () => {
2668 React.startTransition(() => transitionToPage('B'));
2669
2670 await waitForAll(['Suspend! [B]', 'Loading...']);
2671
2672 Scheduler.unstable_advanceTime(2999);
2673 await advanceTimers(2999);
2674 // Since the timeout is infinite (or effectively infinite),
2675 // we have still not yet flushed the loading state.
2676 expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
2677 });
2678
2679 // Later we load the data.
2680 await act(() => resolveText('B'));
2681 assertLog(['B']);
2682 expect(ReactNoop).toMatchRenderedOutput(<span prop="B" />);
2683
2684 // Start a long (infinite) transition.
2685 await act(async () => {
2686 React.startTransition(() => transitionToPage('C'));
2687
2688 await waitForAll(['Suspend! [C]', 'Loading...']);
2689
2690 // Even after lots of time has passed, we have still not yet flushed the
2691 // loading state.
2692 Scheduler.unstable_advanceTime(100000);
2693 await advanceTimers(100000);
2694 expect(ReactNoop).toMatchRenderedOutput(<span prop="B" />);
2695 });
2696 });
2697
2698 // @gate enableLegacyCache
2699 it('classes', async () => {
2700 let transitionToPage;
2701 class App extends React.Component {
2702 state = {page: 'none'};
2703 render() {
2704 transitionToPage = page => this.setState({page});
2705 const page = this.state.page;
2706 if (page === 'none') {
2707 return null;
2708 }
2709 return (
2710 <Suspense fallback={<Text text="Loading..." />}>
2711 <AsyncText text={page} />
2712 </Suspense>
2713 );
2714 }
2715 }
2716
2717 ReactNoop.render(<App />);
2718 await waitForAll([]);
2719
2720 // Initial render.
2721 await act(async () => {
2722 React.startTransition(() => transitionToPage('A'));
2723
2724 await waitForAll([
2725 'Suspend! [A]',
2726 'Loading...',
2727 // pre-warming
2728 'Suspend! [A]',
2729 ]);
2730 // Only a short time is needed to unsuspend the initial loading state.
2731 Scheduler.unstable_advanceTime(400);
2732 await advanceTimers(400);
2733 expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
2734 });
2735
2736 // Later we load the data.
2737 await act(() => resolveText('A'));
2738 assertLog(['A']);
2739 expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
2740
2741 // Start transition.
2742 await act(async () => {
2743 React.startTransition(() => transitionToPage('B'));
2744
2745 await waitForAll(['Suspend! [B]', 'Loading...']);
2746 Scheduler.unstable_advanceTime(2999);
2747 await advanceTimers(2999);
2748 // Since the timeout is infinite (or effectively infinite),
2749 // we have still not yet flushed the loading state.
2750 expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
2751 });
2752
2753 // Later we load the data.
2754 await act(() => resolveText('B'));
2755 assertLog(['B']);
2756 expect(ReactNoop).toMatchRenderedOutput(<span prop="B" />);
2757
2758 // Start a long (infinite) transition.
2759 await act(async () => {
2760 React.startTransition(() => transitionToPage('C'));
2761
2762 await waitForAll(['Suspend! [C]', 'Loading...']);
2763
2764 // Even after lots of time has passed, we have still not yet flushed the
2765 // loading state.
2766 Scheduler.unstable_advanceTime(100000);
2767 await advanceTimers(100000);
2768 expect(ReactNoop).toMatchRenderedOutput(<span prop="B" />);
2769 });
2770 });
2771 });
2772
2773 // @gate enableLegacyCache && enableSuspenseAvoidThisFallback
2774 it('do not show placeholder when updating an avoided boundary with startTransition', async () => {
2775 function App({page}) {
2776 return (
2777 <Suspense fallback={<Text text="Loading..." />}>
2778 <Text text="Hi!" />
2779 <Suspense
2780 fallback={<Text text={'Loading ' + page + '...'} />}
2781 unstable_avoidThisFallback={true}>
2782 <AsyncText text={page} />
2783 </Suspense>
2784 </Suspense>
2785 );
2786 }
2787
2788 // Initial render.
2789 ReactNoop.render(<App page="A" />);
2790 await waitForAll([
2791 'Hi!',
2792 'Suspend! [A]',
2793 'Loading...',
2794 // pre-warming
2795 'Hi!',
2796 'Suspend! [A]',
2797 ]);
2798 await act(() => resolveText('A'));
2799 assertLog(['Hi!', 'A']);
2800 expect(ReactNoop).toMatchRenderedOutput(
2801 <>
2802 <span prop="Hi!" />
2803 <span prop="A" />
2804 </>,
2805 );
2806
2807 // Start transition.
2808 React.startTransition(() => ReactNoop.render(<App page="B" />));
2809
2810 await waitForAll(['Hi!', 'Suspend! [B]', 'Loading B...']);
2811
2812 // Suspended
2813 expect(ReactNoop).toMatchRenderedOutput(
2814 <>
2815 <span prop="Hi!" />
2816 <span prop="A" />
2817 </>,
2818 );
2819 Scheduler.unstable_advanceTime(1800);
2820 await advanceTimers(1800);
2821 await waitForAll([]);
2822 // We should still be suspended here because this loading state should be avoided.
2823 expect(ReactNoop).toMatchRenderedOutput(
2824 <>
2825 <span prop="Hi!" />
2826 <span prop="A" />
2827 </>,
2828 );
2829 await resolveText('B');
2830 await waitForAll(['Hi!', 'B']);
2831 expect(ReactNoop).toMatchRenderedOutput(
2832 <>
2833 <span prop="Hi!" />
2834 <span prop="B" />
2835 </>,
2836 );
2837 });
2838
2839 // @gate enableLegacyCache && enableSuspenseAvoidThisFallback
2840 it('do not show placeholder when mounting an avoided boundary with startTransition', async () => {
2841 function App({page}) {
2842 return (
2843 <Suspense fallback={<Text text="Loading..." />}>
2844 <Text text="Hi!" />
2845 {page === 'A' ? (
2846 <Text text="A" />
2847 ) : (
2848 <Suspense
2849 fallback={<Text text={'Loading ' + page + '...'} />}
2850 unstable_avoidThisFallback={true}>
2851 <AsyncText text={page} />
2852 </Suspense>
2853 )}
2854 </Suspense>
2855 );
2856 }
2857
2858 // Initial render.
2859 ReactNoop.render(<App page="A" />);
2860 await waitForAll(['Hi!', 'A']);
2861 expect(ReactNoop).toMatchRenderedOutput(
2862 <>
2863 <span prop="Hi!" />
2864 <span prop="A" />
2865 </>,
2866 );
2867
2868 // Start transition.
2869 React.startTransition(() => ReactNoop.render(<App page="B" />));
2870
2871 await waitForAll(['Hi!', 'Suspend! [B]', 'Loading B...']);
2872
2873 // Suspended
2874 expect(ReactNoop).toMatchRenderedOutput(
2875 <>
2876 <span prop="Hi!" />
2877 <span prop="A" />
2878 </>,
2879 );
2880 Scheduler.unstable_advanceTime(1800);
2881 await advanceTimers(1800);
2882 await waitForAll([]);
2883 // We should still be suspended here because this loading state should be avoided.
2884 expect(ReactNoop).toMatchRenderedOutput(
2885 <>
2886 <span prop="Hi!" />
2887 <span prop="A" />
2888 </>,
2889 );
2890 await resolveText('B');
2891 await waitForAll(['Hi!', 'B']);
2892 expect(ReactNoop).toMatchRenderedOutput(
2893 <>
2894 <span prop="Hi!" />
2895 <span prop="B" />
2896 </>,
2897 );
2898 });
2899
2900 it('regression test: resets current "debug phase" after suspending', async () => {
2901 function App() {
2902 return (
2903 <Suspense fallback="Loading...">
2904 <Foo suspend={false} />
2905 </Suspense>
2906 );
2907 }
2908
2909 const thenable = {then() {}};
2910
2911 let foo;
2912 class Foo extends React.Component {
2913 state = {suspend: false};
2914 render() {
2915 foo = this;
2916
2917 if (this.state.suspend) {
2918 Scheduler.log('Suspend!');
2919 throw thenable;
2920 }
2921
2922 return <Text text="Foo" />;
2923 }
2924 }
2925
2926 const root = ReactNoop.createRoot();
2927 await act(() => {
2928 root.render(<App />);
2929 });
2930
2931 assertLog(['Foo']);
2932
2933 await act(async () => {
2934 foo.setState({suspend: true});
2935
2936 // In the regression that this covers, we would neglect to reset the
2937 // current debug phase after suspending (in the catch block), so React
2938 // thinks we're still inside the render phase.
2939 await waitFor(['Suspend!']);
2940
2941 // Then when this setState happens, React would incorrectly fire a warning
2942 // about updates that happen the render phase (only fired by classes).
2943 foo.setState({suspend: false});
2944 });
2945
2946 assertLog([
2947 // First setState
2948 'Foo',
2949 ]);
2950 expect(root).toMatchRenderedOutput(<span prop="Foo" />);
2951 });
2952
2953 // @gate enableLegacyCache && enableLegacyHidden
2954 it('should not render hidden content while suspended on higher pri', async () => {
2955 function Offscreen() {
2956 Scheduler.log('Offscreen');
2957 return 'Offscreen';
2958 }
2959 function App({showContent}) {
2960 React.useLayoutEffect(() => {
2961 Scheduler.log('Commit');
2962 });
2963 return (
2964 <>
2965 <LegacyHiddenDiv mode="hidden">
2966 <Offscreen />
2967 </LegacyHiddenDiv>
2968 <Suspense fallback={<Text text="Loading..." />}>
2969 {showContent ? <AsyncText text="A" /> : null}
2970 </Suspense>
2971 </>
2972 );
2973 }
2974
2975 // Initial render.
2976 ReactNoop.render(<App showContent={false} />);
2977 await waitFor(['Commit']);
2978 expect(ReactNoop).toMatchRenderedOutput(<div hidden={true} />);
2979
2980 // Start transition.
2981 React.startTransition(() => {
2982 ReactNoop.render(<App showContent={true} />);
2983 });
2984
2985 await waitForAll(['Suspend! [A]', 'Loading...']);
2986 await resolveText('A');
2987 await waitFor(['A', 'Commit']);
2988 expect(ReactNoop).toMatchRenderedOutput(
2989 <>
2990 <div hidden={true} />
2991 <span prop="A" />
2992 </>,
2993 );
2994 await waitForAll(['Offscreen']);
2995 expect(ReactNoop).toMatchRenderedOutput(
2996 <>
2997 <div hidden={true}>Offscreen</div>
2998 <span prop="A" />
2999 </>,
3000 );
3001 });
3002
3003 // @gate enableLegacyCache && enableLegacyHidden
3004 it('should be able to unblock higher pri content before suspended hidden', async () => {
3005 function Offscreen() {
3006 Scheduler.log('Offscreen');
3007 return 'Offscreen';
3008 }
3009 function App({showContent}) {
3010 React.useLayoutEffect(() => {
3011 Scheduler.log('Commit');
3012 });
3013 return (
3014 <Suspense fallback={<Text text="Loading..." />}>
3015 <LegacyHiddenDiv mode="hidden">
3016 <AsyncText text="A" />
3017 <Offscreen />
3018 </LegacyHiddenDiv>
3019 {showContent ? <AsyncText text="A" /> : null}
3020 </Suspense>
3021 );
3022 }
3023
3024 // Initial render.
3025 ReactNoop.render(<App showContent={false} />);
3026 await waitFor(['Commit']);
3027 expect(ReactNoop).toMatchRenderedOutput(<div hidden={true} />);
3028
3029 // Partially render through the hidden content.
3030 await waitFor(['Suspend! [A]']);
3031
3032 // Start transition.
3033 React.startTransition(() => {
3034 ReactNoop.render(<App showContent={true} />);
3035 });
3036
3037 await waitForAll(['Suspend! [A]', 'Loading...']);
3038 await resolveText('A');
3039 await waitFor(['A', 'Commit']);
3040 expect(ReactNoop).toMatchRenderedOutput(
3041 <>
3042 <div hidden={true} />
3043 <span prop="A" />
3044 </>,
3045 );
3046 await waitForAll(['A', 'Offscreen']);
3047 expect(ReactNoop).toMatchRenderedOutput(
3048 <>
3049 <div hidden={true}>
3050 <span prop="A" />
3051 Offscreen
3052 </div>
3053 <span prop="A" />
3054 </>,
3055 );
3056 });
3057
3058 // @gate enableLegacyCache
3059 it(
3060 'multiple updates originating inside a Suspense boundary at different ' +
3061 'priority levels are not dropped',
3062 async () => {
3063 const {useState} = React;
3064 const root = ReactNoop.createRoot();
3065
3066 function Parent() {
3067 return (
3068 <>
3069 <Suspense fallback={<Text text="Loading..." />}>
3070 <Child />
3071 </Suspense>
3072 </>
3073 );
3074 }
3075
3076 let setText;
3077 function Child() {
3078 const [text, _setText] = useState('A');
3079 setText = _setText;
3080 return <AsyncText text={text} />;
3081 }
3082
3083 await seedNextTextCache('A');
3084 await act(() => {
3085 root.render(<Parent />);
3086 });
3087 assertLog(['A']);
3088 expect(root).toMatchRenderedOutput(<span prop="A" />);
3089
3090 await act(async () => {
3091 // Schedule two updates that originate inside the Suspense boundary.
3092 // The first one causes the boundary to suspend. The second one is at
3093 // lower priority and unsuspends the tree.
3094 ReactNoop.discreteUpdates(() => {
3095 setText('B');
3096 });
3097 startTransition(() => {
3098 setText('C');
3099 });
3100 // Assert that neither update has happened yet. Both the high pri and
3101 // low pri updates are in the queue.
3102 assertLog([]);
3103
3104 // Resolve this before starting to render so that C doesn't suspend.
3105 await resolveText('C');
3106 });
3107 assertLog([
3108 // First we attempt the high pri update. It suspends.
3109 'Suspend! [B]',
3110 'Loading...',
3111 // Then we attempt the low pri update, which finishes successfully.
3112 'C',
3113 ]);
3114 expect(root).toMatchRenderedOutput(<span prop="C" />);
3115 },
3116 );
3117
3118 // @gate enableLegacyCache
3119 it(
3120 'fallback component can update itself even after a high pri update to ' +
3121 'the primary tree suspends',
3122 async () => {
3123 const {useState} = React;
3124 const root = ReactNoop.createRoot();
3125
3126 let setAppText;
3127 function App() {
3128 const [text, _setText] = useState('A');
3129 setAppText = _setText;
3130 return (
3131 <>
3132 <Suspense fallback={<Fallback />}>
3133 <AsyncText text={text} />
3134 </Suspense>
3135 </>
3136 );
3137 }
3138
3139 let setFallbackText;
3140 function Fallback() {
3141 const [text, _setText] = useState('Loading...');
3142 setFallbackText = _setText;
3143 return <Text text={text} />;
3144 }
3145
3146 // Resolve the initial tree
3147 await seedNextTextCache('A');
3148 await act(() => {
3149 root.render(<App />);
3150 });
3151 assertLog(['A']);
3152 expect(root).toMatchRenderedOutput(<span prop="A" />);
3153
3154 await act(async () => {
3155 // Schedule an update inside the Suspense boundary that suspends.
3156 setAppText('B');
3157 await waitForAll([
3158 'Suspend! [B]',
3159 'Loading...',
3160 // pre-warming
3161 'Suspend! [B]',
3162 ]);
3163 });
3164
3165 expect(root).toMatchRenderedOutput(
3166 <>
3167 <span hidden={true} prop="A" />
3168 <span prop="Loading..." />
3169 </>,
3170 );
3171
3172 // Schedule a default pri update on the boundary, and a lower pri update
3173 // on the fallback. We're testing to make sure the fallback can still
3174 // update even though the primary tree is suspended.
3175 await act(() => {
3176 setAppText('C');
3177 React.startTransition(() => {
3178 setFallbackText('Still loading...');
3179 });
3180 });
3181
3182 assertLog([
3183 // First try to render the high pri update. Still suspended.
3184 'Suspend! [C]',
3185 'Loading...',
3186
3187 // In the expiration times model, once the high pri update suspends,
3188 // we can't be sure if there's additional work at a lower priority
3189 // that might unblock the tree. We do know that there's a lower
3190 // priority update *somewhere* in the entire root, though (the update
3191 // to the fallback). So we try rendering one more time, just in case.
3192 // TODO: We shouldn't need to do this with lanes, because we always
3193 // know exactly which lanes have pending work in each tree.
3194 'Suspend! [C]',
3195
3196 // Then complete the update to the fallback.
3197 'Still loading...',
3198 'Suspend! [C]',
3199 ]);
3200 expect(root).toMatchRenderedOutput(
3201 <>
3202 <span hidden={true} prop="A" />
3203 <span prop="Still loading..." />
3204 </>,
3205 );
3206 },
3207 );
3208
3209 // @gate enableLegacyCache
3210 it(
3211 'regression: primary fragment fiber is not always part of setState ' +
3212 'return path',
3213 async () => {
3214 // Reproduces a bug where updates inside a suspended tree are dropped
3215 // because the fragment fiber we insert to wrap the hidden children is not
3216 // part of the return path, so it doesn't get marked during setState.
3217 const {useState} = React;
3218 const root = ReactNoop.createRoot();
3219
3220 function Parent() {
3221 return (
3222 <>
3223 <Suspense fallback={<Text text="Loading..." />}>
3224 <Child />
3225 </Suspense>
3226 </>
3227 );
3228 }
3229
3230 let setText;
3231 function Child() {
3232 const [text, _setText] = useState('A');
3233 setText = _setText;
3234 return <AsyncText text={text} />;
3235 }
3236
3237 // Mount an initial tree. Resolve A so that it doesn't suspend.
3238 await seedNextTextCache('A');
3239 await act(() => {
3240 root.render(<Parent />);
3241 });
3242 assertLog(['A']);
3243 // At this point, the setState return path follows current fiber.
3244 expect(root).toMatchRenderedOutput(<span prop="A" />);
3245
3246 // Schedule another update. This will "flip" the alternate pairs.
3247 await resolveText('B');
3248 await act(() => {
3249 setText('B');
3250 });
3251 assertLog(['B']);
3252 // Now the setState return path follows the *alternate* fiber.
3253 expect(root).toMatchRenderedOutput(<span prop="B" />);
3254
3255 // Schedule another update. This time, we'll suspend.
3256 await act(() => {
3257 setText('C');
3258 });
3259 assertLog(['Suspend! [C]', 'Loading...', 'Suspend! [C]']);
3260
3261 // Commit. This will insert a fragment fiber to wrap around the component
3262 // that triggered the update.
3263 await act(async () => {
3264 await advanceTimers(250);
3265 });
3266 // The fragment fiber is part of the current tree, but the setState return
3267 // path still follows the alternate path. That means the fragment fiber is
3268 // not part of the return path.
3269 expect(root).toMatchRenderedOutput(
3270 <>
3271 <span hidden={true} prop="B" />
3272 <span prop="Loading..." />
3273 </>,
3274 );
3275
3276 // Update again. This should unsuspend the tree.
3277 await resolveText('D');
3278 await act(() => {
3279 setText('D');
3280 });
3281 // Even though the fragment fiber is not part of the return path, we should
3282 // be able to finish rendering.
3283 assertLog(['D']);
3284 expect(root).toMatchRenderedOutput(<span prop="D" />);
3285 },
3286 );
3287
3288 // @gate enableLegacyCache
3289 it(
3290 'regression: primary fragment fiber is not always part of setState ' +
3291 'return path (another case)',
3292 async () => {
3293 // Reproduces a bug where updates inside a suspended tree are dropped
3294 // because the fragment fiber we insert to wrap the hidden children is not
3295 // part of the return path, so it doesn't get marked during setState.
3296 const {useState} = React;
3297 const root = ReactNoop.createRoot();
3298
3299 function Parent() {
3300 return (
3301 <Suspense fallback={<Text text="Loading..." />}>
3302 <Child />
3303 </Suspense>
3304 );
3305 }
3306
3307 let setText;
3308 function Child() {
3309 const [text, _setText] = useState('A');
3310 setText = _setText;
3311 return <AsyncText text={text} />;
3312 }
3313
3314 // Mount an initial tree. Resolve A so that it doesn't suspend.
3315 await seedNextTextCache('A');
3316 await act(() => {
3317 root.render(<Parent />);
3318 });
3319 assertLog(['A']);
3320 // At this point, the setState return path follows current fiber.
3321 expect(root).toMatchRenderedOutput(<span prop="A" />);
3322
3323 // Schedule another update. This will "flip" the alternate pairs.
3324 await resolveText('B');
3325 await act(() => {
3326 setText('B');
3327 });
3328 assertLog(['B']);
3329 // Now the setState return path follows the *alternate* fiber.
3330 expect(root).toMatchRenderedOutput(<span prop="B" />);
3331
3332 // Schedule another update. This time, we'll suspend.
3333 await act(() => {
3334 setText('C');
3335 });
3336 assertLog([
3337 'Suspend! [C]',
3338 'Loading...',
3339 // pre-warming
3340 'Suspend! [C]',
3341 ]);
3342
3343 // Commit. This will insert a fragment fiber to wrap around the component
3344 // that triggered the update.
3345 await act(async () => {
3346 await advanceTimers(250);
3347 });
3348 // The fragment fiber is part of the current tree, but the setState return
3349 // path still follows the alternate path. That means the fragment fiber is
3350 // not part of the return path.
3351 expect(root).toMatchRenderedOutput(
3352 <>
3353 <span hidden={true} prop="B" />
3354 <span prop="Loading..." />
3355 </>,
3356 );
3357
3358 await act(async () => {
3359 // Schedule a normal pri update. This will suspend again.
3360 setText('D');
3361
3362 // And another update at lower priority. This will unblock.
3363 await resolveText('E');
3364 ReactNoop.idleUpdates(() => {
3365 setText('E');
3366 });
3367 });
3368 // Even though the fragment fiber is not part of the return path, we should
3369 // be able to finish rendering.
3370 assertLog([
3371 'Suspend! [D]',
3372 // pre-warming
3373 'Suspend! [D]',
3374 // end pre-warming
3375 'E',
3376 ]);
3377 expect(root).toMatchRenderedOutput(<span prop="E" />);
3378 },
3379 );
3380
3381 // @gate enableLegacyCache
3382 it(
3383 'after showing fallback, should not flip back to primary content until ' +
3384 'the update that suspended finishes',
3385 async () => {
3386 const {useState, useEffect} = React;
3387 const root = ReactNoop.createRoot();
3388
3389 let setOuterText;
3390 function Parent({step}) {
3391 const [text, _setText] = useState('A');
3392 setOuterText = _setText;
3393 return (
3394 <>
3395 <Text text={'Outer text: ' + text} />
3396 <Text text={'Outer step: ' + step} />
3397 <Suspense fallback={<Text text="Loading..." />}>
3398 <Child step={step} outerText={text} />
3399 </Suspense>
3400 </>
3401 );
3402 }
3403
3404 let setInnerText;
3405 function Child({step, outerText}) {
3406 const [text, _setText] = useState('A');
3407 setInnerText = _setText;
3408
3409 // This will log if the component commits in an inconsistent state
3410 useEffect(() => {
3411 if (text === outerText) {
3412 Scheduler.log('Commit Child');
3413 } else {
3414 Scheduler.log('FIXME: Texts are inconsistent (tearing)');
3415 }
3416 }, [text, outerText]);
3417
3418 return (
3419 <>
3420 <AsyncText text={'Inner text: ' + text} />
3421 <Text text={'Inner step: ' + step} />
3422 </>
3423 );
3424 }
3425
3426 // These always update simultaneously. They must be consistent.
3427 function setText(text) {
3428 setOuterText(text);
3429 setInnerText(text);
3430 }
3431
3432 // Mount an initial tree. Resolve A so that it doesn't suspend.
3433 await seedNextTextCache('Inner text: A');
3434 await act(() => {
3435 root.render(<Parent step={0} />);
3436 });
3437 assertLog([
3438 'Outer text: A',
3439 'Outer step: 0',
3440 'Inner text: A',
3441 'Inner step: 0',
3442 'Commit Child',
3443 ]);
3444 expect(root).toMatchRenderedOutput(
3445 <>
3446 <span prop="Outer text: A" />
3447 <span prop="Outer step: 0" />
3448 <span prop="Inner text: A" />
3449 <span prop="Inner step: 0" />
3450 </>,
3451 );
3452
3453 // Update. This causes the inner component to suspend.
3454 await act(() => {
3455 setText('B');
3456 });
3457 assertLog([
3458 'Outer text: B',
3459 'Outer step: 0',
3460 'Suspend! [Inner text: B]',
3461 'Loading...',
3462 // pre-warming
3463 'Suspend! [Inner text: B]',
3464 'Inner step: 0',
3465 ]);
3466 // Commit the placeholder
3467 await advanceTimers(250);
3468 expect(root).toMatchRenderedOutput(
3469 <>
3470 <span prop="Outer text: B" />
3471 <span prop="Outer step: 0" />
3472 <span hidden={true} prop="Inner text: A" />
3473 <span hidden={true} prop="Inner step: 0" />
3474 <span prop="Loading..." />
3475 </>,
3476 );
3477
3478 // Schedule a high pri update on the parent.
3479 await act(() => {
3480 ReactNoop.discreteUpdates(() => {
3481 root.render(<Parent step={1} />);
3482 });
3483 });
3484
3485 // Only the outer part can update. The inner part should still show a
3486 // fallback because we haven't finished loading B yet. Otherwise, the
3487 // inner text would be inconsistent with the outer text.
3488 assertLog([
3489 'Outer text: B',
3490 'Outer step: 1',
3491 'Suspend! [Inner text: B]',
3492 'Loading...',
3493 // pre-warming
3494 'Suspend! [Inner text: B]',
3495 'Inner step: 1',
3496 ]);
3497 expect(root).toMatchRenderedOutput(
3498 <>
3499 <span prop="Outer text: B" />
3500 <span prop="Outer step: 1" />
3501 <span hidden={true} prop="Inner text: A" />
3502 <span hidden={true} prop="Inner step: 0" />
3503 <span prop="Loading..." />
3504 </>,
3505 );
3506
3507 // Now finish resolving the inner text
3508 await act(async () => {
3509 await resolveText('Inner text: B');
3510 });
3511 assertLog(['Inner text: B', 'Inner step: 1', 'Commit Child']);
3512 expect(root).toMatchRenderedOutput(
3513 <>
3514 <span prop="Outer text: B" />
3515 <span prop="Outer step: 1" />
3516 <span prop="Inner text: B" />
3517 <span prop="Inner step: 1" />
3518 </>,
3519 );
3520 },
3521 );
3522
3523 // @gate enableLegacyCache
3524 it('a high pri update can unhide a boundary that suspended at a different level', async () => {
3525 const {useState, useEffect} = React;
3526 const root = ReactNoop.createRoot();
3527
3528 let setOuterText;
3529 function Parent({step}) {
3530 const [text, _setText] = useState('A');
3531 setOuterText = _setText;
3532 return (
3533 <>
3534 <Text text={'Outer: ' + text + step} />
3535 <Suspense fallback={<Text text="Loading..." />}>
3536 <Child step={step} outerText={text} />
3537 </Suspense>
3538 </>
3539 );
3540 }
3541
3542 let setInnerText;
3543 function Child({step, outerText}) {
3544 const [text, _setText] = useState('A');
3545 setInnerText = _setText;
3546
3547 // This will log if the component commits in an inconsistent state
3548 useEffect(() => {
3549 if (text === outerText) {
3550 Scheduler.log('Commit Child');
3551 } else {
3552 Scheduler.log('FIXME: Texts are inconsistent (tearing)');
3553 }
3554 }, [text, outerText]);
3555
3556 return (
3557 <>
3558 <AsyncText text={'Inner: ' + text + step} />
3559 </>
3560 );
3561 }
3562
3563 // These always update simultaneously. They must be consistent.
3564 function setText(text) {
3565 setOuterText(text);
3566 setInnerText(text);
3567 }
3568
3569 // Mount an initial tree. Resolve A so that it doesn't suspend.
3570 await seedNextTextCache('Inner: A0');
3571 await act(() => {
3572 root.render(<Parent step={0} />);
3573 });
3574 assertLog(['Outer: A0', 'Inner: A0', 'Commit Child']);
3575 expect(root).toMatchRenderedOutput(
3576 <>
3577 <span prop="Outer: A0" />
3578 <span prop="Inner: A0" />
3579 </>,
3580 );
3581
3582 // Update. This causes the inner component to suspend.
3583 await act(() => {
3584 setText('B');
3585 });
3586 assertLog([
3587 'Outer: B0',
3588 'Suspend! [Inner: B0]',
3589 'Loading...',
3590 // pre-warming
3591 'Suspend! [Inner: B0]',
3592 ]);
3593 // Commit the placeholder
3594 await advanceTimers(250);
3595 expect(root).toMatchRenderedOutput(
3596 <>
3597 <span prop="Outer: B0" />
3598 <span hidden={true} prop="Inner: A0" />
3599 <span prop="Loading..." />
3600 </>,
3601 );
3602
3603 // Schedule a high pri update on the parent. This will unblock the content.
3604 await resolveText('Inner: B1');
3605 await act(() => {
3606 ReactNoop.discreteUpdates(() => {
3607 root.render(<Parent step={1} />);
3608 });
3609 });
3610
3611 assertLog(['Outer: B1', 'Inner: B1', 'Commit Child']);
3612 expect(root).toMatchRenderedOutput(
3613 <>
3614 <span prop="Outer: B1" />
3615 <span prop="Inner: B1" />
3616 </>,
3617 );
3618 });
3619
3620 // @gate enableLegacyCache
3621 it('regression: ping at high priority causes update to be dropped', async () => {
3622 const {useState, useTransition} = React;
3623
3624 let setTextA;
3625 function A() {
3626 const [textA, _setTextA] = useState('A');
3627 setTextA = _setTextA;
3628 return (
3629 <Suspense fallback={<Text text="Loading..." />}>
3630 <AsyncText text={textA} />
3631 </Suspense>
3632 );
3633 }
3634
3635 let setTextB;
3636 let startTransitionFromB;
3637 function B() {
3638 const [textB, _setTextB] = useState('B');
3639 // eslint-disable-next-line no-unused-vars
3640 const [_, _startTransition] = useTransition();
3641 startTransitionFromB = _startTransition;
3642 setTextB = _setTextB;
3643 return (
3644 <Suspense fallback={<Text text="Loading..." />}>
3645 <AsyncText text={textB} />
3646 </Suspense>
3647 );
3648 }
3649
3650 function App() {
3651 return (
3652 <>
3653 <A />
3654 <B />
3655 </>
3656 );
3657 }
3658
3659 const root = ReactNoop.createRoot();
3660 await act(async () => {
3661 await seedNextTextCache('A');
3662 await seedNextTextCache('B');
3663 root.render(<App />);
3664 });
3665 assertLog(['A', 'B']);
3666 expect(root).toMatchRenderedOutput(
3667 <>
3668 <span prop="A" />
3669 <span prop="B" />
3670 </>,
3671 );
3672
3673 await act(async () => {
3674 // Triggers suspense at normal pri
3675 setTextA('A1');
3676 // Triggers in an unrelated tree at a different pri
3677 startTransitionFromB(() => {
3678 // Update A again so that it doesn't suspend on A1. That way we can ping
3679 // the A1 update without also pinging this one. This is a workaround
3680 // because there's currently no way to render at a lower priority (B2)
3681 // without including all updates at higher priority (A1).
3682 setTextA('A2');
3683 setTextB('B2');
3684 });
3685
3686 await waitFor([
3687 'Suspend! [A1]',
3688 'Loading...',
3689 'B',
3690 'Suspend! [A2]',
3691 'Loading...',
3692 'Suspend! [B2]',
3693 'Loading...',
3694 ]);
3695 expect(root).toMatchRenderedOutput(
3696 <>
3697 <span hidden={true} prop="A" />
3698 <span prop="Loading..." />
3699 <span prop="B" />
3700 </>,
3701 );
3702
3703 await resolveText('A1');
3704 await waitFor(['A1']);
3705 });
3706 assertLog(['Suspend! [A2]', 'Loading...', 'Suspend! [B2]', 'Loading...']);
3707 expect(root).toMatchRenderedOutput(
3708 <>
3709 <span prop="A1" />
3710 <span prop="B" />
3711 </>,
3712 );
3713
3714 await act(async () => {
3715 await resolveText('A2');
3716 await resolveText('B2');
3717 });
3718 assertLog(['A2', 'B2']);
3719 expect(root).toMatchRenderedOutput(
3720 <>
3721 <span prop="A2" />
3722 <span prop="B2" />
3723 </>,
3724 );
3725 });
3726
3727 // Regression: https://github.com/facebook/react/issues/18486
3728 // @gate enableLegacyCache
3729 it('does not get stuck in pending state with render phase updates', async () => {
3730 let setTextWithShortTransition;
3731 let setTextWithLongTransition;
3732
3733 function App() {
3734 const [isPending1, startShortTransition] = React.useTransition();
3735 const [isPending2, startLongTransition] = React.useTransition();
3736 const isPending = isPending1 || isPending2;
3737 const [text, setText] = React.useState('');
3738 const [mirror, setMirror] = React.useState('');
3739
3740 if (text !== mirror) {
3741 // Render phase update was needed to repro the bug.
3742 setMirror(text);
3743 }
3744
3745 setTextWithShortTransition = value => {
3746 startShortTransition(() => {
3747 setText(value);
3748 });
3749 };
3750 setTextWithLongTransition = value => {
3751 startLongTransition(() => {
3752 setText(value);
3753 });
3754 };
3755
3756 return (
3757 <>
3758 {isPending ? <Text text="Pending..." /> : null}
3759 {text !== '' ? <AsyncText text={text} /> : <Text text={text} />}
3760 </>
3761 );
3762 }
3763
3764 function Root() {
3765 return (
3766 <Suspense fallback={<Text text="Loading..." />}>
3767 <App />
3768 </Suspense>
3769 );
3770 }
3771
3772 const root = ReactNoop.createRoot();
3773 await act(() => {
3774 root.render(<Root />);
3775 });
3776 assertLog(['']);
3777 expect(root).toMatchRenderedOutput(<span prop="" />);
3778
3779 // Update to "a". That will suspend.
3780 await act(async () => {
3781 setTextWithShortTransition('a');
3782 await waitForAll(['Pending...', '', 'Suspend! [a]', 'Loading...']);
3783 });
3784 assertLog([]);
3785 expect(root).toMatchRenderedOutput(
3786 <>
3787 <span prop="Pending..." />
3788 <span prop="" />
3789 </>,
3790 );
3791
3792 // Update to "b". That will suspend, too.
3793 await act(async () => {
3794 setTextWithLongTransition('b');
3795 await waitForAll([
3796 // Neither is resolved yet.
3797 'Pending...',
3798 '',
3799 'Suspend! [b]',
3800 'Loading...',
3801 ]);
3802 });
3803 assertLog([]);
3804 expect(root).toMatchRenderedOutput(
3805 <>
3806 <span prop="Pending..." />
3807 <span prop="" />
3808 </>,
3809 );
3810
3811 // Resolve "a". But "b" is still pending.
3812 await act(async () => {
3813 await resolveText('a');
3814
3815 await waitForAll(['Suspend! [b]', 'Loading...']);
3816 expect(root).toMatchRenderedOutput(
3817 <>
3818 <span prop="Pending..." />
3819 <span prop="" />
3820 </>,
3821 );
3822
3823 // Resolve "b". This should remove the pending state.
3824 await act(async () => {
3825 await resolveText('b');
3826 });
3827 assertLog(['b']);
3828 // The bug was that the pending state got stuck forever.
3829 expect(root).toMatchRenderedOutput(<span prop="b" />);
3830 });
3831 });
3832
3833 // @gate enableLegacyCache
3834 it('retries have lower priority than normal updates', async () => {
3835 const {useState} = React;
3836
3837 let setText;
3838 function UpdatingText() {
3839 const [text, _setText] = useState('A');
3840 setText = _setText;
3841 return <Text text={text} />;
3842 }
3843
3844 const root = ReactNoop.createRoot();
3845 await act(() => {
3846 root.render(
3847 <>
3848 <UpdatingText />
3849 <Suspense fallback={<Text text="Loading..." />}>
3850 <AsyncText text="Async" />
3851 </Suspense>
3852 </>,
3853 );
3854 });
3855 assertLog([
3856 'A',
3857 'Suspend! [Async]',
3858 'Loading...',
3859 // pre-warming
3860 'Suspend! [Async]',
3861 ]);
3862 expect(root).toMatchRenderedOutput(
3863 <>
3864 <span prop="A" />
3865 <span prop="Loading..." />
3866 </>,
3867 );
3868
3869 await act(async () => {
3870 // Resolve the promise. This will trigger a retry.
3871 await resolveText('Async');
3872 // Before the retry happens, schedule a new update.
3873 setText('B');
3874
3875 // The update should be allowed to finish before the retry is attempted.
3876 await waitForPaint(['B']);
3877 expect(root).toMatchRenderedOutput(
3878 <>
3879 <span prop="B" />
3880 <span prop="Loading..." />
3881 </>,
3882 );
3883 });
3884 // Then do the retry.
3885 assertLog(['Async']);
3886 expect(root).toMatchRenderedOutput(
3887 <>
3888 <span prop="B" />
3889 <span prop="Async" />
3890 </>,
3891 );
3892 });
3893
3894 // @gate enableLegacyCache
3895 it('should fire effect clean-up when deleting suspended tree', async () => {
3896 const {useEffect} = React;
3897
3898 function App({show}) {
3899 return (
3900 <Suspense fallback={<Text text="Loading..." />}>
3901 <Child />
3902 {show && <AsyncText text="Async" />}
3903 </Suspense>
3904 );
3905 }
3906
3907 function Child() {
3908 useEffect(() => {
3909 Scheduler.log('Mount Child');
3910 return () => {
3911 Scheduler.log('Unmount Child');
3912 };
3913 }, []);
3914 return <span prop="Child" />;
3915 }
3916
3917 const root = ReactNoop.createRoot();
3918
3919 await act(() => {
3920 root.render(<App show={false} />);
3921 });
3922 assertLog(['Mount Child']);
3923 expect(root).toMatchRenderedOutput(<span prop="Child" />);
3924
3925 await act(() => {
3926 root.render(<App show={true} />);
3927 });
3928 assertLog([
3929 'Suspend! [Async]',
3930 'Loading...',
3931 // pre-warming
3932 'Suspend! [Async]',
3933 ]);
3934 expect(root).toMatchRenderedOutput(
3935 <>
3936 <span hidden={true} prop="Child" />
3937 <span prop="Loading..." />
3938 </>,
3939 );
3940
3941 await act(() => {
3942 root.render(null);
3943 });
3944 assertLog(['Unmount Child']);
3945 });
3946
3947 // @gate enableLegacyCache && !disableLegacyMode
3948 it('should fire effect clean-up when deleting suspended tree (legacy)', async () => {
3949 const {useEffect} = React;
3950
3951 function App({show}) {
3952 return (
3953 <Suspense fallback={<Text text="Loading..." />}>
3954 <Child />
3955 {show && <AsyncText text="Async" />}
3956 </Suspense>
3957 );
3958 }
3959
3960 function Child() {
3961 useEffect(() => {
3962 Scheduler.log('Mount Child');
3963 return () => {
3964 Scheduler.log('Unmount Child');
3965 };
3966 }, []);
3967 return <span prop="Child" />;
3968 }
3969
3970 const root = ReactNoop.createLegacyRoot();
3971
3972 await act(() => {
3973 root.render(<App show={false} />);
3974 });
3975 assertLog(['Mount Child']);
3976 expect(root).toMatchRenderedOutput(<span prop="Child" />);
3977
3978 await act(() => {
3979 root.render(<App show={true} />);
3980 });
3981 assertLog(['Suspend! [Async]', 'Loading...']);
3982 expect(root).toMatchRenderedOutput(
3983 <>
3984 <span hidden={true} prop="Child" />
3985 <span prop="Loading..." />
3986 </>,
3987 );
3988
3989 await act(() => {
3990 root.render(null);
3991 });
3992 assertLog(['Unmount Child']);
3993 });
3994
3995 // @gate enableLegacyCache
3996 it(
3997 'regression test: pinging synchronously within the render phase ' +
3998 'does not unwind the stack',
3999 async () => {
4000 // This is a regression test that reproduces a very specific scenario that
4001 // used to cause a crash.
4002 const thenable = {
4003 then(resolve) {
4004 resolve('B');
4005 },
4006 status: 'pending',
4007 };
4008
4009 function ImmediatelyPings() {
4010 if (thenable.status === 'pending') {
4011 thenable.status = 'fulfilled';
4012 throw thenable;
4013 }
4014 return <Text text="B" />;
4015 }
4016
4017 function App({showMore}) {
4018 return (
4019 <div>
4020 <Suspense fallback={<Text text="Loading A..." />}>
4021 {showMore ? (
4022 <>
4023 <AsyncText text="A" />
4024 </>
4025 ) : null}
4026 </Suspense>
4027 {showMore ? (
4028 <Suspense fallback={<Text text="Loading B..." />}>
4029 <ImmediatelyPings />
4030 </Suspense>
4031 ) : null}
4032 </div>
4033 );
4034 }
4035
4036 // Initial render. This mounts a Suspense boundary, so that in the next
4037 // update we can trigger a "suspend with delay" scenario.
4038 const root = ReactNoop.createRoot();
4039 await act(() => {
4040 root.render(<App showMore={false} />);
4041 });
4042 assertLog([]);
4043 expect(root).toMatchRenderedOutput(<div />);
4044
4045 // Update. This will cause two separate trees to suspend. The first tree
4046 // will be inside an already mounted Suspense boundary, so it will trigger
4047 // a "suspend with delay". The second tree will be a new Suspense
4048 // boundary, but the thenable that is thrown will immediately call its
4049 // ping listener.
4050 //
4051 // Before the bug was fixed, this would lead to a `prepareFreshStack` call
4052 // that unwinds the work-in-progress stack. When that code was written, it
4053 // was expected that pings always happen from an asynchronous task (or
4054 // microtask). But this test shows an example where that's not the case.
4055 //
4056 // The fix was to check if we're in the render phase before calling
4057 // `prepareFreshStack`. The synchronous ping is instead recorded so the
4058 // lane can be retried.
4059 await act(() => {
4060 startTransition(() => root.render(<App showMore={true} />));
4061 });
4062 assertLog([
4063 'Suspend! [A]',
4064 'Loading A...',
4065 'Loading B...',
4066 // The synchronous ping was recorded, so B retries and renders.
4067 'Suspend! [A]',
4068 'Loading A...',
4069 'B',
4070 ]);
4071 expect(root).toMatchRenderedOutput(<div />);
4072 },
4073 );
4074
4075 // @gate enableLegacyCache && enableRetryLaneExpiration
4076 it('recurring updates in siblings should not block expensive content in suspense boundary from committing', async () => {
4077 const {useState} = React;
4078
4079 let setText;
4080 function UpdatingText() {
4081 const [text, _setText] = useState('1');
4082 setText = _setText;
4083 return <Text text={text} />;
4084 }
4085
4086 function ExpensiveText({text, ms}) {
4087 Scheduler.log(text);
4088 Scheduler.unstable_advanceTime(ms);
4089 return <span prop={text} />;
4090 }
4091
4092 function App() {
4093 return (
4094 <>
4095 <UpdatingText />
4096 <Suspense fallback={<Text text="Loading..." />}>
4097 <AsyncText text="Async" />
4098 <ExpensiveText text="A" ms={1000} />
4099 <ExpensiveText text="B" ms={3999} />
4100 <ExpensiveText text="C" ms={100000} />
4101 </Suspense>
4102 </>
4103 );
4104 }
4105
4106 const root = ReactNoop.createRoot();
4107 root.render(<App />);
4108 await waitForAll([
4109 '1',
4110 'Suspend! [Async]',
4111 'Loading...',
4112 // pre-warming
4113 'Suspend! [Async]',
4114 'A',
4115 'B',
4116 'C',
4117 ]);
4118 expect(root).toMatchRenderedOutput(
4119 <>
4120 <span prop="1" />
4121 <span prop="Loading..." />
4122 </>,
4123 );
4124
4125 await resolveText('Async');
4126 expect(root).toMatchRenderedOutput(
4127 <>
4128 <span prop="1" />
4129 <span prop="Loading..." />
4130 </>,
4131 );
4132
4133 await waitFor(['Async', 'A', 'B']);
4134 ReactNoop.expire(100000);
4135 await advanceTimers(100000);
4136 setText('2');
4137 await waitForPaint(['2']);
4138
4139 await waitForMicrotasks();
4140 Scheduler.unstable_flushNumberOfYields(1);
4141 assertLog(['Async', 'A', 'B', 'C']);
4142
4143 expect(root).toMatchRenderedOutput(
4144 <>
4145 <span prop="2" />
4146 <span prop="Async" />
4147 <span prop="A" />
4148 <span prop="B" />
4149 <span prop="C" />
4150 </>,
4151 );
4152 });
4153
4154 it('can rerender after resolving a promise', async () => {
4155 const promise = Promise.resolve(null);
4156 const root = ReactNoop.createRoot();
4157
4158 await act(() => {
4159 startTransition(() => {
4160 root.render(<Suspense>{promise}</Suspense>);
4161 });
4162 });
4163
4164 assertLog([]);
4165 expect(root).toMatchRenderedOutput(null);
4166
4167 await act(() => {
4168 startTransition(() => {
4169 root.render(
4170 <Suspense>
4171 <div />
4172 </Suspense>,
4173 );
4174 });
4175 });
4176 });
4177 });