main
js 3,104 lines 83.7 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 *
7 * @emails react-core
8 * @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 useSyncExternalStore;
22 let act;
23 let IdleEventPriority;
24 let waitForAll;
25 let waitFor;
26 let assertLog;
27 let assertConsoleErrorDev;
28
29 function normalizeError(msg) {
30 // Take the first sentence to make it easier to assert on.
31 const idx = msg.indexOf('.');
32 if (idx > -1) {
33 return msg.slice(0, idx + 1);
34 }
35 return msg;
36 }
37
38 function dispatchMouseEvent(to, from) {
39 if (!to) {
40 to = null;
41 }
42 if (!from) {
43 from = null;
44 }
45 if (from) {
46 const mouseOutEvent = document.createEvent('MouseEvents');
47 mouseOutEvent.initMouseEvent(
48 'mouseout',
49 true,
50 true,
51 window,
52 0,
53 50,
54 50,
55 50,
56 50,
57 false,
58 false,
59 false,
60 false,
61 0,
62 to,
63 );
64 from.dispatchEvent(mouseOutEvent);
65 }
66 if (to) {
67 const mouseOverEvent = document.createEvent('MouseEvents');
68 mouseOverEvent.initMouseEvent(
69 'mouseover',
70 true,
71 true,
72 window,
73 0,
74 50,
75 50,
76 50,
77 50,
78 false,
79 false,
80 false,
81 false,
82 0,
83 from,
84 );
85 to.dispatchEvent(mouseOverEvent);
86 }
87 }
88
89 describe('ReactDOMServerPartialHydrationActivity', () => {
90 beforeEach(() => {
91 jest.resetModules();
92
93 ReactFeatureFlags = require('shared/ReactFeatureFlags');
94 ReactFeatureFlags.enableSuspenseCallback = true;
95 ReactFeatureFlags.enableCreateEventHandleAPI = true;
96
97 React = require('react');
98 ReactDOM = require('react-dom');
99 ReactDOMClient = require('react-dom/client');
100 act = require('internal-test-utils').act;
101 ReactDOMServer = require('react-dom/server');
102 Scheduler = require('scheduler');
103 Activity = React.Activity;
104 Suspense = React.Suspense;
105 useSyncExternalStore = React.useSyncExternalStore;
106
107 const InternalTestUtils = require('internal-test-utils');
108 waitForAll = InternalTestUtils.waitForAll;
109 assertLog = InternalTestUtils.assertLog;
110 waitFor = InternalTestUtils.waitFor;
111 assertConsoleErrorDev = InternalTestUtils.assertConsoleErrorDev;
112
113 IdleEventPriority = require('react-reconciler/constants').IdleEventPriority;
114 });
115
116 it('hydrates a parent even if a child Activity boundary is blocked', async () => {
117 let suspend = false;
118 let resolve;
119 const promise = new Promise(resolvePromise => (resolve = resolvePromise));
120 const ref = React.createRef();
121
122 function Child() {
123 if (suspend) {
124 throw promise;
125 } else {
126 return 'Hello';
127 }
128 }
129
130 function App() {
131 return (
132 <div>
133 <Activity>
134 <span ref={ref}>
135 <Child />
136 </span>
137 </Activity>
138 </div>
139 );
140 }
141
142 // First we render the final HTML. With the streaming renderer
143 // this may have suspense points on the server but here we want
144 // to test the completed HTML. Don't suspend on the server.
145 suspend = false;
146 const finalHTML = ReactDOMServer.renderToString(<App />);
147
148 const container = document.createElement('div');
149 container.innerHTML = finalHTML;
150
151 const span = container.getElementsByTagName('span')[0];
152
153 // On the client we don't have all data yet but we want to start
154 // hydrating anyway.
155 suspend = true;
156 ReactDOMClient.hydrateRoot(container, <App />);
157 await waitForAll([]);
158
159 expect(ref.current).toBe(null);
160
161 // Resolving the promise should continue hydration
162 suspend = false;
163 resolve();
164 await promise;
165 await waitForAll([]);
166
167 // We should now have hydrated with a ref on the existing span.
168 expect(ref.current).toBe(span);
169 });
170
171 it('can hydrate siblings of a suspended component without errors', async () => {
172 let suspend = false;
173 let resolve;
174 const promise = new Promise(resolvePromise => (resolve = resolvePromise));
175 function Child() {
176 if (suspend) {
177 throw promise;
178 } else {
179 return 'Hello';
180 }
181 }
182
183 function App() {
184 return (
185 <Activity>
186 <Child />
187 <Activity>
188 <div>Hello</div>
189 </Activity>
190 </Activity>
191 );
192 }
193
194 // First we render the final HTML. With the streaming renderer
195 // this may have suspense points on the server but here we want
196 // to test the completed HTML. Don't suspend on the server.
197 suspend = false;
198 const finalHTML = ReactDOMServer.renderToString(<App />);
199
200 const container = document.createElement('div');
201 container.innerHTML = finalHTML;
202 expect(container.textContent).toBe('HelloHello');
203
204 // On the client we don't have all data yet but we want to start
205 // hydrating anyway.
206 suspend = true;
207 ReactDOMClient.hydrateRoot(container, <App />, {
208 onRecoverableError(error) {
209 Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
210 if (error.cause) {
211 Scheduler.log('Cause: ' + normalizeError(error.cause.message));
212 }
213 },
214 });
215 await waitForAll([]);
216
217 // Expect the server-generated HTML to stay intact.
218 expect(container.textContent).toBe('HelloHello');
219
220 // Resolving the promise should continue hydration
221 suspend = false;
222 resolve();
223 await promise;
224 await waitForAll([]);
225 // Hydration should not change anything.
226 expect(container.textContent).toBe('HelloHello');
227 });
228
229 it('falls back to client rendering boundary on mismatch', async () => {
230 let client = false;
231 let suspend = false;
232 let resolve;
233 const promise = new Promise(resolvePromise => {
234 resolve = () => {
235 suspend = false;
236 resolvePromise();
237 };
238 });
239 function Child() {
240 if (suspend) {
241 Scheduler.log('Suspend');
242 throw promise;
243 } else {
244 Scheduler.log('Hello');
245 return 'Hello';
246 }
247 }
248 function Component({shouldMismatch}) {
249 Scheduler.log('Component');
250 if (shouldMismatch && client) {
251 return <article>Mismatch</article>;
252 }
253 return <div>Component</div>;
254 }
255 function App() {
256 return (
257 <Activity>
258 <Child />
259 <Component />
260 <Component />
261 <Component />
262 <Component shouldMismatch={true} />
263 </Activity>
264 );
265 }
266 const finalHTML = ReactDOMServer.renderToString(<App />);
267 const container = document.createElement('section');
268 container.innerHTML = finalHTML;
269 assertLog(['Hello', 'Component', 'Component', 'Component', 'Component']);
270
271 expect(container.innerHTML).toBe(
272 '<!--&-->Hello<div>Component</div><div>Component</div><div>Component</div><div>Component</div><!--/&-->',
273 );
274
275 suspend = true;
276 client = true;
277
278 ReactDOMClient.hydrateRoot(container, <App />, {
279 onRecoverableError(error) {
280 Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
281 if (error.cause) {
282 Scheduler.log('Cause: ' + normalizeError(error.cause.message));
283 }
284 },
285 });
286 await waitForAll(['Suspend']);
287 jest.runAllTimers();
288
289 // Unchanged
290 expect(container.innerHTML).toBe(
291 '<!--&-->Hello<div>Component</div><div>Component</div><div>Component</div><div>Component</div><!--/&-->',
292 );
293
294 suspend = false;
295 resolve();
296 await promise;
297 await waitForAll([
298 // first pass, mismatches at end
299 'Hello',
300 'Component',
301 'Component',
302 'Component',
303 'Component',
304
305 // second pass as client render
306 'Hello',
307 'Component',
308 'Component',
309 'Component',
310 'Component',
311 // Hydration mismatch is logged
312 "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
313 ]);
314
315 // Client rendered - suspense comment nodes removed
316 expect(container.innerHTML).toBe(
317 'Hello<div>Component</div><div>Component</div><div>Component</div><article>Mismatch</article>',
318 );
319 });
320
321 it('handles if mismatch is after suspending', async () => {
322 let client = false;
323 let suspend = false;
324 let resolve;
325 const promise = new Promise(resolvePromise => {
326 resolve = () => {
327 suspend = false;
328 resolvePromise();
329 };
330 });
331 function Child() {
332 if (suspend) {
333 Scheduler.log('Suspend');
334 throw promise;
335 } else {
336 Scheduler.log('Hello');
337 return 'Hello';
338 }
339 }
340 function Component({shouldMismatch}) {
341 Scheduler.log('Component');
342 if (shouldMismatch && client) {
343 return <article>Mismatch</article>;
344 }
345 return <div>Component</div>;
346 }
347 function App() {
348 return (
349 <Activity>
350 <Child />
351 <Component shouldMismatch={true} />
352 </Activity>
353 );
354 }
355 const finalHTML = ReactDOMServer.renderToString(<App />);
356 const container = document.createElement('section');
357 container.innerHTML = finalHTML;
358 assertLog(['Hello', 'Component']);
359
360 expect(container.innerHTML).toBe(
361 '<!--&-->Hello<div>Component</div><!--/&-->',
362 );
363
364 suspend = true;
365 client = true;
366
367 ReactDOMClient.hydrateRoot(container, <App />, {
368 onRecoverableError(error) {
369 Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
370 if (error.cause) {
371 Scheduler.log('Cause: ' + normalizeError(error.cause.message));
372 }
373 },
374 });
375 await waitForAll(['Suspend']);
376 jest.runAllTimers();
377
378 // !! Unchanged, continue showing server content while suspended.
379 expect(container.innerHTML).toBe(
380 '<!--&-->Hello<div>Component</div><!--/&-->',
381 );
382
383 suspend = false;
384 resolve();
385 await promise;
386 await waitForAll([
387 // first pass, mismatches at end
388 'Hello',
389 'Component',
390 'Hello',
391 'Component',
392 "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
393 ]);
394 jest.runAllTimers();
395
396 // Client rendered - suspense comment nodes removed.
397 expect(container.innerHTML).toBe('Hello<article>Mismatch</article>');
398 });
399
400 it('handles if mismatch is child of suspended component', async () => {
401 let client = false;
402 let suspend = false;
403 let resolve;
404 const promise = new Promise(resolvePromise => {
405 resolve = () => {
406 suspend = false;
407 resolvePromise();
408 };
409 });
410 function Child({children}) {
411 if (suspend) {
412 Scheduler.log('Suspend');
413 throw promise;
414 } else {
415 Scheduler.log('Hello');
416 return <div>{children}</div>;
417 }
418 }
419 function Component({shouldMismatch}) {
420 Scheduler.log('Component');
421 if (shouldMismatch && client) {
422 return <article>Mismatch</article>;
423 }
424 return <div>Component</div>;
425 }
426 function App() {
427 return (
428 <Activity>
429 <Child>
430 <Component shouldMismatch={true} />
431 </Child>
432 </Activity>
433 );
434 }
435 const finalHTML = ReactDOMServer.renderToString(<App />);
436 const container = document.createElement('section');
437 container.innerHTML = finalHTML;
438 assertLog(['Hello', 'Component']);
439
440 expect(container.innerHTML).toBe(
441 '<!--&--><div><div>Component</div></div><!--/&-->',
442 );
443
444 suspend = true;
445 client = true;
446
447 ReactDOMClient.hydrateRoot(container, <App />, {
448 onRecoverableError(error) {
449 Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
450 if (error.cause) {
451 Scheduler.log('Cause: ' + normalizeError(error.cause.message));
452 }
453 },
454 });
455 await waitForAll(['Suspend']);
456 jest.runAllTimers();
457
458 // !! Unchanged, continue showing server content while suspended.
459 expect(container.innerHTML).toBe(
460 '<!--&--><div><div>Component</div></div><!--/&-->',
461 );
462
463 suspend = false;
464 resolve();
465 await promise;
466 await waitForAll([
467 // first pass, mismatches at end
468 'Hello',
469 'Component',
470 'Hello',
471 'Component',
472 "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
473 ]);
474 jest.runAllTimers();
475
476 // Client rendered - suspense comment nodes removed
477 expect(container.innerHTML).toBe('<div><article>Mismatch</article></div>');
478 });
479
480 it('handles if mismatch is parent and first child suspends', async () => {
481 let client = false;
482 let suspend = false;
483 let resolve;
484 const promise = new Promise(resolvePromise => {
485 resolve = () => {
486 suspend = false;
487 resolvePromise();
488 };
489 });
490 function Child({children}) {
491 if (suspend) {
492 Scheduler.log('Suspend');
493 throw promise;
494 } else {
495 Scheduler.log('Hello');
496 return <div>{children}</div>;
497 }
498 }
499 function Component({shouldMismatch, children}) {
500 Scheduler.log('Component');
501 if (shouldMismatch && client) {
502 return (
503 <div>
504 {children}
505 <article>Mismatch</article>
506 </div>
507 );
508 }
509 return (
510 <div>
511 {children}
512 <div>Component</div>
513 </div>
514 );
515 }
516 function App() {
517 return (
518 <Activity>
519 <Component shouldMismatch={true}>
520 <Child />
521 </Component>
522 </Activity>
523 );
524 }
525 const finalHTML = ReactDOMServer.renderToString(<App />);
526 const container = document.createElement('section');
527 container.innerHTML = finalHTML;
528 assertLog(['Component', 'Hello']);
529
530 expect(container.innerHTML).toBe(
531 '<!--&--><div><div></div><div>Component</div></div><!--/&-->',
532 );
533
534 suspend = true;
535 client = true;
536
537 ReactDOMClient.hydrateRoot(container, <App />, {
538 onRecoverableError(error) {
539 Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
540 if (error.cause) {
541 Scheduler.log('Cause: ' + normalizeError(error.cause.message));
542 }
543 },
544 });
545 await waitForAll(['Component', 'Suspend']);
546 jest.runAllTimers();
547
548 // !! Unchanged, continue showing server content while suspended.
549 expect(container.innerHTML).toBe(
550 '<!--&--><div><div></div><div>Component</div></div><!--/&-->',
551 );
552
553 suspend = false;
554 resolve();
555 await promise;
556 await waitForAll([
557 // first pass, mismatches at end
558 'Component',
559 'Hello',
560 'Component',
561 'Hello',
562 "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
563 ]);
564 jest.runAllTimers();
565
566 // Client rendered - suspense comment nodes removed
567 expect(container.innerHTML).toBe(
568 '<div><div></div><article>Mismatch</article></div>',
569 );
570 });
571
572 it('does show a parent fallback if mismatch is parent and second child suspends', async () => {
573 let client = false;
574 let suspend = false;
575 let resolve;
576 const promise = new Promise(resolvePromise => {
577 resolve = () => {
578 suspend = false;
579 resolvePromise();
580 };
581 });
582 function Child({children}) {
583 if (suspend) {
584 Scheduler.log('Suspend');
585 throw promise;
586 } else {
587 Scheduler.log('Hello');
588 return <div>{children}</div>;
589 }
590 }
591 function Component({shouldMismatch, children}) {
592 Scheduler.log('Component');
593 if (shouldMismatch && client) {
594 return (
595 <div>
596 <article>Mismatch</article>
597 {children}
598 </div>
599 );
600 }
601 return (
602 <div>
603 <div>Component</div>
604 {children}
605 </div>
606 );
607 }
608 function Fallback() {
609 Scheduler.log('Fallback');
610 return 'Loading...';
611 }
612 function App() {
613 return (
614 <Suspense fallback={<Fallback />}>
615 <Activity>
616 <Component shouldMismatch={true}>
617 <Child />
618 </Component>
619 </Activity>
620 </Suspense>
621 );
622 }
623 const finalHTML = ReactDOMServer.renderToString(<App />);
624 const container = document.createElement('section');
625 container.innerHTML = finalHTML;
626 assertLog(['Component', 'Hello']);
627
628 const div = container.getElementsByTagName('div')[0];
629
630 expect(container.innerHTML).toBe(
631 '<!--$--><!--&--><div><div>Component</div><div></div></div><!--/&--><!--/$-->',
632 );
633
634 suspend = true;
635 client = true;
636
637 ReactDOMClient.hydrateRoot(container, <App />, {
638 onRecoverableError(error) {
639 Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
640 if (error.cause) {
641 Scheduler.log('Cause: ' + normalizeError(error.cause.message));
642 }
643 },
644 });
645 await waitForAll(['Component', 'Component', 'Suspend', 'Fallback']);
646 jest.runAllTimers();
647
648 // !! Client switches to suspense fallback. The dehydrated content is still hidden because we never
649 // committed the client rendering.
650 expect(container.innerHTML).toBe(
651 '<!--$--><!--&--><div style="display: none;"><div>Component</div><div></div></div><!--/&--><!--/$-->' +
652 'Loading...',
653 );
654
655 suspend = false;
656 resolve();
657 await promise;
658 if (gate(flags => flags.alwaysThrottleRetries)) {
659 await waitForAll(['Component', 'Component', 'Hello']);
660 } else {
661 await waitForAll([
662 'Component',
663 'Component',
664 'Hello',
665 "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
666 ]);
667 }
668 jest.runAllTimers();
669
670 // Now that we've hit the throttle timeout, we can commit the failed hydration.
671 if (gate(flags => flags.alwaysThrottleRetries)) {
672 assertLog([
673 "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
674 ]);
675 }
676
677 // Client rendered - activity comment nodes removed
678 expect(container.innerHTML).toBe(
679 '<!--$--><!--/$--><div><article>Mismatch</article><div></div></div>',
680 );
681 });
682
683 it('does show a parent fallback if mismatch is in parent element only', async () => {
684 let client = false;
685 let suspend = false;
686 let resolve;
687 const promise = new Promise(resolvePromise => {
688 resolve = () => {
689 suspend = false;
690 resolvePromise();
691 };
692 });
693 function Child({children}) {
694 if (suspend) {
695 Scheduler.log('Suspend');
696 throw promise;
697 } else {
698 Scheduler.log('Hello');
699 return <div>{children}</div>;
700 }
701 }
702 function Component({shouldMismatch, children}) {
703 Scheduler.log('Component');
704 if (shouldMismatch && client) {
705 return <article>{children}</article>;
706 }
707 return <div>{children}</div>;
708 }
709 function Fallback() {
710 Scheduler.log('Fallback');
711 return 'Loading...';
712 }
713 function App() {
714 return (
715 <Suspense fallback={<Fallback />}>
716 <Activity>
717 <Component shouldMismatch={true}>
718 <Child />
719 </Component>
720 </Activity>
721 </Suspense>
722 );
723 }
724 const finalHTML = ReactDOMServer.renderToString(<App />);
725 const container = document.createElement('section');
726 container.innerHTML = finalHTML;
727 assertLog(['Component', 'Hello']);
728
729 expect(container.innerHTML).toBe(
730 '<!--$--><!--&--><div><div></div></div><!--/&--><!--/$-->',
731 );
732
733 suspend = true;
734 client = true;
735
736 ReactDOMClient.hydrateRoot(container, <App />, {
737 onRecoverableError(error) {
738 Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
739 if (error.cause) {
740 Scheduler.log('Cause: ' + normalizeError(error.cause.message));
741 }
742 },
743 });
744 await waitForAll(['Component', 'Component', 'Suspend', 'Fallback']);
745 jest.runAllTimers();
746
747 // !! Client switches to suspense fallback. The dehydrated content is still hidden because we never
748 // committed the client rendering.
749 expect(container.innerHTML).toBe(
750 '<!--$--><!--&--><div style="display: none;"><div></div></div><!--/&--><!--/$-->' +
751 'Loading...',
752 );
753
754 suspend = false;
755 resolve();
756 await promise;
757 if (gate(flags => flags.alwaysThrottleRetries)) {
758 await waitForAll(['Component', 'Component', 'Hello']);
759 } else {
760 await waitForAll([
761 'Component',
762 'Component',
763 'Hello',
764 "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
765 ]);
766 }
767 jest.runAllTimers();
768
769 // Now that we've hit the throttle timeout, we can commit the failed hydration.
770 if (gate(flags => flags.alwaysThrottleRetries)) {
771 assertLog([
772 "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
773 ]);
774 }
775
776 // Client rendered - activity comment nodes removed
777 expect(container.innerHTML).toBe(
778 '<!--$--><!--/$--><article><div></div></article>',
779 );
780 });
781
782 it('does show a parent fallback if mismatch is before suspending', async () => {
783 let client = false;
784 let suspend = false;
785 let resolve;
786 const promise = new Promise(resolvePromise => {
787 resolve = () => {
788 suspend = false;
789 resolvePromise();
790 };
791 });
792 function Child() {
793 if (suspend) {
794 Scheduler.log('Suspend');
795 throw promise;
796 } else {
797 Scheduler.log('Hello');
798 return 'Hello';
799 }
800 }
801 function Component({shouldMismatch}) {
802 Scheduler.log('Component');
803 if (shouldMismatch && client) {
804 return <article>Mismatch</article>;
805 }
806 return <div>Component</div>;
807 }
808 function Fallback() {
809 Scheduler.log('Fallback');
810 return 'Loading...';
811 }
812 function App() {
813 return (
814 <Suspense fallback={<Fallback />}>
815 <Activity>
816 <Component shouldMismatch={true} />
817 <Child />
818 </Activity>
819 </Suspense>
820 );
821 }
822 const finalHTML = ReactDOMServer.renderToString(<App />);
823 const container = document.createElement('section');
824 container.innerHTML = finalHTML;
825 assertLog(['Component', 'Hello']);
826
827 expect(container.innerHTML).toBe(
828 '<!--$--><!--&--><div>Component</div>Hello<!--/&--><!--/$-->',
829 );
830
831 suspend = true;
832 client = true;
833
834 ReactDOMClient.hydrateRoot(container, <App />, {
835 onRecoverableError(error) {
836 Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
837 if (error.cause) {
838 Scheduler.log('Cause: ' + normalizeError(error.cause.message));
839 }
840 },
841 });
842 await waitForAll(['Component', 'Component', 'Suspend', 'Fallback']);
843 jest.runAllTimers();
844
845 // !! Client switches to suspense fallback. The dehydrated content is still hidden because we never
846 // committed the client rendering.
847 expect(container.innerHTML).toBe(
848 '<!--$--><!--&--><div style="display: none;">Component</div><!--/&--><!--/$-->' +
849 'Loading...',
850 );
851
852 suspend = false;
853 resolve();
854 await promise;
855 if (gate(flags => flags.alwaysThrottleRetries)) {
856 await waitForAll(['Component', 'Component', 'Hello']);
857 } else {
858 await waitForAll([
859 'Component',
860 'Component',
861 'Hello',
862 "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
863 ]);
864 }
865 jest.runAllTimers();
866
867 // Now that we've hit the throttle timeout, we can commit the failed hydration.
868 if (gate(flags => flags.alwaysThrottleRetries)) {
869 assertLog([
870 "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
871 ]);
872 }
873
874 // Client rendered - activity comment nodes removed
875 expect(container.innerHTML).toBe(
876 '<!--$--><!--/$--><article>Mismatch</article>Hello',
877 );
878 });
879
880 it('does show a parent fallback if mismatch is before suspending in a child', async () => {
881 let client = false;
882 let suspend = false;
883 let resolve;
884 const promise = new Promise(resolvePromise => {
885 resolve = () => {
886 suspend = false;
887 resolvePromise();
888 };
889 });
890 function Child() {
891 if (suspend) {
892 Scheduler.log('Suspend');
893 throw promise;
894 } else {
895 Scheduler.log('Hello');
896 return 'Hello';
897 }
898 }
899 function Component({shouldMismatch}) {
900 Scheduler.log('Component');
901 if (shouldMismatch && client) {
902 return <article>Mismatch</article>;
903 }
904 return <div>Component</div>;
905 }
906 function Fallback() {
907 Scheduler.log('Fallback');
908 return 'Loading...';
909 }
910 function App() {
911 return (
912 <Suspense fallback={<Fallback />}>
913 <Activity>
914 <Component shouldMismatch={true} />
915 <div>
916 <Child />
917 </div>
918 </Activity>
919 </Suspense>
920 );
921 }
922 const finalHTML = ReactDOMServer.renderToString(<App />);
923 const container = document.createElement('section');
924 container.innerHTML = finalHTML;
925 assertLog(['Component', 'Hello']);
926
927 expect(container.innerHTML).toBe(
928 '<!--$--><!--&--><div>Component</div><div>Hello</div><!--/&--><!--/$-->',
929 );
930
931 suspend = true;
932 client = true;
933
934 ReactDOMClient.hydrateRoot(container, <App />, {
935 onRecoverableError(error) {
936 Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
937 if (error.cause) {
938 Scheduler.log('Cause: ' + normalizeError(error.cause.message));
939 }
940 },
941 });
942 await waitForAll(['Component', 'Component', 'Suspend', 'Fallback']);
943 jest.runAllTimers();
944
945 // !! Client switches to suspense fallback. The dehydrated content is still hidden because we never
946 // committed the client rendering.
947 expect(container.innerHTML).toBe(
948 '<!--$--><!--&--><div style="display: none;">Component</div><div style="display: none;">Hello</div><!--/&--><!--/$-->' +
949 'Loading...',
950 );
951
952 suspend = false;
953 resolve();
954 await promise;
955 if (gate(flags => flags.alwaysThrottleRetries)) {
956 await waitForAll(['Component', 'Component', 'Hello']);
957 } else {
958 await waitForAll([
959 'Component',
960 'Component',
961 'Hello',
962 "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
963 ]);
964 }
965 jest.runAllTimers();
966
967 // Now that we've hit the throttle timeout, we can commit the failed hydration.
968 if (gate(flags => flags.alwaysThrottleRetries)) {
969 assertLog([
970 "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
971 ]);
972 }
973
974 // Client rendered - activity comment nodes removed
975 expect(container.innerHTML).toBe(
976 '<!--$--><!--/$--><article>Mismatch</article><div>Hello</div>',
977 );
978 });
979
980 it('calls the hydration callbacks after hydration or deletion', async () => {
981 let suspend = false;
982 let resolve;
983 const promise = new Promise(resolvePromise => (resolve = resolvePromise));
984 function Child() {
985 if (suspend) {
986 throw promise;
987 } else {
988 return 'Hello';
989 }
990 }
991
992 let suspend2 = false;
993 const promise2 = new Promise(() => {});
994 function Child2({value}) {
995 if (suspend2 && !value) {
996 throw promise2;
997 } else {
998 return 'World';
999 }
1000 }
1001
1002 function App({value}) {
1003 return (
1004 <div>
1005 <Activity>
1006 <Child />
1007 </Activity>
1008 <Activity>
1009 <Child2 value={value} />
1010 </Activity>
1011 </div>
1012 );
1013 }
1014
1015 // First we render the final HTML. With the streaming renderer
1016 // this may have suspense points on the server but here we want
1017 // to test the completed HTML. Don't suspend on the server.
1018 suspend = false;
1019 suspend2 = false;
1020 const finalHTML = ReactDOMServer.renderToString(<App />);
1021
1022 const container = document.createElement('div');
1023 container.innerHTML = finalHTML;
1024
1025 const hydrated = [];
1026 const deleted = [];
1027
1028 // On the client we don't have all data yet but we want to start
1029 // hydrating anyway.
1030 suspend = true;
1031 suspend2 = true;
1032 const root = ReactDOMClient.hydrateRoot(container, <App />, {
1033 onHydrated(node) {
1034 hydrated.push(node);
1035 },
1036 onDeleted(node) {
1037 deleted.push(node);
1038 },
1039 onRecoverableError(error) {
1040 Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
1041 if (error.cause) {
1042 Scheduler.log('Cause: ' + normalizeError(error.cause.message));
1043 }
1044 },
1045 });
1046 await waitForAll([]);
1047
1048 expect(hydrated.length).toBe(0);
1049 expect(deleted.length).toBe(0);
1050
1051 await act(async () => {
1052 // Resolving the promise should continue hydration
1053 suspend = false;
1054 resolve();
1055 await promise;
1056 });
1057
1058 expect(hydrated.length).toBe(1);
1059 expect(deleted.length).toBe(0);
1060
1061 // Performing an update should force it to delete the boundary if
1062 // it could be unsuspended by the update.
1063 await act(() => {
1064 root.render(<App value={true} />);
1065 });
1066
1067 expect(hydrated.length).toBe(1);
1068 expect(deleted.length).toBe(1);
1069 });
1070
1071 it('hydrates an empty activity boundary', async () => {
1072 function App() {
1073 return (
1074 <div>
1075 <Activity />
1076 <div>Sibling</div>
1077 </div>
1078 );
1079 }
1080
1081 const finalHTML = ReactDOMServer.renderToString(<App />);
1082
1083 const container = document.createElement('div');
1084 container.innerHTML = finalHTML;
1085
1086 ReactDOMClient.hydrateRoot(container, <App />);
1087 await waitForAll([]);
1088
1089 expect(container.innerHTML).toContain('<div>Sibling</div>');
1090 });
1091
1092 it('recovers with client render when server rendered additional nodes at suspense root', async () => {
1093 function CheckIfHydrating({children}) {
1094 // This is a trick to check whether we're hydrating or not, since React
1095 // doesn't expose that information currently except
1096 // via useSyncExternalStore.
1097 let serverOrClient = '(unknown)';
1098 useSyncExternalStore(
1099 () => {},
1100 () => {
1101 serverOrClient = 'Client rendered';
1102 return null;
1103 },
1104 () => {
1105 serverOrClient = 'Server rendered';
1106 return null;
1107 },
1108 );
1109 Scheduler.log(serverOrClient);
1110 return null;
1111 }
1112
1113 const ref = React.createRef();
1114 function App({hasB}) {
1115 return (
1116 <div>
1117 <Activity>
1118 <span ref={ref}>A</span>
1119 {hasB ? <span>B</span> : null}
1120 <CheckIfHydrating />
1121 </Activity>
1122 <div>Sibling</div>
1123 </div>
1124 );
1125 }
1126
1127 const finalHTML = ReactDOMServer.renderToString(<App hasB={true} />);
1128 assertLog(['Server rendered']);
1129
1130 const container = document.createElement('div');
1131 container.innerHTML = finalHTML;
1132
1133 const span = container.getElementsByTagName('span')[0];
1134
1135 expect(container.innerHTML).toContain('<span>A</span>');
1136 expect(container.innerHTML).toContain('<span>B</span>');
1137 expect(ref.current).toBe(null);
1138
1139 await act(() => {
1140 ReactDOMClient.hydrateRoot(container, <App hasB={false} />, {
1141 onRecoverableError(error) {
1142 Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
1143 if (error.cause) {
1144 Scheduler.log('Cause: ' + normalizeError(error.cause.message));
1145 }
1146 },
1147 });
1148 });
1149
1150 expect(container.innerHTML).toContain('<span>A</span>');
1151 expect(container.innerHTML).not.toContain('<span>B</span>');
1152
1153 assertLog([
1154 'Server rendered',
1155 'Client rendered',
1156 "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
1157 ]);
1158 expect(ref.current).not.toBe(span);
1159 });
1160
1161 it('recovers with client render when server rendered additional nodes at suspense root after unsuspending', async () => {
1162 const ref = React.createRef();
1163 let shouldSuspend = false;
1164 let resolve;
1165 const promise = new Promise(res => {
1166 resolve = () => {
1167 shouldSuspend = false;
1168 res();
1169 };
1170 });
1171 function Suspender() {
1172 if (shouldSuspend) {
1173 throw promise;
1174 }
1175 return <></>;
1176 }
1177 function App({hasB}) {
1178 return (
1179 <div>
1180 <Activity>
1181 <Suspender />
1182 <span ref={ref}>A</span>
1183 {hasB ? <span>B</span> : null}
1184 </Activity>
1185 <div>Sibling</div>
1186 </div>
1187 );
1188 }
1189 const finalHTML = ReactDOMServer.renderToString(<App hasB={true} />);
1190
1191 const container = document.createElement('div');
1192 container.innerHTML = finalHTML;
1193
1194 const span = container.getElementsByTagName('span')[0];
1195
1196 expect(container.innerHTML).toContain('<span>A</span>');
1197 expect(container.innerHTML).toContain('<span>B</span>');
1198 expect(ref.current).toBe(null);
1199
1200 shouldSuspend = true;
1201 await act(() => {
1202 ReactDOMClient.hydrateRoot(container, <App hasB={false} />, {
1203 onRecoverableError(error) {
1204 Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
1205 if (error.cause) {
1206 Scheduler.log('Cause: ' + normalizeError(error.cause.message));
1207 }
1208 },
1209 });
1210 });
1211
1212 await act(() => {
1213 resolve();
1214 });
1215
1216 assertLog([
1217 "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
1218 ]);
1219
1220 expect(container.innerHTML).toContain('<span>A</span>');
1221 expect(container.innerHTML).not.toContain('<span>B</span>');
1222 expect(ref.current).not.toBe(span);
1223 });
1224
1225 it('recovers with client render when server rendered additional nodes deep inside suspense root', async () => {
1226 const ref = React.createRef();
1227 function App({hasB}) {
1228 return (
1229 <div>
1230 <Activity>
1231 <div>
1232 <span ref={ref}>A</span>
1233 {hasB ? <span>B</span> : null}
1234 </div>
1235 </Activity>
1236 <div>Sibling</div>
1237 </div>
1238 );
1239 }
1240
1241 const finalHTML = ReactDOMServer.renderToString(<App hasB={true} />);
1242
1243 const container = document.createElement('div');
1244 container.innerHTML = finalHTML;
1245
1246 const span = container.getElementsByTagName('span')[0];
1247
1248 expect(container.innerHTML).toContain('<span>A</span>');
1249 expect(container.innerHTML).toContain('<span>B</span>');
1250 expect(ref.current).toBe(null);
1251
1252 await act(() => {
1253 ReactDOMClient.hydrateRoot(container, <App hasB={false} />, {
1254 onRecoverableError(error) {
1255 Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
1256 if (error.cause) {
1257 Scheduler.log('Cause: ' + normalizeError(error.cause.message));
1258 }
1259 },
1260 });
1261 });
1262 assertLog([
1263 "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
1264 ]);
1265
1266 expect(container.innerHTML).toContain('<span>A</span>');
1267 expect(container.innerHTML).not.toContain('<span>B</span>');
1268 expect(ref.current).not.toBe(span);
1269 });
1270
1271 it('calls the onDeleted hydration callback if the parent gets deleted', async () => {
1272 let suspend = false;
1273 const promise = new Promise(() => {});
1274 function Child() {
1275 if (suspend) {
1276 throw promise;
1277 } else {
1278 return 'Hello';
1279 }
1280 }
1281
1282 function App({deleted}) {
1283 if (deleted) {
1284 return null;
1285 }
1286 return (
1287 <div>
1288 <Activity>
1289 <Child />
1290 </Activity>
1291 </div>
1292 );
1293 }
1294
1295 suspend = false;
1296 const finalHTML = ReactDOMServer.renderToString(<App />);
1297
1298 const container = document.createElement('div');
1299 container.innerHTML = finalHTML;
1300
1301 const deleted = [];
1302
1303 // On the client we don't have all data yet but we want to start
1304 // hydrating anyway.
1305 suspend = true;
1306 const root = await act(() => {
1307 return ReactDOMClient.hydrateRoot(container, <App />, {
1308 onDeleted(node) {
1309 deleted.push(node);
1310 },
1311 });
1312 });
1313
1314 expect(deleted.length).toBe(0);
1315
1316 await act(() => {
1317 root.render(<App deleted={true} />);
1318 });
1319
1320 // The callback should have been invoked.
1321 expect(deleted.length).toBe(1);
1322 });
1323
1324 it('can insert siblings before the dehydrated boundary', async () => {
1325 let suspend = false;
1326 const promise = new Promise(() => {});
1327 let showSibling;
1328
1329 function Child() {
1330 if (suspend) {
1331 throw promise;
1332 } else {
1333 return 'Second';
1334 }
1335 }
1336
1337 function Sibling() {
1338 const [visible, setVisibilty] = React.useState(false);
1339 showSibling = () => setVisibilty(true);
1340 if (visible) {
1341 return <div>First</div>;
1342 }
1343 return null;
1344 }
1345
1346 function App() {
1347 return (
1348 <div>
1349 <Sibling />
1350 <Activity>
1351 <span>
1352 <Child />
1353 </span>
1354 </Activity>
1355 </div>
1356 );
1357 }
1358
1359 suspend = false;
1360 const finalHTML = ReactDOMServer.renderToString(<App />);
1361 const container = document.createElement('div');
1362 container.innerHTML = finalHTML;
1363
1364 // On the client we don't have all data yet but we want to start
1365 // hydrating anyway.
1366 suspend = true;
1367
1368 await act(() => {
1369 ReactDOMClient.hydrateRoot(container, <App />);
1370 });
1371
1372 expect(container.firstChild.firstChild.tagName).not.toBe('DIV');
1373
1374 // In this state, we can still update the siblings.
1375 await act(() => showSibling());
1376
1377 expect(container.firstChild.firstChild.tagName).toBe('DIV');
1378 expect(container.firstChild.firstChild.textContent).toBe('First');
1379 });
1380
1381 it('can delete the dehydrated boundary before it is hydrated', async () => {
1382 let suspend = false;
1383 const promise = new Promise(() => {});
1384 let hideMiddle;
1385
1386 function Child() {
1387 if (suspend) {
1388 throw promise;
1389 } else {
1390 return (
1391 <>
1392 <div>Middle</div>
1393 Some text
1394 </>
1395 );
1396 }
1397 }
1398
1399 function App() {
1400 const [visible, setVisibilty] = React.useState(true);
1401 hideMiddle = () => setVisibilty(false);
1402
1403 return (
1404 <div>
1405 <div>Before</div>
1406 {visible ? (
1407 <Activity>
1408 <Child />
1409 </Activity>
1410 ) : null}
1411 <div>After</div>
1412 </div>
1413 );
1414 }
1415
1416 suspend = false;
1417 const finalHTML = ReactDOMServer.renderToString(<App />);
1418 const container = document.createElement('div');
1419 container.innerHTML = finalHTML;
1420
1421 // On the client we don't have all data yet but we want to start
1422 // hydrating anyway.
1423 suspend = true;
1424 await act(() => {
1425 ReactDOMClient.hydrateRoot(container, <App />);
1426 });
1427
1428 expect(container.firstChild.children[1].textContent).toBe('Middle');
1429
1430 // In this state, we can still delete the boundary.
1431 await act(() => hideMiddle());
1432
1433 expect(container.firstChild.children[1].textContent).toBe('After');
1434 });
1435
1436 it('blocks updates to hydrate the content first if props have changed', async () => {
1437 let suspend = false;
1438 let resolve;
1439 const promise = new Promise(resolvePromise => (resolve = resolvePromise));
1440 const ref = React.createRef();
1441
1442 function Child({text}) {
1443 if (suspend) {
1444 throw promise;
1445 } else {
1446 return text;
1447 }
1448 }
1449
1450 function App({text, className}) {
1451 return (
1452 <div>
1453 <Activity>
1454 <span ref={ref} className={className}>
1455 <Child text={text} />
1456 </span>
1457 </Activity>
1458 </div>
1459 );
1460 }
1461
1462 suspend = false;
1463 const finalHTML = ReactDOMServer.renderToString(
1464 <App text="Hello" className="hello" />,
1465 );
1466 const container = document.createElement('div');
1467 container.innerHTML = finalHTML;
1468
1469 const span = container.getElementsByTagName('span')[0];
1470
1471 // On the client we don't have all data yet but we want to start
1472 // hydrating anyway.
1473 suspend = true;
1474 const root = ReactDOMClient.hydrateRoot(
1475 container,
1476 <App text="Hello" className="hello" />,
1477 );
1478 await waitForAll([]);
1479
1480 expect(ref.current).toBe(null);
1481 expect(span.textContent).toBe('Hello');
1482
1483 // Render an update, which will be higher or the same priority as pinging the hydration.
1484 root.render(<App text="Hi" className="hi" />);
1485
1486 // At the same time, resolving the promise so that rendering can complete.
1487 // This should first complete the hydration and then flush the update onto the hydrated state.
1488 await act(async () => {
1489 suspend = false;
1490 resolve();
1491 await promise;
1492 });
1493
1494 // The new span should be the same since we should have successfully hydrated
1495 // before changing it.
1496 const newSpan = container.getElementsByTagName('span')[0];
1497 expect(span).toBe(newSpan);
1498
1499 // We should now have fully rendered with a ref on the new span.
1500 expect(ref.current).toBe(span);
1501 expect(span.textContent).toBe('Hi');
1502 // If we ended up hydrating the existing content, we won't have properly
1503 // patched up the tree, which might mean we haven't patched the className.
1504 expect(span.className).toBe('hi');
1505 });
1506
1507 // @gate www
1508 it('blocks updates to hydrate the content first if props changed at idle priority', async () => {
1509 let suspend = false;
1510 let resolve;
1511 const promise = new Promise(resolvePromise => (resolve = resolvePromise));
1512 const ref = React.createRef();
1513
1514 function Child({text}) {
1515 if (suspend) {
1516 throw promise;
1517 } else {
1518 return text;
1519 }
1520 }
1521
1522 function App({text, className}) {
1523 return (
1524 <div>
1525 <Activity>
1526 <span ref={ref} className={className}>
1527 <Child text={text} />
1528 </span>
1529 </Activity>
1530 </div>
1531 );
1532 }
1533
1534 suspend = false;
1535 const finalHTML = ReactDOMServer.renderToString(
1536 <App text="Hello" className="hello" />,
1537 );
1538 const container = document.createElement('div');
1539 container.innerHTML = finalHTML;
1540
1541 const span = container.getElementsByTagName('span')[0];
1542
1543 // On the client we don't have all data yet but we want to start
1544 // hydrating anyway.
1545 suspend = true;
1546 const root = ReactDOMClient.hydrateRoot(
1547 container,
1548 <App text="Hello" className="hello" />,
1549 );
1550 await waitForAll([]);
1551
1552 expect(ref.current).toBe(null);
1553 expect(span.textContent).toBe('Hello');
1554
1555 // Schedule an update at idle priority
1556 ReactDOM.unstable_runWithPriority(IdleEventPriority, () => {
1557 root.render(<App text="Hi" className="hi" />);
1558 });
1559
1560 // At the same time, resolving the promise so that rendering can complete.
1561 suspend = false;
1562 resolve();
1563 await promise;
1564
1565 // This should first complete the hydration and then flush the update onto the hydrated state.
1566 await waitForAll([]);
1567
1568 // The new span should be the same since we should have successfully hydrated
1569 // before changing it.
1570 const newSpan = container.getElementsByTagName('span')[0];
1571 expect(span).toBe(newSpan);
1572
1573 // We should now have fully rendered with a ref on the new span.
1574 expect(ref.current).toBe(span);
1575 expect(span.textContent).toBe('Hi');
1576 // If we ended up hydrating the existing content, we won't have properly
1577 // patched up the tree, which might mean we haven't patched the className.
1578 expect(span.className).toBe('hi');
1579 });
1580
1581 it('shows the fallback of the parent if props have changed before hydration completes and is still suspended', async () => {
1582 let suspend = false;
1583 let resolve;
1584 const promise = new Promise(resolvePromise => (resolve = resolvePromise));
1585 const outerRef = React.createRef();
1586 const ref = React.createRef();
1587
1588 function Child({text}) {
1589 if (suspend) {
1590 throw promise;
1591 } else {
1592 return text;
1593 }
1594 }
1595
1596 function App({text, className}) {
1597 return (
1598 <Suspense fallback="Loading...">
1599 <div ref={outerRef}>
1600 <Activity>
1601 <span ref={ref} className={className}>
1602 <Child text={text} />
1603 </span>
1604 </Activity>
1605 </div>
1606 </Suspense>
1607 );
1608 }
1609
1610 suspend = false;
1611 const finalHTML = ReactDOMServer.renderToString(
1612 <App text="Hello" className="hello" />,
1613 );
1614 const container = document.createElement('div');
1615 container.innerHTML = finalHTML;
1616
1617 // On the client we don't have all data yet but we want to start
1618 // hydrating anyway.
1619 suspend = true;
1620 const root = ReactDOMClient.hydrateRoot(
1621 container,
1622 <App text="Hello" className="hello" />,
1623 {
1624 onRecoverableError(error) {
1625 Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
1626 if (error.cause) {
1627 Scheduler.log('Cause: ' + normalizeError(error.cause.message));
1628 }
1629 },
1630 },
1631 );
1632 await waitForAll([]);
1633
1634 expect(container.getElementsByTagName('div').length).toBe(1); // hidden
1635 const div = container.getElementsByTagName('div')[0];
1636
1637 expect(outerRef.current).toBe(div);
1638 expect(ref.current).toBe(null);
1639
1640 // Render an update, but leave it still suspended.
1641 await act(() => {
1642 root.render(<App text="Hi" className="hi" />);
1643 });
1644
1645 // Flushing now should hide the existing content and show the fallback.
1646
1647 expect(outerRef.current).toBe(null);
1648 expect(div.style.display).toBe('none');
1649 expect(container.getElementsByTagName('span').length).toBe(1); // hidden
1650 expect(ref.current).toBe(null);
1651 expect(container.textContent).toBe('HelloLoading...');
1652
1653 // Unsuspending shows the content.
1654 await act(async () => {
1655 suspend = false;
1656 resolve();
1657 await promise;
1658 });
1659
1660 const span = container.getElementsByTagName('span')[0];
1661 expect(span.textContent).toBe('Hi');
1662 expect(span.className).toBe('hi');
1663 expect(ref.current).toBe(span);
1664 expect(container.textContent).toBe('Hi');
1665 });
1666
1667 it('clears nested activity boundaries if they did not hydrate yet', async () => {
1668 let suspend = false;
1669 const promise = new Promise(() => {});
1670 const ref = React.createRef();
1671
1672 function Child({text}) {
1673 if (suspend && text !== 'Hi') {
1674 throw promise;
1675 } else {
1676 return text;
1677 }
1678 }
1679
1680 function App({text, className}) {
1681 return (
1682 <div>
1683 <Activity>
1684 <Activity>
1685 <Child text={text} />
1686 </Activity>{' '}
1687 <span ref={ref} className={className}>
1688 <Child text={text} />
1689 </span>
1690 </Activity>
1691 </div>
1692 );
1693 }
1694
1695 suspend = false;
1696 const finalHTML = ReactDOMServer.renderToString(
1697 <App text="Hello" className="hello" />,
1698 );
1699 const container = document.createElement('div');
1700 container.innerHTML = finalHTML;
1701
1702 // On the client we don't have all data yet but we want to start
1703 // hydrating anyway.
1704 suspend = true;
1705 const root = ReactDOMClient.hydrateRoot(
1706 container,
1707 <App text="Hello" className="hello" />,
1708 {
1709 onRecoverableError(error) {
1710 Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
1711 if (error.cause) {
1712 Scheduler.log('Cause: ' + normalizeError(error.cause.message));
1713 }
1714 },
1715 },
1716 );
1717 await waitForAll([]);
1718
1719 expect(ref.current).toBe(null);
1720
1721 // Render an update, that unblocks.
1722 // Flushing now should delete the existing content and show the update.
1723 await act(() => {
1724 root.render(<App text="Hi" className="hi" />);
1725 });
1726
1727 const span = container.getElementsByTagName('span')[0];
1728 expect(span.textContent).toBe('Hi');
1729 expect(span.className).toBe('hi');
1730 expect(ref.current).toBe(span);
1731 expect(container.textContent).toBe('Hi Hi');
1732 });
1733
1734 it('hydrates first if props changed but we are able to resolve within a timeout', async () => {
1735 let suspend = false;
1736 let resolve;
1737 const promise = new Promise(resolvePromise => (resolve = resolvePromise));
1738 const ref = React.createRef();
1739
1740 function Child({text}) {
1741 if (suspend) {
1742 throw promise;
1743 } else {
1744 return text;
1745 }
1746 }
1747
1748 function App({text, className}) {
1749 return (
1750 <div>
1751 <Activity>
1752 <span ref={ref} className={className}>
1753 <Child text={text} />
1754 </span>
1755 </Activity>
1756 </div>
1757 );
1758 }
1759
1760 suspend = false;
1761 const finalHTML = ReactDOMServer.renderToString(
1762 <App text="Hello" className="hello" />,
1763 );
1764 const container = document.createElement('div');
1765 container.innerHTML = finalHTML;
1766
1767 const span = container.getElementsByTagName('span')[0];
1768
1769 // On the client we don't have all data yet but we want to start
1770 // hydrating anyway.
1771 suspend = true;
1772 const root = ReactDOMClient.hydrateRoot(
1773 container,
1774 <App text="Hello" className="hello" />,
1775 );
1776 await waitForAll([]);
1777
1778 expect(ref.current).toBe(null);
1779 expect(container.textContent).toBe('Hello');
1780
1781 // Render an update with a long timeout.
1782 React.startTransition(() => root.render(<App text="Hi" className="hi" />));
1783 // This shouldn't force the fallback yet.
1784 await waitForAll([]);
1785
1786 expect(ref.current).toBe(null);
1787 expect(container.textContent).toBe('Hello');
1788
1789 // Resolving the promise so that rendering can complete.
1790 // This should first complete the hydration and then flush the update onto the hydrated state.
1791 suspend = false;
1792 await act(() => resolve());
1793
1794 // The new span should be the same since we should have successfully hydrated
1795 // before changing it.
1796 const newSpan = container.getElementsByTagName('span')[0];
1797 expect(span).toBe(newSpan);
1798
1799 // We should now have fully rendered with a ref on the new span.
1800 expect(ref.current).toBe(span);
1801 expect(container.textContent).toBe('Hi');
1802 // If we ended up hydrating the existing content, we won't have properly
1803 // patched up the tree, which might mean we haven't patched the className.
1804 expect(span.className).toBe('hi');
1805 });
1806
1807 it('warns but works if setState is called before commit in a dehydrated component', async () => {
1808 let suspend = false;
1809 let resolve;
1810 const promise = new Promise(resolvePromise => (resolve = resolvePromise));
1811
1812 let updateText;
1813
1814 function Child() {
1815 const [state, setState] = React.useState('Hello');
1816 updateText = setState;
1817 Scheduler.log('Child');
1818 if (suspend) {
1819 throw promise;
1820 } else {
1821 return state;
1822 }
1823 }
1824
1825 function Sibling() {
1826 Scheduler.log('Sibling');
1827 return null;
1828 }
1829
1830 function App() {
1831 return (
1832 <div>
1833 <Activity>
1834 <Child />
1835 <Sibling />
1836 </Activity>
1837 </div>
1838 );
1839 }
1840
1841 suspend = false;
1842 const finalHTML = ReactDOMServer.renderToString(<App />);
1843 assertLog(['Child', 'Sibling']);
1844
1845 const container = document.createElement('div');
1846 container.innerHTML = finalHTML;
1847
1848 ReactDOMClient.hydrateRoot(
1849 container,
1850 <App text="Hello" className="hello" />,
1851 );
1852
1853 await act(async () => {
1854 suspend = true;
1855 await waitFor(['Child']);
1856
1857 // While we're part way through the hydration, we update the state.
1858 // This will schedule an update on the children of the activity boundary.
1859 updateText('Hi');
1860 assertConsoleErrorDev([
1861 "Can't perform a React state update on a component that hasn't mounted yet. " +
1862 'This indicates that you have a side-effect in your render function that ' +
1863 'asynchronously tries to update the component. Move this work to useEffect instead.\n' +
1864 ' in App (at **)',
1865 ]);
1866
1867 // This will throw it away and rerender.
1868 await waitForAll(['Child']);
1869
1870 expect(container.textContent).toBe('Hello');
1871
1872 suspend = false;
1873 resolve();
1874 await promise;
1875 });
1876 assertLog(['Child', 'Sibling']);
1877
1878 expect(container.textContent).toBe('Hello');
1879 });
1880
1881 it('blocks the update to hydrate first if context has changed', async () => {
1882 let suspend = false;
1883 let resolve;
1884 const promise = new Promise(resolvePromise => (resolve = resolvePromise));
1885 const ref = React.createRef();
1886 const Context = React.createContext(null);
1887
1888 function Child() {
1889 const {text, className} = React.useContext(Context);
1890 if (suspend) {
1891 throw promise;
1892 } else {
1893 return (
1894 <span ref={ref} className={className}>
1895 {text}
1896 </span>
1897 );
1898 }
1899 }
1900
1901 const App = React.memo(function App() {
1902 return (
1903 <div>
1904 <Activity>
1905 <Child />
1906 </Activity>
1907 </div>
1908 );
1909 });
1910
1911 suspend = false;
1912 const finalHTML = ReactDOMServer.renderToString(
1913 <Context.Provider value={{text: 'Hello', className: 'hello'}}>
1914 <App />
1915 </Context.Provider>,
1916 );
1917 const container = document.createElement('div');
1918 container.innerHTML = finalHTML;
1919
1920 const span = container.getElementsByTagName('span')[0];
1921
1922 // On the client we don't have all data yet but we want to start
1923 // hydrating anyway.
1924 suspend = true;
1925 const root = ReactDOMClient.hydrateRoot(
1926 container,
1927 <Context.Provider value={{text: 'Hello', className: 'hello'}}>
1928 <App />
1929 </Context.Provider>,
1930 );
1931 await waitForAll([]);
1932
1933 expect(ref.current).toBe(null);
1934 expect(span.textContent).toBe('Hello');
1935
1936 // Render an update, which will be higher or the same priority as pinging the hydration.
1937 root.render(
1938 <Context.Provider value={{text: 'Hi', className: 'hi'}}>
1939 <App />
1940 </Context.Provider>,
1941 );
1942
1943 // At the same time, resolving the promise so that rendering can complete.
1944 // This should first complete the hydration and then flush the update onto the hydrated state.
1945 await act(async () => {
1946 suspend = false;
1947 resolve();
1948 await promise;
1949 });
1950
1951 // Since this should have been hydrated, this should still be the same span.
1952 const newSpan = container.getElementsByTagName('span')[0];
1953 expect(newSpan).toBe(span);
1954
1955 // We should now have fully rendered with a ref on the new span.
1956 expect(ref.current).toBe(span);
1957 expect(span.textContent).toBe('Hi');
1958 // If we ended up hydrating the existing content, we won't have properly
1959 // patched up the tree, which might mean we haven't patched the className.
1960 expect(span.className).toBe('hi');
1961 });
1962
1963 it('shows the parent fallback if context has changed before hydration completes and is still suspended', async () => {
1964 let suspend = false;
1965 let resolve;
1966 const promise = new Promise(resolvePromise => (resolve = resolvePromise));
1967 const ref = React.createRef();
1968 const Context = React.createContext(null);
1969
1970 function Child() {
1971 const {text, className} = React.useContext(Context);
1972 if (suspend) {
1973 throw promise;
1974 } else {
1975 return (
1976 <span ref={ref} className={className}>
1977 {text}
1978 </span>
1979 );
1980 }
1981 }
1982
1983 const App = React.memo(function App() {
1984 return (
1985 <Suspense fallback="Loading...">
1986 <div>
1987 <Activity>
1988 <Child />
1989 </Activity>
1990 </div>
1991 </Suspense>
1992 );
1993 });
1994
1995 suspend = false;
1996 const finalHTML = ReactDOMServer.renderToString(
1997 <Context.Provider value={{text: 'Hello', className: 'hello'}}>
1998 <App />
1999 </Context.Provider>,
2000 );
2001 const container = document.createElement('div');
2002 container.innerHTML = finalHTML;
2003
2004 // On the client we don't have all data yet but we want to start
2005 // hydrating anyway.
2006 suspend = true;
2007 const root = ReactDOMClient.hydrateRoot(
2008 container,
2009 <Context.Provider value={{text: 'Hello', className: 'hello'}}>
2010 <App />
2011 </Context.Provider>,
2012 {
2013 onRecoverableError(error) {
2014 Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
2015 if (error.cause) {
2016 Scheduler.log('Cause: ' + normalizeError(error.cause.message));
2017 }
2018 },
2019 },
2020 );
2021 await waitForAll([]);
2022
2023 expect(ref.current).toBe(null);
2024
2025 // Render an update, but leave it still suspended.
2026 // Flushing now should delete the existing content and show the fallback.
2027 await act(() => {
2028 root.render(
2029 <Context.Provider value={{text: 'Hi', className: 'hi'}}>
2030 <App />
2031 </Context.Provider>,
2032 );
2033 });
2034
2035 expect(container.getElementsByTagName('span').length).toBe(1); // hidden
2036 expect(ref.current).toBe(null);
2037 expect(container.textContent).toBe('HelloLoading...');
2038
2039 // Unsuspending shows the content.
2040 await act(async () => {
2041 suspend = false;
2042 resolve();
2043 await promise;
2044 });
2045
2046 const span = container.getElementsByTagName('span')[0];
2047 expect(span.textContent).toBe('Hi');
2048 expect(span.className).toBe('hi');
2049 expect(ref.current).toBe(span);
2050 expect(container.textContent).toBe('Hi');
2051 });
2052
2053 it('can hydrate TWO activity boundaries', async () => {
2054 const ref1 = React.createRef();
2055 const ref2 = React.createRef();
2056
2057 function App() {
2058 return (
2059 <div>
2060 <Activity>
2061 <span ref={ref1}>1</span>
2062 </Activity>
2063 <Activity>
2064 <span ref={ref2}>2</span>
2065 </Activity>
2066 </div>
2067 );
2068 }
2069
2070 // First we render the final HTML. With the streaming renderer
2071 // this may have suspense points on the server but here we want
2072 // to test the completed HTML. Don't suspend on the server.
2073 const finalHTML = ReactDOMServer.renderToString(<App />);
2074
2075 const container = document.createElement('div');
2076 container.innerHTML = finalHTML;
2077
2078 const span1 = container.getElementsByTagName('span')[0];
2079 const span2 = container.getElementsByTagName('span')[1];
2080
2081 // On the client we don't have all data yet but we want to start
2082 // hydrating anyway.
2083 ReactDOMClient.hydrateRoot(container, <App />);
2084 await waitForAll([]);
2085
2086 expect(ref1.current).toBe(span1);
2087 expect(ref2.current).toBe(span2);
2088 });
2089
2090 it('regenerates if it cannot hydrate before changes to props/context expire', async () => {
2091 let suspend = false;
2092 const promise = new Promise(resolvePromise => {});
2093 const ref = React.createRef();
2094 const ClassName = React.createContext(null);
2095
2096 function Child({text}) {
2097 const className = React.useContext(ClassName);
2098 if (suspend && className !== 'hi' && text !== 'Hi') {
2099 // Never suspends on the newer data.
2100 throw promise;
2101 } else {
2102 return (
2103 <span ref={ref} className={className}>
2104 {text}
2105 </span>
2106 );
2107 }
2108 }
2109
2110 function App({text, className}) {
2111 return (
2112 <div>
2113 <Activity>
2114 <Child text={text} />
2115 </Activity>
2116 </div>
2117 );
2118 }
2119
2120 suspend = false;
2121 const finalHTML = ReactDOMServer.renderToString(
2122 <ClassName.Provider value={'hello'}>
2123 <App text="Hello" />
2124 </ClassName.Provider>,
2125 );
2126 const container = document.createElement('div');
2127 container.innerHTML = finalHTML;
2128
2129 const span = container.getElementsByTagName('span')[0];
2130
2131 // On the client we don't have all data yet but we want to start
2132 // hydrating anyway.
2133 suspend = true;
2134 const root = ReactDOMClient.hydrateRoot(
2135 container,
2136 <ClassName.Provider value={'hello'}>
2137 <App text="Hello" />
2138 </ClassName.Provider>,
2139 {
2140 onRecoverableError(error) {
2141 Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
2142 if (error.cause) {
2143 Scheduler.log('Cause: ' + normalizeError(error.cause.message));
2144 }
2145 },
2146 },
2147 );
2148 await waitForAll([]);
2149
2150 expect(ref.current).toBe(null);
2151 expect(span.textContent).toBe('Hello');
2152
2153 // Render an update, which will be higher or the same priority as pinging the hydration.
2154 // The new update doesn't suspend.
2155 // Since we're still suspended on the original data, we can't hydrate.
2156 // This will force all expiration times to flush.
2157 await act(() => {
2158 root.render(
2159 <ClassName.Provider value={'hi'}>
2160 <App text="Hi" />
2161 </ClassName.Provider>,
2162 );
2163 });
2164
2165 // This will now be a new span because we weren't able to hydrate before
2166 const newSpan = container.getElementsByTagName('span')[0];
2167 expect(newSpan).not.toBe(span);
2168
2169 // We should now have fully rendered with a ref on the new span.
2170 expect(ref.current).toBe(newSpan);
2171 expect(newSpan.textContent).toBe('Hi');
2172 // If we ended up hydrating the existing content, we won't have properly
2173 // patched up the tree, which might mean we haven't patched the className.
2174 expect(newSpan.className).toBe('hi');
2175 });
2176
2177 it('does not invoke an event on a hydrated node until it commits', async () => {
2178 let suspend = false;
2179 let resolve;
2180 const promise = new Promise(resolvePromise => (resolve = resolvePromise));
2181
2182 function Sibling({text}) {
2183 if (suspend) {
2184 throw promise;
2185 } else {
2186 return 'Hello';
2187 }
2188 }
2189
2190 let clicks = 0;
2191
2192 function Button() {
2193 const [clicked, setClicked] = React.useState(false);
2194 if (clicked) {
2195 return null;
2196 }
2197 return (
2198 <a
2199 onClick={() => {
2200 setClicked(true);
2201 clicks++;
2202 }}>
2203 Click me
2204 </a>
2205 );
2206 }
2207
2208 function App() {
2209 return (
2210 <div>
2211 <Activity>
2212 <Button />
2213 <Sibling />
2214 </Activity>
2215 </div>
2216 );
2217 }
2218
2219 suspend = false;
2220 const finalHTML = ReactDOMServer.renderToString(<App />);
2221 const container = document.createElement('div');
2222 container.innerHTML = finalHTML;
2223
2224 // We need this to be in the document since we'll dispatch events on it.
2225 document.body.appendChild(container);
2226
2227 const a = container.getElementsByTagName('a')[0];
2228
2229 // On the client we don't have all data yet but we want to start
2230 // hydrating anyway.
2231 suspend = true;
2232 ReactDOMClient.hydrateRoot(container, <App />);
2233 await waitForAll([]);
2234
2235 expect(container.textContent).toBe('Click meHello');
2236
2237 // We're now partially hydrated.
2238 await act(() => {
2239 a.click();
2240 });
2241 expect(clicks).toBe(0);
2242
2243 // Resolving the promise so that rendering can complete.
2244 await act(async () => {
2245 suspend = false;
2246 resolve();
2247 await promise;
2248 });
2249
2250 expect(clicks).toBe(0);
2251 expect(container.textContent).toBe('Click meHello');
2252
2253 document.body.removeChild(container);
2254 });
2255
2256 // @gate www
2257 it('does not invoke an event on a hydrated event handle until it commits', async () => {
2258 const setClick = ReactDOM.unstable_createEventHandle('click');
2259 let suspend = false;
2260 let isServerRendering = true;
2261 let resolve;
2262 const promise = new Promise(resolvePromise => (resolve = resolvePromise));
2263
2264 function Sibling({text}) {
2265 if (suspend) {
2266 throw promise;
2267 } else {
2268 return 'Hello';
2269 }
2270 }
2271
2272 const onEvent = jest.fn();
2273
2274 function Button() {
2275 const ref = React.useRef(null);
2276 if (!isServerRendering) {
2277 React.useLayoutEffect(() => {
2278 return setClick(ref.current, onEvent);
2279 });
2280 }
2281 return <a ref={ref}>Click me</a>;
2282 }
2283
2284 function App() {
2285 return (
2286 <div>
2287 <Activity>
2288 <Button />
2289 <Sibling />
2290 </Activity>
2291 </div>
2292 );
2293 }
2294
2295 suspend = false;
2296 const finalHTML = ReactDOMServer.renderToString(<App />);
2297 const container = document.createElement('div');
2298 container.innerHTML = finalHTML;
2299
2300 // We need this to be in the document since we'll dispatch events on it.
2301 document.body.appendChild(container);
2302
2303 const a = container.getElementsByTagName('a')[0];
2304
2305 // On the client we don't have all data yet but we want to start
2306 // hydrating anyway.
2307 suspend = true;
2308 isServerRendering = false;
2309 ReactDOMClient.hydrateRoot(container, <App />);
2310
2311 // We'll do one click before hydrating.
2312 a.click();
2313 // This should be delayed.
2314 expect(onEvent).toHaveBeenCalledTimes(0);
2315
2316 await waitForAll([]);
2317
2318 // We're now partially hydrated.
2319 await act(() => {
2320 a.click();
2321 });
2322 // We should not have invoked the event yet because we're not
2323 // yet hydrated.
2324 expect(onEvent).toHaveBeenCalledTimes(0);
2325
2326 // Resolving the promise so that rendering can complete.
2327 await act(async () => {
2328 suspend = false;
2329 resolve();
2330 await promise;
2331 });
2332
2333 expect(onEvent).toHaveBeenCalledTimes(0);
2334
2335 document.body.removeChild(container);
2336 });
2337
2338 it('invokes discrete events on nested activity boundaries in a root (legacy system)', async () => {
2339 let suspend = false;
2340 let resolve;
2341 const promise = new Promise(resolvePromise => (resolve = resolvePromise));
2342
2343 let clicks = 0;
2344
2345 function Button() {
2346 return (
2347 <a
2348 onClick={() => {
2349 clicks++;
2350 }}>
2351 Click me
2352 </a>
2353 );
2354 }
2355
2356 function Child() {
2357 if (suspend) {
2358 throw promise;
2359 } else {
2360 return (
2361 <Activity>
2362 <Button />
2363 </Activity>
2364 );
2365 }
2366 }
2367
2368 function App() {
2369 return (
2370 <Activity>
2371 <Child />
2372 </Activity>
2373 );
2374 }
2375
2376 suspend = false;
2377 const finalHTML = ReactDOMServer.renderToString(<App />);
2378 const container = document.createElement('div');
2379 container.innerHTML = finalHTML;
2380
2381 // We need this to be in the document since we'll dispatch events on it.
2382 document.body.appendChild(container);
2383
2384 const a = container.getElementsByTagName('a')[0];
2385
2386 // On the client we don't have all data yet but we want to start
2387 // hydrating anyway.
2388 suspend = true;
2389 ReactDOMClient.hydrateRoot(container, <App />);
2390
2391 // We'll do one click before hydrating.
2392 await act(() => {
2393 a.click();
2394 });
2395 // This should be delayed.
2396 expect(clicks).toBe(0);
2397
2398 await waitForAll([]);
2399
2400 // We're now partially hydrated.
2401 await act(() => {
2402 a.click();
2403 });
2404 expect(clicks).toBe(0);
2405
2406 // Resolving the promise so that rendering can complete.
2407 await act(async () => {
2408 suspend = false;
2409 resolve();
2410 await promise;
2411 });
2412
2413 expect(clicks).toBe(0);
2414
2415 document.body.removeChild(container);
2416 });
2417
2418 // @gate www
2419 it('invokes discrete events on nested activity boundaries in a root (createEventHandle)', async () => {
2420 let suspend = false;
2421 let isServerRendering = true;
2422 let resolve;
2423 const promise = new Promise(resolvePromise => (resolve = resolvePromise));
2424
2425 const onEvent = jest.fn();
2426 const setClick = ReactDOM.unstable_createEventHandle('click');
2427
2428 function Button() {
2429 const ref = React.useRef(null);
2430
2431 if (!isServerRendering) {
2432 React.useLayoutEffect(() => {
2433 return setClick(ref.current, onEvent);
2434 });
2435 }
2436
2437 return <a ref={ref}>Click me</a>;
2438 }
2439
2440 function Child() {
2441 if (suspend) {
2442 throw promise;
2443 } else {
2444 return (
2445 <Activity>
2446 <Button />
2447 </Activity>
2448 );
2449 }
2450 }
2451
2452 function App() {
2453 return (
2454 <Activity>
2455 <Child />
2456 </Activity>
2457 );
2458 }
2459
2460 suspend = false;
2461 const finalHTML = ReactDOMServer.renderToString(<App />);
2462 const container = document.createElement('div');
2463 container.innerHTML = finalHTML;
2464
2465 // We need this to be in the document since we'll dispatch events on it.
2466 document.body.appendChild(container);
2467
2468 const a = container.getElementsByTagName('a')[0];
2469
2470 // On the client we don't have all data yet but we want to start
2471 // hydrating anyway.
2472 suspend = true;
2473 isServerRendering = false;
2474 ReactDOMClient.hydrateRoot(container, <App />);
2475
2476 // We'll do one click before hydrating.
2477 a.click();
2478 // This should be delayed.
2479 expect(onEvent).toHaveBeenCalledTimes(0);
2480
2481 await waitForAll([]);
2482
2483 // We're now partially hydrated.
2484 await act(() => {
2485 a.click();
2486 });
2487 // We should not have invoked the event yet because we're not
2488 // yet hydrated.
2489 expect(onEvent).toHaveBeenCalledTimes(0);
2490
2491 // Resolving the promise so that rendering can complete.
2492 await act(async () => {
2493 suspend = false;
2494 resolve();
2495 await promise;
2496 });
2497
2498 expect(onEvent).toHaveBeenCalledTimes(0);
2499
2500 document.body.removeChild(container);
2501 });
2502
2503 it('does not invoke the parent of dehydrated boundary event', async () => {
2504 let suspend = false;
2505 let resolve;
2506 const promise = new Promise(resolvePromise => (resolve = resolvePromise));
2507
2508 let clicksOnParent = 0;
2509 let clicksOnChild = 0;
2510
2511 function Child({text}) {
2512 if (suspend) {
2513 throw promise;
2514 } else {
2515 return (
2516 <span
2517 onClick={e => {
2518 // The stopPropagation is showing an example why invoking
2519 // the event on only a parent might not be correct.
2520 e.stopPropagation();
2521 clicksOnChild++;
2522 }}>
2523 Hello
2524 </span>
2525 );
2526 }
2527 }
2528
2529 function App() {
2530 return (
2531 <div onClick={() => clicksOnParent++}>
2532 <Activity>
2533 <Child />
2534 </Activity>
2535 </div>
2536 );
2537 }
2538
2539 suspend = false;
2540 const finalHTML = ReactDOMServer.renderToString(<App />);
2541 const container = document.createElement('div');
2542 container.innerHTML = finalHTML;
2543
2544 // We need this to be in the document since we'll dispatch events on it.
2545 document.body.appendChild(container);
2546
2547 const span = container.getElementsByTagName('span')[0];
2548
2549 // On the client we don't have all data yet but we want to start
2550 // hydrating anyway.
2551 suspend = true;
2552 ReactDOMClient.hydrateRoot(container, <App />);
2553 await waitForAll([]);
2554
2555 // We're now partially hydrated.
2556 await act(() => {
2557 span.click();
2558 });
2559 expect(clicksOnChild).toBe(0);
2560 expect(clicksOnParent).toBe(0);
2561
2562 // Resolving the promise so that rendering can complete.
2563 await act(async () => {
2564 suspend = false;
2565 resolve();
2566 await promise;
2567 });
2568
2569 expect(clicksOnChild).toBe(0);
2570 expect(clicksOnParent).toBe(0);
2571
2572 document.body.removeChild(container);
2573 });
2574
2575 it('does not invoke an event on a parent tree when a subtree is dehydrated', async () => {
2576 let suspend = false;
2577 let resolve;
2578 const promise = new Promise(resolvePromise => (resolve = resolvePromise));
2579
2580 let clicks = 0;
2581 const childSlotRef = React.createRef();
2582
2583 function Parent() {
2584 return <div onClick={() => clicks++} ref={childSlotRef} />;
2585 }
2586
2587 function Child({text}) {
2588 if (suspend) {
2589 throw promise;
2590 } else {
2591 return <a>Click me</a>;
2592 }
2593 }
2594
2595 function App() {
2596 // The root is a Suspense boundary.
2597 return (
2598 <Activity>
2599 <Child />
2600 </Activity>
2601 );
2602 }
2603
2604 suspend = false;
2605 const finalHTML = ReactDOMServer.renderToString(<App />);
2606
2607 const parentContainer = document.createElement('div');
2608 const childContainer = document.createElement('div');
2609
2610 // We need this to be in the document since we'll dispatch events on it.
2611 document.body.appendChild(parentContainer);
2612
2613 // We're going to use a different root as a parent.
2614 // This lets us detect whether an event goes through React's event system.
2615 const parentRoot = ReactDOMClient.createRoot(parentContainer);
2616 await act(() => parentRoot.render(<Parent />));
2617
2618 childSlotRef.current.appendChild(childContainer);
2619
2620 childContainer.innerHTML = finalHTML;
2621
2622 const a = childContainer.getElementsByTagName('a')[0];
2623
2624 suspend = true;
2625
2626 // Hydrate asynchronously.
2627 await act(() => ReactDOMClient.hydrateRoot(childContainer, <App />));
2628
2629 // The Suspense boundary is not yet hydrated.
2630 await act(() => {
2631 a.click();
2632 });
2633 expect(clicks).toBe(0);
2634
2635 // Resolving the promise so that rendering can complete.
2636 await act(async () => {
2637 suspend = false;
2638 resolve();
2639 await promise;
2640 });
2641
2642 expect(clicks).toBe(0);
2643
2644 document.body.removeChild(parentContainer);
2645 });
2646
2647 it('blocks only on the last continuous event (legacy system)', async () => {
2648 let suspend1 = false;
2649 let resolve1;
2650 const promise1 = new Promise(resolvePromise => (resolve1 = resolvePromise));
2651 let suspend2 = false;
2652 let resolve2;
2653 const promise2 = new Promise(resolvePromise => (resolve2 = resolvePromise));
2654
2655 function First({text}) {
2656 if (suspend1) {
2657 throw promise1;
2658 } else {
2659 return 'Hello';
2660 }
2661 }
2662
2663 function Second({text}) {
2664 if (suspend2) {
2665 throw promise2;
2666 } else {
2667 return 'World';
2668 }
2669 }
2670
2671 const ops = [];
2672
2673 function App() {
2674 return (
2675 <div>
2676 <Activity>
2677 <span
2678 onMouseEnter={() => ops.push('Mouse Enter First')}
2679 onMouseLeave={() => ops.push('Mouse Leave First')}
2680 />
2681 {/* We suspend after to test what happens when we eager
2682 attach the listener. */}
2683 <First />
2684 </Activity>
2685 <Activity>
2686 <span
2687 onMouseEnter={() => ops.push('Mouse Enter Second')}
2688 onMouseLeave={() => ops.push('Mouse Leave Second')}>
2689 <Second />
2690 </span>
2691 </Activity>
2692 </div>
2693 );
2694 }
2695
2696 const finalHTML = ReactDOMServer.renderToString(<App />);
2697 const container = document.createElement('div');
2698 container.innerHTML = finalHTML;
2699
2700 // We need this to be in the document since we'll dispatch events on it.
2701 document.body.appendChild(container);
2702
2703 const appDiv = container.getElementsByTagName('div')[0];
2704 const firstSpan = appDiv.getElementsByTagName('span')[0];
2705 const secondSpan = appDiv.getElementsByTagName('span')[1];
2706 expect(firstSpan.textContent).toBe('');
2707 expect(secondSpan.textContent).toBe('World');
2708
2709 // On the client we don't have all data yet but we want to start
2710 // hydrating anyway.
2711 suspend1 = true;
2712 suspend2 = true;
2713 ReactDOMClient.hydrateRoot(container, <App />);
2714
2715 await waitForAll([]);
2716
2717 dispatchMouseEvent(appDiv, null);
2718 dispatchMouseEvent(firstSpan, appDiv);
2719 dispatchMouseEvent(secondSpan, firstSpan);
2720
2721 // Neither target is yet hydrated.
2722 expect(ops).toEqual([]);
2723
2724 // Resolving the second promise so that rendering can complete.
2725 suspend2 = false;
2726 resolve2();
2727 await promise2;
2728
2729 await waitForAll([]);
2730
2731 // We've unblocked the current hover target so we should be
2732 // able to replay it now.
2733 expect(ops).toEqual(['Mouse Enter Second']);
2734
2735 // Resolving the first promise has no effect now.
2736 suspend1 = false;
2737 resolve1();
2738 await promise1;
2739
2740 await waitForAll([]);
2741
2742 expect(ops).toEqual(['Mouse Enter Second']);
2743
2744 document.body.removeChild(container);
2745 });
2746
2747 it('finishes normal pri work before continuing to hydrate a retry', async () => {
2748 let suspend = false;
2749 let resolve;
2750 const promise = new Promise(resolvePromise => (resolve = resolvePromise));
2751 const ref = React.createRef();
2752
2753 function Child() {
2754 if (suspend) {
2755 throw promise;
2756 } else {
2757 Scheduler.log('Child');
2758 return 'Hello';
2759 }
2760 }
2761
2762 function Sibling() {
2763 Scheduler.log('Sibling');
2764 React.useLayoutEffect(() => {
2765 Scheduler.log('Commit Sibling');
2766 });
2767 return 'World';
2768 }
2769
2770 // Avoid rerendering the tree by hoisting it.
2771 const tree = (
2772 <Activity>
2773 <span ref={ref}>
2774 <Child />
2775 </span>
2776 </Activity>
2777 );
2778
2779 function App({showSibling}) {
2780 return (
2781 <div>
2782 {tree}
2783 {showSibling ? <Sibling /> : null}
2784 </div>
2785 );
2786 }
2787
2788 suspend = false;
2789 const finalHTML = ReactDOMServer.renderToString(<App />);
2790 assertLog(['Child']);
2791
2792 const container = document.createElement('div');
2793 container.innerHTML = finalHTML;
2794
2795 suspend = true;
2796 const root = ReactDOMClient.hydrateRoot(
2797 container,
2798 <App showSibling={false} />,
2799 );
2800 await waitForAll([]);
2801
2802 expect(ref.current).toBe(null);
2803 expect(container.textContent).toBe('Hello');
2804
2805 // Resolving the promise should continue hydration
2806 suspend = false;
2807 resolve();
2808 await promise;
2809
2810 Scheduler.unstable_advanceTime(100);
2811
2812 // Before we have a chance to flush it, we'll also render an update.
2813 root.render(<App showSibling={true} />);
2814
2815 // When we flush we expect the Normal pri render to take priority
2816 // over hydration.
2817 await waitFor(['Sibling', 'Commit Sibling']);
2818
2819 // We shouldn't have hydrated the child yet.
2820 expect(ref.current).toBe(null);
2821 // But we did have a chance to update the content.
2822 expect(container.textContent).toBe('HelloWorld');
2823
2824 await waitForAll(['Child']);
2825
2826 // Now we're hydrated.
2827 expect(ref.current).not.toBe(null);
2828 });
2829
2830 it('regression test: does not overfire non-bubbling browser events', async () => {
2831 let suspend = false;
2832 let resolve;
2833 const promise = new Promise(resolvePromise => (resolve = resolvePromise));
2834
2835 function Sibling({text}) {
2836 if (suspend) {
2837 throw promise;
2838 } else {
2839 return 'Hello';
2840 }
2841 }
2842
2843 let submits = 0;
2844
2845 function Form() {
2846 const [submitted, setSubmitted] = React.useState(false);
2847 if (submitted) {
2848 return null;
2849 }
2850 return (
2851 <form
2852 onSubmit={() => {
2853 setSubmitted(true);
2854 submits++;
2855 }}>
2856 Click me
2857 </form>
2858 );
2859 }
2860
2861 function App() {
2862 return (
2863 <div>
2864 <Activity>
2865 <Form />
2866 <Sibling />
2867 </Activity>
2868 </div>
2869 );
2870 }
2871
2872 suspend = false;
2873 const finalHTML = ReactDOMServer.renderToString(<App />);
2874 const container = document.createElement('div');
2875 container.innerHTML = finalHTML;
2876
2877 // We need this to be in the document since we'll dispatch events on it.
2878 document.body.appendChild(container);
2879
2880 const form = container.getElementsByTagName('form')[0];
2881
2882 // On the client we don't have all data yet but we want to start
2883 // hydrating anyway.
2884 suspend = true;
2885 ReactDOMClient.hydrateRoot(container, <App />);
2886 await waitForAll([]);
2887
2888 expect(container.textContent).toBe('Click meHello');
2889
2890 // We're now partially hydrated.
2891 await act(() => {
2892 form.dispatchEvent(
2893 new window.Event('submit', {
2894 bubbles: true,
2895 }),
2896 );
2897 });
2898 expect(submits).toBe(0);
2899
2900 // Resolving the promise so that rendering can complete.
2901 await act(async () => {
2902 suspend = false;
2903 resolve();
2904 await promise;
2905 });
2906
2907 // discrete event not replayed
2908 expect(submits).toBe(0);
2909 expect(container.textContent).toBe('Click meHello');
2910
2911 document.body.removeChild(container);
2912 });
2913
2914 it('fallback to client render on hydration mismatch at root', async () => {
2915 let suspend = true;
2916 let resolve;
2917 const promise = new Promise((res, rej) => {
2918 resolve = () => {
2919 suspend = false;
2920 res();
2921 };
2922 });
2923 function App({isClient}) {
2924 return (
2925 <>
2926 <Activity>
2927 <ChildThatSuspends id={1} isClient={isClient} />
2928 </Activity>
2929 {isClient ? <span>client</span> : <div>server</div>}
2930 <Activity>
2931 <ChildThatSuspends id={2} isClient={isClient} />
2932 </Activity>
2933 </>
2934 );
2935 }
2936 function ChildThatSuspends({id, isClient}) {
2937 if (isClient && suspend) {
2938 throw promise;
2939 }
2940 return <div>{id}</div>;
2941 }
2942
2943 const finalHTML = ReactDOMServer.renderToString(<App isClient={false} />);
2944
2945 const container = document.createElement('div');
2946 document.body.appendChild(container);
2947 container.innerHTML = finalHTML;
2948
2949 await act(() => {
2950 ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
2951 onRecoverableError(error) {
2952 Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
2953 if (error.cause) {
2954 Scheduler.log('Cause: ' + normalizeError(error.cause.message));
2955 }
2956 },
2957 });
2958 });
2959
2960 // We suspend the root while we wait for the promises to resolve, leaving the
2961 // existing content in place.
2962 expect(container.innerHTML).toEqual(
2963 '<!--&--><div>1</div><!--/&--><div>server</div><!--&--><div>2</div><!--/&-->',
2964 );
2965
2966 await act(async () => {
2967 resolve();
2968 await promise;
2969 });
2970
2971 assertLog([
2972 "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
2973 ]);
2974
2975 expect(container.innerHTML).toEqual(
2976 '<div>1</div><span>client</span><div>2</div>',
2977 );
2978 });
2979
2980 it('commits new suspending content next to a dehydrated Activity that hides', async () => {
2981 let suspend = false;
2982 let resolve;
2983 const promise = new Promise(resolvePromise => (resolve = resolvePromise));
2984
2985 function Second() {
2986 if (suspend) {
2987 throw promise;
2988 }
2989 return <span id="second">Second</span>;
2990 }
2991
2992 function App({showSecondOnMount}) {
2993 const [active, setActive] = React.useState('first');
2994 React.useEffect(() => {
2995 if (showSecondOnMount) {
2996 // Not a transition: this update reaches the dehydrated Activity at
2997 // default priority, before it has hydrated.
2998 setActive('second');
2999 }
3000 }, [showSecondOnMount]);
3001 return (
3002 <div>
3003 <Suspense fallback={null}>
3004 {active === 'second' ? <Second /> : null}
3005 <Activity mode={active === 'first' ? 'visible' : 'hidden'}>
3006 <span id="first">First</span>
3007 </Activity>
3008 </Suspense>
3009 </div>
3010 );
3011 }
3012
3013 // Don't suspend on the server.
3014 suspend = false;
3015 const finalHTML = ReactDOMServer.renderToString(
3016 <App showSecondOnMount={false} />,
3017 );
3018 const container = document.createElement('div');
3019 container.innerHTML = finalHTML;
3020 expect(container.textContent).toBe('First');
3021
3022 // Hydrate. The first effect mounts new content (still loading) and hides
3023 // the server-rendered Activity while its subtree is still dehydrated.
3024 suspend = true;
3025 await act(() => {
3026 ReactDOMClient.hydrateRoot(container, <App showSecondOnMount={true} />);
3027 });
3028
3029 // The data for the new row arrives.
3030 suspend = false;
3031 await act(async () => {
3032 resolve();
3033 await promise;
3034 });
3035
3036 // The new row should be visible and the old row hidden.
3037 const second = container.querySelector('#second');
3038 const first = container.querySelector('#first');
3039 expect(second).not.toBe(null);
3040 expect(second.style.display).not.toBe('none');
3041 expect(first === null || first.style.display === 'none').toBe(true);
3042 });
3043
3044 it('commits new suspending content next to a dehydrated Activity that hides (transition)', async () => {
3045 // Same as the previous test, except the update is wrapped
3046 // in startTransition.
3047 let suspend = false;
3048 let resolve;
3049 const promise = new Promise(resolvePromise => (resolve = resolvePromise));
3050
3051 function Second() {
3052 if (suspend) {
3053 throw promise;
3054 }
3055 return <span id="second">Second</span>;
3056 }
3057
3058 function App({showSecondOnMount}) {
3059 const [active, setActive] = React.useState('first');
3060 React.useEffect(() => {
3061 if (showSecondOnMount) {
3062 React.startTransition(() => {
3063 setActive('second');
3064 });
3065 }
3066 }, [showSecondOnMount]);
3067 return (
3068 <div>
3069 <Suspense fallback={null}>
3070 {active === 'second' ? <Second /> : null}
3071 <Activity mode={active === 'first' ? 'visible' : 'hidden'}>
3072 <span id="first">First</span>
3073 </Activity>
3074 </Suspense>
3075 </div>
3076 );
3077 }
3078
3079 suspend = false;
3080 const finalHTML = ReactDOMServer.renderToString(
3081 <App showSecondOnMount={false} />,
3082 );
3083 const container = document.createElement('div');
3084 container.innerHTML = finalHTML;
3085 expect(container.textContent).toBe('First');
3086
3087 suspend = true;
3088 await act(() => {
3089 ReactDOMClient.hydrateRoot(container, <App showSecondOnMount={true} />);
3090 });
3091
3092 suspend = false;
3093 await act(async () => {
3094 resolve();
3095 await promise;
3096 });
3097
3098 const second = container.querySelector('#second');
3099 const first = container.querySelector('#first');
3100 expect(second).not.toBe(null);
3101 expect(second.style.display).not.toBe('none');
3102 expect(first === null || first.style.display === 'none').toBe(true);
3103 });
3104 });