main
js 791 lines 21.7 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 *
7 * @emails react-core
8 * @jest-environment node
9 */
10
11 'use strict';
12
13 let Stream;
14 let React;
15 let ReactDOMFizzServer;
16 let Suspense;
17 let act;
18
19 describe('ReactDOMFizzServerNode', () => {
20 beforeEach(() => {
21 jest.resetModules();
22 React = require('react');
23 ReactDOMFizzServer = require('react-dom/server');
24 Stream = require('stream');
25 Suspense = React.Suspense;
26 act = require('internal-test-utils').act;
27 });
28
29 function getTestWritable() {
30 const writable = new Stream.PassThrough();
31 writable.setEncoding('utf8');
32 const output = {result: '', error: undefined};
33 writable.on('data', chunk => {
34 output.result += chunk;
35 });
36 writable.on('error', error => {
37 output.error = error;
38 });
39 const completed = new Promise(resolve => {
40 writable.on('finish', () => {
41 resolve();
42 });
43 writable.on('error', () => {
44 resolve();
45 });
46 });
47 return {writable, completed, output};
48 }
49
50 const theError = new Error('This is an error');
51 function Throw() {
52 throw theError;
53 }
54 const theInfinitePromise = new Promise(() => {});
55 function InfiniteSuspend() {
56 throw theInfinitePromise;
57 }
58
59 async function readContentWeb(stream) {
60 const reader = stream.getReader();
61 let content = '';
62 while (true) {
63 const {done, value} = await reader.read();
64 if (done) {
65 return content;
66 }
67 content += Buffer.from(value).toString('utf8');
68 }
69 }
70
71 it('should call renderToPipeableStream', async () => {
72 const {writable, output} = getTestWritable();
73 await act(() => {
74 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
75 <div>hello world</div>,
76 );
77 pipe(writable);
78 });
79 expect(output.result).toMatchInlineSnapshot(`"<div>hello world</div>"`);
80 });
81
82 it('should support web streams', async () => {
83 const stream = await act(() =>
84 ReactDOMFizzServer.renderToReadableStream(<div>hello world</div>),
85 );
86 const result = await readContentWeb(stream);
87 expect(result).toMatchInlineSnapshot(`"<div>hello world</div>"`);
88 });
89
90 it('flush fully if piping in on onShellReady', async () => {
91 const {writable, output} = getTestWritable();
92 await act(() => {
93 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
94 <div>hello world</div>,
95 {
96 onShellReady() {
97 pipe(writable);
98 },
99 },
100 );
101 });
102 expect(output.result).toMatchInlineSnapshot(`"<div>hello world</div>"`);
103 });
104
105 it('should emit DOCTYPE at the root of the document', async () => {
106 const {writable, output} = getTestWritable();
107 await act(() => {
108 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
109 <html>
110 <body>hello world</body>
111 </html>,
112 );
113 pipe(writable);
114 });
115 // with Float, we emit empty heads if they are elided when rendering <html>
116 if (gate(flags => flags.enableFizzBlockingRender)) {
117 expect(output.result).toMatchInlineSnapshot(
118 `"<!DOCTYPE html><html><head><link rel="expect" href="#_R_" blocking="render"/></head><body>hello world<template id="_R_"></template></body></html>"`,
119 );
120 } else {
121 expect(output.result).toMatchInlineSnapshot(
122 `"<!DOCTYPE html><html><head></head><body>hello world</body></html>"`,
123 );
124 }
125 });
126
127 it('should emit bootstrap script src at the end', async () => {
128 const {writable, output} = getTestWritable();
129 await act(() => {
130 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
131 <div>hello world</div>,
132 {
133 bootstrapScriptContent: 'INIT();',
134 bootstrapScripts: ['init.js'],
135 bootstrapModules: ['init.mjs'],
136 },
137 );
138 pipe(writable);
139 });
140 expect(output.result).toMatchInlineSnapshot(
141 `"<link rel="preload" as="script" fetchPriority="low" href="init.js"/><link rel="modulepreload" fetchPriority="low" href="init.mjs"/><div>hello world</div><script id="_R_">INIT();</script><script src="init.js" async=""></script><script type="module" src="init.mjs" async=""></script>"`,
142 );
143 });
144
145 it('should start writing after pipe', async () => {
146 const {writable, output} = getTestWritable();
147 let pipe;
148 await act(() => {
149 pipe = ReactDOMFizzServer.renderToPipeableStream(
150 <div>hello world</div>,
151 ).pipe;
152 });
153 // First we write our header.
154 output.result +=
155 '<!doctype html><html><head><title>test</title><head><body>';
156 // Then React starts writing.
157 pipe(writable);
158 expect(output.result).toMatchInlineSnapshot(
159 `"<!doctype html><html><head><title>test</title><head><body><div>hello world</div>"`,
160 );
161 });
162
163 it('emits all HTML as one unit if we wait until the end to start', async () => {
164 let hasLoaded = false;
165 let resolve;
166 const promise = new Promise(r => (resolve = r));
167 function Wait() {
168 if (!hasLoaded) {
169 throw promise;
170 }
171 return 'Done';
172 }
173 let isCompleteCalls = 0;
174 const {writable, output} = getTestWritable();
175 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
176 <div>
177 <Suspense fallback="Loading">
178 <Wait />
179 </Suspense>
180 </div>,
181
182 {
183 onAllReady() {
184 isCompleteCalls++;
185 },
186 },
187 );
188 await jest.runAllTimers();
189 expect(output.result).toBe('');
190 expect(isCompleteCalls).toBe(0);
191 // Resolve the loading.
192 hasLoaded = true;
193 await resolve();
194
195 await jest.runAllTimers();
196
197 expect(output.result).toBe('');
198 expect(isCompleteCalls).toBe(1);
199
200 // First we write our header.
201 output.result +=
202 '<!doctype html><html><head><title>test</title><head><body>';
203 // Then React starts writing.
204 pipe(writable);
205 expect(output.result).toMatchInlineSnapshot(
206 `"<!doctype html><html><head><title>test</title><head><body><div><!--$-->Done<!-- --><!--/$--></div>"`,
207 );
208 });
209
210 it('should error the stream when an error is thrown at the root', async () => {
211 const reportedErrors = [];
212 const reportedShellErrors = [];
213 let shellReadyCalls = 0;
214 let allReadyCalls = 0;
215 const {writable, output, completed} = getTestWritable();
216 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
217 <div>
218 <Throw />
219 </div>,
220 {
221 onError(x) {
222 reportedErrors.push(x);
223 },
224 onShellError(x) {
225 reportedShellErrors.push(x);
226 },
227 onShellReady() {
228 shellReadyCalls++;
229 },
230 onAllReady() {
231 allReadyCalls++;
232 },
233 },
234 );
235
236 // The stream is errored once we start writing.
237 pipe(writable);
238
239 await completed;
240
241 expect(output.error).toBe(theError);
242 expect(output.result).toBe('');
243 // This type of error is reported to the error callback too.
244 expect(reportedErrors).toEqual([theError]);
245 expect(reportedShellErrors).toEqual([theError]);
246 expect(shellReadyCalls).toBe(0);
247 expect(allReadyCalls).toBe(0);
248 });
249
250 it('should not report aborts after the shell has fatally errored', async () => {
251 const reportedErrors = [];
252 const reportedShellErrors = [];
253 const {abort} = ReactDOMFizzServer.renderToPipeableStream(
254 <div>
255 <Suspense fallback="Loading">
256 <InfiniteSuspend />
257 </Suspense>
258 <Throw />
259 </div>,
260 {
261 onError(x) {
262 reportedErrors.push(x);
263 },
264 onShellError(x) {
265 reportedShellErrors.push(x);
266 },
267 },
268 );
269
270 await jest.runAllTimers();
271
272 expect(reportedErrors).toEqual([theError]);
273 expect(reportedShellErrors).toEqual([theError]);
274
275 abort(new Error('too late'));
276
277 expect(reportedErrors).toEqual([theError]);
278 expect(reportedShellErrors).toEqual([theError]);
279 });
280
281 it('should error the stream when an error is thrown inside a fallback', async () => {
282 const reportedErrors = [];
283 const reportedShellErrors = [];
284 const {writable, output, completed} = getTestWritable();
285 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
286 <div>
287 <Suspense fallback={<Throw />}>
288 <InfiniteSuspend />
289 </Suspense>
290 </div>,
291
292 {
293 onError(x) {
294 reportedErrors.push(x.message);
295 },
296 onShellError(x) {
297 reportedShellErrors.push(x);
298 },
299 },
300 );
301 pipe(writable);
302
303 await completed;
304
305 expect(output.error).toBe(theError);
306 expect(output.result).toBe('');
307 expect(reportedErrors).toEqual([theError.message]);
308 expect(reportedShellErrors).toEqual([theError]);
309 });
310
311 it('should not error the stream when an error is thrown inside suspense boundary', async () => {
312 const reportedErrors = [];
313 const reportedShellErrors = [];
314 let allReadyCalls = 0;
315 const {writable, output, completed} = getTestWritable();
316 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
317 <div>
318 <Suspense fallback={<div>Loading</div>}>
319 <Throw />
320 </Suspense>
321 </div>,
322 {
323 onError(x) {
324 reportedErrors.push(x);
325 },
326 onShellError(x) {
327 reportedShellErrors.push(x);
328 },
329 onAllReady() {
330 allReadyCalls++;
331 },
332 },
333 );
334 pipe(writable);
335
336 await completed;
337
338 expect(output.error).toBe(undefined);
339 expect(output.result).toContain('Loading');
340 // While no error is reported to the stream, the error is reported to the callback.
341 expect(reportedErrors).toEqual([theError]);
342 expect(reportedShellErrors).toEqual([]);
343 // The shell stays valid, the boundary client-renders, and the render
344 // completes, so onAllReady fires. This is documented behavior.
345 expect(allReadyCalls).toBe(1);
346 });
347
348 it('should not attempt to render the fallback if the main content completes first', async () => {
349 const {writable, output, completed} = getTestWritable();
350
351 let renderedFallback = false;
352 function Fallback() {
353 renderedFallback = true;
354 return 'Loading...';
355 }
356 function Content() {
357 return 'Hi';
358 }
359 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
360 <Suspense fallback={<Fallback />}>
361 <Content />
362 </Suspense>,
363 );
364 pipe(writable);
365
366 await completed;
367
368 expect(output.result).toContain('Hi');
369 expect(output.result).not.toContain('Loading');
370 expect(renderedFallback).toBe(false);
371 });
372
373 it('should be able to complete by aborting even if the promise never resolves', async () => {
374 let isCompleteCalls = 0;
375 const errors = [];
376 const {writable, output, completed} = getTestWritable();
377 let abort;
378 await act(() => {
379 const pipeable = ReactDOMFizzServer.renderToPipeableStream(
380 <div>
381 <Suspense fallback={<div>Loading</div>}>
382 <InfiniteSuspend />
383 </Suspense>
384 </div>,
385 {
386 onError(x) {
387 errors.push(x.message);
388 },
389 onAllReady() {
390 isCompleteCalls++;
391 },
392 },
393 );
394 pipeable.pipe(writable);
395 abort = pipeable.abort;
396 });
397
398 expect(output.result).toContain('Loading');
399 expect(isCompleteCalls).toBe(0);
400
401 abort(new Error('uh oh'));
402 await jest.runAllTimers();
403
404 await completed;
405
406 expect(errors).toEqual(['uh oh']);
407 expect(output.error).toBe(undefined);
408 expect(output.result).toContain('Loading');
409 expect(isCompleteCalls).toBe(1);
410 });
411
412 it('should fail the shell if you abort before work has begun', async () => {
413 let isCompleteCalls = 0;
414 const errors = [];
415 const shellErrors = [];
416 const {writable, output, completed} = getTestWritable();
417 const {pipe, abort} = ReactDOMFizzServer.renderToPipeableStream(
418 <div>
419 <Suspense fallback={<div>Loading</div>}>
420 <InfiniteSuspend />
421 </Suspense>
422 </div>,
423 {
424 onError(x) {
425 errors.push(x.message);
426 },
427 onShellError(x) {
428 shellErrors.push(x.message);
429 },
430 onAllReady() {
431 isCompleteCalls++;
432 },
433 },
434 );
435 pipe(writable);
436
437 // Currently we delay work so if we abort, we abort the remaining CPU
438 // work as well.
439
440 // Abort before running the timers that perform the work
441 const theReason = new Error('uh oh');
442 abort(theReason);
443
444 jest.runAllTimers();
445
446 await completed;
447
448 expect(errors).toEqual(['uh oh']);
449 expect(shellErrors).toEqual(['uh oh']);
450 expect(output.error).toBe(theReason);
451 expect(output.result).toBe('');
452 expect(isCompleteCalls).toBe(0);
453 });
454
455 it('should report abort errors for every suspended task but fail the shell only once', async () => {
456 const promise = new Promise(() => {});
457 const rendered = [];
458 function Suspend({label}) {
459 rendered.push(label);
460 React.use(promise);
461 return null;
462 }
463
464 const errors = [];
465 const shellErrors = [];
466 const {abort} = ReactDOMFizzServer.renderToPipeableStream(
467 <>
468 <Suspense fallback="Loading...">
469 <Suspend label="boundary" />
470 </Suspense>
471 <Suspend label="root one" />
472 <Suspend label="root two" />
473 </>,
474 {
475 onError(error) {
476 errors.push(error.message);
477 },
478 onShellError(error) {
479 shellErrors.push(error);
480 },
481 },
482 );
483
484 await jest.runAllTimers();
485 expect(rendered).toEqual(['boundary', 'root one', 'root two']);
486
487 const reason = new Error('abort reason');
488 abort(reason);
489 await jest.runAllTimers();
490
491 expect(shellErrors).toEqual([reason]);
492 expect(errors).toEqual(['abort reason', 'abort reason', 'abort reason']);
493 });
494
495 it('should be able to complete by abort when the fallback is also suspended', async () => {
496 let isCompleteCalls = 0;
497 const errors = [];
498 const {writable, output, completed} = getTestWritable();
499 let abort;
500 await act(() => {
501 const pipeable = ReactDOMFizzServer.renderToPipeableStream(
502 <div>
503 <Suspense fallback="Loading">
504 <Suspense fallback={<InfiniteSuspend />}>
505 <InfiniteSuspend />
506 </Suspense>
507 </Suspense>
508 </div>,
509 {
510 onError(x) {
511 errors.push(x.message);
512 },
513 onAllReady() {
514 isCompleteCalls++;
515 },
516 },
517 );
518 pipeable.pipe(writable);
519 abort = pipeable.abort;
520 });
521
522 expect(output.result).toContain('Loading');
523 expect(isCompleteCalls).toBe(0);
524
525 abort();
526 await jest.runAllTimers();
527
528 await completed;
529
530 expect(errors).toEqual([
531 // There are two boundaries that abort
532 'The render was aborted by the server without a reason.',
533 'The render was aborted by the server without a reason.',
534 ]);
535 expect(output.error).toBe(undefined);
536 expect(output.result).toContain('Loading');
537 expect(isCompleteCalls).toBe(1);
538 });
539
540 it('should be able to get context value when promise resolves', async () => {
541 class DelayClient {
542 get() {
543 if (this.resolved) return this.resolved;
544 if (this.pending) return this.pending;
545 return (this.pending = new Promise(resolve => {
546 setTimeout(() => {
547 delete this.pending;
548 this.resolved = 'OK';
549 resolve();
550 }, 500);
551 }));
552 }
553 }
554
555 const DelayContext = React.createContext(undefined);
556 const Component = () => {
557 const client = React.useContext(DelayContext);
558 if (!client) {
559 return 'context not found.';
560 }
561 const result = client.get();
562 if (typeof result === 'string') {
563 return result;
564 }
565 throw result;
566 };
567
568 const client = new DelayClient();
569 const {writable, output, completed} = getTestWritable();
570 await act(() => {
571 ReactDOMFizzServer.renderToPipeableStream(
572 <DelayContext.Provider value={client}>
573 <div>
574 <Suspense fallback="loading">
575 <Component />
576 </Suspense>
577 </div>
578 </DelayContext.Provider>,
579 ).pipe(writable);
580 });
581
582 expect(output.error).toBe(undefined);
583 expect(output.result).toContain('loading');
584
585 await completed;
586
587 expect(output.error).toBe(undefined);
588 expect(output.result).not.toContain('context never found');
589 expect(output.result).toContain('OK');
590 });
591
592 it('should be able to get context value when calls renderToPipeableStream twice at the same time', async () => {
593 class DelayClient {
594 get() {
595 if (this.resolved) return this.resolved;
596 if (this.pending) return this.pending;
597 return (this.pending = new Promise(resolve => {
598 setTimeout(() => {
599 delete this.pending;
600 this.resolved = 'OK';
601 resolve();
602 }, 500);
603 }));
604 }
605 }
606 const DelayContext = React.createContext(undefined);
607 const Component = () => {
608 const client = React.useContext(DelayContext);
609 if (!client) {
610 return 'context never found';
611 }
612 const result = client.get();
613 if (typeof result === 'string') {
614 return result;
615 }
616 throw result;
617 };
618
619 const client0 = new DelayClient();
620 const {
621 writable: writable0,
622 output: output0,
623 completed: completed0,
624 } = getTestWritable();
625 const client1 = new DelayClient();
626 const {
627 writable: writable1,
628 output: output1,
629 completed: completed1,
630 } = getTestWritable();
631 await act(() => {
632 ReactDOMFizzServer.renderToPipeableStream(
633 <DelayContext.Provider value={client0}>
634 <div>
635 <Suspense fallback="loading">
636 <Component />
637 </Suspense>
638 </div>
639 </DelayContext.Provider>,
640 ).pipe(writable0);
641 ReactDOMFizzServer.renderToPipeableStream(
642 <DelayContext.Provider value={client1}>
643 <div>
644 <Suspense fallback="loading">
645 <Component />
646 </Suspense>
647 </div>
648 </DelayContext.Provider>,
649 ).pipe(writable1);
650 });
651
652 expect(output0.error).toBe(undefined);
653 expect(output0.result).toContain('loading');
654
655 expect(output1.error).toBe(undefined);
656 expect(output1.result).toContain('loading');
657
658 await Promise.all([completed0, completed1]);
659
660 expect(output0.error).toBe(undefined);
661 expect(output0.result).not.toContain('context never found');
662 expect(output0.result).toContain('OK');
663
664 expect(output1.error).toBe(undefined);
665 expect(output1.result).not.toContain('context never found');
666 expect(output1.result).toContain('OK');
667 });
668
669 it('should be able to pop context after suspending', async () => {
670 class DelayClient {
671 get() {
672 if (this.resolved) return this.resolved;
673 if (this.pending) return this.pending;
674 return (this.pending = new Promise(resolve => {
675 setTimeout(() => {
676 delete this.pending;
677 this.resolved = 'OK';
678 resolve();
679 }, 500);
680 }));
681 }
682 }
683
684 const DelayContext = React.createContext(undefined);
685 const Component = () => {
686 const client = React.useContext(DelayContext);
687 if (!client) {
688 return 'context not found.';
689 }
690 const result = client.get();
691 if (typeof result === 'string') {
692 return result;
693 }
694 throw result;
695 };
696
697 const client = new DelayClient();
698 const {writable, output, completed} = getTestWritable();
699 await act(() => {
700 ReactDOMFizzServer.renderToPipeableStream(
701 <div>
702 <DelayContext.Provider value={client}>
703 <Suspense fallback="loading">
704 <Component />
705 </Suspense>
706 </DelayContext.Provider>
707 <DelayContext.Provider value={client}>
708 <Suspense fallback="loading">
709 <Component />
710 </Suspense>
711 </DelayContext.Provider>
712 </div>,
713 ).pipe(writable);
714 });
715
716 expect(output.error).toBe(undefined);
717 expect(output.result).toContain('loading');
718
719 await completed;
720
721 expect(output.error).toBe(undefined);
722 expect(output.result).not.toContain('context never found');
723 expect(output.result).toContain('OK');
724 });
725
726 it('should not continue rendering after the writable ends unexpectedly', async () => {
727 let hasLoaded = false;
728 let resolve;
729 let isComplete = false;
730 let rendered = false;
731 const promise = new Promise(r => (resolve = r));
732 function Wait({prop}) {
733 if (!hasLoaded) {
734 throw promise;
735 }
736 rendered = true;
737 return 'Done';
738 }
739 const errors = [];
740 const {writable, completed} = getTestWritable();
741 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
742 <div>
743 <Suspense fallback={<div>Loading</div>}>
744 <Wait />
745 </Suspense>
746 </div>,
747 {
748 onError(x) {
749 errors.push(x.message);
750 },
751 onAllReady() {
752 isComplete = true;
753 },
754 },
755 );
756 pipe(writable);
757
758 expect(rendered).toBe(false);
759 expect(isComplete).toBe(false);
760
761 writable.end();
762
763 await jest.runAllTimers();
764
765 hasLoaded = true;
766 resolve();
767
768 await completed;
769 await jest.runAllTimers();
770
771 expect(errors).toEqual([
772 'The destination stream errored while writing data.',
773 ]);
774 expect(rendered).toBe(false);
775 expect(isComplete).toBe(true);
776 });
777
778 it('should encode multibyte characters correctly without nulls (#24985)', async () => {
779 const {writable, output} = getTestWritable();
780 await act(() => {
781 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
782 <div>{Array(700).fill('ののの')}</div>,
783 );
784 pipe(writable);
785 });
786 expect(output.result.indexOf('\u0000')).toBe(-1);
787 expect(output.result).toEqual(
788 '<div>' + Array(700).fill('ののの').join('<!-- -->') + '</div>',
789 );
790 });
791 });