main
js 1,628 lines 42.6 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 import {patchMessageChannel} from '../../../../scripts/jest/patchMessageChannel';
14
15 import {
16 getVisibleChildren,
17 insertNodesAndExecuteScripts,
18 } from '../test-utils/FizzTestUtils';
19
20 // Polyfills for test environment
21 global.ReadableStream =
22 require('web-streams-polyfill/ponyfill/es6').ReadableStream;
23 global.TextEncoder = require('util').TextEncoder;
24 global.TextDecoder = require('util').TextDecoder;
25
26 let JSDOM;
27 let React;
28 let ReactDOMFizzServer;
29 let ReactDOMFizzStatic;
30 let Suspense;
31 let SuspenseList;
32 let container;
33 let serverAct;
34
35 describe('ReactDOMFizzStaticBrowser', () => {
36 beforeEach(() => {
37 jest.resetModules();
38 JSDOM = require('jsdom').JSDOM;
39
40 // We need the mocked version of setTimeout inside the document.
41 window.setTimeout = setTimeout;
42 window.requestAnimationFrame = setTimeout;
43
44 patchMessageChannel();
45 serverAct = require('internal-test-utils').serverAct;
46
47 React = require('react');
48 ReactDOMFizzServer = require('react-dom/server.browser');
49 ReactDOMFizzStatic = require('react-dom/static.browser');
50 Suspense = React.Suspense;
51 SuspenseList = React.unstable_SuspenseList;
52 container = document.createElement('div');
53 document.body.appendChild(container);
54 });
55
56 afterEach(() => {
57 if (typeof global.window.__restoreGlobalScope === 'function') {
58 global.window.__restoreGlobalScope();
59 }
60 document.body.removeChild(container);
61 });
62
63 const theError = new Error('This is an error');
64 function Throw() {
65 throw theError;
66 }
67 const theInfinitePromise = new Promise(() => {});
68 function InfiniteSuspend() {
69 throw theInfinitePromise;
70 }
71
72 async function readContent(stream) {
73 const reader = stream.getReader();
74 let content = '';
75 while (true) {
76 const {done, value} = await reader.read();
77 if (done) {
78 return content;
79 }
80 content += Buffer.from(value).toString('utf8');
81 }
82 }
83
84 async function readIntoContainer(stream) {
85 const reader = stream.getReader();
86 let result = '';
87 while (true) {
88 const {done, value} = await reader.read();
89 if (done) {
90 break;
91 }
92 result += Buffer.from(value).toString('utf8');
93 }
94 const temp = document.createElement('div');
95 temp.innerHTML = result;
96 await insertNodesAndExecuteScripts(temp, container, null);
97 jest.runAllTimers();
98 }
99
100 async function readIntoNewDocument(stream) {
101 const content = await readContent(stream);
102 const jsdom = new JSDOM(
103 // The Fizz runtime assumes requestAnimationFrame exists so we need to polyfill it.
104 '<script>window.requestAnimationFrame = setTimeout;</script>' + content,
105 {
106 runScripts: 'dangerously',
107 },
108 );
109 const originalWindow = global.window;
110 const originalDocument = global.document;
111 const originalNavigator = global.navigator;
112 const originalNode = global.Node;
113 const originalAddEventListener = global.addEventListener;
114 const originalMutationObserver = global.MutationObserver;
115 global.window = jsdom.window;
116 global.document = global.window.document;
117 global.navigator = global.window.navigator;
118 global.Node = global.window.Node;
119 global.addEventListener = global.window.addEventListener;
120 global.MutationObserver = global.window.MutationObserver;
121 global.window.__restoreGlobalScope = () => {
122 global.window = originalWindow;
123 global.document = originalDocument;
124 global.navigator = originalNavigator;
125 global.Node = originalNode;
126 global.addEventListener = originalAddEventListener;
127 global.MutationObserver = originalMutationObserver;
128 };
129 }
130
131 async function readIntoCurrentDocument(stream) {
132 const content = await readContent(stream);
133 const temp = document.createElement('div');
134 temp.innerHTML = content;
135 await insertNodesAndExecuteScripts(temp, document.body, null);
136 jest.runAllTimers();
137 }
138
139 it('should call prerender', async () => {
140 const result = await serverAct(() =>
141 ReactDOMFizzStatic.prerender(<div>hello world</div>),
142 );
143 const prelude = await readContent(result.prelude);
144 expect(prelude).toMatchInlineSnapshot(`"<div>hello world</div>"`);
145 });
146
147 it('should emit DOCTYPE at the root of the document', async () => {
148 const result = await serverAct(() =>
149 ReactDOMFizzStatic.prerender(
150 <html>
151 <body>hello world</body>
152 </html>,
153 ),
154 );
155 const prelude = await readContent(result.prelude);
156 if (gate(flags => flags.enableFizzBlockingRender)) {
157 expect(prelude).toMatchInlineSnapshot(
158 `"<!DOCTYPE html><html><head><link rel="expect" href="#_R_" blocking="render"/></head><body>hello world<template id="_R_"></template></body></html>"`,
159 );
160 } else {
161 expect(prelude).toMatchInlineSnapshot(
162 `"<!DOCTYPE html><html><head></head><body>hello world</body></html>"`,
163 );
164 }
165 });
166
167 it('should emit bootstrap script src at the end', async () => {
168 const result = await serverAct(() =>
169 ReactDOMFizzStatic.prerender(<div>hello world</div>, {
170 bootstrapScriptContent: 'INIT();',
171 bootstrapScripts: ['init.js'],
172 bootstrapModules: ['init.mjs'],
173 }),
174 );
175 const prelude = await readContent(result.prelude);
176 expect(prelude).toMatchInlineSnapshot(
177 `"<link rel="preload" as="script" fetchPriority="low" href="init.js"/><link rel="modulepreload" fetchPriority="low" href="init.mjs"/><div>hello world</div><script id="_R_">INIT();</script><script src="init.js" async=""></script><script type="module" src="init.mjs" async=""></script>"`,
178 );
179 });
180
181 it('emits all HTML as one unit', async () => {
182 let hasLoaded = false;
183 let resolve;
184 const promise = new Promise(r => (resolve = r));
185 function Wait() {
186 if (!hasLoaded) {
187 throw promise;
188 }
189 return 'Done';
190 }
191 const resultPromise = serverAct(() =>
192 ReactDOMFizzStatic.prerender(
193 <div>
194 <Suspense fallback="Loading">
195 <Wait />
196 </Suspense>
197 </div>,
198 ),
199 );
200
201 await jest.runAllTimers();
202
203 // Resolve the loading.
204 hasLoaded = true;
205 await resolve();
206
207 const result = await resultPromise;
208 const prelude = await readContent(result.prelude);
209 expect(prelude).toMatchInlineSnapshot(`"<div><!--$-->Done<!--/$--></div>"`);
210 });
211
212 it('should reject the promise when an error is thrown at the root', async () => {
213 const reportedErrors = [];
214 let caughtError = null;
215 try {
216 await serverAct(() =>
217 ReactDOMFizzStatic.prerender(
218 <div>
219 <Throw />
220 </div>,
221 {
222 onError(x) {
223 reportedErrors.push(x);
224 },
225 },
226 ),
227 );
228 } catch (error) {
229 caughtError = error;
230 }
231 expect(caughtError).toBe(theError);
232 expect(reportedErrors).toEqual([theError]);
233 });
234
235 it('should reject the promise when an error is thrown inside a fallback', async () => {
236 const reportedErrors = [];
237 let caughtError = null;
238 try {
239 await serverAct(() =>
240 ReactDOMFizzStatic.prerender(
241 <div>
242 <Suspense fallback={<Throw />}>
243 <InfiniteSuspend />
244 </Suspense>
245 </div>,
246 {
247 onError(x) {
248 reportedErrors.push(x);
249 },
250 },
251 ),
252 );
253 } catch (error) {
254 caughtError = error;
255 }
256 expect(caughtError).toBe(theError);
257 expect(reportedErrors).toEqual([theError]);
258 });
259
260 it('should not error the stream when an error is thrown inside suspense boundary', async () => {
261 const reportedErrors = [];
262 const result = await serverAct(() =>
263 ReactDOMFizzStatic.prerender(
264 <div>
265 <Suspense fallback={<div>Loading</div>}>
266 <Throw />
267 </Suspense>
268 </div>,
269 {
270 onError(x) {
271 reportedErrors.push(x);
272 },
273 },
274 ),
275 );
276
277 const prelude = await readContent(result.prelude);
278 expect(prelude).toContain('Loading');
279 expect(reportedErrors).toEqual([theError]);
280 });
281
282 it('should be able to complete by aborting even if the promise never resolves', async () => {
283 const errors = [];
284 const controller = new AbortController();
285 let resultPromise;
286 await serverAct(() => {
287 resultPromise = ReactDOMFizzStatic.prerender(
288 <div>
289 <Suspense fallback={<div>Loading</div>}>
290 <InfiniteSuspend />
291 </Suspense>
292 </div>,
293 {
294 signal: controller.signal,
295 onError(x) {
296 errors.push(x.message);
297 },
298 },
299 );
300 });
301
302 await serverAct(() => {
303 controller.abort();
304 });
305
306 const result = await resultPromise;
307
308 const prelude = await readContent(result.prelude);
309 expect(prelude).toContain('Loading');
310
311 expect(errors).toEqual(['This operation was aborted']);
312 });
313
314 it('should resolve an empty prelude if aborting before the shell is complete', async () => {
315 const errors = [];
316 const controller = new AbortController();
317 const promise = serverAct(() =>
318 ReactDOMFizzStatic.prerender(
319 <div>
320 <InfiniteSuspend />
321 </div>,
322 {
323 signal: controller.signal,
324 onError(x) {
325 errors.push(x.message);
326 },
327 },
328 ),
329 );
330
331 await jest.runAllTimers();
332
333 const theReason = new Error('aborted for reasons');
334 await serverAct(() => {
335 controller.abort(theReason);
336 });
337
338 let rejected = false;
339 let prelude;
340 try {
341 ({prelude} = await promise);
342 } catch (error) {
343 rejected = true;
344 }
345 expect(rejected).toBe(false);
346 expect(errors).toEqual(['aborted for reasons']);
347 const content = await readContent(prelude);
348 expect(content).toBe('');
349 });
350
351 it('should be able to abort before something suspends', async () => {
352 const errors = [];
353 const controller = new AbortController();
354 function App() {
355 controller.abort();
356 return (
357 <Suspense fallback={<div>Loading</div>}>
358 <InfiniteSuspend />
359 </Suspense>
360 );
361 }
362 const streamPromise = serverAct(() =>
363 ReactDOMFizzStatic.prerender(
364 <div>
365 <App />
366 </div>,
367 {
368 signal: controller.signal,
369 onError(x) {
370 errors.push(x.message);
371 },
372 },
373 ),
374 );
375
376 const {prelude} = await streamPromise;
377 const content = await readContent(prelude);
378 expect(errors).toEqual(['This operation was aborted']);
379 expect(content).toBe('');
380 });
381
382 it('reports the abort reason if a task suspends after aborting a prerender', async () => {
383 const promise = new Promise(() => {});
384 const errors = [];
385 const controller = new AbortController();
386 function App() {
387 controller.abort(new Error('abort reason'));
388 React.use(promise);
389 return null;
390 }
391
392 const result = await serverAct(() =>
393 ReactDOMFizzStatic.prerender(<App />, {
394 signal: controller.signal,
395 onError(error) {
396 errors.push(error.message);
397 },
398 }),
399 );
400
401 expect(errors).toEqual(['abort reason']);
402 expect(await readContent(result.prelude)).toBe('');
403 });
404
405 it('should resolve an empty prelude if passing an already aborted signal', async () => {
406 const errors = [];
407 const controller = new AbortController();
408 const theReason = new Error('aborted for reasons');
409 controller.abort(theReason);
410
411 const promise = serverAct(() =>
412 ReactDOMFizzStatic.prerender(
413 <div>
414 <Suspense fallback={<div>Loading</div>}>
415 <InfiniteSuspend />
416 </Suspense>
417 </div>,
418 {
419 signal: controller.signal,
420 onError(x) {
421 errors.push(x.message);
422 },
423 },
424 ),
425 );
426
427 // Technically we could still continue rendering the shell but currently the
428 // semantics mean that we also abort any pending CPU work.
429 let didThrow = false;
430 let prelude;
431 try {
432 ({prelude} = await promise);
433 } catch (error) {
434 didThrow = true;
435 }
436 expect(didThrow).toBe(false);
437 expect(errors).toEqual(['aborted for reasons']);
438 const content = await readContent(prelude);
439 expect(content).toBe('');
440 });
441
442 it('supports custom abort reasons with a string', async () => {
443 const promise = new Promise(r => {});
444 function Wait() {
445 throw promise;
446 }
447 function App() {
448 return (
449 <div>
450 <p>
451 <Suspense fallback={'p'}>
452 <Wait />
453 </Suspense>
454 </p>
455 <span>
456 <Suspense fallback={'span'}>
457 <Wait />
458 </Suspense>
459 </span>
460 </div>
461 );
462 }
463
464 const errors = [];
465 const controller = new AbortController();
466 let resultPromise;
467 await serverAct(() => {
468 resultPromise = ReactDOMFizzStatic.prerender(<App />, {
469 signal: controller.signal,
470 onError(x) {
471 errors.push(x);
472 return 'a digest';
473 },
474 });
475 });
476
477 await serverAct(() => {
478 controller.abort('foobar');
479 });
480
481 await resultPromise;
482
483 expect(errors).toEqual(['foobar', 'foobar']);
484 });
485
486 it('supports custom abort reasons with an Error', async () => {
487 const promise = new Promise(r => {});
488 function Wait() {
489 throw promise;
490 }
491 function App() {
492 return (
493 <div>
494 <p>
495 <Suspense fallback={'p'}>
496 <Wait />
497 </Suspense>
498 </p>
499 <span>
500 <Suspense fallback={'span'}>
501 <Wait />
502 </Suspense>
503 </span>
504 </div>
505 );
506 }
507
508 const errors = [];
509 const controller = new AbortController();
510 let resultPromise;
511 await serverAct(() => {
512 resultPromise = ReactDOMFizzStatic.prerender(<App />, {
513 signal: controller.signal,
514 onError(x) {
515 errors.push(x.message);
516 return 'a digest';
517 },
518 });
519 });
520
521 await serverAct(() => {
522 controller.abort(new Error('uh oh'));
523 });
524
525 await resultPromise;
526
527 expect(errors).toEqual(['uh oh', 'uh oh']);
528 });
529
530 it('uses a rejection reason when an abort listener rejects pending work before the abort finishes', async () => {
531 let reject;
532 const rejectedPromise = new Promise((resolve, rejectPromise) => {
533 reject = rejectPromise;
534 });
535 const haltedPromise = new Promise(() => {});
536 function RejectedWait() {
537 React.use(rejectedPromise);
538 return null;
539 }
540 function HaltedWait() {
541 React.use(haltedPromise);
542 return null;
543 }
544
545 const errors = [];
546 const controller = new AbortController();
547 let resultPromise;
548 await serverAct(() => {
549 resultPromise = ReactDOMFizzStatic.prerender(
550 <>
551 <Suspense fallback="Loading rejected">
552 <RejectedWait />
553 </Suspense>
554 <Suspense fallback="Loading halted">
555 <HaltedWait />
556 </Suspense>
557 </>,
558 {
559 signal: controller.signal,
560 onError(error) {
561 errors.push(error.message);
562 },
563 },
564 );
565 });
566
567 controller.signal.addEventListener('abort', () => {
568 reject(new Error('rejected during abort'));
569 });
570 await serverAct(() => {
571 controller.abort(new Error('abort reason'));
572 });
573 await resultPromise;
574
575 expect(errors).toEqual(['rejected during abort', 'abort reason']);
576 });
577
578 it('logs an error if onHeaders throws but continues the prerender', async () => {
579 const errors = [];
580 function onError(error) {
581 errors.push(error.message);
582 }
583
584 function onHeaders(x) {
585 throw new Error('bad onHeaders');
586 }
587
588 const prerendered = await serverAct(() =>
589 ReactDOMFizzStatic.prerender(<div>hello</div>, {
590 onHeaders,
591 onError,
592 }),
593 );
594 expect(prerendered.postponed).toBe(null);
595 expect(errors).toEqual(['bad onHeaders']);
596
597 await readIntoContainer(prerendered.prelude);
598 expect(getVisibleChildren(container)).toEqual(<div>hello</div>);
599 });
600
601 it('can resume render of a prerender', async () => {
602 const errors = [];
603
604 let resolveA;
605 const promiseA = new Promise(r => (resolveA = r));
606 let resolveB;
607 const promiseB = new Promise(r => (resolveB = r));
608
609 async function ComponentA() {
610 await promiseA;
611 return (
612 <Suspense fallback="Loading B">
613 <ComponentB />
614 </Suspense>
615 );
616 }
617
618 async function ComponentB() {
619 await promiseB;
620 return 'Hello';
621 }
622
623 function App() {
624 return (
625 <div>
626 <Suspense fallback="Loading A">
627 <ComponentA />
628 </Suspense>
629 </div>
630 );
631 }
632
633 const controller = new AbortController();
634 let pendingResult;
635 await serverAct(async () => {
636 pendingResult = ReactDOMFizzStatic.prerender(<App />, {
637 signal: controller.signal,
638 onError(x) {
639 errors.push(x.message);
640 },
641 });
642 });
643
644 await serverAct(() => {
645 controller.abort();
646 });
647 const prerendered = await pendingResult;
648 const postponedState = JSON.stringify(prerendered.postponed);
649
650 await readIntoContainer(prerendered.prelude);
651 expect(getVisibleChildren(container)).toEqual(<div>Loading A</div>);
652
653 await resolveA();
654
655 expect(prerendered.postponed).not.toBe(null);
656
657 const controller2 = new AbortController();
658 await serverAct(async () => {
659 pendingResult = ReactDOMFizzStatic.resumeAndPrerender(
660 <App />,
661 JSON.parse(postponedState),
662 {
663 signal: controller2.signal,
664 onError(x) {
665 errors.push(x.message);
666 },
667 },
668 );
669 });
670
671 await serverAct(() => {
672 controller2.abort();
673 });
674
675 const prerendered2 = await pendingResult;
676 const postponedState2 = JSON.stringify(prerendered2.postponed);
677
678 await readIntoContainer(prerendered2.prelude);
679 expect(getVisibleChildren(container)).toEqual(<div>Loading B</div>);
680
681 await resolveB();
682
683 const dynamic = await serverAct(() =>
684 ReactDOMFizzServer.resume(<App />, JSON.parse(postponedState2)),
685 );
686
687 await readIntoContainer(dynamic);
688 expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
689 });
690
691 it('can abort while replaying a prerendered tree', async () => {
692 const promise = new Promise(() => {});
693 let prerendering = true;
694 const resumeController = new AbortController();
695
696 function AbortDuringReplay({children}) {
697 if (!prerendering) {
698 resumeController.abort('resume abort');
699 }
700 return children;
701 }
702
703 function Wait() {
704 return prerendering ? React.use(promise) : 'Hello';
705 }
706
707 function App() {
708 return (
709 <div>
710 <AbortDuringReplay>
711 <Suspense fallback="Loading 1...">
712 <Wait />
713 </Suspense>
714 </AbortDuringReplay>
715 </div>
716 );
717 }
718
719 const controller = new AbortController();
720 let pendingResult;
721 await serverAct(() => {
722 pendingResult = ReactDOMFizzStatic.prerender(<App />, {
723 signal: controller.signal,
724 onError() {},
725 });
726 });
727 await serverAct(() => {
728 controller.abort('prerender abort');
729 });
730 const prerendered = await pendingResult;
731
732 prerendering = false;
733 const errors = [];
734 await serverAct(() =>
735 ReactDOMFizzServer.resume(
736 <App />,
737 JSON.parse(JSON.stringify(prerendered.postponed)),
738 {
739 signal: resumeController.signal,
740 onError(error) {
741 errors.push(error);
742 },
743 },
744 ),
745 );
746
747 expect(errors).toEqual(['resume abort']);
748 });
749
750 it('can abort and suspend while replaying a prerendered tree', async () => {
751 const promise = new Promise(() => {});
752 let prerendering = true;
753 const resumeController = new AbortController();
754
755 function AbortDuringReplay({children}) {
756 if (!prerendering) {
757 resumeController.abort('resume abort');
758 React.use(promise);
759 }
760 return children;
761 }
762
763 function Wait() {
764 return React.use(promise);
765 }
766
767 function App() {
768 return (
769 <div>
770 <AbortDuringReplay>
771 <Suspense fallback="Loading...">
772 <Wait />
773 </Suspense>
774 </AbortDuringReplay>
775 </div>
776 );
777 }
778
779 const controller = new AbortController();
780 let pendingResult;
781 await serverAct(() => {
782 pendingResult = ReactDOMFizzStatic.prerender(<App />, {
783 signal: controller.signal,
784 onError() {},
785 });
786 });
787 await serverAct(() => {
788 controller.abort('prerender abort');
789 });
790 const prerendered = await pendingResult;
791
792 prerendering = false;
793 const errors = [];
794 await serverAct(() =>
795 ReactDOMFizzServer.resume(
796 <App />,
797 JSON.parse(JSON.stringify(prerendered.postponed)),
798 {
799 signal: resumeController.signal,
800 onError(error) {
801 errors.push(error);
802 },
803 },
804 ),
805 );
806
807 expect(errors).toEqual(['resume abort']);
808 });
809
810 it('can abort while rendering a resumed segment', async () => {
811 const promise = new Promise(() => {});
812 let prerendering = true;
813 const resumeController = new AbortController();
814
815 function Wait() {
816 if (prerendering) {
817 return React.use(promise);
818 }
819 resumeController.abort('resume abort');
820 return 'Hello';
821 }
822
823 function App() {
824 return (
825 <div>
826 <Suspense fallback="Loading...">
827 <Wait />
828 </Suspense>
829 </div>
830 );
831 }
832
833 const controller = new AbortController();
834 let pendingResult;
835 await serverAct(() => {
836 pendingResult = ReactDOMFizzStatic.prerender(<App />, {
837 signal: controller.signal,
838 onError() {},
839 });
840 });
841 await serverAct(() => {
842 controller.abort('prerender abort');
843 });
844 const prerendered = await pendingResult;
845
846 prerendering = false;
847 const errors = [];
848 await serverAct(() =>
849 ReactDOMFizzServer.resume(
850 <App />,
851 JSON.parse(JSON.stringify(prerendered.postponed)),
852 {
853 signal: resumeController.signal,
854 onError(error) {
855 errors.push(error);
856 },
857 },
858 ),
859 );
860
861 expect(errors).toEqual(['resume abort']);
862 });
863
864 it('can prerender a preamble', async () => {
865 const errors = [];
866
867 let resolveA;
868 const promiseA = new Promise(r => (resolveA = r));
869 let resolveB;
870 const promiseB = new Promise(r => (resolveB = r));
871
872 async function ComponentA() {
873 await promiseA;
874 return (
875 <Suspense fallback="Loading B">
876 <ComponentB />
877 </Suspense>
878 );
879 }
880
881 async function ComponentB() {
882 await promiseB;
883 return 'Hello';
884 }
885
886 function App() {
887 return (
888 <Suspense>
889 <html data-x="">
890 <body data-x="">
891 <Suspense fallback="Loading A">
892 <ComponentA />
893 </Suspense>
894 </body>
895 </html>
896 </Suspense>
897 );
898 }
899
900 const controller = new AbortController();
901 let pendingResult;
902 await serverAct(async () => {
903 pendingResult = ReactDOMFizzStatic.prerender(<App />, {
904 signal: controller.signal,
905 onError(x) {
906 errors.push(x.message);
907 },
908 });
909 });
910
911 await serverAct(() => {
912 controller.abort();
913 });
914
915 const prerendered = await pendingResult;
916 const postponedState = JSON.stringify(prerendered.postponed);
917
918 await readIntoNewDocument(prerendered.prelude);
919 expect(getVisibleChildren(document)).toEqual(
920 <html data-x="">
921 <head />
922 <body data-x="">Loading A</body>
923 </html>,
924 );
925
926 await resolveA();
927
928 expect(prerendered.postponed).not.toBe(null);
929
930 const controller2 = new AbortController();
931 await serverAct(async () => {
932 pendingResult = ReactDOMFizzStatic.resumeAndPrerender(
933 <App />,
934 JSON.parse(postponedState),
935 {
936 signal: controller2.signal,
937 onError(x) {
938 errors.push(x.message);
939 },
940 },
941 );
942 });
943
944 await serverAct(() => {
945 controller2.abort();
946 });
947
948 const prerendered2 = await pendingResult;
949 const postponedState2 = JSON.stringify(prerendered2.postponed);
950
951 await readIntoCurrentDocument(prerendered2.prelude);
952 expect(getVisibleChildren(document)).toEqual(
953 <html data-x="">
954 <head />
955 <body data-x="">Loading B</body>
956 </html>,
957 );
958
959 await resolveB();
960
961 const dynamic = await serverAct(() =>
962 ReactDOMFizzServer.resume(<App />, JSON.parse(postponedState2)),
963 );
964
965 await readIntoCurrentDocument(dynamic);
966 expect(getVisibleChildren(document)).toEqual(
967 <html data-x="">
968 <head />
969 <body data-x="">Hello</body>
970 </html>,
971 );
972 });
973
974 it('can suspend inside <head> tag', async () => {
975 const promise = new Promise(() => {});
976
977 function App() {
978 return (
979 <html>
980 <head>
981 <Suspense fallback={<meta itemProp="" content="fallback" />}>
982 <Metadata />
983 </Suspense>
984 </head>
985 <body>
986 <div>hello</div>
987 </body>
988 </html>
989 );
990 }
991
992 function Metadata() {
993 React.use(promise);
994 return <meta itemProp="" content="primary" />;
995 }
996
997 const controller = new AbortController();
998 let pendingResult;
999 const errors = [];
1000 await serverAct(() => {
1001 pendingResult = ReactDOMFizzStatic.prerender(<App />, {
1002 signal: controller.signal,
1003 onError: e => {
1004 errors.push(e.message);
1005 },
1006 });
1007 });
1008
1009 await serverAct(() => {
1010 controller.abort(new Error('boom'));
1011 });
1012
1013 const prerendered = await pendingResult;
1014
1015 await readIntoNewDocument(prerendered.prelude);
1016 expect(getVisibleChildren(document)).toEqual(
1017 <html>
1018 <head>
1019 <meta itemprop="" content="fallback" />
1020 </head>
1021 <body>
1022 <div>hello</div>
1023 </body>
1024 </html>,
1025 );
1026
1027 expect(errors).toEqual(['boom']);
1028 });
1029
1030 it('will render fallback Document when erroring a boundary above the body', async () => {
1031 let isPrerendering = true;
1032 const promise = new Promise(() => {});
1033
1034 function Boom() {
1035 if (isPrerendering) {
1036 React.use(promise);
1037 }
1038 throw new Error('Boom!');
1039 }
1040
1041 function App() {
1042 return (
1043 <Suspense
1044 fallback={
1045 <html data-error-html="">
1046 <body data-error-body="">
1047 <span>hello error</span>
1048 </body>
1049 </html>
1050 }>
1051 <html data-content-html="">
1052 <body data-content-body="">
1053 <Boom />
1054 <span>hello world</span>
1055 </body>
1056 </html>
1057 </Suspense>
1058 );
1059 }
1060
1061 const controller = new AbortController();
1062 let pendingResult;
1063 const errors = [];
1064 await serverAct(() => {
1065 pendingResult = ReactDOMFizzStatic.prerender(<App />, {
1066 signal: controller.signal,
1067 onError: e => {
1068 errors.push(e.message);
1069 },
1070 });
1071 });
1072
1073 await serverAct(() => {
1074 controller.abort();
1075 });
1076
1077 const prerendered = await pendingResult;
1078
1079 expect(errors).toEqual(['This operation was aborted']);
1080 const content = await readContent(prerendered.prelude);
1081 expect(content).toBe('');
1082
1083 isPrerendering = false;
1084 const postponedState = JSON.stringify(prerendered.postponed);
1085
1086 const resumeErrors = [];
1087 const dynamic = await serverAct(() =>
1088 ReactDOMFizzServer.resume(<App />, JSON.parse(postponedState), {
1089 onError: e => {
1090 resumeErrors.push(e.message);
1091 },
1092 }),
1093 );
1094
1095 expect(resumeErrors).toEqual(['Boom!']);
1096 await readIntoNewDocument(dynamic);
1097
1098 expect(getVisibleChildren(document)).toEqual(
1099 <html data-error-html="">
1100 <head />
1101 <body data-error-body="">
1102 <span>hello error</span>
1103 </body>
1104 </html>,
1105 );
1106 });
1107
1108 it('reveals a resumed boundary even when the shell outlined a completed boundary', async () => {
1109 // Regression test for a segment-id collision between the prerendered shell
1110 // and the resume. The prelude flush outlines a large *completed* boundary
1111 // into the shell, advancing request.nextSegmentId past the value
1112 // getPostponedState snapshotted before the flush. If that snapshot isn't
1113 // finalized after the flush, the resume re-allocates ids the shell already
1114 // used; in the served document $RC (getElementById, first match) then
1115 // reveals the wrong element and the resumed boundary stays on its fallback.
1116 // Asserts on the rendered output rather than the segment ids.
1117 let prerendering = true;
1118 const shellText = 'a'.repeat(800); // > 500 bytes => eligible for outlining
1119 const resumeText = 'b'.repeat(800);
1120
1121 // Completes during the prerender; large enough that the prelude flush
1122 // outlines it into the shell.
1123 function ShellBoundary() {
1124 return <div>{shellText}</div>;
1125 }
1126
1127 // Suspends during the prerender so its boundary becomes a hole the resume
1128 // fills. On resume it renders a nested large boundary that itself outlines,
1129 // so the resume allocates fresh segment ids from the postponed seed.
1130 function Hole() {
1131 if (prerendering) {
1132 return React.use(theInfinitePromise);
1133 }
1134 return (
1135 <Suspense fallback="LoadingC">
1136 <div>{resumeText}</div>
1137 </Suspense>
1138 );
1139 }
1140
1141 function App() {
1142 return (
1143 <div>
1144 <Suspense fallback="LoadingA">
1145 <ShellBoundary />
1146 </Suspense>
1147 <Suspense fallback="LoadingB">
1148 <Hole />
1149 </Suspense>
1150 </div>
1151 );
1152 }
1153
1154 const controller = new AbortController();
1155 let pendingResult;
1156 await serverAct(() => {
1157 pendingResult = ReactDOMFizzStatic.prerender(<App />, {
1158 signal: controller.signal,
1159 progressiveChunkSize: 100, // force the completed boundary to outline
1160 onError() {},
1161 });
1162 });
1163 await serverAct(() => controller.abort());
1164 const prerendered = await pendingResult;
1165 expect(prerendered.postponed).not.toBe(null);
1166
1167 const shellHTML = await readContent(prerendered.prelude);
1168
1169 prerendering = false;
1170 const resumed = await serverAct(() =>
1171 ReactDOMFizzServer.resume(
1172 <App />,
1173 JSON.parse(JSON.stringify(prerendered.postponed)),
1174 {onError() {}},
1175 ),
1176 );
1177 const resumeHTML = await readContent(resumed);
1178
1179 // Run the shell and the resume as one served document so the completion
1180 // instructions ($RC) execute against both together, the way a browser does.
1181 const temp = document.createElement('div');
1182 temp.innerHTML = shellHTML + resumeHTML;
1183 await insertNodesAndExecuteScripts(temp, container, null);
1184 jest.runAllTimers();
1185
1186 // Both boundaries reveal their own content. Without the fix the resumed
1187 // boundary reuses the shell's ids, so its $RC resolves to the shell's
1188 // element and it stays on its "LoadingC" fallback.
1189 expect(getVisibleChildren(container)).toEqual(
1190 <div>
1191 <div>{shellText}</div>
1192 <div>{resumeText}</div>
1193 </div>,
1194 );
1195 });
1196
1197 it('can omit a preamble with an empty shell if no preamble is ready when prerendering finishes', async () => {
1198 const errors = [];
1199
1200 let resolveA;
1201 const promiseA = new Promise(r => (resolveA = r));
1202 let resolveB;
1203 const promiseB = new Promise(r => (resolveB = r));
1204
1205 async function ComponentA() {
1206 await promiseA;
1207 return (
1208 <Suspense fallback="Loading B">
1209 <ComponentB />
1210 </Suspense>
1211 );
1212 }
1213
1214 async function ComponentB() {
1215 await promiseB;
1216 return 'Hello';
1217 }
1218
1219 function App() {
1220 return (
1221 <Suspense>
1222 <html data-x="">
1223 <body data-x="">
1224 <ComponentA />
1225 </body>
1226 </html>
1227 </Suspense>
1228 );
1229 }
1230
1231 const controller = new AbortController();
1232 let pendingResult;
1233 await serverAct(async () => {
1234 pendingResult = ReactDOMFizzStatic.prerender(<App />, {
1235 signal: controller.signal,
1236 onError(x) {
1237 errors.push(x.message);
1238 },
1239 });
1240 });
1241
1242 await serverAct(() => {
1243 controller.abort();
1244 });
1245
1246 const prerendered = await pendingResult;
1247 const postponedState = JSON.stringify(prerendered.postponed);
1248
1249 const content = await readContent(prerendered.prelude);
1250 expect(content).toBe('');
1251
1252 await resolveA();
1253
1254 expect(prerendered.postponed).not.toBe(null);
1255
1256 const controller2 = new AbortController();
1257 await serverAct(async () => {
1258 pendingResult = ReactDOMFizzStatic.resumeAndPrerender(
1259 <App />,
1260 JSON.parse(postponedState),
1261 {
1262 signal: controller2.signal,
1263 onError(x) {
1264 errors.push(x.message);
1265 },
1266 },
1267 );
1268 });
1269
1270 await serverAct(() => {
1271 controller2.abort();
1272 });
1273
1274 const prerendered2 = await pendingResult;
1275 const postponedState2 = JSON.stringify(prerendered2.postponed);
1276
1277 await readIntoNewDocument(prerendered2.prelude);
1278 expect(getVisibleChildren(document)).toEqual(
1279 <html data-x="">
1280 <head />
1281 <body data-x="">Loading B</body>
1282 </html>,
1283 );
1284
1285 await resolveB();
1286
1287 const dynamic = await serverAct(() =>
1288 ReactDOMFizzServer.resume(<App />, JSON.parse(postponedState2)),
1289 );
1290
1291 await readIntoCurrentDocument(dynamic);
1292 expect(getVisibleChildren(document)).toEqual(
1293 <html data-x="">
1294 <head />
1295 <body data-x="">Hello</body>
1296 </html>,
1297 );
1298 });
1299
1300 // @gate enableSuspenseList
1301 it('can resume a partially prerendered SuspenseList', async () => {
1302 const errors = [];
1303
1304 let resolveA;
1305 const promiseA = new Promise(r => (resolveA = r));
1306 let resolveB;
1307 const promiseB = new Promise(r => (resolveB = r));
1308
1309 async function ComponentA() {
1310 await promiseA;
1311 return 'A';
1312 }
1313
1314 async function ComponentB() {
1315 await promiseB;
1316 return 'B';
1317 }
1318
1319 function App() {
1320 return (
1321 <div>
1322 <SuspenseList revealOrder="forwards" tail="visible">
1323 <Suspense fallback="Loading A">
1324 <ComponentA />
1325 </Suspense>
1326 <Suspense fallback="Loading B">
1327 <ComponentB />
1328 </Suspense>
1329 <Suspense fallback="Loading C">C</Suspense>
1330 <Suspense fallback="Loading D">D</Suspense>
1331 </SuspenseList>
1332 </div>
1333 );
1334 }
1335
1336 const controller = new AbortController();
1337 const pendingResult = serverAct(() =>
1338 ReactDOMFizzStatic.prerender(<App />, {
1339 signal: controller.signal,
1340 onError(x) {
1341 errors.push(x.message);
1342 },
1343 }),
1344 );
1345
1346 await serverAct(() => {
1347 controller.abort();
1348 });
1349
1350 const prerendered = await pendingResult;
1351
1352 const postponedState = JSON.stringify(prerendered.postponed);
1353
1354 await readIntoContainer(prerendered.prelude);
1355 expect(getVisibleChildren(container)).toEqual(
1356 <div>
1357 {'Loading A'}
1358 {'Loading B'}
1359 {'Loading C'}
1360 {'Loading D'}
1361 </div>,
1362 );
1363
1364 expect(prerendered.postponed).not.toBe(null);
1365
1366 await resolveA();
1367 await resolveB();
1368
1369 const dynamic = await serverAct(() =>
1370 ReactDOMFizzServer.resume(<App />, JSON.parse(postponedState)),
1371 );
1372
1373 await readIntoContainer(dynamic);
1374
1375 expect(getVisibleChildren(container)).toEqual(
1376 <div>
1377 {'A'}
1378 {'B'}
1379 {'C'}
1380 {'D'}
1381 </div>,
1382 );
1383 });
1384
1385 // @gate enableOptimisticKey
1386 it('can resume an optimistic keyed slot', async () => {
1387 const errors = [];
1388
1389 let resolve;
1390 const promise = new Promise(r => (resolve = r));
1391
1392 async function Component() {
1393 await promise;
1394 return 'Hi';
1395 }
1396
1397 if (React.optimisticKey === undefined) {
1398 throw new Error('optimisticKey missing');
1399 }
1400
1401 function App() {
1402 return (
1403 <div>
1404 <Suspense fallback="Loading">
1405 <Component key={React.optimisticKey} />
1406 </Suspense>
1407 </div>
1408 );
1409 }
1410
1411 const controller = new AbortController();
1412 const pendingResult = serverAct(() =>
1413 ReactDOMFizzStatic.prerender(<App />, {
1414 signal: controller.signal,
1415 onError(x) {
1416 errors.push(x.message);
1417 },
1418 }),
1419 );
1420
1421 await serverAct(() => {
1422 controller.abort();
1423 });
1424
1425 const prerendered = await pendingResult;
1426
1427 const postponedState = JSON.stringify(prerendered.postponed);
1428
1429 await readIntoContainer(prerendered.prelude);
1430 expect(getVisibleChildren(container)).toEqual(<div>Loading</div>);
1431
1432 expect(prerendered.postponed).not.toBe(null);
1433
1434 await resolve();
1435
1436 const dynamic = await serverAct(() =>
1437 ReactDOMFizzServer.resume(<App />, JSON.parse(postponedState)),
1438 );
1439
1440 await readIntoContainer(dynamic);
1441
1442 expect(getVisibleChildren(container)).toEqual(<div>Hi</div>);
1443 });
1444
1445 describe('abort signal lifetime', () => {
1446 // Collects the lifetime signal that React bounds each abort listener with.
1447 // React passes that signal to addEventListener instead of calling
1448 // removeEventListener, so the runtime performs the removal and nothing here
1449 // observes it directly. An aborted lifetime is what shows the listener is
1450 // gone.
1451 function trackAbortListenerLifetimes(signal) {
1452 const lifetimes = [];
1453 const add = signal.addEventListener.bind(signal);
1454 signal.addEventListener = (type, listener, options) => {
1455 if (type === 'abort') {
1456 lifetimes.push(options.signal);
1457 }
1458 return add(type, listener, options);
1459 };
1460 return lifetimes;
1461 }
1462
1463 it('detaches the listener when a prerender completes', async () => {
1464 const controller = new AbortController();
1465 const lifetimes = trackAbortListenerLifetimes(controller.signal);
1466
1467 const result = await serverAct(() =>
1468 ReactDOMFizzStatic.prerender(<div>hello world</div>, {
1469 signal: controller.signal,
1470 }),
1471 );
1472 expect(lifetimes).toHaveLength(1);
1473 expect(lifetimes[0].aborted).toBe(false);
1474
1475 await readContent(result.prelude);
1476 expect(lifetimes[0].aborted).toBe(true);
1477 });
1478
1479 it('detaches the listener when a render completes', async () => {
1480 const controller = new AbortController();
1481 const lifetimes = trackAbortListenerLifetimes(controller.signal);
1482
1483 const stream = await serverAct(() =>
1484 ReactDOMFizzServer.renderToReadableStream(<div>hello world</div>, {
1485 signal: controller.signal,
1486 }),
1487 );
1488 expect(lifetimes).toHaveLength(1);
1489 expect(lifetimes[0].aborted).toBe(false);
1490
1491 await readContent(stream);
1492 expect(lifetimes[0].aborted).toBe(true);
1493 });
1494
1495 it('detaches the listener when the signal aborts mid-render', async () => {
1496 let hasLoaded = false;
1497 let resolve;
1498 const promise = new Promise(r => (resolve = r));
1499 function Wait() {
1500 if (!hasLoaded) {
1501 throw promise;
1502 }
1503 return 'Done';
1504 }
1505
1506 const controller = new AbortController();
1507 const lifetimes = trackAbortListenerLifetimes(controller.signal);
1508
1509 const resultPromise = ReactDOMFizzStatic.prerender(
1510 <div>
1511 <Suspense fallback="Loading">
1512 <Wait />
1513 </Suspense>
1514 </div>,
1515 {signal: controller.signal, onError() {}},
1516 );
1517 await jest.runAllTimers();
1518 expect(lifetimes).toHaveLength(1);
1519 expect(lifetimes[0].aborted).toBe(false);
1520
1521 controller.abort();
1522 hasLoaded = true;
1523 resolve();
1524 await serverAct(() => resultPromise);
1525
1526 expect(lifetimes[0].aborted).toBe(true);
1527 });
1528
1529 it('detaches the listener when the stream is cancelled', async () => {
1530 let hasLoaded = false;
1531 let resolve;
1532 const promise = new Promise(r => (resolve = r));
1533 function Wait() {
1534 if (!hasLoaded) {
1535 throw promise;
1536 }
1537 return 'Done';
1538 }
1539
1540 const controller = new AbortController();
1541 const lifetimes = trackAbortListenerLifetimes(controller.signal);
1542
1543 const stream = await serverAct(() =>
1544 ReactDOMFizzServer.renderToReadableStream(
1545 <div>
1546 <Suspense fallback="Loading">
1547 <Wait />
1548 </Suspense>
1549 </div>,
1550 {signal: controller.signal, onError() {}},
1551 ),
1552 );
1553 expect(lifetimes).toHaveLength(1);
1554 expect(lifetimes[0].aborted).toBe(false);
1555
1556 await serverAct(() => stream.cancel());
1557 hasLoaded = true;
1558 resolve();
1559
1560 expect(lifetimes[0].aborted).toBe(true);
1561 });
1562
1563 it('detaches the listener when the shell errors', async () => {
1564 // A shell error rejects before the caller ever receives a stream, so
1565 // nothing consumes the request and it never closes. The listener has to
1566 // come off at the fatal error itself.
1567 function Boom() {
1568 throw new Error('boom');
1569 }
1570
1571 const controller = new AbortController();
1572 const lifetimes = trackAbortListenerLifetimes(controller.signal);
1573
1574 await expect(
1575 serverAct(() =>
1576 ReactDOMFizzServer.renderToReadableStream(<Boom />, {
1577 signal: controller.signal,
1578 onError() {},
1579 }),
1580 ),
1581 ).rejects.toThrow('boom');
1582
1583 expect(lifetimes).toHaveLength(1);
1584 expect(lifetimes[0].aborted).toBe(true);
1585 });
1586
1587 it('constructs no abort controller when no signal is passed', async () => {
1588 // The render lifetime exists only to bound the caller's abort listener,
1589 // so a render that is given no signal has nothing to bound and should not
1590 // pay for a controller. It also means such a render never requires
1591 // AbortController to exist or to work.
1592 const RealAbortController = globalThis.AbortController;
1593 let constructed = 0;
1594 globalThis.AbortController = class extends RealAbortController {
1595 constructor() {
1596 super();
1597 constructed++;
1598 }
1599 };
1600
1601 try {
1602 const stream = await serverAct(() =>
1603 ReactDOMFizzServer.renderToReadableStream(<div>hello world</div>),
1604 );
1605 await readContent(stream);
1606 } finally {
1607 globalThis.AbortController = RealAbortController;
1608 }
1609
1610 expect(constructed).toBe(0);
1611 });
1612
1613 it('attaches no listener when the signal is already aborted', async () => {
1614 const controller = new AbortController();
1615 controller.abort();
1616 const lifetimes = trackAbortListenerLifetimes(controller.signal);
1617
1618 await serverAct(() =>
1619 ReactDOMFizzStatic.prerender(<div>hello world</div>, {
1620 signal: controller.signal,
1621 onError() {},
1622 }),
1623 );
1624
1625 expect(lifetimes).toHaveLength(0);
1626 });
1627 });
1628 });