main
js 838 lines 22.3 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 let JSDOM;
12 let React;
13 let startTransition;
14 let ReactDOMClient;
15 let Scheduler;
16 let clientAct;
17 let ReactDOMFizzServer;
18 let Stream;
19 let document;
20 let writable;
21 let container;
22 let buffer = '';
23 let hasErrored = false;
24 let fatalError = undefined;
25 let textCache;
26 let assertLog;
27
28 describe('ReactDOMFizzShellHydration', () => {
29 beforeEach(() => {
30 jest.resetModules();
31 JSDOM = require('jsdom').JSDOM;
32 React = require('react');
33 ReactDOMClient = require('react-dom/client');
34 Scheduler = require('scheduler');
35 clientAct = require('internal-test-utils').act;
36 ReactDOMFizzServer = require('react-dom/server');
37 Stream = require('stream');
38
39 const InternalTestUtils = require('internal-test-utils');
40 assertLog = InternalTestUtils.assertLog;
41
42 startTransition = React.startTransition;
43
44 textCache = new Map();
45
46 // Test Environment
47 const jsdom = new JSDOM(
48 '<!DOCTYPE html><html><head></head><body><div id="container">',
49 {
50 runScripts: 'dangerously',
51 },
52 );
53 document = jsdom.window.document;
54 container = document.getElementById('container');
55
56 buffer = '';
57 hasErrored = false;
58
59 writable = new Stream.PassThrough();
60 writable.setEncoding('utf8');
61 writable.on('data', chunk => {
62 buffer += chunk;
63 });
64 writable.on('error', error => {
65 hasErrored = true;
66 fatalError = error;
67 });
68 });
69
70 afterEach(() => {
71 jest.restoreAllMocks();
72 });
73
74 async function serverAct(callback) {
75 await callback();
76 // Await one turn around the event loop.
77 // This assumes that we'll flush everything we have so far.
78 await new Promise(resolve => {
79 setImmediate(resolve);
80 });
81 if (hasErrored) {
82 throw fatalError;
83 }
84 // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment.
85 // We also want to execute any scripts that are embedded.
86 // We assume that we have now received a proper fragment of HTML.
87 const bufferedContent = buffer;
88 buffer = '';
89 const fakeBody = document.createElement('body');
90 fakeBody.innerHTML = bufferedContent;
91 while (fakeBody.firstChild) {
92 const node = fakeBody.firstChild;
93 if (node.nodeName === 'SCRIPT') {
94 const script = document.createElement('script');
95 script.textContent = node.textContent;
96 fakeBody.removeChild(node);
97 container.appendChild(script);
98 } else {
99 container.appendChild(node);
100 }
101 }
102 }
103
104 async function hydrateRootAndCollectErrors(reactNode) {
105 const errors = [];
106 await clientAct(async () => {
107 ReactDOMClient.hydrateRoot(container, reactNode, {
108 onCaughtError(error) {
109 Scheduler.log('onCaughtError: ' + error.message);
110 errors.push('caught: ' + error.message);
111 },
112 onUncaughtError(error) {
113 Scheduler.log('onUncaughtError: ' + error.message);
114 errors.push('uncaught: ' + error.message);
115 },
116 onRecoverableError(error) {
117 Scheduler.log('onRecoverableError: ' + error.message);
118 errors.push('recoverable: ' + error.message);
119 },
120 });
121 });
122 return errors;
123 }
124
125 function createErrorBoundaryAndBomb() {
126 class ErrorBoundary extends React.Component {
127 constructor(props) {
128 super(props);
129 this.state = {error: null};
130 }
131
132 static getDerivedStateFromError(error) {
133 return {error};
134 }
135
136 componentDidCatch() {}
137
138 render() {
139 if (this.state.error) {
140 return 'Something went wrong: ' + this.state.error.message;
141 }
142
143 return this.props.children;
144 }
145 }
146
147 function Bomb() {
148 throw new Error('boom');
149 }
150
151 return {ErrorBoundary, Bomb};
152 }
153
154 function resolveText(text) {
155 const record = textCache.get(text);
156 if (record === undefined) {
157 const newRecord = {
158 status: 'resolved',
159 value: text,
160 };
161 textCache.set(text, newRecord);
162 } else if (record.status === 'pending') {
163 const thenable = record.value;
164 record.status = 'resolved';
165 record.value = text;
166 thenable.pings.forEach(t => t());
167 }
168 }
169
170 function readText(text) {
171 const record = textCache.get(text);
172 if (record !== undefined) {
173 switch (record.status) {
174 case 'pending':
175 throw record.value;
176 case 'rejected':
177 throw record.value;
178 case 'resolved':
179 return record.value;
180 }
181 } else {
182 Scheduler.log(`Suspend! [${text}]`);
183
184 const thenable = {
185 pings: [],
186 then(resolve) {
187 if (newRecord.status === 'pending') {
188 thenable.pings.push(resolve);
189 } else {
190 Promise.resolve().then(() => resolve(newRecord.value));
191 }
192 },
193 };
194
195 const newRecord = {
196 status: 'pending',
197 value: thenable,
198 };
199 textCache.set(text, newRecord);
200
201 throw thenable;
202 }
203 }
204
205 function Text({text}) {
206 Scheduler.log(text);
207 return text;
208 }
209
210 function AsyncText({text}) {
211 readText(text);
212 Scheduler.log(text);
213 return text;
214 }
215
216 function resetTextCache() {
217 textCache = new Map();
218 }
219
220 it('suspending in the shell during hydration', async () => {
221 const div = React.createRef(null);
222
223 function App() {
224 return (
225 <div ref={div}>
226 <AsyncText text="Shell" />
227 </div>
228 );
229 }
230
231 // Server render
232 await resolveText('Shell');
233 await serverAct(async () => {
234 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
235 pipe(writable);
236 });
237 assertLog(['Shell']);
238 const dehydratedDiv = container.getElementsByTagName('div')[0];
239
240 // Clear the cache and start rendering on the client
241 resetTextCache();
242
243 // Hydration suspends because the data for the shell hasn't loaded yet
244 await clientAct(async () => {
245 ReactDOMClient.hydrateRoot(container, <App />);
246 });
247 assertLog(['Suspend! [Shell]']);
248 expect(div.current).toBe(null);
249 expect(container.textContent).toBe('Shell');
250
251 // The shell loads and hydration finishes
252 await clientAct(async () => {
253 await resolveText('Shell');
254 });
255 assertLog(['Shell']);
256 expect(div.current).toBe(dehydratedDiv);
257 expect(container.textContent).toBe('Shell');
258 });
259
260 it('suspending in the shell during a normal client render', async () => {
261 // Same as previous test but during a normal client render, no hydration
262 function App() {
263 return <AsyncText text="Shell" />;
264 }
265
266 const root = ReactDOMClient.createRoot(container);
267 await clientAct(async () => {
268 root.render(<App />);
269 });
270 assertLog(['Suspend! [Shell]']);
271
272 await clientAct(async () => {
273 await resolveText('Shell');
274 });
275 assertLog(['Shell']);
276 expect(container.textContent).toBe('Shell');
277 });
278
279 it(
280 'updating the root at lower priority than initial hydration does not ' +
281 'force a client render',
282 async () => {
283 function App() {
284 return <Text text="Initial" />;
285 }
286
287 // Server render
288 await resolveText('Initial');
289 await serverAct(async () => {
290 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
291 pipe(writable);
292 });
293 assertLog(['Initial']);
294
295 await clientAct(async () => {
296 const root = ReactDOMClient.hydrateRoot(container, <App />);
297 // This has lower priority than the initial hydration, so the update
298 // won't be processed until after hydration finishes.
299 startTransition(() => {
300 root.render(<Text text="Updated" />);
301 });
302 });
303 assertLog(['Initial', 'Updated']);
304 expect(container.textContent).toBe('Updated');
305 },
306 );
307
308 it(
309 'updating the root at same priority as initial hydration does not ' +
310 'force a client render',
311 async () => {
312 function App() {
313 return <Text text="Initial" />;
314 }
315
316 // Server render
317 await resolveText('Initial');
318 await serverAct(async () => {
319 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
320 pipe(writable);
321 });
322 assertLog(['Initial']);
323
324 await clientAct(async () => {
325 let root;
326 startTransition(() => {
327 root = ReactDOMClient.hydrateRoot(container, <App />);
328 });
329 // This has lower priority than the initial hydration, so the update
330 // won't be processed until after hydration finishes.
331 startTransition(() => {
332 root.render(<Text text="Updated" />);
333 });
334 });
335 assertLog(['Initial', 'Updated']);
336 expect(container.textContent).toBe('Updated');
337 },
338 );
339
340 it('updating the root while the shell is suspended forces a client render', async () => {
341 function App() {
342 return <AsyncText text="Shell" />;
343 }
344
345 // Server render
346 await resolveText('Shell');
347 await serverAct(async () => {
348 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
349 pipe(writable);
350 });
351 assertLog(['Shell']);
352
353 // Clear the cache and start rendering on the client
354 resetTextCache();
355
356 // Hydration suspends because the data for the shell hasn't loaded yet
357 const root = await clientAct(async () => {
358 return ReactDOMClient.hydrateRoot(container, <App />, {
359 onRecoverableError(error) {
360 Scheduler.log(error.message);
361 },
362 });
363 });
364 assertLog(['Suspend! [Shell]']);
365 expect(container.textContent).toBe('Shell');
366
367 await clientAct(async () => {
368 root.render(<Text text="New screen" />);
369 });
370 assertLog([
371 'New screen',
372 'This root received an early update, before anything was able ' +
373 'hydrate. Switched the entire root to client rendering.',
374 ]);
375 expect(container.textContent).toBe('New screen');
376 });
377
378 it('recovers from a large component stack during SSR', async () => {
379 spyOnDevAndProd(console, 'error').mockImplementation(() => {});
380
381 function NestedComponent({depth}: {depth: number}) {
382 if (depth <= 0) {
383 return <AsyncText text="Shell" />;
384 }
385 return <NestedComponent depth={depth - 1} />;
386 }
387
388 await resolveText('Shell');
389 await serverAct(async () => {
390 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
391 <NestedComponent depth={3000} />,
392 );
393 pipe(writable);
394 });
395 expect(console.error).not.toHaveBeenCalled();
396 assertLog(['Shell']);
397 expect(container.textContent).toBe('Shell');
398 });
399
400 it('client renders when an error is thrown in an error boundary', async () => {
401 function Throws() {
402 throw new Error('plain error');
403 }
404
405 class ErrorBoundary extends React.Component {
406 state = {error: null};
407 static getDerivedStateFromError(error) {
408 return {error};
409 }
410 render() {
411 if (this.state.error) {
412 return <div>Caught an error: {this.state.error.message}</div>;
413 }
414 return this.props.children;
415 }
416 }
417
418 function App() {
419 return (
420 <ErrorBoundary>
421 <Throws />
422 </ErrorBoundary>
423 );
424 }
425
426 // Server render
427 let shellError;
428 try {
429 await serverAct(async () => {
430 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />, {
431 onError(error) {
432 Scheduler.log('onError: ' + error.message);
433 },
434 });
435 pipe(writable);
436 });
437 } catch (x) {
438 shellError = x;
439 }
440 expect(shellError).toEqual(
441 expect.objectContaining({message: 'plain error'}),
442 );
443 assertLog(['onError: plain error']);
444
445 function ErroredApp() {
446 return <span>loading</span>;
447 }
448
449 // Reset test environment
450 buffer = '';
451 hasErrored = false;
452 writable = new Stream.PassThrough();
453 writable.setEncoding('utf8');
454 writable.on('data', chunk => {
455 buffer += chunk;
456 });
457 writable.on('error', error => {
458 hasErrored = true;
459 fatalError = error;
460 });
461
462 // The Server errored at the shell. The recommended approach is to render a
463 // fallback loading state, which can then be hydrated with a mismatch.
464 await serverAct(async () => {
465 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<ErroredApp />);
466 pipe(writable);
467 });
468
469 expect(container.innerHTML).toBe('<span>loading</span>');
470
471 // Hydration suspends because the data for the shell hasn't loaded yet
472 await clientAct(async () => {
473 ReactDOMClient.hydrateRoot(container, <App />, {
474 onCaughtError(error) {
475 Scheduler.log('onCaughtError: ' + error.message);
476 },
477 onUncaughtError(error) {
478 Scheduler.log('onUncaughtError: ' + error.message);
479 },
480 onRecoverableError(error) {
481 Scheduler.log('onRecoverableError: ' + error.message);
482 if (error.cause) {
483 Scheduler.log('Cause: ' + error.cause.message);
484 }
485 },
486 });
487 });
488
489 assertLog(['onCaughtError: plain error']);
490 expect(container.textContent).toBe('Caught an error: plain error');
491 });
492
493 it('client renders when a client error is thrown in an error boundary', async () => {
494 let isClient = false;
495
496 function Throws() {
497 if (isClient) {
498 throw new Error('plain error');
499 }
500 return <div>Hello world</div>;
501 }
502
503 class ErrorBoundary extends React.Component {
504 state = {error: null};
505 static getDerivedStateFromError(error) {
506 return {error};
507 }
508 render() {
509 if (this.state.error) {
510 return <div>Caught an error: {this.state.error.message}</div>;
511 }
512 return this.props.children;
513 }
514 }
515
516 function App() {
517 return (
518 <ErrorBoundary>
519 <Throws />
520 </ErrorBoundary>
521 );
522 }
523
524 // Server render
525 await serverAct(async () => {
526 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />, {
527 onError(error) {
528 Scheduler.log('onError: ' + error.message);
529 },
530 });
531 pipe(writable);
532 });
533 assertLog([]);
534
535 expect(container.innerHTML).toBe('<div>Hello world</div>');
536
537 isClient = true;
538
539 // Hydration suspends because the data for the shell hasn't loaded yet
540 await clientAct(async () => {
541 ReactDOMClient.hydrateRoot(container, <App />, {
542 onCaughtError(error) {
543 Scheduler.log('onCaughtError: ' + error.message);
544 },
545 onUncaughtError(error) {
546 Scheduler.log('onUncaughtError: ' + error.message);
547 },
548 onRecoverableError(error) {
549 Scheduler.log('onRecoverableError: ' + error.message);
550 if (error.cause) {
551 Scheduler.log('Cause: ' + error.cause.message);
552 }
553 },
554 });
555 });
556
557 assertLog(['onCaughtError: plain error']);
558 expect(container.textContent).toBe('Caught an error: plain error');
559 });
560
561 it('client renders when a hydration pass error is thrown in an error boundary', async () => {
562 let isClient = false;
563 let isFirst = true;
564
565 function Throws() {
566 if (isClient && isFirst) {
567 isFirst = false; // simulate a hydration or concurrent error
568 throw new Error('plain error');
569 }
570 return <div>Hello world</div>;
571 }
572
573 class ErrorBoundary extends React.Component {
574 state = {error: null};
575 static getDerivedStateFromError(error) {
576 return {error};
577 }
578 render() {
579 if (this.state.error) {
580 return <div>Caught an error: {this.state.error.message}</div>;
581 }
582 return this.props.children;
583 }
584 }
585
586 function App() {
587 return (
588 <ErrorBoundary>
589 <Throws />
590 </ErrorBoundary>
591 );
592 }
593
594 // Server render
595 await serverAct(async () => {
596 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />, {
597 onError(error) {
598 Scheduler.log('onError: ' + error.message);
599 },
600 });
601 pipe(writable);
602 });
603 assertLog([]);
604
605 expect(container.innerHTML).toBe('<div>Hello world</div>');
606
607 isClient = true;
608
609 // Hydration suspends because the data for the shell hasn't loaded yet
610 await clientAct(async () => {
611 ReactDOMClient.hydrateRoot(container, <App />, {
612 onCaughtError(error) {
613 Scheduler.log('onCaughtError: ' + error.message);
614 },
615 onUncaughtError(error) {
616 Scheduler.log('onUncaughtError: ' + error.message);
617 },
618 onRecoverableError(error) {
619 Scheduler.log('onRecoverableError: ' + error.message);
620 if (error.cause) {
621 Scheduler.log('Cause: ' + error.cause.message);
622 }
623 },
624 });
625 });
626
627 assertLog([
628 'onRecoverableError: There was an error while hydrating but React was able to recover by instead client rendering the entire root.',
629 'Cause: plain error',
630 ]);
631 expect(container.textContent).toBe('Hello world');
632 });
633
634 it(
635 'handles suspending while recovering from a hydration error (in the ' +
636 'shell, no Suspense boundary)',
637 async () => {
638 const useSyncExternalStore = React.useSyncExternalStore;
639
640 let isClient = false;
641
642 let resolve;
643 const clientPromise = new Promise(res => {
644 resolve = res;
645 });
646
647 function App() {
648 const state = useSyncExternalStore(
649 function subscribe() {
650 return () => {};
651 },
652 function getSnapshot() {
653 return 'Client';
654 },
655 function getServerSnapshot() {
656 const isHydrating = isClient;
657 if (isHydrating) {
658 // This triggers an error during hydration
659 throw new Error('Oops!');
660 }
661 return 'Server';
662 },
663 );
664
665 if (state === 'Client') {
666 return React.use(clientPromise);
667 }
668
669 return state;
670 }
671
672 // Server render
673 await serverAct(async () => {
674 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
675 pipe(writable);
676 });
677 assertLog([]);
678
679 expect(container.innerHTML).toBe('Server');
680
681 // During hydration, an error is thrown. React attempts to recover by
682 // switching to client render
683 isClient = true;
684 await clientAct(async () => {
685 ReactDOMClient.hydrateRoot(container, <App />, {
686 onRecoverableError(error) {
687 Scheduler.log('onRecoverableError: ' + error.message);
688 if (error.cause) {
689 Scheduler.log('Cause: ' + error.cause.message);
690 }
691 },
692 });
693 });
694 expect(container.innerHTML).toBe('Server'); // Still suspended
695 assertLog([]);
696
697 await clientAct(async () => {
698 resolve('Client');
699 });
700 assertLog([
701 'onRecoverableError: There was an error while hydrating but React was ' +
702 'able to recover by instead client rendering the entire root.',
703 'Cause: Oops!',
704 ]);
705 expect(container.innerHTML).toBe('Client');
706 },
707 );
708
709 it(
710 'does not corrupt hooks during hydration when conditional use suspends ' +
711 'after a cascading update (#33580)',
712 async () => {
713 const {ErrorBoundary, Bomb} = createErrorBoundaryAndBomb();
714
715 function Updater({setPromise}) {
716 const [state, setState] = React.useState(false);
717
718 React.useEffect(() => {
719 setState(true);
720 startTransition(() => {
721 setPromise(Promise.resolve('resolved'));
722 });
723 }, [state]);
724
725 return null;
726 }
727
728 function Page() {
729 const [promise, setPromise] = React.useState(null);
730 const value = promise ? React.use(promise) : promise;
731
732 React.useMemo(() => {}, []);
733
734 return (
735 <>
736 <Updater setPromise={setPromise} />
737 <React.Suspense fallback="Loading...">
738 <ErrorBoundary>
739 <Bomb />
740 </ErrorBoundary>
741 </React.Suspense>
742 {value !== null ? value : 'hello world'}
743 </>
744 );
745 }
746
747 function App() {
748 return <Page />;
749 }
750
751 await serverAct(async () => {
752 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />, {
753 onError(error) {
754 Scheduler.log('onError: ' + error.message);
755 },
756 });
757 pipe(writable);
758 });
759 assertLog(['onError: boom']);
760
761 const errors = await hydrateRootAndCollectErrors(<App />);
762 assertLog(['onCaughtError: boom']);
763
764 expect(
765 errors.find(error => error.includes('Rendered more hooks')),
766 ).toBeUndefined();
767 expect(container.textContent).toBe('Something went wrong: boomresolved');
768 },
769 );
770
771 it('preserves hooks when suspension happens before the first tracked hook', async () => {
772 const {ErrorBoundary, Bomb} = createErrorBoundaryAndBomb();
773 let setReady;
774
775 function Updater({setPromise}) {
776 React.useEffect(() => {
777 setReady(true);
778 startTransition(() => {
779 setPromise(Promise.resolve('resolved'));
780 });
781 }, []);
782
783 return null;
784 }
785
786 function Page({promise}) {
787 const value = promise ? React.use(promise) : promise;
788
789 const [ready, _setReady] = React.useState(false);
790 setReady = _setReady;
791
792 React.useMemo(() => {}, []);
793
794 return (
795 <>
796 <React.Suspense fallback="Loading...">
797 <ErrorBoundary>
798 <Bomb />
799 </ErrorBoundary>
800 </React.Suspense>
801 <span>{ready ? 'ready' : 'not-ready'}</span>
802 <span>{value !== null ? value : 'hello world'}</span>
803 </>
804 );
805 }
806
807 function App() {
808 const [promise, setPromise] = React.useState(null);
809
810 return (
811 <>
812 <Updater setPromise={setPromise} />
813 <Page promise={promise} />
814 </>
815 );
816 }
817
818 await serverAct(async () => {
819 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />, {
820 onError(error) {
821 Scheduler.log('onError: ' + error.message);
822 },
823 });
824 pipe(writable);
825 });
826 assertLog(['onError: boom']);
827
828 const errors = await hydrateRootAndCollectErrors(<App />);
829 assertLog(['onCaughtError: boom']);
830
831 expect(
832 errors.find(error => error.includes('Rendered more hooks')),
833 ).toBeUndefined();
834 expect(container.textContent).toBe(
835 'Something went wrong: boomreadyresolved',
836 );
837 });
838 });