main
js 10,927 lines 295 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 import {
13 insertNodesAndExecuteScripts,
14 mergeOptions,
15 stripExternalRuntimeInNodes,
16 getVisibleChildren,
17 } from '../test-utils/FizzTestUtils';
18
19 let JSDOM;
20 let Stream;
21 let Scheduler;
22 let React;
23 let ReactDOM;
24 let ReactDOMClient;
25 let ReactDOMFizzServer;
26 let ReactDOMFizzStatic;
27 let Suspense;
28 let SuspenseList;
29
30 let assertConsoleErrorDev;
31 let useSyncExternalStore;
32 let useSyncExternalStoreWithSelector;
33 let use;
34 let useActionState;
35 let PropTypes;
36 let textCache;
37 let writable;
38 let CSPnonce = null;
39 let container;
40 let buffer = '';
41 let hasErrored = false;
42 let fatalError = undefined;
43 let renderOptions;
44 let waitFor;
45 let waitForAll;
46 let assertLog;
47 let waitForPaint;
48 let clientAct;
49 let streamingContainer;
50
51 function normalizeError(msg) {
52 // Take the first sentence to make it easier to assert on.
53 const idx = msg.indexOf('.');
54 if (idx > -1) {
55 return msg.slice(0, idx + 1);
56 }
57 return msg;
58 }
59
60 describe('ReactDOMFizzServer', () => {
61 beforeEach(() => {
62 jest.resetModules();
63 JSDOM = require('jsdom').JSDOM;
64
65 const jsdom = new JSDOM(
66 '<!DOCTYPE html><html><head></head><body><div id="container">',
67 {
68 runScripts: 'dangerously',
69 },
70 );
71 // We mock matchMedia. for simplicity it only matches 'all' or '' and misses everything else
72 Object.defineProperty(jsdom.window, 'matchMedia', {
73 writable: true,
74 value: jest.fn().mockImplementation(query => ({
75 matches: query === 'all' || query === '',
76 media: query,
77 })),
78 });
79 streamingContainer = null;
80 global.window = jsdom.window;
81 global.document = global.window.document;
82 global.navigator = global.window.navigator;
83 global.Node = global.window.Node;
84 global.addEventListener = global.window.addEventListener;
85 global.MutationObserver = global.window.MutationObserver;
86 // The Fizz runtime assumes requestAnimationFrame exists so we need to polyfill it.
87 global.requestAnimationFrame = global.window.requestAnimationFrame = cb =>
88 setTimeout(cb);
89 container = document.getElementById('container');
90
91 CSPnonce = null;
92 Scheduler = require('scheduler');
93 React = require('react');
94 ReactDOM = require('react-dom');
95 ReactDOMClient = require('react-dom/client');
96 ReactDOMFizzServer = require('react-dom/server');
97 ReactDOMFizzStatic = require('react-dom/static');
98 Stream = require('stream');
99 Suspense = React.Suspense;
100 use = React.use;
101 if (gate(flags => flags.enableSuspenseList)) {
102 SuspenseList = React.unstable_SuspenseList;
103 }
104 PropTypes = require('prop-types');
105 if (__VARIANT__) {
106 const originalConsoleError = console.error;
107 console.error = (error, ...args) => {
108 if (
109 typeof error !== 'string' ||
110 error.indexOf('ReactDOM.useFormState has been renamed') === -1
111 ) {
112 originalConsoleError(error, ...args);
113 }
114 };
115
116 // Remove after API is deleted.
117 useActionState = ReactDOM.useFormState;
118 } else {
119 useActionState = React.useActionState;
120 }
121
122 ({
123 assertConsoleErrorDev,
124 assertLog,
125 act: clientAct,
126 waitFor,
127 waitForAll,
128 waitForPaint,
129 } = require('internal-test-utils'));
130
131 if (gate(flags => flags.source)) {
132 // The `with-selector` module composes the main `use-sync-external-store`
133 // entrypoint. In the compiled artifacts, this is resolved to the `shim`
134 // implementation by our build config, but when running the tests against
135 // the source files, we need to tell Jest how to resolve it. Because this
136 // is a source module, this mock has no affect on the build tests.
137 jest.mock('use-sync-external-store/src/useSyncExternalStore', () =>
138 jest.requireActual('react'),
139 );
140 }
141 useSyncExternalStore = React.useSyncExternalStore;
142 useSyncExternalStoreWithSelector =
143 require('use-sync-external-store/with-selector').useSyncExternalStoreWithSelector;
144
145 textCache = new Map();
146
147 buffer = '';
148 hasErrored = false;
149
150 writable = new Stream.PassThrough();
151 writable.setEncoding('utf8');
152 writable.on('data', chunk => {
153 buffer += chunk;
154 });
155 writable.on('error', error => {
156 hasErrored = true;
157 fatalError = error;
158 });
159
160 renderOptions = {};
161 if (gate(flags => flags.shouldUseFizzExternalRuntime)) {
162 renderOptions.unstable_externalRuntimeSrc =
163 'react-dom-bindings/src/server/ReactDOMServerExternalRuntime.js';
164 }
165 });
166
167 function expectErrors(errorsArr, toBeDevArr, toBeProdArr) {
168 const mappedErrows = errorsArr.map(({error, errorInfo}) => {
169 const stack = errorInfo && errorInfo.componentStack;
170 const digest = error.digest;
171 if (stack) {
172 return [error.message, digest, normalizeCodeLocInfo(stack)];
173 } else if (digest) {
174 return [error.message, digest];
175 }
176 return error.message;
177 });
178 if (__DEV__) {
179 expect(mappedErrows).toEqual(toBeDevArr);
180 } else {
181 expect(mappedErrows).toEqual(toBeProdArr);
182 }
183 }
184
185 function componentStack(components) {
186 return components
187 .map(component => `\n in ${component} (at **)`)
188 .join('');
189 }
190
191 const bodyStartMatch = /<body(?:>| .*?>)/;
192 const headStartMatch = /<head(?:>| .*?>)/;
193
194 async function act(callback) {
195 await callback();
196 // Await one turn around the event loop.
197 // This assumes that we'll flush everything we have so far.
198 await new Promise(resolve => {
199 setImmediate(resolve);
200 });
201 if (hasErrored) {
202 throw fatalError;
203 }
204 // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment.
205 // We also want to execute any scripts that are embedded.
206 // We assume that we have now received a proper fragment of HTML.
207 let bufferedContent = buffer;
208 buffer = '';
209
210 if (!bufferedContent) {
211 jest.runAllTimers();
212 return;
213 }
214
215 const bodyMatch = bufferedContent.match(bodyStartMatch);
216 const headMatch = bufferedContent.match(headStartMatch);
217
218 if (streamingContainer === null) {
219 // This is the first streamed content. We decide here where to insert it. If we get <html>, <head>, or <body>
220 // we abandon the pre-built document and start from scratch. If we get anything else we assume it goes into the
221 // container. This is not really production behavior because you can't correctly stream into a deep div effectively
222 // but it's pragmatic for tests.
223
224 if (
225 bufferedContent.startsWith('<head>') ||
226 bufferedContent.startsWith('<head ') ||
227 bufferedContent.startsWith('<body>') ||
228 bufferedContent.startsWith('<body ')
229 ) {
230 // wrap in doctype to normalize the parsing process
231 bufferedContent = '<!DOCTYPE html><html>' + bufferedContent;
232 } else if (
233 bufferedContent.startsWith('<html>') ||
234 bufferedContent.startsWith('<html ')
235 ) {
236 throw new Error(
237 'Recieved <html> without a <!DOCTYPE html> which is almost certainly a bug in React',
238 );
239 }
240
241 if (bufferedContent.startsWith('<!DOCTYPE html>')) {
242 // we can just use the whole document
243 const tempDom = new JSDOM(bufferedContent);
244
245 // Wipe existing head and body content
246 document.head.innerHTML = '';
247 document.body.innerHTML = '';
248
249 // Copy the <html> attributes over
250 const tempHtmlNode = tempDom.window.document.documentElement;
251 for (let i = 0; i < tempHtmlNode.attributes.length; i++) {
252 const attr = tempHtmlNode.attributes[i];
253 document.documentElement.setAttribute(attr.name, attr.value);
254 }
255
256 if (headMatch) {
257 // We parsed a head open tag. we need to copy head attributes and insert future
258 // content into <head>
259 streamingContainer = document.head;
260 const tempHeadNode = tempDom.window.document.head;
261 for (let i = 0; i < tempHeadNode.attributes.length; i++) {
262 const attr = tempHeadNode.attributes[i];
263 document.head.setAttribute(attr.name, attr.value);
264 }
265 const source = document.createElement('head');
266 source.innerHTML = tempHeadNode.innerHTML;
267 await insertNodesAndExecuteScripts(source, document.head, CSPnonce);
268 }
269
270 if (bodyMatch) {
271 // We parsed a body open tag. we need to copy head attributes and insert future
272 // content into <body>
273 streamingContainer = document.body;
274 const tempBodyNode = tempDom.window.document.body;
275 for (let i = 0; i < tempBodyNode.attributes.length; i++) {
276 const attr = tempBodyNode.attributes[i];
277 document.body.setAttribute(attr.name, attr.value);
278 }
279 const source = document.createElement('body');
280 source.innerHTML = tempBodyNode.innerHTML;
281 await insertNodesAndExecuteScripts(source, document.body, CSPnonce);
282 }
283
284 if (!headMatch && !bodyMatch) {
285 throw new Error('expected <head> or <body> after <html>');
286 }
287 } else {
288 // we assume we are streaming into the default container'
289 streamingContainer = container;
290 const div = document.createElement('div');
291 div.innerHTML = bufferedContent;
292 await insertNodesAndExecuteScripts(div, container, CSPnonce);
293 }
294 } else if (streamingContainer === document.head) {
295 bufferedContent = '<!DOCTYPE html><html><head>' + bufferedContent;
296 const tempDom = new JSDOM(bufferedContent);
297
298 const tempHeadNode = tempDom.window.document.head;
299 const source = document.createElement('head');
300 source.innerHTML = tempHeadNode.innerHTML;
301 await insertNodesAndExecuteScripts(source, document.head, CSPnonce);
302
303 if (bodyMatch) {
304 streamingContainer = document.body;
305
306 const tempBodyNode = tempDom.window.document.body;
307 for (let i = 0; i < tempBodyNode.attributes.length; i++) {
308 const attr = tempBodyNode.attributes[i];
309 document.body.setAttribute(attr.name, attr.value);
310 }
311 const bodySource = document.createElement('body');
312 bodySource.innerHTML = tempBodyNode.innerHTML;
313 await insertNodesAndExecuteScripts(bodySource, document.body, CSPnonce);
314 }
315 } else {
316 const div = document.createElement('div');
317 div.innerHTML = bufferedContent;
318 await insertNodesAndExecuteScripts(div, streamingContainer, CSPnonce);
319 }
320 // Let throttled boundaries reveal
321 jest.runAllTimers();
322 }
323
324 function resolveText(text) {
325 const record = textCache.get(text);
326 if (record === undefined) {
327 const newRecord = {
328 status: 'resolved',
329 value: text,
330 };
331 textCache.set(text, newRecord);
332 } else if (record.status === 'pending') {
333 const thenable = record.value;
334 record.status = 'resolved';
335 record.value = text;
336 thenable.pings.forEach(t => t());
337 }
338 }
339
340 function rejectText(text, error) {
341 const record = textCache.get(text);
342 if (record === undefined) {
343 const newRecord = {
344 status: 'rejected',
345 value: error,
346 };
347 textCache.set(text, newRecord);
348 } else if (record.status === 'pending') {
349 const thenable = record.value;
350 record.status = 'rejected';
351 record.value = error;
352 thenable.pings.forEach(t => t());
353 }
354 }
355
356 function readText(text) {
357 const record = textCache.get(text);
358 if (record !== undefined) {
359 switch (record.status) {
360 case 'pending':
361 throw record.value;
362 case 'rejected':
363 throw record.value;
364 case 'resolved':
365 return record.value;
366 }
367 } else {
368 const thenable = {
369 pings: [],
370 then(resolve) {
371 if (newRecord.status === 'pending') {
372 thenable.pings.push(resolve);
373 } else {
374 Promise.resolve().then(() => resolve(newRecord.value));
375 }
376 },
377 };
378
379 const newRecord = {
380 status: 'pending',
381 value: thenable,
382 };
383 textCache.set(text, newRecord);
384
385 throw thenable;
386 }
387 }
388
389 function Text({text}) {
390 return text;
391 }
392
393 function AsyncText({text}) {
394 return readText(text);
395 }
396
397 function AsyncTextWrapped({as, text}) {
398 const As = as;
399 return <As>{readText(text)}</As>;
400 }
401 function renderToPipeableStream(jsx, options) {
402 // Merge options with renderOptions, which may contain featureFlag specific behavior
403 return ReactDOMFizzServer.renderToPipeableStream(
404 jsx,
405 mergeOptions(options, renderOptions),
406 );
407 }
408
409 // @gate enableBrowserAPI
410 it('can opt a component into browser-only rendering', async () => {
411 let resolveBrowserText;
412 const browserText = new Promise(resolve => {
413 resolveBrowserText = resolve;
414 });
415 let browserReason;
416 const initializeReason = jest.fn(() => {
417 browserReason = Object.freeze(
418 new Error('Only render this content in a browser'),
419 );
420 return browserReason;
421 });
422 const browserOnly = ReactDOM.browser(initializeReason);
423
424 function BrowserOnly() {
425 use(browserOnly);
426 const text = use(browserText);
427 Scheduler.log(text);
428 return <span>{text}</span>;
429 }
430
431 function App() {
432 return (
433 <div>
434 <Suspense fallback={<span>Fallback</span>}>
435 <BrowserOnly />
436 </Suspense>
437 </div>
438 );
439 }
440
441 const serverErrors = [];
442 const browserBailouts = [];
443 await act(() => {
444 const {pipe} = renderToPipeableStream(<App />, {
445 onError(error) {
446 serverErrors.push(error);
447 },
448 onBrowserBailout(error, errorInfo) {
449 browserBailouts.push({error, errorInfo});
450 },
451 });
452 pipe(writable);
453 });
454
455 expect(serverErrors).toEqual([]);
456 expect(initializeReason).toHaveBeenCalledTimes(1);
457 expect(browserBailouts).toHaveLength(1);
458 expect(browserBailouts[0].error).toBeInstanceOf(Error);
459 expect(browserBailouts[0].error.message).toBe(
460 'Browser-only rendering was requested by `browser()`.',
461 );
462 expect(browserBailouts[0].error.stack).toContain('BrowserOnly');
463 expect(browserBailouts[0].error.cause).toBe(browserReason);
464 expect(
465 normalizeCodeLocInfo(browserBailouts[0].errorInfo.componentStack),
466 ).toBe(componentStack(['BrowserOnly', 'Suspense', 'div', 'App']));
467 expect(getVisibleChildren(container)).toEqual(
468 <div>
469 <span>Fallback</span>
470 </div>,
471 );
472 const recoverableErrors = [];
473 ReactDOMClient.hydrateRoot(container, <App />, {
474 onRecoverableError(error) {
475 recoverableErrors.push(error);
476 },
477 });
478 await waitForAll([]);
479
480 expect(getVisibleChildren(container)).toEqual(
481 <div>
482 <span>Fallback</span>
483 </div>,
484 );
485
486 await clientAct(() => {
487 resolveBrowserText('Browser');
488 });
489 assertLog(['Browser']);
490
491 expect(recoverableErrors).toEqual([]);
492 expect(initializeReason).toHaveBeenCalledTimes(1);
493 expect(getVisibleChildren(container)).toEqual(
494 <div>
495 <span>Browser</span>
496 </div>,
497 );
498 });
499
500 // @gate enableBrowserAPI
501 it('can opt a component into browser-only rendering after streaming the fallback', async () => {
502 let resolveServerReady;
503 const serverReady = new Promise(resolve => {
504 resolveServerReady = resolve;
505 });
506 const initializeReason = jest.fn(
507 () => 'Only render this content in a browser',
508 );
509
510 function BrowserOnly() {
511 use(serverReady);
512 use(ReactDOM.browser(initializeReason));
513 return <span>Browser</span>;
514 }
515
516 function App() {
517 return (
518 <div>
519 <Suspense fallback={<span>Fallback</span>}>
520 <BrowserOnly />
521 </Suspense>
522 </div>
523 );
524 }
525
526 const serverErrors = [];
527 const browserBailouts = [];
528 await act(() => {
529 const {pipe} = renderToPipeableStream(<App />, {
530 onError(error) {
531 serverErrors.push(error);
532 },
533 onBrowserBailout(error) {
534 browserBailouts.push(error);
535 },
536 });
537 pipe(writable);
538 });
539
540 expect(getVisibleChildren(container)).toEqual(
541 <div>
542 <span>Fallback</span>
543 </div>,
544 );
545
546 await act(() => {
547 resolveServerReady();
548 });
549
550 expect(serverErrors).toEqual([]);
551 expect(initializeReason).toHaveBeenCalledTimes(1);
552 expect(browserBailouts).toHaveLength(1);
553 expect(browserBailouts[0].message).toBe(
554 'Browser-only rendering was requested by `browser()`.',
555 );
556 expect(browserBailouts[0].stack).toContain('BrowserOnly');
557 expect(browserBailouts[0].cause).toBe(
558 'Only render this content in a browser',
559 );
560
561 const recoverableErrors = [];
562 ReactDOMClient.hydrateRoot(container, <App />, {
563 onRecoverableError(error) {
564 recoverableErrors.push(error);
565 },
566 });
567 await waitForAll([]);
568
569 expect(recoverableErrors).toEqual([]);
570 expect(initializeReason).toHaveBeenCalledTimes(1);
571 expect(getVisibleChildren(container)).toEqual(
572 <div>
573 <span>Browser</span>
574 </div>,
575 );
576 });
577
578 // @gate enableBrowserAPI
579 it('supports omitted and direct string browser reasons', async () => {
580 const directReason = 'Only render this content in a browser';
581 const withoutReason = ReactDOM.browser();
582 const withDirectReason = ReactDOM.browser(directReason);
583
584 function WithoutReason() {
585 use(withoutReason);
586 return <span>Browser</span>;
587 }
588
589 function WithDirectReason() {
590 use(withDirectReason);
591 return <span>Browser</span>;
592 }
593
594 const serverErrors = [];
595 const browserBailouts = [];
596 await act(() => {
597 const {pipe} = renderToPipeableStream(
598 <>
599 <Suspense fallback={<span>Fallback A</span>}>
600 <WithoutReason />
601 </Suspense>
602 <Suspense fallback={<span>Fallback B</span>}>
603 <WithDirectReason />
604 </Suspense>
605 </>,
606 {
607 onError(error) {
608 serverErrors.push(error);
609 },
610 onBrowserBailout(error) {
611 browserBailouts.push(error);
612 },
613 },
614 );
615 pipe(writable);
616 });
617
618 expect(serverErrors).toEqual([]);
619 expect(browserBailouts).toHaveLength(2);
620 expect(browserBailouts[0].message).toBe(
621 'Browser-only rendering was requested by `browser()`.',
622 );
623 expect(browserBailouts[0].stack).toContain('WithoutReason');
624 expect(
625 Object.prototype.hasOwnProperty.call(browserBailouts[0], 'cause'),
626 ).toBe(false);
627 expect(browserBailouts[1].message).toBe(
628 'Browser-only rendering was requested by `browser()`.',
629 );
630 expect(browserBailouts[1].stack).toContain('WithDirectReason');
631 expect(browserBailouts[1].cause).toBe(directReason);
632 });
633
634 // @gate enableBrowserAPI
635 it('supports any value returned by a browser reason initializer', async () => {
636 const reasonValues = [undefined, null, 42, Symbol('browser reason')];
637 const initializeReasons = reasonValues.map(reason => jest.fn(() => reason));
638 const browserValues = initializeReasons.map(initializeReason =>
639 ReactDOM.browser(initializeReason),
640 );
641
642 function BrowserOnly({browserValue}) {
643 use(browserValue);
644 return <span>Browser</span>;
645 }
646
647 const serverErrors = [];
648 const browserBailouts = [];
649 await act(() => {
650 const {pipe} = renderToPipeableStream(
651 <>
652 {browserValues.map((browserValue, index) => (
653 <Suspense key={index} fallback={<span>Fallback</span>}>
654 <BrowserOnly browserValue={browserValue} />
655 </Suspense>
656 ))}
657 </>,
658 {
659 onError(error) {
660 serverErrors.push(error);
661 },
662 onBrowserBailout(error) {
663 browserBailouts.push(error);
664 },
665 },
666 );
667 pipe(writable);
668 });
669
670 expect(serverErrors).toEqual([]);
671 expect(browserBailouts).toHaveLength(reasonValues.length);
672 initializeReasons.forEach(initializeReason => {
673 expect(initializeReason).toHaveBeenCalledTimes(1);
674 });
675 browserBailouts.forEach((error, index) => {
676 expect(error).toBeInstanceOf(Error);
677 expect(error.message).toBe(
678 'Browser-only rendering was requested by `browser()`.',
679 );
680 expect(Object.prototype.hasOwnProperty.call(error, 'cause')).toBe(true);
681 expect(error.cause).toBe(reasonValues[index]);
682 });
683 });
684
685 // @gate enableBrowserAPI
686 it('initializes a shared browser reason at each use site', async () => {
687 const browserReasons = [];
688 const initializeReason = jest.fn(() => {
689 const browserReason = {index: browserReasons.length};
690 browserReasons.push(browserReason);
691 return browserReason;
692 });
693 const browserValue = ReactDOM.browser(initializeReason);
694
695 function BrowserOnlyA() {
696 use(browserValue);
697 return <span>Browser A</span>;
698 }
699
700 function BrowserOnlyB() {
701 use(browserValue);
702 return <span>Browser B</span>;
703 }
704
705 const browserBailouts = [];
706 await act(() => {
707 const {pipe} = renderToPipeableStream(
708 <>
709 <Suspense fallback={<span>Fallback A</span>}>
710 <BrowserOnlyA />
711 </Suspense>
712 <Suspense fallback={<span>Fallback B</span>}>
713 <BrowserOnlyB />
714 </Suspense>
715 </>,
716 {
717 onBrowserBailout(error) {
718 browserBailouts.push(error);
719 },
720 },
721 );
722 pipe(writable);
723 });
724
725 expect(initializeReason).toHaveBeenCalledTimes(2);
726 expect(browserBailouts).toHaveLength(2);
727 expect(browserBailouts[0]).not.toBe(browserBailouts[1]);
728 expect(browserBailouts[0].cause).toBe(browserReasons[0]);
729 expect(browserBailouts[0].stack).toContain('BrowserOnlyA');
730 expect(browserBailouts[1].cause).toBe(browserReasons[1]);
731 expect(browserBailouts[1].stack).toContain('BrowserOnlyB');
732 });
733
734 // @gate enableBrowserAPI
735 it('uses a fallback if a browser reason initializer throws', async () => {
736 const reasonError = new Error('Failed to initialize browser reason');
737 const initializeReason = jest.fn(() => {
738 throw reasonError;
739 });
740 const browserValue = ReactDOM.browser(initializeReason);
741
742 function BrowserOnly() {
743 use(browserValue);
744 return <span>Browser</span>;
745 }
746
747 const serverErrors = [];
748 const browserBailouts = [];
749 await act(() => {
750 const {pipe} = renderToPipeableStream(
751 <Suspense fallback={<span>Fallback</span>}>
752 <BrowserOnly />
753 </Suspense>,
754 {
755 onError(error) {
756 serverErrors.push(error);
757 },
758 onBrowserBailout(error) {
759 browserBailouts.push(error);
760 },
761 },
762 );
763 pipe(writable);
764 });
765
766 expect(initializeReason).toHaveBeenCalledTimes(1);
767 expect(serverErrors).toEqual([]);
768 expect(browserBailouts).toHaveLength(1);
769 expect(browserBailouts[0].cause).toBe(
770 'The reason for browser-only rendering could not be determined because ' +
771 'its initializer threw.',
772 );
773 expect(getVisibleChildren(container)).toEqual(<span>Fallback</span>);
774 });
775
776 // @gate enableBrowserAPI
777 it('errors if browser-only content is rendered outside Suspense', async () => {
778 const browserReason = 'Only render this content in a browser';
779 const browserValue = ReactDOM.browser(browserReason);
780
781 function BrowserOnly() {
782 use(browserValue);
783 return <span>Browser</span>;
784 }
785
786 const reportedErrors = [];
787 const browserBailouts = [];
788 let shellReady = false;
789 let shellError;
790 await act(() => {
791 renderToPipeableStream(<BrowserOnly />, {
792 onError(error) {
793 reportedErrors.push(error);
794 },
795 onBrowserBailout(error) {
796 browserBailouts.push(error);
797 },
798 onShellReady() {
799 shellReady = true;
800 },
801 onShellError(error) {
802 shellError = error;
803 },
804 });
805 });
806
807 expect(shellError).toBeInstanceOf(Error);
808 expect(shellError.message).toBe(
809 'The server render could not complete because client rendering was ' +
810 "requested outside a Suspense boundary. See this error's cause for " +
811 'additional details.',
812 );
813 expect(shellError.cause).toBe(browserReason);
814 expect(shellError.stack).toContain('BrowserOnly');
815 expect(shellError.stack.split('\n')[0]).toBe(
816 'Error: ' + shellError.message,
817 );
818 expect(shellReady).toBe(false);
819 expect(reportedErrors).toEqual([shellError]);
820 expect(browserBailouts).toEqual([]);
821 });
822
823 // @gate enableBrowserAPI
824 it('can abort all pending boundaries into browser-only rendering', async () => {
825 const never = new Promise(() => {});
826 let isClient = false;
827
828 function Pending({children}) {
829 if (!isClient) {
830 use(never);
831 }
832 return <span>{children}</span>;
833 }
834
835 function App() {
836 return (
837 <div>
838 <span>Shell</span>
839 <Suspense fallback={<span>Loading A</span>}>
840 <Pending>A</Pending>
841 </Suspense>
842 <Suspense fallback={<span>Loading B</span>}>
843 <Pending>B</Pending>
844 </Suspense>
845 </div>
846 );
847 }
848
849 const serverErrors = [];
850 const browserBailouts = [];
851 const browserReason = {code: 'render-pending-content-in-browser'};
852 const initializeReason = jest.fn(() => browserReason);
853 const browserValue = ReactDOM.browser(initializeReason);
854 let abort;
855 await act(() => {
856 const controls = renderToPipeableStream(<App />, {
857 onError(error) {
858 serverErrors.push(error);
859 },
860 onBrowserBailout(error) {
861 browserBailouts.push(error);
862 },
863 });
864 abort = controls.abort;
865 controls.pipe(writable);
866 });
867
868 expect(getVisibleChildren(container)).toEqual(
869 <div>
870 <span>Shell</span>
871 <span>Loading A</span>
872 <span>Loading B</span>
873 </div>,
874 );
875
876 await act(() => {
877 function abortToBrowser() {
878 abort(browserValue);
879 }
880 abortToBrowser();
881 });
882
883 expect(serverErrors).toEqual([]);
884 expect(initializeReason).toHaveBeenCalledTimes(1);
885 expect(browserBailouts).toHaveLength(2);
886 expect(browserBailouts[0]).toBeInstanceOf(Error);
887 expect(browserBailouts[0].message).toBe(
888 'Browser-only rendering was requested by `browser()`.',
889 );
890 expect(browserBailouts[0].stack).toContain('abortToBrowser');
891 expect(browserBailouts[0].cause).toBe(browserReason);
892 expect(browserBailouts[1]).toBe(browserBailouts[0]);
893
894 isClient = true;
895 const recoverableErrors = [];
896 ReactDOMClient.hydrateRoot(container, <App />, {
897 onRecoverableError(error) {
898 recoverableErrors.push(error);
899 },
900 });
901 await waitForAll([]);
902
903 expect(recoverableErrors).toEqual([]);
904 expect(getVisibleChildren(container)).toEqual(
905 <div>
906 <span>Shell</span>
907 <span>A</span>
908 <span>B</span>
909 </div>,
910 );
911 });
912
913 // @gate enableBrowserAPI
914 it('errors if aborted with browser() before the shell completes', async () => {
915 const never = new Promise(() => {});
916 let browserReason;
917 const initializeReason = jest.fn(() => {
918 browserReason = new Error('Only abort this render on the server');
919 return browserReason;
920 });
921 const browserValue = ReactDOM.browser(initializeReason);
922
923 function PendingRoot() {
924 use(never);
925 return <span>Root</span>;
926 }
927
928 const reportedErrors = [];
929 const browserBailouts = [];
930 let shellReady = false;
931 let shellError;
932 let abort;
933 await act(() => {
934 const controls = renderToPipeableStream(<PendingRoot />, {
935 onError(error) {
936 reportedErrors.push(error);
937 },
938 onBrowserBailout(error) {
939 browserBailouts.push(error);
940 },
941 onShellReady() {
942 shellReady = true;
943 },
944 onShellError(error) {
945 shellError = error;
946 },
947 });
948 abort = controls.abort;
949 });
950
951 await act(() => {
952 function abortToBrowser() {
953 abort(browserValue);
954 }
955 abortToBrowser();
956 });
957
958 expect(shellError).toBeInstanceOf(Error);
959 expect(initializeReason).toHaveBeenCalledTimes(1);
960 expect(shellError.message).toBe(
961 'The server render could not complete because client rendering was ' +
962 "requested outside a Suspense boundary. See this error's cause for " +
963 'additional details.',
964 );
965 expect(shellError.cause).toBe(browserReason);
966 expect(shellError.stack).toContain('abortToBrowser');
967 expect(shellReady).toBe(false);
968 expect(reportedErrors).toEqual([shellError]);
969 expect(browserBailouts).toEqual([]);
970 });
971
972 // @gate enableBrowserAPI
973 it('reports nested browser bailouts if aborting fatals the shell', async () => {
974 const never = new Promise(() => {});
975 const browserReason = 'Abort pending work into browser rendering';
976 const browserValue = ReactDOM.browser(browserReason);
977
978 function Pending() {
979 use(never);
980 return <span>Pending</span>;
981 }
982
983 const reportedErrors = [];
984 const browserBailouts = [];
985 let shellError;
986 let abort;
987 await act(() => {
988 const controls = renderToPipeableStream(
989 <>
990 <Suspense fallback={<span>Fallback</span>}>
991 <Pending />
992 </Suspense>
993 <Pending />
994 <Suspense fallback={<span>Fallback</span>}>
995 <Pending />
996 </Suspense>
997 <Pending />
998 </>,
999 {
1000 onError(error) {
1001 reportedErrors.push(error);
1002 },
1003 onBrowserBailout(error) {
1004 browserBailouts.push(error);
1005 },
1006 onShellError(error) {
1007 shellError = error;
1008 },
1009 },
1010 );
1011 abort = controls.abort;
1012 });
1013
1014 await act(() => {
1015 abort(browserValue);
1016 });
1017
1018 expect(shellError).toBeInstanceOf(Error);
1019 expect(shellError.message).toBe(
1020 'The server render could not complete because client rendering was ' +
1021 "requested outside a Suspense boundary. See this error's cause for " +
1022 'additional details.',
1023 );
1024 expect(shellError.cause).toBe(browserReason);
1025 expect(reportedErrors).toHaveLength(2);
1026 expect(reportedErrors[0]).toBe(shellError);
1027 expect(reportedErrors[1].message).toBe(shellError.message);
1028 expect(reportedErrors[1].cause).toBe(browserReason);
1029 expect(browserBailouts).toHaveLength(2);
1030 expect(browserBailouts[0]).toBe(browserBailouts[1]);
1031 expect(browserBailouts[0]).not.toBe(shellError);
1032 expect(browserBailouts[0].message).toBe(
1033 'Browser-only rendering was requested by `browser()`.',
1034 );
1035 expect(browserBailouts[0].cause).toBe(browserReason);
1036 });
1037
1038 // @gate enableBrowserAPI
1039 it('uses a fallback if a browser reason initializer throws during abort', async () => {
1040 const never = new Promise(() => {});
1041 const reasonError = new Error('Failed to initialize browser reason');
1042 const initializeReason = jest.fn(() => {
1043 throw reasonError;
1044 });
1045 const browserValue = ReactDOM.browser(initializeReason);
1046
1047 function PendingRoot() {
1048 use(never);
1049 return <span>Root</span>;
1050 }
1051
1052 const reportedErrors = [];
1053 const browserBailouts = [];
1054 let shellError;
1055 let abort;
1056 await act(() => {
1057 const controls = renderToPipeableStream(<PendingRoot />, {
1058 onError(error) {
1059 reportedErrors.push(error);
1060 },
1061 onBrowserBailout(error) {
1062 browserBailouts.push(error);
1063 },
1064 onShellError(error) {
1065 shellError = error;
1066 },
1067 });
1068 abort = controls.abort;
1069 });
1070
1071 await act(() => {
1072 abort(browserValue);
1073 });
1074
1075 expect(initializeReason).toHaveBeenCalledTimes(1);
1076 expect(shellError).toBeInstanceOf(Error);
1077 expect(shellError.cause).toBe(
1078 'The reason for browser-only rendering could not be determined because ' +
1079 'its initializer threw.',
1080 );
1081 expect(reportedErrors).toEqual([shellError]);
1082 expect(browserBailouts).toEqual([]);
1083 });
1084
1085 // @gate enableBrowserAPI
1086 it('reports the browser value if it is thrown instead of passed to use', async () => {
1087 const initializeReason = jest.fn(
1088 () => new Error('Only render this content in a browser'),
1089 );
1090 const browserValue = ReactDOM.browser(initializeReason);
1091
1092 function BrowserOnly() {
1093 throw browserValue;
1094 }
1095
1096 const reportedErrors = [];
1097 const browserBailouts = [];
1098 await act(() => {
1099 const {pipe} = renderToPipeableStream(
1100 <Suspense fallback={<span>Fallback</span>}>
1101 <BrowserOnly />
1102 </Suspense>,
1103 {
1104 onError(error) {
1105 reportedErrors.push(error);
1106 },
1107 onBrowserBailout(error) {
1108 browserBailouts.push(error);
1109 },
1110 },
1111 );
1112 pipe(writable);
1113 });
1114
1115 expect(reportedErrors).toEqual([browserValue]);
1116 expect(browserBailouts).toEqual([]);
1117 expect(initializeReason).not.toHaveBeenCalled();
1118 expect(getVisibleChildren(container)).toEqual(<span>Fallback</span>);
1119 });
1120
1121 ['', 'BROWSER'].forEach(userDigest => {
1122 it(`does not reserve the ${JSON.stringify(
1123 userDigest,
1124 )} user error digest for browser rendering`, async () => {
1125 let isClient = false;
1126 const serverError = new Error('Server error');
1127
1128 function ServerError() {
1129 if (!isClient) {
1130 throw serverError;
1131 }
1132 return <span>Client</span>;
1133 }
1134
1135 function App() {
1136 return (
1137 <Suspense fallback={<span>Fallback</span>}>
1138 <ServerError />
1139 </Suspense>
1140 );
1141 }
1142
1143 const serverErrors = [];
1144 await act(() => {
1145 const {pipe} = renderToPipeableStream(<App />, {
1146 onError(error) {
1147 serverErrors.push(error);
1148 return userDigest;
1149 },
1150 });
1151 pipe(writable);
1152 });
1153
1154 expect(serverErrors).toEqual([serverError]);
1155 expect(getVisibleChildren(container)).toEqual(<span>Fallback</span>);
1156
1157 isClient = true;
1158 const recoverableErrors = [];
1159 ReactDOMClient.hydrateRoot(container, <App />, {
1160 onRecoverableError(error) {
1161 recoverableErrors.push(error);
1162 },
1163 });
1164 await waitForAll([]);
1165
1166 expect(recoverableErrors).toHaveLength(1);
1167 expect(recoverableErrors[0].digest).toBe(userDigest || undefined);
1168 expect(getVisibleChildren(container)).toEqual(<span>Client</span>);
1169 });
1170 });
1171
1172 it('should asynchronously load a lazy component', async () => {
1173 let resolveA;
1174 const LazyA = React.lazy(() => {
1175 return new Promise(r => {
1176 resolveA = r;
1177 });
1178 });
1179
1180 let resolveB;
1181 const LazyB = React.lazy(() => {
1182 return new Promise(r => {
1183 resolveB = r;
1184 });
1185 });
1186
1187 class TextWithPunctuation extends React.Component {
1188 render() {
1189 return <Text text={this.props.text + this.props.punctuation} />;
1190 }
1191 }
1192
1193 // This tests that default props of the inner element is resolved.
1194 TextWithPunctuation.defaultProps = {
1195 punctuation: '!',
1196 };
1197
1198 await act(() => {
1199 const {pipe} = renderToPipeableStream(
1200 <div>
1201 <div>
1202 <Suspense fallback={<Text text="Loading..." />}>
1203 <LazyA text="Hello" />
1204 </Suspense>
1205 </div>
1206 <div>
1207 <Suspense fallback={<Text text="Loading..." />}>
1208 <LazyB text="world" />
1209 </Suspense>
1210 </div>
1211 </div>,
1212 );
1213 pipe(writable);
1214 });
1215
1216 expect(getVisibleChildren(container)).toEqual(
1217 <div>
1218 <div>Loading...</div>
1219 <div>Loading...</div>
1220 </div>,
1221 );
1222 await act(() => {
1223 resolveA({default: Text});
1224 });
1225 expect(getVisibleChildren(container)).toEqual(
1226 <div>
1227 <div>Hello</div>
1228 <div>Loading...</div>
1229 </div>,
1230 );
1231 await act(() => {
1232 resolveB({default: TextWithPunctuation});
1233 });
1234 expect(getVisibleChildren(container)).toEqual(
1235 <div>
1236 <div>Hello</div>
1237 <div>world!</div>
1238 </div>,
1239 );
1240 });
1241
1242 it('#23331: does not warn about hydration mismatches if something suspended in an earlier sibling', async () => {
1243 const makeApp = () => {
1244 let resolve;
1245 const imports = new Promise(r => {
1246 resolve = () => r({default: () => <span id="async">async</span>});
1247 });
1248 const Lazy = React.lazy(() => imports);
1249
1250 const App = () => (
1251 <div>
1252 <Suspense fallback={<span>Loading...</span>}>
1253 <Lazy />
1254 <span id="after">after</span>
1255 </Suspense>
1256 </div>
1257 );
1258
1259 return [App, resolve];
1260 };
1261
1262 // Server-side
1263 const [App, resolve] = makeApp();
1264 await act(() => {
1265 const {pipe} = renderToPipeableStream(<App />);
1266 pipe(writable);
1267 });
1268 expect(getVisibleChildren(container)).toEqual(
1269 <div>
1270 <span>Loading...</span>
1271 </div>,
1272 );
1273 await act(() => {
1274 resolve();
1275 });
1276 expect(getVisibleChildren(container)).toEqual(
1277 <div>
1278 <span id="async">async</span>
1279 <span id="after">after</span>
1280 </div>,
1281 );
1282
1283 // Client-side
1284 const [HydrateApp, hydrateResolve] = makeApp();
1285 await act(() => {
1286 ReactDOMClient.hydrateRoot(container, <HydrateApp />);
1287 });
1288
1289 expect(getVisibleChildren(container)).toEqual(
1290 <div>
1291 <span id="async">async</span>
1292 <span id="after">after</span>
1293 </div>,
1294 );
1295
1296 await act(() => {
1297 hydrateResolve();
1298 });
1299 expect(getVisibleChildren(container)).toEqual(
1300 <div>
1301 <span id="async">async</span>
1302 <span id="after">after</span>
1303 </div>,
1304 );
1305 });
1306
1307 it('should support nonce for bootstrap and runtime scripts', async () => {
1308 CSPnonce = 'R4nd0m';
1309 try {
1310 let resolve;
1311 const Lazy = React.lazy(() => {
1312 return new Promise(r => {
1313 resolve = r;
1314 });
1315 });
1316
1317 await act(() => {
1318 const {pipe} = renderToPipeableStream(
1319 <div>
1320 <Suspense fallback={<Text text="Loading..." />}>
1321 <Lazy text="Hello" />
1322 </Suspense>
1323 </div>,
1324 {
1325 nonce: 'R4nd0m',
1326 bootstrapScriptContent: 'function noop(){}',
1327 bootstrapScripts: [
1328 'init.js',
1329 {src: 'init2.js', integrity: 'init2hash'},
1330 ],
1331 bootstrapModules: [
1332 'init.mjs',
1333 {src: 'init2.mjs', integrity: 'init2hash'},
1334 ],
1335 },
1336 );
1337 pipe(writable);
1338 });
1339
1340 expect(getVisibleChildren(container)).toEqual([
1341 <link
1342 rel="preload"
1343 fetchpriority="low"
1344 href="init.js"
1345 as="script"
1346 nonce={CSPnonce}
1347 />,
1348 <link
1349 rel="preload"
1350 fetchpriority="low"
1351 href="init2.js"
1352 as="script"
1353 nonce={CSPnonce}
1354 integrity="init2hash"
1355 />,
1356 <link
1357 rel="modulepreload"
1358 fetchpriority="low"
1359 href="init.mjs"
1360 nonce={CSPnonce}
1361 />,
1362 <link
1363 rel="modulepreload"
1364 fetchpriority="low"
1365 href="init2.mjs"
1366 nonce={CSPnonce}
1367 integrity="init2hash"
1368 />,
1369 <div>Loading...</div>,
1370 ]);
1371
1372 // check that there are 6 scripts with a matching nonce:
1373 // The runtime script or initial paint time, an inline bootstrap script, two bootstrap scripts and two bootstrap modules
1374 expect(
1375 Array.from(container.getElementsByTagName('script')).filter(
1376 node => node.getAttribute('nonce') === CSPnonce,
1377 ).length,
1378 ).toEqual(6);
1379
1380 await act(() => {
1381 resolve({default: Text});
1382 });
1383 expect(getVisibleChildren(container)).toEqual([
1384 <link
1385 rel="preload"
1386 fetchpriority="low"
1387 href="init.js"
1388 as="script"
1389 nonce={CSPnonce}
1390 />,
1391 <link
1392 rel="preload"
1393 fetchpriority="low"
1394 href="init2.js"
1395 as="script"
1396 nonce={CSPnonce}
1397 integrity="init2hash"
1398 />,
1399 <link
1400 rel="modulepreload"
1401 fetchpriority="low"
1402 href="init.mjs"
1403 nonce={CSPnonce}
1404 />,
1405 <link
1406 rel="modulepreload"
1407 fetchpriority="low"
1408 href="init2.mjs"
1409 nonce={CSPnonce}
1410 integrity="init2hash"
1411 />,
1412 <div>Hello</div>,
1413 ]);
1414 } finally {
1415 CSPnonce = null;
1416 }
1417 });
1418
1419 it('should not automatically add nonce to rendered scripts', async () => {
1420 CSPnonce = 'R4nd0m';
1421 try {
1422 await act(async () => {
1423 const {pipe} = renderToPipeableStream(
1424 <html>
1425 <body>
1426 <script nonce={CSPnonce}>{'try { foo() } catch (e) {} ;'}</script>
1427 <script nonce={CSPnonce} src="foo" async={true} />
1428 <script src="bar" />
1429 <script src="baz" integrity="qux" async={true} />
1430 <script type="module" src="quux" async={true} />
1431 <script type="module" src="corge" async={true} />
1432 <script
1433 type="module"
1434 src="grault"
1435 integrity="garply"
1436 async={true}
1437 />
1438 </body>
1439 </html>,
1440 {
1441 nonce: CSPnonce,
1442 },
1443 );
1444 pipe(writable);
1445 });
1446
1447 expect(
1448 stripExternalRuntimeInNodes(
1449 document.getElementsByTagName('script'),
1450 renderOptions.unstable_externalRuntimeSrc,
1451 ).map(n => n.outerHTML),
1452 ).toEqual([
1453 `<script nonce="${CSPnonce}" src="foo" async=""></script>`,
1454 `<script src="baz" integrity="qux" async=""></script>`,
1455 `<script type="module" src="quux" async=""></script>`,
1456 `<script type="module" src="corge" async=""></script>`,
1457 `<script type="module" src="grault" integrity="garply" async=""></script>`,
1458 `<script nonce="${CSPnonce}">try { foo() } catch (e) {} ;</script>`,
1459 `<script src="bar"></script>`,
1460 ]);
1461 } finally {
1462 CSPnonce = null;
1463 }
1464 });
1465
1466 it('should client render a boundary if a lazy component rejects', async () => {
1467 let rejectComponent;
1468 const promise = new Promise((resolve, reject) => {
1469 rejectComponent = reject;
1470 });
1471 const LazyComponent = React.lazy(() => {
1472 return promise;
1473 });
1474
1475 const LazyLazy = React.lazy(async () => {
1476 return {
1477 default: LazyComponent,
1478 };
1479 });
1480
1481 function Wrapper({children}) {
1482 return children;
1483 }
1484 const LazyWrapper = React.lazy(() => {
1485 return {
1486 then(callback) {
1487 callback({
1488 default: Wrapper,
1489 });
1490 },
1491 };
1492 });
1493
1494 function App({isClient}) {
1495 return (
1496 <div>
1497 <Suspense fallback={<Text text="Loading..." />}>
1498 <LazyWrapper>
1499 {isClient ? <Text text="Hello" /> : <LazyLazy text="Hello" />}
1500 </LazyWrapper>
1501 </Suspense>
1502 </div>
1503 );
1504 }
1505
1506 let bootstrapped = false;
1507 const errors = [];
1508 window.__INIT__ = function () {
1509 bootstrapped = true;
1510 // Attempt to hydrate the content.
1511 ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
1512 onRecoverableError(error, errorInfo) {
1513 errors.push({error, errorInfo});
1514 },
1515 });
1516 };
1517
1518 const theError = new Error('Test');
1519 const loggedErrors = [];
1520 function onError(x, errorInfo) {
1521 loggedErrors.push(x);
1522 return 'Hash of (' + x.message + ')';
1523 }
1524 const expectedDigest = onError(theError);
1525 loggedErrors.length = 0;
1526
1527 await act(() => {
1528 const {pipe} = renderToPipeableStream(<App isClient={false} />, {
1529 bootstrapScriptContent: '__INIT__();',
1530 onError,
1531 });
1532 pipe(writable);
1533 });
1534
1535 expect(loggedErrors).toEqual([]);
1536 expect(bootstrapped).toBe(true);
1537
1538 await waitForAll([]);
1539
1540 // We're still loading because we're waiting for the server to stream more content.
1541 expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
1542
1543 expect(loggedErrors).toEqual([]);
1544
1545 await act(() => {
1546 rejectComponent(theError);
1547 });
1548
1549 expect(loggedErrors).toEqual([theError]);
1550
1551 // We haven't ran the client hydration yet.
1552 expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
1553
1554 // Now we can client render it instead.
1555 await waitForAll([]);
1556 expectErrors(
1557 errors,
1558 [
1559 [
1560 'Switched to client rendering because the server rendering errored:\n\n' +
1561 theError.message,
1562 expectedDigest,
1563 componentStack(['Lazy', 'Wrapper', 'Suspense', 'div', 'App']),
1564 ],
1565 ],
1566 [
1567 [
1568 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
1569 expectedDigest,
1570 ],
1571 ],
1572 );
1573
1574 // The client rendered HTML is now in place.
1575 expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
1576
1577 expect(loggedErrors).toEqual([theError]);
1578 });
1579
1580 it('should have special stacks if Suspense fallback', async () => {
1581 const infinitePromise = new Promise(() => {});
1582 const InfiniteComponent = React.lazy(() => {
1583 return infinitePromise;
1584 });
1585
1586 function Throw({text}) {
1587 throw new Error(text);
1588 }
1589
1590 function App() {
1591 return (
1592 <Suspense fallback="Loading">
1593 <div>
1594 <Suspense fallback={<Throw text="Bye" />}>
1595 <InfiniteComponent text="Hi" />
1596 </Suspense>
1597 </div>
1598 </Suspense>
1599 );
1600 }
1601
1602 const loggedErrors = [];
1603 function onError(x, errorInfo) {
1604 loggedErrors.push({
1605 message: x.message,
1606 componentStack: errorInfo.componentStack,
1607 });
1608 return 'Hash of (' + x.message + ')';
1609 }
1610 loggedErrors.length = 0;
1611
1612 await act(() => {
1613 const {pipe} = renderToPipeableStream(<App />, {
1614 onError,
1615 });
1616 pipe(writable);
1617 });
1618
1619 expect(loggedErrors.length).toBe(1);
1620 expect(loggedErrors[0].message).toBe('Bye');
1621 expect(normalizeCodeLocInfo(loggedErrors[0].componentStack)).toBe(
1622 componentStack(['Throw', 'Suspense Fallback', 'div', 'Suspense', 'App']),
1623 );
1624 });
1625
1626 it('should asynchronously load a lazy element', async () => {
1627 let resolveElement;
1628 const lazyElement = React.lazy(() => {
1629 return new Promise(r => {
1630 resolveElement = r;
1631 });
1632 });
1633
1634 await act(() => {
1635 const {pipe} = renderToPipeableStream(
1636 <div>
1637 <Suspense fallback={<Text text="Loading..." />}>
1638 {lazyElement}
1639 </Suspense>
1640 </div>,
1641 );
1642 pipe(writable);
1643 });
1644 expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
1645 // Because there is no content inside the Suspense boundary that could've
1646 // been written, we expect to not see any additional partial data flushed
1647 // yet.
1648 expect(
1649 stripExternalRuntimeInNodes(
1650 container.childNodes,
1651 renderOptions.unstable_externalRuntimeSrc,
1652 ).length,
1653 ).toBe(gate(flags => flags.shouldUseFizzExternalRuntime) ? 1 : 2);
1654 await act(() => {
1655 resolveElement({default: <Text text="Hello" />});
1656 });
1657 expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
1658 });
1659
1660 it('should client render a boundary if a lazy element rejects', async () => {
1661 let rejectElement;
1662 const element = <Text text="Hello" />;
1663 const lazyElement = React.lazy(() => {
1664 return new Promise((resolve, reject) => {
1665 rejectElement = reject;
1666 });
1667 });
1668
1669 const theError = new Error('Test');
1670 const loggedErrors = [];
1671 function onError(x, errorInfo) {
1672 loggedErrors.push(x);
1673 return 'hash of (' + x.message + ')';
1674 }
1675 const expectedDigest = onError(theError);
1676 loggedErrors.length = 0;
1677
1678 function App({isClient}) {
1679 return (
1680 <div>
1681 <Suspense fallback={<Text text="Loading..." />}>
1682 {isClient ? element : lazyElement}
1683 </Suspense>
1684 </div>
1685 );
1686 }
1687
1688 await act(() => {
1689 const {pipe} = renderToPipeableStream(<App isClient={false} />, {
1690 onError,
1691 });
1692 pipe(writable);
1693 });
1694 expect(loggedErrors).toEqual([]);
1695
1696 const errors = [];
1697 // Attempt to hydrate the content.
1698 ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
1699 onRecoverableError(error, errorInfo) {
1700 errors.push({error, errorInfo});
1701 },
1702 });
1703 await waitForAll([]);
1704
1705 // We're still loading because we're waiting for the server to stream more content.
1706 expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
1707
1708 expect(loggedErrors).toEqual([]);
1709
1710 await act(() => {
1711 rejectElement(theError);
1712 });
1713
1714 expect(loggedErrors).toEqual([theError]);
1715
1716 // We haven't ran the client hydration yet.
1717 expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
1718
1719 // Now we can client render it instead.
1720 await waitForAll([]);
1721
1722 expectErrors(
1723 errors,
1724 [
1725 [
1726 'Switched to client rendering because the server rendering errored:\n\n' +
1727 theError.message,
1728 expectedDigest,
1729 componentStack(['Suspense', 'div', 'App']),
1730 ],
1731 ],
1732 [
1733 [
1734 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
1735 expectedDigest,
1736 ],
1737 ],
1738 );
1739
1740 // The client rendered HTML is now in place.
1741 // expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
1742
1743 expect(loggedErrors).toEqual([theError]);
1744 });
1745
1746 it('Errors in boundaries should be sent to the client and reported on client render - Error before flushing', async () => {
1747 function Indirection({level, children}) {
1748 if (level > 0) {
1749 return <Indirection level={level - 1}>{children}</Indirection>;
1750 }
1751 return children;
1752 }
1753
1754 const theError = new Error('uh oh');
1755
1756 function Erroring({isClient}) {
1757 if (isClient) {
1758 return 'Hello World';
1759 }
1760 throw theError;
1761 }
1762
1763 function App({isClient}) {
1764 return (
1765 <div>
1766 <Suspense fallback={<span>loading...</span>}>
1767 <Indirection level={2}>
1768 <Erroring isClient={isClient} />
1769 </Indirection>
1770 </Suspense>
1771 </div>
1772 );
1773 }
1774
1775 const loggedErrors = [];
1776 function onError(x) {
1777 loggedErrors.push(x);
1778 return 'hash(' + x.message + ')';
1779 }
1780 const expectedDigest = onError(theError);
1781 loggedErrors.length = 0;
1782
1783 await act(() => {
1784 const {pipe} = renderToPipeableStream(
1785 <App />,
1786
1787 {
1788 onError,
1789 },
1790 );
1791 pipe(writable);
1792 });
1793 expect(loggedErrors).toEqual([theError]);
1794
1795 const errors = [];
1796 // Attempt to hydrate the content.
1797 ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
1798 onRecoverableError(error, errorInfo) {
1799 errors.push({error, errorInfo});
1800 },
1801 });
1802 await waitForAll([]);
1803
1804 expect(getVisibleChildren(container)).toEqual(<div>Hello World</div>);
1805
1806 expectErrors(
1807 errors,
1808 [
1809 [
1810 'Switched to client rendering because the server rendering errored:\n\n' +
1811 theError.message,
1812 expectedDigest,
1813 componentStack([
1814 'Erroring',
1815 'Indirection',
1816 'Indirection',
1817 'Indirection',
1818 'Suspense',
1819 'div',
1820 'App',
1821 ]),
1822 ],
1823 ],
1824 [
1825 [
1826 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
1827 expectedDigest,
1828 ],
1829 ],
1830 );
1831 });
1832
1833 it('Errors in boundaries should be sent to the client and reported on client render - Error after flushing', async () => {
1834 let rejectComponent;
1835 const LazyComponent = React.lazy(() => {
1836 return new Promise((resolve, reject) => {
1837 rejectComponent = reject;
1838 });
1839 });
1840
1841 function App({isClient}) {
1842 return (
1843 <div>
1844 <Suspense fallback={<Text text="Loading..." />}>
1845 {isClient ? <Text text="Hello" /> : <LazyComponent text="Hello" />}
1846 </Suspense>
1847 </div>
1848 );
1849 }
1850
1851 const loggedErrors = [];
1852 const theError = new Error('uh oh');
1853 function onError(x) {
1854 loggedErrors.push(x);
1855 return 'hash(' + x.message + ')';
1856 }
1857 const expectedDigest = onError(theError);
1858 loggedErrors.length = 0;
1859
1860 await act(() => {
1861 const {pipe} = renderToPipeableStream(
1862 <App />,
1863
1864 {
1865 onError,
1866 },
1867 );
1868 pipe(writable);
1869 });
1870 expect(loggedErrors).toEqual([]);
1871
1872 const errors = [];
1873 // Attempt to hydrate the content.
1874 ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
1875 onRecoverableError(error, errorInfo) {
1876 errors.push({error, errorInfo});
1877 },
1878 });
1879 await waitForAll([]);
1880
1881 expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
1882
1883 await act(() => {
1884 rejectComponent(theError);
1885 });
1886
1887 expect(loggedErrors).toEqual([theError]);
1888 expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
1889
1890 // Now we can client render it instead.
1891 await waitForAll([]);
1892
1893 expectErrors(
1894 errors,
1895 [
1896 [
1897 'Switched to client rendering because the server rendering errored:\n\n' +
1898 theError.message,
1899 expectedDigest,
1900 componentStack(['Lazy', 'Suspense', 'div', 'App']),
1901 ],
1902 ],
1903 [
1904 [
1905 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
1906 expectedDigest,
1907 ],
1908 ],
1909 );
1910
1911 // The client rendered HTML is now in place.
1912 expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
1913 expect(loggedErrors).toEqual([theError]);
1914 });
1915
1916 it('should asynchronously load the suspense boundary', async () => {
1917 await act(() => {
1918 const {pipe} = renderToPipeableStream(
1919 <div>
1920 <Suspense fallback={<Text text="Loading..." />}>
1921 <AsyncText text="Hello World" />
1922 </Suspense>
1923 </div>,
1924 );
1925 pipe(writable);
1926 });
1927 expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
1928 await act(() => {
1929 resolveText('Hello World');
1930 });
1931 expect(getVisibleChildren(container)).toEqual(<div>Hello World</div>);
1932 });
1933
1934 it('waits for pending content to come in from the server and then hydrates it', async () => {
1935 const ref = React.createRef();
1936
1937 function App() {
1938 return (
1939 <div>
1940 <Suspense fallback="Loading...">
1941 <h1 ref={ref}>
1942 <AsyncText text="Hello" />
1943 </h1>
1944 </Suspense>
1945 </div>
1946 );
1947 }
1948
1949 let bootstrapped = false;
1950 window.__INIT__ = function () {
1951 bootstrapped = true;
1952 // Attempt to hydrate the content.
1953 ReactDOMClient.hydrateRoot(container, <App />);
1954 };
1955
1956 await act(() => {
1957 const {pipe} = renderToPipeableStream(<App />, {
1958 bootstrapScriptContent: '__INIT__();',
1959 });
1960 pipe(writable);
1961 });
1962
1963 // We're still showing a fallback.
1964 expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
1965
1966 // We already bootstrapped.
1967 expect(bootstrapped).toBe(true);
1968
1969 // Attempt to hydrate the content.
1970 await waitForAll([]);
1971
1972 // We're still loading because we're waiting for the server to stream more content.
1973 expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
1974
1975 // The server now updates the content in place in the fallback.
1976 await act(() => {
1977 resolveText('Hello');
1978 });
1979
1980 // The final HTML is now in place.
1981 expect(getVisibleChildren(container)).toEqual(
1982 <div>
1983 <h1>Hello</h1>
1984 </div>,
1985 );
1986 const h1 = container.getElementsByTagName('h1')[0];
1987
1988 // But it is not yet hydrated.
1989 expect(ref.current).toBe(null);
1990
1991 await waitForAll([]);
1992
1993 // Now it's hydrated.
1994 expect(ref.current).toBe(h1);
1995 });
1996
1997 it('handles an error on the client if the server ends up erroring', async () => {
1998 const ref = React.createRef();
1999
2000 class ErrorBoundary extends React.Component {
2001 state = {error: null};
2002 static getDerivedStateFromError(error) {
2003 return {error};
2004 }
2005 render() {
2006 if (this.state.error) {
2007 return <b ref={ref}>{this.state.error.message}</b>;
2008 }
2009 return this.props.children;
2010 }
2011 }
2012
2013 function App() {
2014 return (
2015 <ErrorBoundary>
2016 <div>
2017 <Suspense fallback="Loading...">
2018 <span ref={ref}>
2019 <AsyncText text="This Errors" />
2020 </span>
2021 </Suspense>
2022 </div>
2023 </ErrorBoundary>
2024 );
2025 }
2026
2027 const loggedErrors = [];
2028
2029 // We originally suspend the boundary and start streaming the loading state.
2030 await act(() => {
2031 const {pipe} = renderToPipeableStream(
2032 <App />,
2033
2034 {
2035 onError(x) {
2036 loggedErrors.push(x);
2037 },
2038 },
2039 );
2040 pipe(writable);
2041 });
2042
2043 // We're still showing a fallback.
2044 expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
2045
2046 expect(loggedErrors).toEqual([]);
2047
2048 // Attempt to hydrate the content.
2049 ReactDOMClient.hydrateRoot(container, <App />);
2050 await waitForAll([]);
2051
2052 // We're still loading because we're waiting for the server to stream more content.
2053 expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
2054
2055 const theError = new Error('Error Message');
2056 await act(() => {
2057 rejectText('This Errors', theError);
2058 });
2059
2060 expect(loggedErrors).toEqual([theError]);
2061
2062 // The server errored, but we still haven't hydrated. We don't know if the
2063 // client will succeed yet, so we still show the loading state.
2064 expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
2065 expect(ref.current).toBe(null);
2066
2067 // Flush the hydration.
2068 await waitForAll([]);
2069
2070 // Hydrating should've generated an error and replaced the suspense boundary.
2071 expect(getVisibleChildren(container)).toEqual(<b>Error Message</b>);
2072
2073 const b = container.getElementsByTagName('b')[0];
2074 expect(ref.current).toBe(b);
2075 });
2076
2077 // @gate enableSuspenseList
2078 it('shows inserted items before pending in a SuspenseList as fallbacks while hydrating', async () => {
2079 const ref = React.createRef();
2080
2081 // These are hoisted to avoid them from rerendering.
2082 const a = (
2083 <Suspense fallback="Loading A">
2084 <span ref={ref}>
2085 <AsyncText text="A" />
2086 </span>
2087 </Suspense>
2088 );
2089 const b = (
2090 <Suspense fallback="Loading B">
2091 <span>
2092 <Text text="B" />
2093 </span>
2094 </Suspense>
2095 );
2096
2097 function App({showMore}) {
2098 return (
2099 <div>
2100 <SuspenseList revealOrder="forwards" tail="visible">
2101 {a}
2102 {b}
2103 {showMore ? (
2104 <Suspense fallback="Loading C">
2105 <span>C</span>
2106 </Suspense>
2107 ) : null}
2108 </SuspenseList>
2109 </div>
2110 );
2111 }
2112
2113 // We originally suspend the boundary and start streaming the loading state.
2114 await act(() => {
2115 const {pipe} = renderToPipeableStream(<App showMore={false} />);
2116 pipe(writable);
2117 });
2118
2119 const root = ReactDOMClient.hydrateRoot(
2120 container,
2121 <App showMore={false} />,
2122 );
2123 await waitForAll([]);
2124
2125 // We're not hydrated yet.
2126 expect(ref.current).toBe(null);
2127 expect(getVisibleChildren(container)).toEqual(
2128 <div>
2129 {'Loading A'}
2130 {'Loading B'}
2131 </div>,
2132 );
2133
2134 // Add more rows before we've hydrated the first two.
2135 root.render(<App showMore={true} />);
2136 await waitForAll([]);
2137
2138 // We're not hydrated yet.
2139 expect(ref.current).toBe(null);
2140
2141 // We haven't resolved yet.
2142 expect(getVisibleChildren(container)).toEqual(
2143 <div>
2144 {'Loading A'}
2145 {'Loading B'}
2146 {'Loading C'}
2147 </div>,
2148 );
2149
2150 await act(async () => {
2151 await resolveText('A');
2152 });
2153
2154 await waitForAll([]);
2155
2156 expect(getVisibleChildren(container)).toEqual(
2157 <div>
2158 <span>A</span>
2159 <span>B</span>
2160 <span>C</span>
2161 </div>,
2162 );
2163
2164 const span = container.getElementsByTagName('span')[0];
2165 expect(ref.current).toBe(span);
2166 });
2167
2168 it('client renders a boundary if it does not resolve before aborting', async () => {
2169 function App() {
2170 return (
2171 <div>
2172 <Suspense fallback="Loading...">
2173 <h1>
2174 <AsyncText text="Hello" />
2175 </h1>
2176 </Suspense>
2177 <main>
2178 <Suspense fallback="loading...">
2179 <AsyncText text="World" />
2180 </Suspense>
2181 </main>
2182 </div>
2183 );
2184 }
2185
2186 const loggedErrors = [];
2187 const expectedDigest = 'Hash for Abort';
2188 function onError(error) {
2189 loggedErrors.push(error);
2190 return expectedDigest;
2191 }
2192
2193 let controls;
2194 await act(() => {
2195 controls = renderToPipeableStream(<App />, {onError});
2196 controls.pipe(writable);
2197 });
2198
2199 // We're still showing a fallback.
2200
2201 const errors = [];
2202 // Attempt to hydrate the content.
2203 ReactDOMClient.hydrateRoot(container, <App />, {
2204 onRecoverableError(error, errorInfo) {
2205 errors.push({error, errorInfo});
2206 },
2207 });
2208 await waitForAll([]);
2209
2210 // We're still loading because we're waiting for the server to stream more content.
2211 expect(getVisibleChildren(container)).toEqual(
2212 <div>
2213 Loading...<main>loading...</main>
2214 </div>,
2215 );
2216
2217 // We abort the server response.
2218 await act(() => {
2219 controls.abort();
2220 });
2221
2222 // We still can't render it on the client.
2223 await waitForAll([]);
2224 expectErrors(
2225 errors,
2226 [
2227 [
2228 'Switched to client rendering because the server rendering aborted due to:\n\n' +
2229 'The render was aborted by the server without a reason.',
2230 expectedDigest,
2231 // We get the stack of the task when it was aborted which is why we see `h1`
2232 componentStack(['AsyncText', 'h1', 'Suspense', 'div', 'App']),
2233 ],
2234 [
2235 'Switched to client rendering because the server rendering aborted due to:\n\n' +
2236 'The render was aborted by the server without a reason.',
2237 expectedDigest,
2238 componentStack(['AsyncText', 'Suspense', 'main', 'div', 'App']),
2239 ],
2240 ],
2241 [
2242 [
2243 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
2244 expectedDigest,
2245 ],
2246 [
2247 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
2248 expectedDigest,
2249 ],
2250 ],
2251 );
2252 expect(getVisibleChildren(container)).toEqual(
2253 <div>
2254 Loading...<main>loading...</main>
2255 </div>,
2256 );
2257
2258 // We now resolve it on the client.
2259 await clientAct(() => {
2260 resolveText('Hello');
2261 resolveText('World');
2262 });
2263 assertLog([]);
2264
2265 // The client rendered HTML is now in place.
2266 expect(getVisibleChildren(container)).toEqual(
2267 <div>
2268 <h1>Hello</h1>
2269 <main>World</main>
2270 </div>,
2271 );
2272 });
2273
2274 it('should allow for two containers to be written to the same document', async () => {
2275 // We create two passthrough streams for each container to write into.
2276 // Notably we don't implement a end() call for these. Because we don't want to
2277 // close the underlying stream just because one of the streams is done. Instead
2278 // we manually close when both are done.
2279 const writableA = new Stream.Writable();
2280 writableA._write = (chunk, encoding, next) => {
2281 writable.write(chunk, encoding, next);
2282 };
2283 const writableB = new Stream.Writable();
2284 writableB._write = (chunk, encoding, next) => {
2285 writable.write(chunk, encoding, next);
2286 };
2287
2288 await act(() => {
2289 const {pipe} = renderToPipeableStream(
2290 // We use two nested boundaries to flush out coverage of an old reentrancy bug.
2291 <div>
2292 <Suspense fallback="Loading...">
2293 <Suspense fallback={<Text text="Loading A..." />}>
2294 <>
2295 <Text text="This will show A: " />
2296 <div>
2297 <AsyncText text="A" />
2298 </div>
2299 </>
2300 </Suspense>
2301 </Suspense>
2302 </div>,
2303 {
2304 identifierPrefix: 'A_',
2305 onShellReady() {
2306 writableA.write('<div id="container-A">');
2307 pipe(writableA);
2308 writableA.write('</div>');
2309 },
2310 },
2311 );
2312 });
2313
2314 await act(() => {
2315 const {pipe} = renderToPipeableStream(
2316 <div>
2317 <Suspense fallback={<Text text="Loading B..." />}>
2318 <Text text="This will show B: " />
2319 <div>
2320 <AsyncText text="B" />
2321 </div>
2322 </Suspense>
2323 </div>,
2324 {
2325 identifierPrefix: 'B_',
2326 onShellReady() {
2327 writableB.write('<div id="container-B">');
2328 pipe(writableB);
2329 writableB.write('</div>');
2330 },
2331 },
2332 );
2333 });
2334
2335 expect(getVisibleChildren(container)).toEqual([
2336 <div id="container-A">
2337 <div>Loading A...</div>
2338 </div>,
2339 <div id="container-B">
2340 <div>Loading B...</div>
2341 </div>,
2342 ]);
2343
2344 await act(() => {
2345 resolveText('B');
2346 });
2347
2348 expect(getVisibleChildren(container)).toEqual([
2349 <div id="container-A">
2350 <div>Loading A...</div>
2351 </div>,
2352 <div id="container-B">
2353 <div>
2354 This will show B: <div>B</div>
2355 </div>
2356 </div>,
2357 ]);
2358
2359 await act(() => {
2360 resolveText('A');
2361 });
2362
2363 // We're done writing both streams now.
2364 writable.end();
2365
2366 expect(getVisibleChildren(container)).toEqual([
2367 <div id="container-A">
2368 <div>
2369 This will show A: <div>A</div>
2370 </div>
2371 </div>,
2372 <div id="container-B">
2373 <div>
2374 This will show B: <div>B</div>
2375 </div>
2376 </div>,
2377 ]);
2378 });
2379
2380 it('can resolve async content in esoteric parents', async () => {
2381 function AsyncOption({text}) {
2382 return <option>{readText(text)}</option>;
2383 }
2384
2385 function AsyncCol({className}) {
2386 return <col className={readText(className)} />;
2387 }
2388
2389 function AsyncPath({id}) {
2390 return <path id={readText(id)} />;
2391 }
2392
2393 function AsyncMi({id}) {
2394 return <mi id={readText(id)} />;
2395 }
2396
2397 function App() {
2398 return (
2399 <div>
2400 <select>
2401 <Suspense fallback="Loading...">
2402 <AsyncOption text="Hello" />
2403 </Suspense>
2404 </select>
2405 <Suspense fallback="Loading...">
2406 <table>
2407 <colgroup>
2408 <AsyncCol className="World" />
2409 </colgroup>
2410 </table>
2411 <svg>
2412 <g>
2413 <AsyncPath id="my-path" />
2414 </g>
2415 </svg>
2416 <math>
2417 <AsyncMi id="my-mi" />
2418 </math>
2419 </Suspense>
2420 </div>
2421 );
2422 }
2423
2424 await act(() => {
2425 const {pipe} = renderToPipeableStream(<App />);
2426 pipe(writable);
2427 });
2428
2429 expect(getVisibleChildren(container)).toEqual(
2430 <div>
2431 <select>Loading...</select>Loading...
2432 </div>,
2433 );
2434
2435 await act(() => {
2436 resolveText('Hello');
2437 });
2438
2439 await act(() => {
2440 resolveText('World');
2441 });
2442
2443 await act(() => {
2444 resolveText('my-path');
2445 resolveText('my-mi');
2446 });
2447
2448 expect(getVisibleChildren(container)).toEqual(
2449 <div>
2450 <select>
2451 <option>Hello</option>
2452 </select>
2453 <table>
2454 <colgroup>
2455 <col class="World" />
2456 </colgroup>
2457 </table>
2458 <svg>
2459 <g>
2460 <path id="my-path" />
2461 </g>
2462 </svg>
2463 <math>
2464 <mi id="my-mi" />
2465 </math>
2466 </div>,
2467 );
2468
2469 expect(container.querySelector('#my-path').namespaceURI).toBe(
2470 'http://www.w3.org/2000/svg',
2471 );
2472 expect(container.querySelector('#my-mi').namespaceURI).toBe(
2473 'http://www.w3.org/1998/Math/MathML',
2474 );
2475 });
2476
2477 it('can resolve async content in table parents', async () => {
2478 function AsyncTableBody({className, children}) {
2479 return <tbody className={readText(className)}>{children}</tbody>;
2480 }
2481
2482 function AsyncTableRow({className, children}) {
2483 return <tr className={readText(className)}>{children}</tr>;
2484 }
2485
2486 function AsyncTableCell({text}) {
2487 return <td>{readText(text)}</td>;
2488 }
2489
2490 function App() {
2491 return (
2492 <table>
2493 <Suspense
2494 fallback={
2495 <tbody>
2496 <tr>
2497 <td>Loading...</td>
2498 </tr>
2499 </tbody>
2500 }>
2501 <AsyncTableBody className="A">
2502 <AsyncTableRow className="B">
2503 <AsyncTableCell text="C" />
2504 </AsyncTableRow>
2505 </AsyncTableBody>
2506 </Suspense>
2507 </table>
2508 );
2509 }
2510
2511 await act(() => {
2512 const {pipe} = renderToPipeableStream(<App />);
2513 pipe(writable);
2514 });
2515
2516 expect(getVisibleChildren(container)).toEqual(
2517 <table>
2518 <tbody>
2519 <tr>
2520 <td>Loading...</td>
2521 </tr>
2522 </tbody>
2523 </table>,
2524 );
2525
2526 await act(() => {
2527 resolveText('A');
2528 });
2529
2530 await act(() => {
2531 resolveText('B');
2532 });
2533
2534 await act(() => {
2535 resolveText('C');
2536 });
2537
2538 expect(getVisibleChildren(container)).toEqual(
2539 <table>
2540 <tbody class="A">
2541 <tr class="B">
2542 <td>C</td>
2543 </tr>
2544 </tbody>
2545 </table>,
2546 );
2547 });
2548
2549 it('can stream into an SVG container', async () => {
2550 function AsyncPath({id}) {
2551 return <path id={readText(id)} />;
2552 }
2553
2554 function App() {
2555 return (
2556 <g>
2557 <Suspense fallback={<text>Loading...</text>}>
2558 <AsyncPath id="my-path" />
2559 </Suspense>
2560 </g>
2561 );
2562 }
2563
2564 await act(() => {
2565 const {pipe} = renderToPipeableStream(
2566 <App />,
2567
2568 {
2569 namespaceURI: 'http://www.w3.org/2000/svg',
2570 onShellReady() {
2571 writable.write('<svg>');
2572 pipe(writable);
2573 writable.write('</svg>');
2574 },
2575 },
2576 );
2577 });
2578
2579 expect(getVisibleChildren(container)).toEqual(
2580 <svg>
2581 <g>
2582 <text>Loading...</text>
2583 </g>
2584 </svg>,
2585 );
2586
2587 await act(() => {
2588 resolveText('my-path');
2589 });
2590
2591 expect(getVisibleChildren(container)).toEqual(
2592 <svg>
2593 <g>
2594 <path id="my-path" />
2595 </g>
2596 </svg>,
2597 );
2598
2599 expect(container.querySelector('#my-path').namespaceURI).toBe(
2600 'http://www.w3.org/2000/svg',
2601 );
2602 });
2603
2604 function normalizeCodeLocInfo(str) {
2605 return (
2606 str &&
2607 String(str).replace(/\n +(?:at|in) ([^\(]+) [^\n]*/g, function (m, name) {
2608 return '\n in ' + name + ' (at **)';
2609 })
2610 );
2611 }
2612
2613 it('should include a component stack across suspended boundaries', async () => {
2614 function B() {
2615 const children = [readText('Hello'), readText('World')];
2616 // Intentionally trigger a key warning here.
2617 return (
2618 <div>
2619 {children.map(function mapper(t) {
2620 return <span>{t}</span>;
2621 })}
2622 </div>
2623 );
2624 }
2625 function C() {
2626 return (
2627 <inCorrectTag>
2628 <Text text="Loading" />
2629 </inCorrectTag>
2630 );
2631 }
2632 function A() {
2633 return (
2634 <div>
2635 <Suspense fallback={<C />}>
2636 <B />
2637 </Suspense>
2638 </div>
2639 );
2640 }
2641
2642 await act(() => {
2643 const {pipe} = renderToPipeableStream(<A />);
2644 pipe(writable);
2645 });
2646
2647 expect(getVisibleChildren(container)).toEqual(
2648 <div>
2649 <incorrecttag>Loading</incorrecttag>
2650 </div>,
2651 );
2652
2653 assertConsoleErrorDev([
2654 '<inCorrectTag /> is using incorrect casing. Use PascalCase for React components, or lowercase for HTML elements.' +
2655 '\n' +
2656 ' in inCorrectTag (at **)\n' +
2657 ' in C (at **)\n' +
2658 ' in A (at **)',
2659 ]);
2660
2661 await act(() => {
2662 resolveText('Hello');
2663 resolveText('World');
2664 });
2665
2666 assertConsoleErrorDev([
2667 'Each child in a list should have a unique "key" prop.\n\nCheck the render method of `B`.' +
2668 ' See https://react.dev/link/warning-keys for more information.\n' +
2669 ' in span (at **)\n' +
2670 ' in mapper (at **)\n' +
2671 ' in Array.map (at **)\n' +
2672 ' in B (at **)\n' +
2673 ' in A (at **)',
2674 ]);
2675
2676 expect(getVisibleChildren(container)).toEqual(
2677 <div>
2678 <div>
2679 <span>Hello</span>
2680 <span>World</span>
2681 </div>
2682 </div>,
2683 );
2684 });
2685
2686 // @gate !disableLegacyContext
2687 it('should can suspend in a class component with legacy context', async () => {
2688 class TestProvider extends React.Component {
2689 static childContextTypes = {
2690 test: PropTypes.string,
2691 };
2692 state = {ctxToSet: null};
2693 static getDerivedStateFromProps(props, state) {
2694 return {ctxToSet: props.ctx};
2695 }
2696 getChildContext() {
2697 return {
2698 test: this.state.ctxToSet,
2699 };
2700 }
2701 render() {
2702 return this.props.children;
2703 }
2704 }
2705
2706 class TestConsumer extends React.Component {
2707 static contextTypes = {
2708 test: PropTypes.string,
2709 };
2710 render() {
2711 const child = (
2712 <b>
2713 <Text text={this.context.test} />
2714 </b>
2715 );
2716 if (this.props.prefix) {
2717 return (
2718 <>
2719 {readText(this.props.prefix)}
2720 {child}
2721 </>
2722 );
2723 }
2724 return child;
2725 }
2726 }
2727
2728 await act(() => {
2729 const {pipe} = renderToPipeableStream(
2730 <TestProvider ctx="A">
2731 <div>
2732 <Suspense
2733 fallback={
2734 <>
2735 <Text text="Loading: " />
2736 <TestConsumer />
2737 </>
2738 }>
2739 <TestProvider ctx="B">
2740 <TestConsumer prefix="Hello: " />
2741 </TestProvider>
2742 <TestConsumer />
2743 </Suspense>
2744 </div>
2745 </TestProvider>,
2746 );
2747 pipe(writable);
2748 });
2749 assertConsoleErrorDev([
2750 'TestProvider uses the legacy childContextTypes API which will soon be removed. ' +
2751 'Use React.createContext() instead. (https://react.dev/link/legacy-context)\n' +
2752 ' in TestProvider (at **)',
2753 'TestConsumer uses the legacy contextTypes API which will soon be removed. ' +
2754 'Use React.createContext() with static contextType instead. (https://react.dev/link/legacy-context)\n' +
2755 ' in TestConsumer (at **)',
2756 ]);
2757 expect(getVisibleChildren(container)).toEqual(
2758 <div>
2759 Loading: <b>A</b>
2760 </div>,
2761 );
2762 await act(() => {
2763 resolveText('Hello: ');
2764 });
2765 expect(getVisibleChildren(container)).toEqual(
2766 <div>
2767 Hello: <b>B</b>
2768 <b>A</b>
2769 </div>,
2770 );
2771 });
2772
2773 it('should resume the context from where it left off', async () => {
2774 const ContextA = React.createContext('A0');
2775 const ContextB = React.createContext('B0');
2776
2777 function PrintA() {
2778 return (
2779 <ContextA.Consumer>{value => <Text text={value} />}</ContextA.Consumer>
2780 );
2781 }
2782
2783 class PrintB extends React.Component {
2784 static contextType = ContextB;
2785 render() {
2786 return <Text text={this.context} />;
2787 }
2788 }
2789
2790 function AsyncParent({text, children}) {
2791 return (
2792 <>
2793 <AsyncText text={text} />
2794 <b>{children}</b>
2795 </>
2796 );
2797 }
2798
2799 await act(() => {
2800 const {pipe} = renderToPipeableStream(
2801 <div>
2802 <PrintA />
2803 <div>
2804 <ContextA.Provider value="A0.1">
2805 <Suspense fallback={<Text text="Loading..." />}>
2806 <AsyncParent text="Child:">
2807 <PrintA />
2808 </AsyncParent>
2809 <PrintB />
2810 </Suspense>
2811 </ContextA.Provider>
2812 </div>
2813 <PrintA />
2814 </div>,
2815 );
2816 pipe(writable);
2817 });
2818 expect(getVisibleChildren(container)).toEqual(
2819 <div>
2820 A0<div>Loading...</div>A0
2821 </div>,
2822 );
2823 await act(() => {
2824 resolveText('Child:');
2825 });
2826 expect(getVisibleChildren(container)).toEqual(
2827 <div>
2828 A0
2829 <div>
2830 Child:<b>A0.1</b>B0
2831 </div>
2832 A0
2833 </div>,
2834 );
2835 });
2836
2837 it('should recover the outer context when an error happens inside a provider', async () => {
2838 const ContextA = React.createContext('A0');
2839 const ContextB = React.createContext('B0');
2840
2841 function PrintA() {
2842 return (
2843 <ContextA.Consumer>{value => <Text text={value} />}</ContextA.Consumer>
2844 );
2845 }
2846
2847 class PrintB extends React.Component {
2848 static contextType = ContextB;
2849 render() {
2850 return <Text text={this.context} />;
2851 }
2852 }
2853
2854 function Throws() {
2855 const value = React.useContext(ContextA);
2856 throw new Error(value);
2857 }
2858
2859 const loggedErrors = [];
2860 await act(() => {
2861 const {pipe} = renderToPipeableStream(
2862 <div>
2863 <PrintA />
2864 <div>
2865 <ContextA.Provider value="A0.1">
2866 <Suspense
2867 fallback={
2868 <b>
2869 <Text text="Loading..." />
2870 </b>
2871 }>
2872 <ContextA.Provider value="A0.1.1">
2873 <Throws />
2874 </ContextA.Provider>
2875 </Suspense>
2876 <PrintB />
2877 </ContextA.Provider>
2878 </div>
2879 <PrintA />
2880 </div>,
2881
2882 {
2883 onError(x) {
2884 loggedErrors.push(x);
2885 },
2886 },
2887 );
2888 pipe(writable);
2889 });
2890 expect(loggedErrors.length).toBe(1);
2891 expect(loggedErrors[0].message).toEqual('A0.1.1');
2892 expect(getVisibleChildren(container)).toEqual(
2893 <div>
2894 A0
2895 <div>
2896 <b>Loading...</b>B0
2897 </div>
2898 A0
2899 </div>,
2900 );
2901 });
2902
2903 it('client renders a boundary if it errors before finishing the fallback', async () => {
2904 function App({isClient}) {
2905 return (
2906 <div>
2907 <Suspense fallback="Loading root...">
2908 <div>
2909 <Suspense fallback={<AsyncText text="Loading..." />}>
2910 <h1>
2911 {isClient ? (
2912 <Text text="Hello" />
2913 ) : (
2914 <AsyncText text="Hello" />
2915 )}
2916 </h1>
2917 </Suspense>
2918 </div>
2919 </Suspense>
2920 </div>
2921 );
2922 }
2923
2924 const theError = new Error('Test');
2925 const loggedErrors = [];
2926 function onError(x) {
2927 loggedErrors.push(x);
2928 return `hash of (${x.message})`;
2929 }
2930 const expectedDigest = onError(theError);
2931 loggedErrors.length = 0;
2932
2933 let controls;
2934 await act(() => {
2935 controls = renderToPipeableStream(
2936 <App isClient={false} />,
2937
2938 {
2939 onError,
2940 },
2941 );
2942 controls.pipe(writable);
2943 });
2944
2945 // We're still showing a fallback.
2946
2947 const errors = [];
2948 // Attempt to hydrate the content.
2949 ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
2950 onRecoverableError(error, errorInfo) {
2951 errors.push({error, errorInfo});
2952 },
2953 });
2954 await waitForAll([]);
2955
2956 // We're still loading because we're waiting for the server to stream more content.
2957 expect(getVisibleChildren(container)).toEqual(<div>Loading root...</div>);
2958
2959 expect(loggedErrors).toEqual([]);
2960
2961 // Error the content, but we don't have a fallback yet.
2962 await act(() => {
2963 rejectText('Hello', theError);
2964 });
2965
2966 expect(loggedErrors).toEqual([theError]);
2967
2968 // We still can't render it on the client because we haven't unblocked the parent.
2969 await waitForAll([]);
2970 expect(getVisibleChildren(container)).toEqual(<div>Loading root...</div>);
2971
2972 // Unblock the loading state
2973 await act(() => {
2974 resolveText('Loading...');
2975 });
2976
2977 // Now we're able to show the inner boundary.
2978 expect(getVisibleChildren(container)).toEqual(
2979 <div>
2980 <div>Loading...</div>
2981 </div>,
2982 );
2983
2984 // That will let us client render it instead.
2985 await waitForAll([]);
2986 expectErrors(
2987 errors,
2988 [
2989 [
2990 'Switched to client rendering because the server rendering errored:\n\n' +
2991 theError.message,
2992 expectedDigest,
2993 componentStack([
2994 'AsyncText',
2995 'h1',
2996 'Suspense',
2997 'div',
2998 'Suspense',
2999 'div',
3000 'App',
3001 ]),
3002 ],
3003 ],
3004 [
3005 [
3006 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
3007 expectedDigest,
3008 ],
3009 ],
3010 );
3011
3012 // The client rendered HTML is now in place.
3013 expect(getVisibleChildren(container)).toEqual(
3014 <div>
3015 <div>
3016 <h1>Hello</h1>
3017 </div>
3018 </div>,
3019 );
3020
3021 expect(loggedErrors).toEqual([theError]);
3022 });
3023
3024 it('should be able to abort the fallback if the main content finishes first', async () => {
3025 await act(() => {
3026 const {pipe} = renderToPipeableStream(
3027 <div>
3028 <Suspense fallback={<Text text="Loading Outer" />}>
3029 <div>
3030 <Suspense
3031 fallback={
3032 <div>
3033 <AsyncText text="Loading" />
3034 Inner
3035 </div>
3036 }>
3037 <AsyncText text="Hello" />
3038 </Suspense>
3039 </div>
3040 </Suspense>
3041 </div>,
3042 );
3043 pipe(writable);
3044 });
3045 expect(getVisibleChildren(container)).toEqual(<div>Loading Outer</div>);
3046 // We should have received a partial segment containing the a partial of the fallback.
3047 expect(container.innerHTML).toContain('Inner');
3048 await act(() => {
3049 resolveText('Hello');
3050 });
3051 // We should've been able to display the content without waiting for the rest of the fallback.
3052 expect(getVisibleChildren(container)).toEqual(
3053 <div>
3054 <div>Hello</div>
3055 </div>,
3056 );
3057 });
3058
3059 it('calls getServerSnapshot instead of getSnapshot', async () => {
3060 const ref = React.createRef();
3061
3062 function getServerSnapshot() {
3063 return 'server';
3064 }
3065
3066 function getClientSnapshot() {
3067 return 'client';
3068 }
3069
3070 function subscribe() {
3071 return () => {};
3072 }
3073
3074 function Child({text}) {
3075 Scheduler.log(text);
3076 return text;
3077 }
3078
3079 function App() {
3080 const value = useSyncExternalStore(
3081 subscribe,
3082 getClientSnapshot,
3083 getServerSnapshot,
3084 );
3085 return (
3086 <div ref={ref}>
3087 <Child text={value} />
3088 </div>
3089 );
3090 }
3091
3092 const loggedErrors = [];
3093 await act(() => {
3094 const {pipe} = renderToPipeableStream(
3095 <Suspense fallback="Loading...">
3096 <App />
3097 </Suspense>,
3098 {
3099 onError(x) {
3100 loggedErrors.push(x);
3101 },
3102 },
3103 );
3104 pipe(writable);
3105 });
3106 assertLog(['server']);
3107
3108 ReactDOMClient.hydrateRoot(container, <App />, {
3109 onRecoverableError(error) {
3110 Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
3111 if (error.cause) {
3112 Scheduler.log('Cause: ' + normalizeError(error.cause.message));
3113 }
3114 },
3115 });
3116
3117 // The first paint switches to client rendering due to mismatch
3118 await waitForPaint([
3119 'client',
3120 "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
3121 ]);
3122 expect(getVisibleChildren(container)).toEqual(<div>client</div>);
3123 });
3124
3125 // The selector implementation uses the lazy ref initialization pattern
3126
3127 it('calls getServerSnapshot instead of getSnapshot (with selector and isEqual)', async () => {
3128 // Same as previous test, but with a selector that returns a complex object
3129 // that is memoized with a custom `isEqual` function.
3130 const ref = React.createRef();
3131 function getServerSnapshot() {
3132 return {env: 'server', other: 'unrelated'};
3133 }
3134 function getClientSnapshot() {
3135 return {env: 'client', other: 'unrelated'};
3136 }
3137 function selector({env}) {
3138 return {env};
3139 }
3140 function isEqual(a, b) {
3141 return a.env === b.env;
3142 }
3143 function subscribe() {
3144 return () => {};
3145 }
3146 function Child({text}) {
3147 Scheduler.log(text);
3148 return text;
3149 }
3150 function App() {
3151 const {env} = useSyncExternalStoreWithSelector(
3152 subscribe,
3153 getClientSnapshot,
3154 getServerSnapshot,
3155 selector,
3156 isEqual,
3157 );
3158 return (
3159 <div ref={ref}>
3160 <Child text={env} />
3161 </div>
3162 );
3163 }
3164 const loggedErrors = [];
3165 await act(() => {
3166 const {pipe} = renderToPipeableStream(
3167 <Suspense fallback="Loading...">
3168 <App />
3169 </Suspense>,
3170 {
3171 onError(x) {
3172 loggedErrors.push(x);
3173 },
3174 },
3175 );
3176 pipe(writable);
3177 });
3178 assertLog(['server']);
3179
3180 ReactDOMClient.hydrateRoot(container, <App />, {
3181 onRecoverableError(error) {
3182 Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
3183 },
3184 });
3185
3186 // The first paint uses the client due to mismatch forcing client render
3187 // The first paint switches to client rendering due to mismatch
3188 await waitForPaint([
3189 'client',
3190 "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
3191 ]);
3192 expect(getVisibleChildren(container)).toEqual(<div>client</div>);
3193 });
3194
3195 it(
3196 'errors during hydration in the shell force a client render at the ' +
3197 'root, and during the client render it recovers',
3198 async () => {
3199 let isClient = false;
3200
3201 function subscribe() {
3202 return () => {};
3203 }
3204 function getClientSnapshot() {
3205 return 'Yay!';
3206 }
3207
3208 // At the time of writing, the only API that exposes whether it's currently
3209 // hydrating is the `getServerSnapshot` API, so I'm using that here to
3210 // simulate an error during hydration.
3211 function getServerSnapshot() {
3212 if (isClient) {
3213 throw new Error('Hydration error');
3214 }
3215 return 'Yay!';
3216 }
3217
3218 function Child() {
3219 const value = useSyncExternalStore(
3220 subscribe,
3221 getClientSnapshot,
3222 getServerSnapshot,
3223 );
3224 Scheduler.log(value);
3225 return value;
3226 }
3227
3228 const spanRef = React.createRef();
3229
3230 function App() {
3231 return (
3232 <span ref={spanRef}>
3233 <Child />
3234 </span>
3235 );
3236 }
3237
3238 await act(() => {
3239 const {pipe} = renderToPipeableStream(<App />);
3240 pipe(writable);
3241 });
3242 assertLog(['Yay!']);
3243
3244 const span = container.getElementsByTagName('span')[0];
3245
3246 // Hydrate the tree. Child will throw during hydration, but not when it
3247 // falls back to client rendering.
3248 isClient = true;
3249 ReactDOMClient.hydrateRoot(container, <App />, {
3250 onRecoverableError(error) {
3251 Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
3252 if (error.cause) {
3253 Scheduler.log('Cause: ' + normalizeError(error.cause.message));
3254 }
3255 },
3256 });
3257
3258 // An error logged but instead of surfacing it to the UI, we switched
3259 // to client rendering.
3260 await waitForAll([
3261 'Yay!',
3262 'onRecoverableError: There was an error while hydrating but React was able to recover by instead client rendering the entire root.',
3263 'Cause: Hydration error',
3264 ]);
3265 expect(getVisibleChildren(container)).toEqual(<span>Yay!</span>);
3266
3267 // The node that's inside the boundary that errored during hydration was
3268 // not hydrated.
3269 expect(spanRef.current).not.toBe(span);
3270 },
3271 );
3272
3273 it('can hydrate uSES in StrictMode with different client and server snapshot (sync)', async () => {
3274 function subscribe() {
3275 return () => {};
3276 }
3277 function getClientSnapshot() {
3278 return 'Yay!';
3279 }
3280 function getServerSnapshot() {
3281 return 'Nay!';
3282 }
3283
3284 function App() {
3285 const value = useSyncExternalStore(
3286 subscribe,
3287 getClientSnapshot,
3288 getServerSnapshot,
3289 );
3290 Scheduler.log(value);
3291
3292 return value;
3293 }
3294
3295 const element = (
3296 <React.StrictMode>
3297 <App />
3298 </React.StrictMode>
3299 );
3300
3301 await act(async () => {
3302 const {pipe} = renderToPipeableStream(element);
3303 pipe(writable);
3304 });
3305
3306 assertLog(['Nay!']);
3307 expect(getVisibleChildren(container)).toEqual('Nay!');
3308
3309 await clientAct(() => {
3310 ReactDOM.flushSync(() => {
3311 ReactDOMClient.hydrateRoot(container, element);
3312 });
3313 });
3314
3315 expect(getVisibleChildren(container)).toEqual('Yay!');
3316 assertLog(['Nay!', 'Yay!']);
3317 });
3318
3319 it('can hydrate uSES in StrictMode with different client and server snapshot (concurrent)', async () => {
3320 function subscribe() {
3321 return () => {};
3322 }
3323 function getClientSnapshot() {
3324 return 'Yay!';
3325 }
3326 function getServerSnapshot() {
3327 return 'Nay!';
3328 }
3329
3330 function App() {
3331 const value = useSyncExternalStore(
3332 subscribe,
3333 getClientSnapshot,
3334 getServerSnapshot,
3335 );
3336 Scheduler.log(value);
3337
3338 return value;
3339 }
3340
3341 const element = (
3342 <React.StrictMode>
3343 <App />
3344 </React.StrictMode>
3345 );
3346
3347 await act(async () => {
3348 const {pipe} = renderToPipeableStream(element);
3349 pipe(writable);
3350 });
3351
3352 assertLog(['Nay!']);
3353 expect(getVisibleChildren(container)).toEqual('Nay!');
3354
3355 await clientAct(() => {
3356 React.startTransition(() => {
3357 ReactDOMClient.hydrateRoot(container, element);
3358 });
3359 });
3360
3361 expect(getVisibleChildren(container)).toEqual('Yay!');
3362 assertLog(['Nay!', 'Yay!']);
3363 });
3364
3365 it(
3366 'errors during hydration force a client render at the nearest Suspense ' +
3367 'boundary, and during the client render it recovers',
3368 async () => {
3369 let isClient = false;
3370
3371 function subscribe() {
3372 return () => {};
3373 }
3374 function getClientSnapshot() {
3375 return 'Yay!';
3376 }
3377
3378 // At the time of writing, the only API that exposes whether it's currently
3379 // hydrating is the `getServerSnapshot` API, so I'm using that here to
3380 // simulate an error during hydration.
3381 function getServerSnapshot() {
3382 if (isClient) {
3383 throw new Error('Hydration error');
3384 }
3385 return 'Yay!';
3386 }
3387
3388 function Child() {
3389 const value = useSyncExternalStore(
3390 subscribe,
3391 getClientSnapshot,
3392 getServerSnapshot,
3393 );
3394 Scheduler.log(value);
3395 return value;
3396 }
3397
3398 const span1Ref = React.createRef();
3399 const span2Ref = React.createRef();
3400 const span3Ref = React.createRef();
3401
3402 function App() {
3403 return (
3404 <div>
3405 <span ref={span1Ref} />
3406 <Suspense fallback="Loading...">
3407 <span ref={span2Ref}>
3408 <Child />
3409 </span>
3410 </Suspense>
3411 <span ref={span3Ref} />
3412 </div>
3413 );
3414 }
3415
3416 await act(() => {
3417 const {pipe} = renderToPipeableStream(<App />);
3418 pipe(writable);
3419 });
3420 assertLog(['Yay!']);
3421
3422 const [span1, span2, span3] = container.getElementsByTagName('span');
3423
3424 // Hydrate the tree. Child will throw during hydration, but not when it
3425 // falls back to client rendering.
3426 isClient = true;
3427 ReactDOMClient.hydrateRoot(container, <App />, {
3428 onRecoverableError(error) {
3429 Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
3430 if (error.cause) {
3431 Scheduler.log('Cause: ' + normalizeError(error.cause.message));
3432 }
3433 },
3434 });
3435
3436 // An error logged but instead of surfacing it to the UI, we switched
3437 // to client rendering.
3438 await waitForAll([
3439 'Yay!',
3440 'onRecoverableError: There was an error while hydrating but React was able to recover by instead client rendering from the nearest Suspense boundary.',
3441 'Cause: Hydration error',
3442 ]);
3443 expect(getVisibleChildren(container)).toEqual(
3444 <div>
3445 <span />
3446 <span>Yay!</span>
3447 <span />
3448 </div>,
3449 );
3450
3451 // The node that's inside the boundary that errored during hydration was
3452 // not hydrated.
3453 expect(span2Ref.current).not.toBe(span2);
3454
3455 // But the nodes outside the boundary were.
3456 expect(span1Ref.current).toBe(span1);
3457 expect(span3Ref.current).toBe(span3);
3458 },
3459 );
3460
3461 it(
3462 'errors during hydration force a client render at the nearest Suspense ' +
3463 'boundary, and during the client render it fails again',
3464 async () => {
3465 // Similar to previous test, but the client render errors, too. We should
3466 // be able to capture it with an error boundary.
3467
3468 let isClient = false;
3469
3470 class ErrorBoundary extends React.Component {
3471 state = {error: null};
3472 static getDerivedStateFromError(error) {
3473 return {error};
3474 }
3475 render() {
3476 if (this.state.error !== null) {
3477 return this.state.error.message;
3478 }
3479 return this.props.children;
3480 }
3481 }
3482
3483 function Child() {
3484 if (isClient) {
3485 throw new Error('Oops!');
3486 }
3487 Scheduler.log('Yay!');
3488 return 'Yay!';
3489 }
3490
3491 const span1Ref = React.createRef();
3492 const span2Ref = React.createRef();
3493 const span3Ref = React.createRef();
3494
3495 function App() {
3496 return (
3497 <ErrorBoundary>
3498 <span ref={span1Ref} />
3499 <Suspense fallback="Loading...">
3500 <span ref={span2Ref}>
3501 <Child />
3502 </span>
3503 </Suspense>
3504 <span ref={span3Ref} />
3505 </ErrorBoundary>
3506 );
3507 }
3508
3509 await act(() => {
3510 const {pipe} = renderToPipeableStream(<App />);
3511 pipe(writable);
3512 });
3513 assertLog(['Yay!']);
3514
3515 // Hydrate the tree. Child will throw during render.
3516 isClient = true;
3517 const errors = [];
3518 ReactDOMClient.hydrateRoot(container, <App />, {
3519 onRecoverableError(error) {
3520 errors.push(error.message);
3521 },
3522 });
3523
3524 // Because we failed to recover from the error, onRecoverableError
3525 // shouldn't be called.
3526 await waitForAll([]);
3527 expect(getVisibleChildren(container)).toEqual('Oops!');
3528
3529 expectErrors(errors, [], []);
3530 },
3531 );
3532
3533 // Disabled because of a WWW late mutations regression.
3534 // We may want to re-enable this if we figure out why.
3535
3536 // @gate FIXME
3537 it('does not recreate the fallback if server errors and hydration suspends', async () => {
3538 let isClient = false;
3539
3540 function Child() {
3541 if (isClient) {
3542 readText('Yay!');
3543 } else {
3544 throw Error('Oops.');
3545 }
3546 Scheduler.log('Yay!');
3547 return 'Yay!';
3548 }
3549
3550 const fallbackRef = React.createRef();
3551 function App() {
3552 return (
3553 <div>
3554 <Suspense fallback={<p ref={fallbackRef}>Loading...</p>}>
3555 <span>
3556 <Child />
3557 </span>
3558 </Suspense>
3559 </div>
3560 );
3561 }
3562 await act(() => {
3563 const {pipe} = renderToPipeableStream(<App />, {
3564 onError(error) {
3565 Scheduler.log('[s!] ' + error.message);
3566 },
3567 });
3568 pipe(writable);
3569 });
3570 assertLog(['[s!] Oops.']);
3571
3572 // The server could not complete this boundary, so we'll retry on the client.
3573 const serverFallback = container.getElementsByTagName('p')[0];
3574 expect(serverFallback.innerHTML).toBe('Loading...');
3575
3576 // Hydrate the tree. This will suspend.
3577 isClient = true;
3578 ReactDOMClient.hydrateRoot(container, <App />, {
3579 onRecoverableError(error) {
3580 Scheduler.log('onRecoverableError: ' + error.message);
3581 if (error.cause) {
3582 Scheduler.log('Cause: ' + normalizeError(error.cause.message));
3583 }
3584 },
3585 });
3586 // This should not report any errors yet.
3587 await waitForAll([]);
3588 expect(getVisibleChildren(container)).toEqual(
3589 <div>
3590 <p>Loading...</p>
3591 </div>,
3592 );
3593
3594 // Normally, hydrating after server error would force a clean client render.
3595 // However, it suspended so at best we'd only get the same fallback anyway.
3596 // We don't want to recreate the same fallback in the DOM again because
3597 // that's extra work and would restart animations etc. Check we don't do that.
3598 const clientFallback = container.getElementsByTagName('p')[0];
3599 expect(serverFallback).toBe(clientFallback);
3600
3601 // When we're able to fully hydrate, we expect a clean client render.
3602 await act(() => {
3603 resolveText('Yay!');
3604 });
3605 await waitForAll([
3606 'Yay!',
3607 'onRecoverableError: The server could not finish this Suspense boundary, ' +
3608 'likely due to an error during server rendering. ' +
3609 'Switched to client rendering.',
3610 ]);
3611 expect(getVisibleChildren(container)).toEqual(
3612 <div>
3613 <span>Yay!</span>
3614 </div>,
3615 );
3616 });
3617
3618 // Disabled because of a WWW late mutations regression.
3619 // We may want to re-enable this if we figure out why.
3620
3621 // @gate FIXME
3622 it(
3623 'does not recreate the fallback if server errors and hydration suspends ' +
3624 'and root receives a transition',
3625 async () => {
3626 let isClient = false;
3627
3628 function Child({color}) {
3629 if (isClient) {
3630 readText('Yay!');
3631 } else {
3632 throw Error('Oops.');
3633 }
3634 Scheduler.log('Yay! (' + color + ')');
3635 return 'Yay! (' + color + ')';
3636 }
3637
3638 const fallbackRef = React.createRef();
3639 function App({color}) {
3640 return (
3641 <div>
3642 <Suspense fallback={<p ref={fallbackRef}>Loading...</p>}>
3643 <span>
3644 <Child color={color} />
3645 </span>
3646 </Suspense>
3647 </div>
3648 );
3649 }
3650 await act(() => {
3651 const {pipe} = renderToPipeableStream(<App color="red" />, {
3652 onError(error) {
3653 Scheduler.log('[s!] ' + error.message);
3654 },
3655 });
3656 pipe(writable);
3657 });
3658 assertLog(['[s!] Oops.']);
3659
3660 // The server could not complete this boundary, so we'll retry on the client.
3661 const serverFallback = container.getElementsByTagName('p')[0];
3662 expect(serverFallback.innerHTML).toBe('Loading...');
3663
3664 // Hydrate the tree. This will suspend.
3665 isClient = true;
3666 const root = ReactDOMClient.hydrateRoot(container, <App color="red" />, {
3667 onRecoverableError(error) {
3668 Scheduler.log('onRecoverableError: ' + error.message);
3669 if (error.cause) {
3670 Scheduler.log('Cause: ' + normalizeError(error.cause.message));
3671 }
3672 },
3673 });
3674 // This should not report any errors yet.
3675 await waitForAll([]);
3676 expect(getVisibleChildren(container)).toEqual(
3677 <div>
3678 <p>Loading...</p>
3679 </div>,
3680 );
3681
3682 // Normally, hydrating after server error would force a clean client render.
3683 // However, it suspended so at best we'd only get the same fallback anyway.
3684 // We don't want to recreate the same fallback in the DOM again because
3685 // that's extra work and would restart animations etc. Check we don't do that.
3686 const clientFallback = container.getElementsByTagName('p')[0];
3687 expect(serverFallback).toBe(clientFallback);
3688
3689 // Transition updates shouldn't recreate the fallback either.
3690 React.startTransition(() => {
3691 root.render(<App color="blue" />);
3692 });
3693 await waitForAll([]);
3694 jest.runAllTimers();
3695 const clientFallback2 = container.getElementsByTagName('p')[0];
3696 expect(clientFallback2).toBe(serverFallback);
3697
3698 // When we're able to fully hydrate, we expect a clean client render.
3699 await act(() => {
3700 resolveText('Yay!');
3701 });
3702 await waitForAll([
3703 'Yay! (red)',
3704 'onRecoverableError: The server could not finish this Suspense boundary, ' +
3705 'likely due to an error during server rendering. ' +
3706 'Switched to client rendering.',
3707 'Yay! (blue)',
3708 ]);
3709 expect(getVisibleChildren(container)).toEqual(
3710 <div>
3711 <span>Yay! (blue)</span>
3712 </div>,
3713 );
3714 },
3715 );
3716
3717 // Disabled because of a WWW late mutations regression.
3718 // We may want to re-enable this if we figure out why.
3719
3720 // @gate FIXME
3721 it(
3722 'recreates the fallback if server errors and hydration suspends but ' +
3723 'client receives new props',
3724 async () => {
3725 let isClient = false;
3726
3727 function Child() {
3728 const value = 'Yay!';
3729 if (isClient) {
3730 readText(value);
3731 } else {
3732 throw Error('Oops.');
3733 }
3734 Scheduler.log(value);
3735 return value;
3736 }
3737
3738 const fallbackRef = React.createRef();
3739 function App({fallbackText}) {
3740 return (
3741 <div>
3742 <Suspense fallback={<p ref={fallbackRef}>{fallbackText}</p>}>
3743 <span>
3744 <Child />
3745 </span>
3746 </Suspense>
3747 </div>
3748 );
3749 }
3750
3751 await act(() => {
3752 const {pipe} = renderToPipeableStream(
3753 <App fallbackText="Loading..." />,
3754 {
3755 onError(error) {
3756 Scheduler.log('[s!] ' + error.message);
3757 },
3758 },
3759 );
3760 pipe(writable);
3761 });
3762 assertLog(['[s!] Oops.']);
3763
3764 const serverFallback = container.getElementsByTagName('p')[0];
3765 expect(serverFallback.innerHTML).toBe('Loading...');
3766
3767 // Hydrate the tree. This will suspend.
3768 isClient = true;
3769 const root = ReactDOMClient.hydrateRoot(
3770 container,
3771 <App fallbackText="Loading..." />,
3772 {
3773 onRecoverableError(error) {
3774 Scheduler.log('onRecoverableError: ' + error.message);
3775 if (error.cause) {
3776 Scheduler.log('Cause: ' + normalizeError(error.cause.message));
3777 }
3778 },
3779 },
3780 );
3781 // This should not report any errors yet.
3782 await waitForAll([]);
3783 expect(getVisibleChildren(container)).toEqual(
3784 <div>
3785 <p>Loading...</p>
3786 </div>,
3787 );
3788
3789 // Normally, hydration after server error would force a clean client render.
3790 // However, that suspended so at best we'd only get a fallback anyway.
3791 // We don't want to replace a fallback with the same fallback because
3792 // that's extra work and would restart animations etc. Verify we don't do that.
3793 const clientFallback1 = container.getElementsByTagName('p')[0];
3794 expect(serverFallback).toBe(clientFallback1);
3795
3796 // However, an update may have changed the fallback props. In that case we have to
3797 // actually force it to re-render on the client and throw away the server one.
3798 root.render(<App fallbackText="More loading..." />);
3799 await waitForAll([]);
3800 jest.runAllTimers();
3801 assertLog([
3802 'onRecoverableError: The server could not finish this Suspense boundary, ' +
3803 'likely due to an error during server rendering. ' +
3804 'Switched to client rendering.',
3805 ]);
3806 expect(getVisibleChildren(container)).toEqual(
3807 <div>
3808 <p>More loading...</p>
3809 </div>,
3810 );
3811 // This should be a clean render without reusing DOM.
3812 const clientFallback2 = container.getElementsByTagName('p')[0];
3813 expect(clientFallback2).not.toBe(clientFallback1);
3814
3815 // Verify we can still do a clean content render after.
3816 await act(() => {
3817 resolveText('Yay!');
3818 });
3819 await waitForAll(['Yay!']);
3820 expect(getVisibleChildren(container)).toEqual(
3821 <div>
3822 <span>Yay!</span>
3823 </div>,
3824 );
3825 },
3826 );
3827
3828 it(
3829 'errors during hydration force a client render at the nearest Suspense ' +
3830 'boundary, and during the client render it recovers, then a deeper ' +
3831 'child suspends',
3832 async () => {
3833 let isClient = false;
3834
3835 function subscribe() {
3836 return () => {};
3837 }
3838 function getClientSnapshot() {
3839 return 'Yay!';
3840 }
3841
3842 // At the time of writing, the only API that exposes whether it's currently
3843 // hydrating is the `getServerSnapshot` API, so I'm using that here to
3844 // simulate an error during hydration.
3845 function getServerSnapshot() {
3846 if (isClient) {
3847 throw new Error('Hydration error');
3848 }
3849 return 'Yay!';
3850 }
3851
3852 function Child() {
3853 const value = useSyncExternalStore(
3854 subscribe,
3855 getClientSnapshot,
3856 getServerSnapshot,
3857 );
3858 if (isClient) {
3859 readText(value);
3860 }
3861 Scheduler.log(value);
3862 return value;
3863 }
3864
3865 const span1Ref = React.createRef();
3866 const span2Ref = React.createRef();
3867 const span3Ref = React.createRef();
3868
3869 function App() {
3870 return (
3871 <div>
3872 <span ref={span1Ref} />
3873 <Suspense fallback="Loading...">
3874 <span ref={span2Ref}>
3875 <Child />
3876 </span>
3877 </Suspense>
3878 <span ref={span3Ref} />
3879 </div>
3880 );
3881 }
3882
3883 await act(() => {
3884 const {pipe} = renderToPipeableStream(<App />);
3885 pipe(writable);
3886 });
3887 assertLog(['Yay!']);
3888
3889 const [span1, span2, span3] = container.getElementsByTagName('span');
3890
3891 // Hydrate the tree. Child will throw during hydration, but not when it
3892 // falls back to client rendering.
3893 isClient = true;
3894 ReactDOMClient.hydrateRoot(container, <App />, {
3895 onRecoverableError(error) {
3896 Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
3897 if (error.cause) {
3898 Scheduler.log('Cause: ' + normalizeError(error.cause.message));
3899 }
3900 },
3901 });
3902
3903 // An error logged but instead of surfacing it to the UI, we switched
3904 // to client rendering.
3905 await waitForAll([
3906 'onRecoverableError: There was an error while hydrating but React was able to recover by instead client rendering from the nearest Suspense boundary.',
3907 'Cause: Hydration error',
3908 ]);
3909 expect(getVisibleChildren(container)).toEqual(
3910 <div>
3911 <span />
3912 Loading...
3913 <span />
3914 </div>,
3915 );
3916
3917 await clientAct(() => {
3918 resolveText('Yay!');
3919 });
3920 assertLog(['Yay!']);
3921 expect(getVisibleChildren(container)).toEqual(
3922 <div>
3923 <span />
3924 <span>Yay!</span>
3925 <span />
3926 </div>,
3927 );
3928
3929 // The node that's inside the boundary that errored during hydration was
3930 // not hydrated.
3931 expect(span2Ref.current).not.toBe(span2);
3932
3933 // But the nodes outside the boundary were.
3934 expect(span1Ref.current).toBe(span1);
3935 expect(span3Ref.current).toBe(span3);
3936 },
3937 );
3938
3939 it('logs regular (non-hydration) errors when the UI recovers', async () => {
3940 let shouldThrow = true;
3941
3942 function A({unused}) {
3943 if (shouldThrow) {
3944 Scheduler.log('Oops!');
3945 throw new Error('Oops!');
3946 }
3947 Scheduler.log('A');
3948 return 'A';
3949 }
3950
3951 function B() {
3952 Scheduler.log('B');
3953 return 'B';
3954 }
3955
3956 function App() {
3957 return (
3958 <>
3959 <A />
3960 <B />
3961 </>
3962 );
3963 }
3964
3965 const root = ReactDOMClient.createRoot(container, {
3966 onRecoverableError(error) {
3967 Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
3968 if (error.cause) {
3969 Scheduler.log('Cause: ' + normalizeError(error.cause.message));
3970 }
3971 },
3972 });
3973 React.startTransition(() => {
3974 root.render(<App />);
3975 });
3976
3977 // Partially render A, but yield before the render has finished
3978 await waitFor(['Oops!']);
3979
3980 // React will try rendering again synchronously. During the retry, A will
3981 // not throw. This simulates a concurrent data race that is fixed by
3982 // blocking the main thread.
3983 shouldThrow = false;
3984 await waitForAll([
3985 // Render again, synchronously
3986 'A',
3987 'B',
3988
3989 // Log the error
3990 'onRecoverableError: There was an error during concurrent rendering but React was able to recover by instead synchronously rendering the entire root.',
3991 'Cause: Oops!',
3992 ]);
3993
3994 // UI looks normal
3995 expect(container.textContent).toEqual('AB');
3996 });
3997
3998 it('logs multiple hydration errors in the same render', async () => {
3999 let isClient = false;
4000
4001 function subscribe() {
4002 return () => {};
4003 }
4004 function getClientSnapshot() {
4005 return 'Yay!';
4006 }
4007 function getServerSnapshot() {
4008 if (isClient) {
4009 throw new Error('Hydration error');
4010 }
4011 return 'Yay!';
4012 }
4013
4014 function Child({label}) {
4015 // This will throw during client hydration. Only reason to use
4016 // useSyncExternalStore in this test is because getServerSnapshot has the
4017 // ability to observe whether we're hydrating.
4018 useSyncExternalStore(subscribe, getClientSnapshot, getServerSnapshot);
4019 Scheduler.log(label);
4020 return label;
4021 }
4022
4023 function App() {
4024 return (
4025 <>
4026 <Suspense fallback="Loading...">
4027 <Child label="A" />
4028 </Suspense>
4029 <Suspense fallback="Loading...">
4030 <Child label="B" />
4031 </Suspense>
4032 </>
4033 );
4034 }
4035
4036 await act(() => {
4037 const {pipe} = renderToPipeableStream(<App />);
4038 pipe(writable);
4039 });
4040 assertLog(['A', 'B']);
4041
4042 // Hydrate the tree. Child will throw during hydration, but not when it
4043 // falls back to client rendering.
4044 isClient = true;
4045 ReactDOMClient.hydrateRoot(container, <App />, {
4046 onRecoverableError(error) {
4047 Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
4048 if (error.cause) {
4049 Scheduler.log('Cause: ' + normalizeError(error.cause.message));
4050 }
4051 },
4052 });
4053
4054 await waitForAll([
4055 'A',
4056 'B',
4057
4058 'onRecoverableError: There was an error while hydrating but React was able to recover by instead client rendering from the nearest Suspense boundary.',
4059 'Cause: Hydration error',
4060
4061 'onRecoverableError: There was an error while hydrating but React was able to recover by instead client rendering from the nearest Suspense boundary.',
4062 'Cause: Hydration error',
4063 ]);
4064 });
4065
4066 it('supports iterable', async () => {
4067 const Immutable = require('immutable');
4068
4069 const mappedJSX = Immutable.fromJS([
4070 {name: 'a', value: 'a'},
4071 {name: 'b', value: 'b'},
4072 ]).map(item => <li key={item.get('value')}>{item.get('name')}</li>);
4073
4074 await act(() => {
4075 const {pipe} = renderToPipeableStream(<ul>{mappedJSX}</ul>);
4076 pipe(writable);
4077 });
4078 expect(getVisibleChildren(container)).toEqual(
4079 <ul>
4080 <li>a</li>
4081 <li>b</li>
4082 </ul>,
4083 );
4084 });
4085
4086 // @gate enableAsyncIterableChildren
4087 it('supports async generator component', async () => {
4088 async function* App() {
4089 yield <span key="1">{await Promise.resolve('Hi')}</span>;
4090 yield ' ';
4091 yield <span key="2">{await Promise.resolve('World')}</span>;
4092 }
4093
4094 await act(async () => {
4095 const {pipe} = renderToPipeableStream(
4096 <div>
4097 <App />
4098 </div>,
4099 );
4100 pipe(writable);
4101 });
4102
4103 // Each act retries once which causes a new ping which schedules
4104 // new work but only after the act has finished rendering.
4105 await act(() => {});
4106 await act(() => {});
4107 await act(() => {});
4108 await act(() => {});
4109
4110 expect(getVisibleChildren(container)).toEqual(
4111 <div>
4112 <span>Hi</span> <span>World</span>
4113 </div>,
4114 );
4115 });
4116
4117 // @gate enableAsyncIterableChildren
4118 it('supports async iterable children', async () => {
4119 const iterable = {
4120 async *[Symbol.asyncIterator]() {
4121 yield <span key="1">{await Promise.resolve('Hi')}</span>;
4122 yield ' ';
4123 yield <span key="2">{await Promise.resolve('World')}</span>;
4124 },
4125 };
4126
4127 function App({children}) {
4128 return <div>{children}</div>;
4129 }
4130
4131 await act(() => {
4132 const {pipe} = renderToPipeableStream(<App>{iterable}</App>);
4133 pipe(writable);
4134 });
4135
4136 // Each act retries once which causes a new ping which schedules
4137 // new work but only after the act has finished rendering.
4138 await act(() => {});
4139 await act(() => {});
4140 await act(() => {});
4141 await act(() => {});
4142
4143 expect(getVisibleChildren(container)).toEqual(
4144 <div>
4145 <span>Hi</span> <span>World</span>
4146 </div>,
4147 );
4148 });
4149
4150 it('supports bigint', async () => {
4151 await act(async () => {
4152 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
4153 <div>{10n}</div>,
4154 );
4155 pipe(writable);
4156 });
4157 expect(getVisibleChildren(container)).toEqual(<div>10</div>);
4158 });
4159
4160 it('Supports custom abort reasons with a string', async () => {
4161 function App() {
4162 return (
4163 <div>
4164 <p>
4165 <Suspense fallback={'p'}>
4166 <AsyncText text={'hello'} />
4167 </Suspense>
4168 </p>
4169 <span>
4170 <Suspense fallback={'span'}>
4171 <AsyncText text={'world'} />
4172 </Suspense>
4173 </span>
4174 </div>
4175 );
4176 }
4177
4178 let abort;
4179 const loggedErrors = [];
4180 await act(() => {
4181 const {pipe, abort: abortImpl} = renderToPipeableStream(<App />, {
4182 onError(error) {
4183 // In this test we contrive erroring with strings so we push the error whereas in most
4184 // other tests we contrive erroring with Errors and push the message.
4185 loggedErrors.push(error);
4186 return 'a digest';
4187 },
4188 });
4189 abort = abortImpl;
4190 pipe(writable);
4191 });
4192
4193 expect(loggedErrors).toEqual([]);
4194 expect(getVisibleChildren(container)).toEqual(
4195 <div>
4196 <p>p</p>
4197 <span>span</span>
4198 </div>,
4199 );
4200
4201 await act(() => {
4202 abort('foobar');
4203 });
4204
4205 expect(loggedErrors).toEqual(['foobar', 'foobar']);
4206
4207 const errors = [];
4208 ReactDOMClient.hydrateRoot(container, <App />, {
4209 onRecoverableError(error, errorInfo) {
4210 errors.push({error, errorInfo});
4211 },
4212 });
4213
4214 await waitForAll([]);
4215
4216 expectErrors(
4217 errors,
4218 [
4219 [
4220 'Switched to client rendering because the server rendering aborted due to:\n\n' +
4221 'foobar',
4222 'a digest',
4223 componentStack(['AsyncText', 'Suspense', 'p', 'div', 'App']),
4224 ],
4225 [
4226 'Switched to client rendering because the server rendering aborted due to:\n\n' +
4227 'foobar',
4228 'a digest',
4229 componentStack(['AsyncText', 'Suspense', 'span', 'div', 'App']),
4230 ],
4231 ],
4232 [
4233 [
4234 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
4235 'a digest',
4236 ],
4237 [
4238 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
4239 'a digest',
4240 ],
4241 ],
4242 );
4243 });
4244
4245 it('Supports custom abort reasons with an Error', async () => {
4246 function App() {
4247 return (
4248 <div>
4249 <p>
4250 <Suspense fallback={'p'}>
4251 <AsyncText text={'hello'} />
4252 </Suspense>
4253 </p>
4254 <span>
4255 <Suspense fallback={'span'}>
4256 <AsyncText text={'world'} />
4257 </Suspense>
4258 </span>
4259 </div>
4260 );
4261 }
4262
4263 let abort;
4264 const loggedErrors = [];
4265 await act(() => {
4266 const {pipe, abort: abortImpl} = renderToPipeableStream(<App />, {
4267 onError(error) {
4268 loggedErrors.push(error.message);
4269 return 'a digest';
4270 },
4271 });
4272 abort = abortImpl;
4273 pipe(writable);
4274 });
4275
4276 expect(loggedErrors).toEqual([]);
4277 expect(getVisibleChildren(container)).toEqual(
4278 <div>
4279 <p>p</p>
4280 <span>span</span>
4281 </div>,
4282 );
4283
4284 await act(() => {
4285 abort(new Error('uh oh'));
4286 });
4287
4288 expect(loggedErrors).toEqual(['uh oh', 'uh oh']);
4289
4290 const errors = [];
4291 ReactDOMClient.hydrateRoot(container, <App />, {
4292 onRecoverableError(error, errorInfo) {
4293 errors.push({error, errorInfo});
4294 },
4295 });
4296
4297 await waitForAll([]);
4298
4299 expectErrors(
4300 errors,
4301 [
4302 [
4303 'Switched to client rendering because the server rendering aborted due to:\n\n' +
4304 'uh oh',
4305 'a digest',
4306 componentStack(['AsyncText', 'Suspense', 'p', 'div', 'App']),
4307 ],
4308 [
4309 'Switched to client rendering because the server rendering aborted due to:\n\n' +
4310 'uh oh',
4311 'a digest',
4312 componentStack(['AsyncText', 'Suspense', 'span', 'div', 'App']),
4313 ],
4314 ],
4315 [
4316 [
4317 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
4318 'a digest',
4319 ],
4320 [
4321 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
4322 'a digest',
4323 ],
4324 ],
4325 );
4326 });
4327
4328 it('reports abort errors for every suspended task when aborting fatals the shell', async () => {
4329 const promise = new Promise(() => {});
4330 const rendered = [];
4331 function Suspend({label}) {
4332 rendered.push(label);
4333 use(promise);
4334 return null;
4335 }
4336
4337 function App() {
4338 return (
4339 <>
4340 <Suspense fallback="Loading...">
4341 <Suspend label="boundary" />
4342 </Suspense>
4343 <Suspend label="root one" />
4344 <Suspend label="root two" />
4345 </>
4346 );
4347 }
4348
4349 const errors = [];
4350 let abort;
4351 await act(() => {
4352 abort = renderToPipeableStream(<App />, {
4353 onError(error) {
4354 errors.push(error.message);
4355 },
4356 onShellError() {},
4357 }).abort;
4358 });
4359
4360 expect(rendered).toEqual(['boundary', 'root one', 'root two']);
4361
4362 await act(() => {
4363 abort(new Error('abort reason'));
4364 });
4365
4366 expect(errors).toEqual(['abort reason', 'abort reason', 'abort reason']);
4367 });
4368
4369 it('uses a rejection reason from a lazy component before the abort finishes', async () => {
4370 let reject;
4371 const Lazy = React.lazy(
4372 () =>
4373 new Promise((resolve, rejectPromise) => {
4374 reject = rejectPromise;
4375 }),
4376 );
4377 const haltedPromise = new Promise(() => {});
4378 function HaltedWait() {
4379 use(haltedPromise);
4380 return null;
4381 }
4382
4383 const errors = [];
4384 let abort;
4385 await act(() => {
4386 const controls = renderToPipeableStream(
4387 <>
4388 <Suspense fallback="Loading lazy">
4389 <Lazy />
4390 </Suspense>
4391 <Suspense fallback="Loading halted">
4392 <HaltedWait />
4393 </Suspense>
4394 </>,
4395 {
4396 onError(error) {
4397 errors.push(error.message);
4398 },
4399 },
4400 );
4401 abort = controls.abort;
4402 controls.pipe(writable);
4403 });
4404
4405 await act(() => {
4406 abort(new Error('abort reason'));
4407 reject(new Error('rejected during abort'));
4408 });
4409
4410 expect(errors).toEqual(['rejected during abort', 'abort reason']);
4411 });
4412
4413 it('does not report a rejection reason after abort has finished', async () => {
4414 let reject;
4415 const promise = new Promise((resolve, rejectPromise) => {
4416 reject = rejectPromise;
4417 });
4418 function Wait() {
4419 use(promise);
4420 return null;
4421 }
4422
4423 const errors = [];
4424 let abort;
4425 await act(() => {
4426 const controls = renderToPipeableStream(
4427 <Suspense fallback="Loading">
4428 <Wait />
4429 </Suspense>,
4430 {
4431 onError(error) {
4432 errors.push(error.message);
4433 },
4434 },
4435 );
4436 abort = controls.abort;
4437 controls.pipe(writable);
4438 });
4439
4440 await act(() => {
4441 abort(new Error('abort reason'));
4442 });
4443
4444 await act(() => {
4445 reject(new Error('rejected after abort'));
4446 });
4447
4448 expect(errors).toEqual(['abort reason']);
4449 });
4450
4451 it('warns in dev if you access digest from errorInfo in onRecoverableError', async () => {
4452 await act(() => {
4453 const {pipe} = renderToPipeableStream(
4454 <div>
4455 <Suspense fallback={'loading...'}>
4456 <AsyncText text={'hello'} />
4457 </Suspense>
4458 </div>,
4459 {
4460 onError(error) {
4461 return 'a digest';
4462 },
4463 },
4464 );
4465 rejectText('hello');
4466 pipe(writable);
4467 });
4468 expect(getVisibleChildren(container)).toEqual(<div>loading...</div>);
4469
4470 ReactDOMClient.hydrateRoot(
4471 container,
4472 <div>
4473 <Suspense fallback={'loading...'}>hello</Suspense>
4474 </div>,
4475 {
4476 onRecoverableError(error, errorInfo) {
4477 expect(error.digest).toBe('a digest');
4478 expect(errorInfo.digest).toBe(undefined);
4479 assertConsoleErrorDev([
4480 'You are accessing "digest" from the errorInfo object passed to onRecoverableError.' +
4481 ' This property is no longer provided as part of errorInfo but can be accessed as a property' +
4482 ' of the Error instance itself.',
4483 ]);
4484 },
4485 },
4486 );
4487 await waitForAll([]);
4488 });
4489
4490 it('takes an importMap option which emits an "importmap" script in the head', async () => {
4491 const importMap = {
4492 foo: './path/to/foo.js',
4493 };
4494 await act(() => {
4495 renderToPipeableStream(
4496 <html>
4497 <head>
4498 <script async={true} src="foo" />
4499 </head>
4500 <body>
4501 <div>hello world</div>
4502 </body>
4503 </html>,
4504 {
4505 importMap,
4506 },
4507 ).pipe(writable);
4508 });
4509
4510 expect(document.head.innerHTML).toBe(
4511 '<script type="importmap">' +
4512 JSON.stringify(importMap) +
4513 '</script><script async="" src="foo"></script>' +
4514 (gate(flags => flags.shouldUseFizzExternalRuntime)
4515 ? '<script src="react-dom-bindings/src/server/ReactDOMServerExternalRuntime.js" async=""></script>'
4516 : '') +
4517 (gate(flags => flags.enableFizzBlockingRender)
4518 ? '<link rel="expect" href="#_R_" blocking="render">'
4519 : ''),
4520 );
4521 });
4522
4523 // bugfix: https://github.com/facebook/react/issues/27286
4524 it('can render custom elements with children on ther server', async () => {
4525 await act(() => {
4526 renderToPipeableStream(
4527 <html>
4528 <body>
4529 <my-element>
4530 <div>foo</div>
4531 </my-element>
4532 </body>
4533 </html>,
4534 ).pipe(writable);
4535 });
4536
4537 expect(getVisibleChildren(document)).toEqual(
4538 <html>
4539 <head />
4540 <body>
4541 <my-element>
4542 <div>foo</div>
4543 </my-element>
4544 </body>
4545 </html>,
4546 );
4547 });
4548
4549 // https://github.com/facebook/react/issues/27540
4550 // This test is not actually asserting much because there is possibly a bug in the closeing logic for the
4551 // Node implementation of Fizz. The close leads to an abort which sets the destination to null before the Float
4552 // method has an opportunity to schedule a write. We should fix this probably and once we do this test will start
4553 // to fail if the underyling issue of writing after stream completion isn't fixed
4554 it('does not try to write to the stream after it has been closed', async () => {
4555 async function preloadLate() {
4556 await 1;
4557 ReactDOM.preconnect('foo');
4558 }
4559
4560 function Preload() {
4561 preloadLate();
4562 return null;
4563 }
4564
4565 function App() {
4566 return (
4567 <html>
4568 <body>
4569 <main>hello</main>
4570 <Preload />
4571 </body>
4572 </html>
4573 );
4574 }
4575 await act(() => {
4576 renderToPipeableStream(<App />).pipe(writable);
4577 });
4578
4579 expect(getVisibleChildren(document)).toEqual(
4580 <html>
4581 <head />
4582 <body>
4583 <main>hello</main>
4584 </body>
4585 </html>,
4586 );
4587 });
4588
4589 it('provides headers after initial work if onHeaders option used', async () => {
4590 let headers = null;
4591 function onHeaders(x) {
4592 headers = x;
4593 }
4594
4595 function Preloads() {
4596 ReactDOM.preload('font2', {as: 'font'});
4597 ReactDOM.preload('imagepre2', {as: 'image', fetchPriority: 'high'});
4598 ReactDOM.preconnect('pre2', {crossOrigin: 'use-credentials'});
4599 ReactDOM.prefetchDNS('dns2');
4600 }
4601
4602 function Blocked() {
4603 readText('blocked');
4604 return (
4605 <>
4606 <Preloads />
4607 <img src="image2" />
4608 </>
4609 );
4610 }
4611
4612 function App() {
4613 ReactDOM.preload('font', {as: 'font'});
4614 ReactDOM.preload('imagepre', {as: 'image', fetchPriority: 'high'});
4615 ReactDOM.preconnect('pre', {crossOrigin: 'use-credentials'});
4616 ReactDOM.prefetchDNS('dns');
4617 return (
4618 <html>
4619 <body>
4620 <img src="image" />
4621 <Blocked />
4622 </body>
4623 </html>
4624 );
4625 }
4626
4627 await act(() => {
4628 renderToPipeableStream(<App />, {onHeaders});
4629 });
4630
4631 expect(headers).toEqual({
4632 Link: `
4633 <pre>; rel=preconnect; crossorigin="use-credentials",
4634 <dns>; rel=dns-prefetch,
4635 <font>; rel=preload; as="font"; crossorigin="",
4636 <imagepre>; rel=preload; as="image"; fetchpriority="high",
4637 <image>; rel=preload; as="image"
4638 `
4639 .replaceAll('\n', '')
4640 .trim(),
4641 });
4642 });
4643
4644 it('omits images from preload headers if they contain srcset and sizes', async () => {
4645 let headers = null;
4646 function onHeaders(x) {
4647 headers = x;
4648 }
4649
4650 function App() {
4651 ReactDOM.preload('responsive-preload-set-only', {
4652 as: 'image',
4653 fetchPriority: 'high',
4654 imageSrcSet: 'srcset',
4655 });
4656 ReactDOM.preload('responsive-preload', {
4657 as: 'image',
4658 fetchPriority: 'high',
4659 imageSrcSet: 'srcset',
4660 imageSizes: 'sizes',
4661 });
4662 ReactDOM.preload('non-responsive-preload', {
4663 as: 'image',
4664 fetchPriority: 'high',
4665 });
4666 return (
4667 <html>
4668 <body>
4669 <img
4670 src="responsive-img-set-only"
4671 fetchPriority="high"
4672 srcSet="srcset"
4673 />
4674 <img
4675 src="responsive-img"
4676 fetchPriority="high"
4677 srcSet="srcset"
4678 sizes="sizes"
4679 />
4680 <img src="non-responsive-img" fetchPriority="high" />
4681 </body>
4682 </html>
4683 );
4684 }
4685
4686 await act(() => {
4687 renderToPipeableStream(<App />, {onHeaders});
4688 });
4689
4690 expect(headers).toEqual({
4691 Link: `
4692 <non-responsive-preload>; rel=preload; as="image"; fetchpriority="high",
4693 <non-responsive-img>; rel=preload; as="image"; fetchpriority="high"
4694 `
4695 .replaceAll('\n', '')
4696 .trim(),
4697 });
4698 });
4699
4700 it('preserves referrerPolicy for image preload headers', async () => {
4701 let headers = null;
4702 function onHeaders(x) {
4703 headers = x;
4704 }
4705
4706 function App() {
4707 return (
4708 <html>
4709 <body>
4710 <img
4711 src="image-with-referrer-policy"
4712 fetchPriority="high"
4713 referrerPolicy="no-referrer"
4714 />
4715 </body>
4716 </html>
4717 );
4718 }
4719
4720 await act(() => {
4721 renderToPipeableStream(<App />, {onHeaders});
4722 });
4723
4724 expect(headers).toEqual({
4725 Link: `<image-with-referrer-policy>; rel=preload; as="image"; fetchpriority="high"; referrerpolicy="no-referrer"`,
4726 });
4727 });
4728
4729 it('emits nothing for headers if you pipe before work begins', async () => {
4730 let headers = null;
4731 function onHeaders(x) {
4732 headers = x;
4733 }
4734
4735 function App() {
4736 ReactDOM.preload('presrc', {
4737 as: 'image',
4738 fetchPriority: 'high',
4739 imageSrcSet: 'presrcset',
4740 imageSizes: 'presizes',
4741 });
4742 return (
4743 <html>
4744 <body>
4745 <img src="src" srcSet="srcset" sizes="sizes" />
4746 </body>
4747 </html>
4748 );
4749 }
4750
4751 await act(() => {
4752 renderToPipeableStream(<App />, {onHeaders}).pipe(writable);
4753 });
4754
4755 expect(headers).toEqual({});
4756 });
4757
4758 it('stops accumulating new headers once the maxHeadersLength limit is satisifed', async () => {
4759 let headers = null;
4760 function onHeaders(x) {
4761 headers = x;
4762 }
4763
4764 function App() {
4765 ReactDOM.preconnect('foo');
4766 ReactDOM.preconnect('bar');
4767 ReactDOM.preconnect('baz');
4768 return (
4769 <html>
4770 <body>hello</body>
4771 </html>
4772 );
4773 }
4774
4775 await act(() => {
4776 renderToPipeableStream(<App />, {onHeaders, maxHeadersLength: 44});
4777 });
4778
4779 expect(headers).toEqual({
4780 Link: `
4781 <foo>; rel=preconnect,
4782 <bar>; rel=preconnect
4783 `
4784 .replaceAll('\n', '')
4785 .trim(),
4786 });
4787 });
4788
4789 it('logs an error if onHeaders throws but continues the render', async () => {
4790 const errors = [];
4791 function onError(error) {
4792 errors.push(error.message);
4793 }
4794
4795 function onHeaders(x) {
4796 throw new Error('bad onHeaders');
4797 }
4798
4799 let pipe;
4800 await act(() => {
4801 ({pipe} = renderToPipeableStream(<div>hello</div>, {onHeaders, onError}));
4802 });
4803
4804 expect(errors).toEqual(['bad onHeaders']);
4805
4806 await act(() => {
4807 pipe(writable);
4808 });
4809
4810 expect(getVisibleChildren(container)).toEqual(<div>hello</div>);
4811 });
4812
4813 it('accounts for the length of the interstitial between links when computing the headers length', async () => {
4814 let headers = null;
4815 function onHeaders(x) {
4816 headers = x;
4817 }
4818
4819 function App() {
4820 // 20 bytes
4821 ReactDOM.preconnect('01');
4822 // 42 bytes
4823 ReactDOM.preconnect('02');
4824 // 64 bytes
4825 ReactDOM.preconnect('03');
4826 // 86 bytes
4827 ReactDOM.preconnect('04');
4828 // 108 bytes
4829 ReactDOM.preconnect('05');
4830 // 130 bytes
4831 ReactDOM.preconnect('06');
4832 // 152 bytes
4833 ReactDOM.preconnect('07');
4834 // 174 bytes
4835 ReactDOM.preconnect('08');
4836 // 196 bytes
4837 ReactDOM.preconnect('09');
4838 // 218 bytes
4839 ReactDOM.preconnect('10');
4840 // 240 bytes
4841 ReactDOM.preconnect('11');
4842 // 262 bytes
4843 ReactDOM.preconnect('12');
4844 // 284 bytes
4845 ReactDOM.preconnect('13');
4846 // 306 bytes
4847 ReactDOM.preconnect('14');
4848 return (
4849 <html>
4850 <body>hello</body>
4851 </html>
4852 );
4853 }
4854
4855 await act(() => {
4856 renderToPipeableStream(<App />, {onHeaders, maxHeadersLength: 305});
4857 });
4858 expect(headers.Link.length).toBe(284);
4859
4860 await act(() => {
4861 renderToPipeableStream(<App />, {onHeaders, maxHeadersLength: 306});
4862 });
4863 expect(headers.Link.length).toBe(306);
4864 });
4865
4866 it('does not perform any additional work after fatally erroring', async () => {
4867 let resolve: () => void;
4868 const promise = new Promise(r => {
4869 resolve = r;
4870 });
4871 function AsyncComp() {
4872 React.use(promise);
4873 return <DidRender>Async</DidRender>;
4874 }
4875
4876 let didRender = false;
4877 function DidRender({children}) {
4878 didRender = true;
4879 return children;
4880 }
4881
4882 function ErrorComp() {
4883 throw new Error('boom');
4884 }
4885
4886 function App() {
4887 return (
4888 <div>
4889 <Suspense fallback="loading...">
4890 <AsyncComp />
4891 </Suspense>
4892 <ErrorComp />
4893 </div>
4894 );
4895 }
4896
4897 let pipe;
4898 const errors = [];
4899 let didFatal = true;
4900 await act(() => {
4901 pipe = renderToPipeableStream(<App />, {
4902 onError(error) {
4903 errors.push(error.message);
4904 },
4905 onShellError(error) {
4906 didFatal = true;
4907 },
4908 }).pipe;
4909 });
4910
4911 expect(didRender).toBe(false);
4912 await act(() => {
4913 resolve();
4914 });
4915 expect(didRender).toBe(false);
4916
4917 const testWritable = new Stream.Writable();
4918 await act(() => pipe(testWritable));
4919 expect(didRender).toBe(false);
4920 expect(didFatal).toBe(didFatal);
4921 expect(errors).toEqual(['boom']);
4922 });
4923
4924 it('does not report aborts after fatally erroring', async () => {
4925 const promise = new Promise(() => {});
4926 function AsyncComp() {
4927 React.use(promise);
4928 return 'Async';
4929 }
4930
4931 function ErrorComp() {
4932 throw new Error('boom');
4933 }
4934
4935 const errors = [];
4936 let abort;
4937 await act(() => {
4938 abort = renderToPipeableStream(
4939 <div>
4940 <Suspense fallback="loading...">
4941 <AsyncComp />
4942 </Suspense>
4943 <ErrorComp />
4944 </div>,
4945 {
4946 onError(error) {
4947 errors.push(error.message);
4948 },
4949 onShellError() {},
4950 },
4951 ).abort;
4952 });
4953
4954 expect(errors).toEqual(['boom']);
4955
4956 await act(() => {
4957 abort(new Error('too late'));
4958 });
4959
4960 expect(errors).toEqual(['boom']);
4961 });
4962
4963 describe('error escaping', () => {
4964 it('escapes error hash, message, and component stack values in directly flushed errors (html escaping)', async () => {
4965 window.__outlet = {};
4966
4967 const dangerousErrorString =
4968 '"></template></div><script>window.__outlet.message="from error"</script><div><template data-foo="';
4969
4970 function Erroring() {
4971 throw new Error(dangerousErrorString);
4972 }
4973
4974 // We can't test newline in component stacks because the stack always takes just one line and we end up
4975 // dropping the first part including the \n character
4976 Erroring.displayName =
4977 'DangerousName' +
4978 dangerousErrorString.replace(
4979 'message="from error"',
4980 'stack="from_stack"',
4981 );
4982
4983 function App() {
4984 return (
4985 <div>
4986 <Suspense fallback={<div>Loading...</div>}>
4987 <Erroring />
4988 </Suspense>
4989 </div>
4990 );
4991 }
4992
4993 function onError(x) {
4994 return `dangerous hash ${x.message.replace(
4995 'message="from error"',
4996 'hash="from hash"',
4997 )}`;
4998 }
4999
5000 await act(() => {
Showing first 5,000 of 10,927 lines. View raw