main
js 571 lines 14.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 * @jest-environment node
9 */
10
11 'use strict';
12
13 let React;
14 let ReactDOMFizzStatic;
15 let Suspense;
16
17 function normalizeCodeLocInfo(str) {
18 return (
19 str &&
20 str.replace(/^ +(?:at|in) ([\S]+)[^\n]*/gm, function (m, name) {
21 const dot = name.lastIndexOf('.');
22 if (dot !== -1) {
23 name = name.slice(dot + 1);
24 }
25 return ' in ' + name + (/\d/.test(m) ? ' (at **)' : '');
26 })
27 );
28 }
29
30 function ignoreListStack(str) {
31 if (!str) {
32 return str;
33 }
34
35 let ignoreListedStack = '';
36 const lines = str.split('\n');
37 // eslint-disable-next-line no-for-of-loops/no-for-of-loops
38 for (const line of lines) {
39 if (line.indexOf(__filename) !== -1) {
40 ignoreListedStack += '\n' + line;
41 }
42 }
43 return ignoreListedStack;
44 }
45
46 describe('ReactDOMFizzStaticNode', () => {
47 beforeEach(() => {
48 jest.resetModules();
49 React = require('react');
50 ReactDOMFizzStatic = require('react-dom/static');
51 Suspense = React.Suspense;
52 });
53
54 const theError = new Error('This is an error');
55 function Throw() {
56 throw theError;
57 }
58 const theInfinitePromise = new Promise(() => {});
59 function InfiniteSuspend() {
60 throw theInfinitePromise;
61 }
62
63 function readContent(readable) {
64 return new Promise((resolve, reject) => {
65 let content = '';
66 readable.on('data', chunk => {
67 content += Buffer.from(chunk).toString('utf8');
68 });
69 readable.on('error', error => {
70 reject(error);
71 });
72 readable.on('end', () => resolve(content));
73 });
74 }
75
76 async function readContentWeb(stream) {
77 const reader = stream.getReader();
78 let content = '';
79 while (true) {
80 const {done, value} = await reader.read();
81 if (done) {
82 return content;
83 }
84 content += Buffer.from(value).toString('utf8');
85 }
86 }
87
88 it('should call prerenderToNodeStream', async () => {
89 const result = await ReactDOMFizzStatic.prerenderToNodeStream(
90 <div>hello world</div>,
91 );
92 const prelude = await readContent(result.prelude);
93 expect(prelude).toMatchInlineSnapshot(`"<div>hello world</div>"`);
94 });
95
96 it('should suppport web streams', async () => {
97 const result = await ReactDOMFizzStatic.prerender(<div>hello world</div>);
98 const prelude = await readContentWeb(result.prelude);
99 expect(prelude).toMatchInlineSnapshot(`"<div>hello world</div>"`);
100 });
101
102 it('should emit DOCTYPE at the root of the document', async () => {
103 const result = await ReactDOMFizzStatic.prerenderToNodeStream(
104 <html>
105 <body>hello world</body>
106 </html>,
107 );
108 const prelude = await readContent(result.prelude);
109 if (gate(flags => flags.enableFizzBlockingRender)) {
110 expect(prelude).toMatchInlineSnapshot(
111 `"<!DOCTYPE html><html><head><link rel="expect" href="#_R_" blocking="render"/></head><body>hello world<template id="_R_"></template></body></html>"`,
112 );
113 } else {
114 expect(prelude).toMatchInlineSnapshot(
115 `"<!DOCTYPE html><html><head></head><body>hello world</body></html>"`,
116 );
117 }
118 });
119
120 it('should emit bootstrap script src at the end', async () => {
121 const result = await ReactDOMFizzStatic.prerenderToNodeStream(
122 <div>hello world</div>,
123 {
124 bootstrapScriptContent: 'INIT();',
125 bootstrapScripts: ['init.js'],
126 bootstrapModules: ['init.mjs'],
127 },
128 );
129 const prelude = await readContent(result.prelude);
130 expect(prelude).toMatchInlineSnapshot(
131 `"<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>"`,
132 );
133 });
134
135 it('emits all HTML as one unit', async () => {
136 let hasLoaded = false;
137 let resolve;
138 const promise = new Promise(r => (resolve = r));
139 function Wait() {
140 if (!hasLoaded) {
141 throw promise;
142 }
143 return 'Done';
144 }
145 const resultPromise = ReactDOMFizzStatic.prerenderToNodeStream(
146 <div>
147 <Suspense fallback="Loading">
148 <Wait />
149 </Suspense>
150 </div>,
151 );
152
153 await jest.runAllTimers();
154
155 // Resolve the loading.
156 hasLoaded = true;
157 await resolve();
158
159 const result = await resultPromise;
160 const prelude = await readContent(result.prelude);
161 expect(prelude).toMatchInlineSnapshot(`"<div><!--$-->Done<!--/$--></div>"`);
162 });
163
164 it('should reject the promise when an error is thrown at the root', async () => {
165 const reportedErrors = [];
166 let caughtError = null;
167 try {
168 await ReactDOMFizzStatic.prerenderToNodeStream(
169 <div>
170 <Throw />
171 </div>,
172 {
173 onError(x) {
174 reportedErrors.push(x);
175 },
176 },
177 );
178 } catch (error) {
179 caughtError = error;
180 }
181 expect(caughtError).toBe(theError);
182 expect(reportedErrors).toEqual([theError]);
183 });
184
185 it('should reject the promise when an error is thrown inside a fallback', async () => {
186 const reportedErrors = [];
187 let caughtError = null;
188 try {
189 await ReactDOMFizzStatic.prerenderToNodeStream(
190 <div>
191 <Suspense fallback={<Throw />}>
192 <InfiniteSuspend />
193 </Suspense>
194 </div>,
195 {
196 onError(x) {
197 reportedErrors.push(x);
198 },
199 },
200 );
201 } catch (error) {
202 caughtError = error;
203 }
204 expect(caughtError).toBe(theError);
205 expect(reportedErrors).toEqual([theError]);
206 });
207
208 it('should not error the stream when an error is thrown inside suspense boundary', async () => {
209 const reportedErrors = [];
210 const result = await ReactDOMFizzStatic.prerenderToNodeStream(
211 <div>
212 <Suspense fallback={<div>Loading</div>}>
213 <Throw />
214 </Suspense>
215 </div>,
216 {
217 onError(x) {
218 reportedErrors.push(x);
219 },
220 },
221 );
222
223 const prelude = await readContent(result.prelude);
224 expect(prelude).toContain('Loading');
225 expect(reportedErrors).toEqual([theError]);
226 });
227
228 it('should be able to complete by aborting even if the promise never resolves', async () => {
229 const errors = [];
230 const controller = new AbortController();
231 const resultPromise = ReactDOMFizzStatic.prerenderToNodeStream(
232 <div>
233 <Suspense fallback={<div>Loading</div>}>
234 <InfiniteSuspend />
235 </Suspense>
236 </div>,
237 {
238 signal: controller.signal,
239 onError(x) {
240 errors.push(x.message);
241 },
242 },
243 );
244
245 await jest.runAllTimers();
246
247 controller.abort();
248 await jest.runAllTimers();
249
250 const result = await resultPromise;
251
252 const prelude = await readContent(result.prelude);
253 expect(prelude).toContain('Loading');
254
255 expect(errors).toEqual(['This operation was aborted']);
256 });
257
258 it('should resolve an empty shell if aborting before the shell is complete', async () => {
259 const errors = [];
260 const controller = new AbortController();
261 const promise = ReactDOMFizzStatic.prerenderToNodeStream(
262 <div>
263 <InfiniteSuspend />
264 </div>,
265 {
266 signal: controller.signal,
267 onError(x) {
268 errors.push(x.message);
269 },
270 },
271 );
272
273 await jest.runAllTimers();
274
275 const theReason = new Error('aborted for reasons');
276 controller.abort(theReason);
277 await jest.runAllTimers();
278
279 let didThrow = false;
280 let prelude;
281 try {
282 ({prelude} = await promise);
283 } catch (error) {
284 didThrow = true;
285 }
286 expect(didThrow).toBe(false);
287 expect(errors).toEqual(['aborted for reasons']);
288 const content = await readContent(prelude);
289 expect(content).toBe('');
290 });
291
292 it('should be able to abort before something suspends', async () => {
293 const errors = [];
294 const controller = new AbortController();
295 function App() {
296 controller.abort();
297 return (
298 <Suspense fallback={<div>Loading</div>}>
299 <InfiniteSuspend />
300 </Suspense>
301 );
302 }
303 const streamPromise = ReactDOMFizzStatic.prerenderToNodeStream(
304 <div>
305 <App />
306 </div>,
307 {
308 signal: controller.signal,
309 onError(x) {
310 errors.push(x.message);
311 },
312 },
313 );
314
315 await jest.runAllTimers();
316 const {prelude} = await streamPromise;
317 const content = await readContent(prelude);
318 expect(errors).toEqual(['This operation was aborted']);
319 expect(content).toBe('');
320 });
321
322 it('reports the abort reason if a task suspends after aborting a prerender', async () => {
323 const promise = new Promise(() => {});
324 const errors = [];
325 const controller = new AbortController();
326 function App() {
327 controller.abort(new Error('abort reason'));
328 React.use(promise);
329 return null;
330 }
331
332 const resultPromise = ReactDOMFizzStatic.prerenderToNodeStream(<App />, {
333 signal: controller.signal,
334 onError(error) {
335 errors.push(error.message);
336 },
337 });
338
339 await jest.runAllTimers();
340 const result = await resultPromise;
341
342 expect(errors).toEqual(['abort reason']);
343 expect(await readContent(result.prelude)).toBe('');
344 });
345
346 it('should resolve with an empty prelude if passing an already aborted signal', async () => {
347 const errors = [];
348 const controller = new AbortController();
349 const theReason = new Error('aborted for reasons');
350 controller.abort(theReason);
351
352 const promise = ReactDOMFizzStatic.prerenderToNodeStream(
353 <div>
354 <Suspense fallback={<div>Loading</div>}>
355 <InfiniteSuspend />
356 </Suspense>
357 </div>,
358 {
359 signal: controller.signal,
360 onError(x) {
361 errors.push(x.message);
362 },
363 },
364 );
365
366 // Technically we could still continue rendering the shell but currently the
367 // semantics mean that we also abort any pending CPU work.
368 await jest.runAllTimers();
369
370 let didThrow = false;
371 let prelude;
372 try {
373 ({prelude} = await promise);
374 } catch (error) {
375 didThrow = true;
376 }
377 expect(didThrow).toBe(false);
378 expect(errors).toEqual(['aborted for reasons']);
379 const content = await readContent(prelude);
380 expect(content).toBe('');
381 });
382
383 it('supports custom abort reasons with a string', async () => {
384 const promise = new Promise(r => {});
385 function Wait() {
386 throw promise;
387 }
388 function App() {
389 return (
390 <div>
391 <p>
392 <Suspense fallback={'p'}>
393 <Wait />
394 </Suspense>
395 </p>
396 <span>
397 <Suspense fallback={'span'}>
398 <Wait />
399 </Suspense>
400 </span>
401 </div>
402 );
403 }
404
405 const errors = [];
406 const controller = new AbortController();
407 const resultPromise = ReactDOMFizzStatic.prerenderToNodeStream(<App />, {
408 signal: controller.signal,
409 onError(x) {
410 errors.push(x);
411 return 'a digest';
412 },
413 });
414
415 await jest.runAllTimers();
416
417 controller.abort('foobar');
418 await jest.runAllTimers();
419
420 await resultPromise;
421
422 expect(errors).toEqual(['foobar', 'foobar']);
423 });
424
425 it('supports custom abort reasons with an Error', async () => {
426 const promise = new Promise(r => {});
427 function Wait() {
428 throw promise;
429 }
430 function App() {
431 return (
432 <div>
433 <p>
434 <Suspense fallback={'p'}>
435 <Wait />
436 </Suspense>
437 </p>
438 <span>
439 <Suspense fallback={'span'}>
440 <Wait />
441 </Suspense>
442 </span>
443 </div>
444 );
445 }
446
447 const errors = [];
448 const controller = new AbortController();
449 const resultPromise = ReactDOMFizzStatic.prerenderToNodeStream(<App />, {
450 signal: controller.signal,
451 onError(x) {
452 errors.push(x.message);
453 return 'a digest';
454 },
455 });
456
457 await jest.runAllTimers();
458
459 controller.abort(new Error('uh oh'));
460 await jest.runAllTimers();
461
462 await resultPromise;
463
464 expect(errors).toEqual(['uh oh', 'uh oh']);
465 });
466
467 it('uses a rejection reason when an abort listener rejects pending work before the abort finishes', async () => {
468 let reject;
469 const rejectedPromise = new Promise((resolve, rejectPromise) => {
470 reject = rejectPromise;
471 });
472 const haltedPromise = new Promise(() => {});
473 function RejectedWait() {
474 React.use(rejectedPromise);
475 return null;
476 }
477 function HaltedWait() {
478 React.use(haltedPromise);
479 return null;
480 }
481
482 const errors = [];
483 const controller = new AbortController();
484 const resultPromise = ReactDOMFizzStatic.prerenderToNodeStream(
485 <>
486 <Suspense fallback="Loading rejected">
487 <RejectedWait />
488 </Suspense>
489 <Suspense fallback="Loading halted">
490 <HaltedWait />
491 </Suspense>
492 </>,
493 {
494 signal: controller.signal,
495 onError(error) {
496 errors.push(error.message);
497 },
498 },
499 );
500
501 await jest.runAllTimers();
502
503 controller.signal.addEventListener('abort', () => {
504 reject(new Error('rejected during abort'));
505 });
506 controller.abort(new Error('abort reason'));
507
508 await Promise.resolve();
509 await jest.runAllTimers();
510 await resultPromise;
511
512 expect(errors).toEqual(['rejected during abort', 'abort reason']);
513 });
514
515 describe('with real timers', () => {
516 beforeEach(() => {
517 jest.useRealTimers();
518 });
519
520 afterEach(() => {
521 jest.useFakeTimers();
522 });
523
524 it('includes the suspended call site when aborting in the same rendering task', async () => {
525 const promise = new Promise(() => {});
526 const controller = new AbortController();
527 let caughtError;
528 let componentStack;
529 let ownerStack;
530
531 function AbortAndSuspend() {
532 controller.abort(new Error('abort reason'));
533 React.use(promise);
534 return null;
535 }
536
537 function App() {
538 return <AbortAndSuspend />;
539 }
540
541 const {prelude} = await ReactDOMFizzStatic.prerenderToNodeStream(
542 <App />,
543 {
544 signal: controller.signal,
545 onError(error, errorInfo) {
546 caughtError = error;
547 componentStack = errorInfo.componentStack;
548 ownerStack = __DEV__ ? React.captureOwnerStack() : null;
549 },
550 },
551 );
552
553 expect(caughtError).toEqual(
554 expect.objectContaining({message: 'abort reason'}),
555 );
556 expect(await readContent(prelude)).toBe('');
557 if (__DEV__) {
558 expect(normalizeCodeLocInfo(componentStack)).toBe(
559 '\n in AbortAndSuspend (at **)\n in App',
560 );
561 expect(normalizeCodeLocInfo(ignoreListStack(ownerStack))).toBe(
562 (gate(flags => flags.enableAsyncDebugInfo)
563 ? '\n in AbortAndSuspend (at **)'
564 : '') + '\n in App (at **)',
565 );
566 } else {
567 expect(ownerStack).toBeNull();
568 }
569 });
570 });
571 });