main
js 562 lines 17.9 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 */
9
10 'use strict';
11
12 import {patchMessageChannel} from '../../../../scripts/jest/patchMessageChannel';
13
14 // Polyfills for test environment
15 global.ReadableStream =
16 require('web-streams-polyfill/ponyfill/es6').ReadableStream;
17 global.TextEncoder = require('util').TextEncoder;
18
19 let React;
20 let ReactDOMFizzServer;
21 let Suspense;
22 let serverAct;
23
24 describe('ReactDOMFizzServerBrowser', () => {
25 beforeEach(() => {
26 jest.resetModules();
27
28 patchMessageChannel();
29 serverAct = require('internal-test-utils').serverAct;
30
31 React = require('react');
32 ReactDOMFizzServer = require('react-dom/server.browser');
33 Suspense = React.Suspense;
34 });
35
36 const theError = new Error('This is an error');
37 function Throw() {
38 throw theError;
39 }
40 const theInfinitePromise = new Promise(() => {});
41 function InfiniteSuspend() {
42 throw theInfinitePromise;
43 }
44
45 async function readResult(stream) {
46 const reader = stream.getReader();
47 let result = '';
48 while (true) {
49 const {done, value} = await reader.read();
50 if (done) {
51 return result;
52 }
53 result += Buffer.from(value).toString('utf8');
54 }
55 }
56
57 it('should call renderToReadableStream', async () => {
58 const stream = await serverAct(() =>
59 ReactDOMFizzServer.renderToReadableStream(<div>hello world</div>),
60 );
61 const result = await readResult(stream);
62 expect(result).toMatchInlineSnapshot(`"<div>hello world</div>"`);
63 });
64
65 it('should emit DOCTYPE at the root of the document', async () => {
66 const stream = await serverAct(() =>
67 ReactDOMFizzServer.renderToReadableStream(
68 <html>
69 <body>hello world</body>
70 </html>,
71 ),
72 );
73 const result = await readResult(stream);
74 if (gate(flags => flags.enableFizzBlockingRender)) {
75 expect(result).toMatchInlineSnapshot(
76 `"<!DOCTYPE html><html><head><link rel="expect" href="#_R_" blocking="render"/></head><body>hello world<template id="_R_"></template></body></html>"`,
77 );
78 } else {
79 expect(result).toMatchInlineSnapshot(
80 `"<!DOCTYPE html><html><head></head><body>hello world</body></html>"`,
81 );
82 }
83 });
84
85 it('should emit bootstrap script src at the end', async () => {
86 const stream = await serverAct(() =>
87 ReactDOMFizzServer.renderToReadableStream(<div>hello world</div>, {
88 bootstrapScriptContent: 'INIT();',
89 bootstrapScripts: ['init.js'],
90 bootstrapModules: ['init.mjs'],
91 }),
92 );
93 const result = await readResult(stream);
94 expect(result).toMatchInlineSnapshot(
95 `"<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>"`,
96 );
97 });
98
99 it('emits all HTML as one unit if we wait until the end to start', async () => {
100 let hasLoaded = false;
101 let resolve;
102 const promise = new Promise(r => (resolve = r));
103 function Wait() {
104 if (!hasLoaded) {
105 throw promise;
106 }
107 return 'Done';
108 }
109 let isComplete = false;
110 const stream = await serverAct(() =>
111 ReactDOMFizzServer.renderToReadableStream(
112 <div>
113 <Suspense fallback="Loading">
114 <Wait />
115 </Suspense>
116 </div>,
117 ),
118 );
119
120 stream.allReady.then(() => (isComplete = true));
121
122 expect(isComplete).toBe(false);
123 // Resolve the loading.
124 hasLoaded = true;
125 await serverAct(() => resolve());
126
127 expect(isComplete).toBe(true);
128
129 const result = await readResult(stream);
130 expect(result).toMatchInlineSnapshot(
131 `"<div><!--$-->Done<!-- --><!--/$--></div>"`,
132 );
133 });
134
135 it('should reject the promise when an error is thrown at the root', async () => {
136 const reportedErrors = [];
137 let caughtError = null;
138 try {
139 await serverAct(() =>
140 ReactDOMFizzServer.renderToReadableStream(
141 <div>
142 <Throw />
143 </div>,
144 {
145 onError(x) {
146 reportedErrors.push(x);
147 },
148 },
149 ),
150 );
151 } catch (error) {
152 caughtError = error;
153 }
154 expect(caughtError).toBe(theError);
155 expect(reportedErrors).toEqual([theError]);
156 });
157
158 it('should reject the promise when an error is thrown inside a fallback', async () => {
159 const reportedErrors = [];
160 let caughtError = null;
161 try {
162 await serverAct(() =>
163 ReactDOMFizzServer.renderToReadableStream(
164 <div>
165 <Suspense fallback={<Throw />}>
166 <InfiniteSuspend />
167 </Suspense>
168 </div>,
169 {
170 onError(x) {
171 reportedErrors.push(x);
172 },
173 },
174 ),
175 );
176 } catch (error) {
177 caughtError = error;
178 }
179 expect(caughtError).toBe(theError);
180 expect(reportedErrors).toEqual([theError]);
181 });
182
183 it('should not error the stream when an error is thrown inside suspense boundary', async () => {
184 const reportedErrors = [];
185 const stream = await serverAct(() =>
186 ReactDOMFizzServer.renderToReadableStream(
187 <div>
188 <Suspense fallback={<div>Loading</div>}>
189 <Throw />
190 </Suspense>
191 </div>,
192 {
193 onError(x) {
194 reportedErrors.push(x);
195 },
196 },
197 ),
198 );
199
200 const result = await readResult(stream);
201 expect(result).toContain('Loading');
202 expect(reportedErrors).toEqual([theError]);
203 });
204
205 it('should be able to complete by aborting even if the promise never resolves', async () => {
206 const errors = [];
207 const controller = new AbortController();
208 const stream = await serverAct(() =>
209 ReactDOMFizzServer.renderToReadableStream(
210 <div>
211 <Suspense fallback={<div>Loading</div>}>
212 <InfiniteSuspend />
213 </Suspense>
214 </div>,
215 {
216 signal: controller.signal,
217 onError(x) {
218 errors.push(x.message);
219 },
220 },
221 ),
222 );
223
224 await serverAct(() => {
225 controller.abort();
226 });
227
228 const result = await readResult(stream);
229 expect(result).toContain('Loading');
230
231 expect(errors).toEqual(['The operation was aborted.']);
232 });
233
234 it('should reject if aborting before the shell is complete', async () => {
235 const errors = [];
236 const controller = new AbortController();
237 const promise = serverAct(() =>
238 ReactDOMFizzServer.renderToReadableStream(
239 <div>
240 <InfiniteSuspend />
241 </div>,
242 {
243 signal: controller.signal,
244 onError(x) {
245 errors.push(x.message);
246 },
247 },
248 ),
249 );
250
251 const theReason = new Error('aborted for reasons');
252 await serverAct(() => {
253 controller.abort(theReason);
254 });
255
256 let caughtError = null;
257 try {
258 await promise;
259 } catch (error) {
260 caughtError = error;
261 }
262 expect(caughtError).toBe(theReason);
263 expect(errors).toEqual(['aborted for reasons']);
264 });
265
266 it('should be able to abort before something suspends', async () => {
267 const errors = [];
268 const controller = new AbortController();
269 function App() {
270 controller.abort();
271 return (
272 <Suspense fallback={<div>Loading</div>}>
273 <InfiniteSuspend />
274 </Suspense>
275 );
276 }
277 const streamPromise = serverAct(() =>
278 ReactDOMFizzServer.renderToReadableStream(
279 <div>
280 <App />
281 </div>,
282 {
283 signal: controller.signal,
284 onError(x) {
285 errors.push(x.message);
286 },
287 },
288 ),
289 );
290
291 let caughtError = null;
292 try {
293 await streamPromise;
294 } catch (error) {
295 caughtError = error;
296 }
297 expect(caughtError.message).toBe('The operation was aborted.');
298 expect(errors).toEqual(['The operation was aborted.']);
299 });
300
301 it('should reject if passing an already aborted signal', async () => {
302 const errors = [];
303 const controller = new AbortController();
304 const theReason = new Error('aborted for reasons');
305 controller.abort(theReason);
306
307 const promise = serverAct(() =>
308 ReactDOMFizzServer.renderToReadableStream(
309 <div>
310 <Suspense fallback={<div>Loading</div>}>
311 <InfiniteSuspend />
312 </Suspense>
313 </div>,
314 {
315 signal: controller.signal,
316 onError(x) {
317 errors.push(x.message);
318 },
319 },
320 ),
321 );
322
323 // Technically we could still continue rendering the shell but currently the
324 // semantics mean that we also abort any pending CPU work.
325 let caughtError = null;
326 try {
327 await promise;
328 } catch (error) {
329 caughtError = error;
330 }
331 expect(caughtError).toBe(theReason);
332 expect(errors).toEqual(['aborted for reasons']);
333 });
334
335 it('should not continue rendering after the reader cancels', async () => {
336 let hasLoaded = false;
337 let resolve;
338 let isComplete = false;
339 let rendered = false;
340 const promise = new Promise(r => (resolve = r));
341 function Wait() {
342 if (!hasLoaded) {
343 throw promise;
344 }
345 rendered = true;
346 return 'Done';
347 }
348 const errors = [];
349 const stream = await serverAct(() =>
350 ReactDOMFizzServer.renderToReadableStream(
351 <div>
352 <Suspense fallback={<div>Loading</div>}>
353 <Wait />
354 </Suspense>
355 </div>,
356 {
357 onError(x) {
358 errors.push(x.message);
359 },
360 },
361 ),
362 );
363
364 stream.allReady.then(() => (isComplete = true));
365
366 expect(rendered).toBe(false);
367 expect(isComplete).toBe(false);
368
369 const reader = stream.getReader();
370 await reader.read();
371 await serverAct(() => reader.cancel());
372
373 expect(errors).toEqual([
374 'The render was aborted by the server without a reason.',
375 ]);
376
377 hasLoaded = true;
378 await serverAct(() => resolve());
379
380 expect(rendered).toBe(false);
381 expect(isComplete).toBe(true);
382
383 expect(errors).toEqual([
384 'The render was aborted by the server without a reason.',
385 ]);
386 });
387
388 it('should stream large contents that might overlow individual buffers', async () => {
389 const str492 = `(492) This string is intentionally 492 bytes long because we want to make sure we process chunks that will overflow buffer boundaries. It will repeat to fill out the bytes required (inclusive of this prompt):: foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux q :: total count (492)`;
390 const str2049 = `(2049) This string is intentionally 2049 bytes long because we want to make sure we process chunks that will overflow buffer boundaries. It will repeat to fill out the bytes required (inclusive of this prompt):: foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy :: total count (2049)`;
391
392 // this specific layout is somewhat contrived to exercise the landing on
393 // an exact view boundary. it's not critical to test this edge case but
394 // since we are setting up a test in general for larger chunks I contrived it
395 // as such for now. I don't think it needs to be maintained if in the future
396 // the view sizes change or become dynamic becasue of the use of byobRequest
397 let stream;
398 stream = await serverAct(() =>
399 ReactDOMFizzServer.renderToReadableStream(
400 <>
401 <div>
402 <span>{''}</span>
403 </div>
404 <div>{str492}</div>
405 <div>{str492}</div>
406 </>,
407 ),
408 );
409
410 let result;
411 result = await readResult(stream);
412
413 expect(result).toMatchInlineSnapshot(
414 // TODO: remove interpolation because it prevents snapshot updates.
415 // eslint-disable-next-line jest/no-interpolation-in-snapshots
416 `"<div><span></span></div><div>${str492}</div><div>${str492}</div>"`,
417 );
418
419 // this size 2049 was chosen to be a couple base 2 orders larger than the current view
420 // size. if the size changes in the future hopefully this will still exercise
421 // a chunk that is too large for the view size.
422 stream = await serverAct(() =>
423 ReactDOMFizzServer.renderToReadableStream(
424 <>
425 <div>{str2049}</div>
426 </>,
427 ),
428 );
429
430 result = await readResult(stream);
431 // TODO: remove interpolation because it prevents snapshot updates.
432 // eslint-disable-next-line jest/no-interpolation-in-snapshots
433 expect(result).toMatchInlineSnapshot(`"<div>${str2049}</div>"`);
434 });
435
436 it('supports custom abort reasons with a string', async () => {
437 const promise = new Promise(r => {});
438 function Wait() {
439 throw promise;
440 }
441 function App() {
442 return (
443 <div>
444 <p>
445 <Suspense fallback={'p'}>
446 <Wait />
447 </Suspense>
448 </p>
449 <span>
450 <Suspense fallback={'span'}>
451 <Wait />
452 </Suspense>
453 </span>
454 </div>
455 );
456 }
457
458 const errors = [];
459 const controller = new AbortController();
460 await serverAct(() =>
461 ReactDOMFizzServer.renderToReadableStream(<App />, {
462 signal: controller.signal,
463 onError(x) {
464 errors.push(x);
465 return 'a digest';
466 },
467 }),
468 );
469
470 await serverAct(() => {
471 controller.abort('foobar');
472 });
473
474 expect(errors).toEqual(['foobar', 'foobar']);
475 });
476
477 it('supports custom abort reasons with an Error', async () => {
478 const promise = new Promise(r => {});
479 function Wait() {
480 throw promise;
481 }
482 function App() {
483 return (
484 <div>
485 <p>
486 <Suspense fallback={'p'}>
487 <Wait />
488 </Suspense>
489 </p>
490 <span>
491 <Suspense fallback={'span'}>
492 <Wait />
493 </Suspense>
494 </span>
495 </div>
496 );
497 }
498
499 const errors = [];
500 const controller = new AbortController();
501 await serverAct(() =>
502 ReactDOMFizzServer.renderToReadableStream(<App />, {
503 signal: controller.signal,
504 onError(x) {
505 errors.push(x.message);
506 return 'a digest';
507 },
508 }),
509 );
510
511 await serverAct(() => {
512 controller.abort(new Error('uh oh'));
513 });
514
515 expect(errors).toEqual(['uh oh', 'uh oh']);
516 });
517
518 // https://github.com/facebook/react/pull/25534/files - fix transposed escape functions
519 it('should encode title properly', async () => {
520 const stream = await serverAct(() =>
521 ReactDOMFizzServer.renderToReadableStream(
522 <html>
523 <head>
524 <title>foo</title>
525 </head>
526 <body>bar</body>
527 </html>,
528 ),
529 );
530
531 const result = await readResult(stream);
532 expect(result).toEqual(
533 '<!DOCTYPE html><html><head>' +
534 (gate(flags => flags.enableFizzBlockingRender)
535 ? '<link rel="expect" href="#_R_" blocking="render"/>'
536 : '') +
537 '<title>foo</title></head><body>bar' +
538 (gate(flags => flags.enableFizzBlockingRender)
539 ? '<template id="_R_"></template>'
540 : '') +
541 '</body></html>',
542 );
543 });
544
545 it('should support nonce attribute for bootstrap scripts', async () => {
546 const nonce = 'R4nd0m';
547 const stream = await serverAct(() =>
548 ReactDOMFizzServer.renderToReadableStream(<div>hello world</div>, {
549 nonce,
550 bootstrapScriptContent: 'INIT();',
551 bootstrapScripts: ['init.js'],
552 bootstrapModules: ['init.mjs'],
553 }),
554 );
555 const result = await readResult(stream);
556 expect(result).toMatchInlineSnapshot(
557 // TODO: remove interpolation because it prevents snapshot updates.
558 // eslint-disable-next-line jest/no-interpolation-in-snapshots
559 `"<link rel="preload" as="script" fetchPriority="low" nonce="R4nd0m" href="init.js"/><link rel="modulepreload" fetchPriority="low" nonce="R4nd0m" href="init.mjs"/><div>hello world</div><script nonce="${nonce}" id="_R_">INIT();</script><script src="init.js" nonce="${nonce}" async=""></script><script type="module" src="init.mjs" nonce="${nonce}" async=""></script>"`,
560 );
561 });
562 });