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