main
js 412 lines 10.6 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
13 let JSDOM;
14 let Stream;
15 let React;
16 let ReactDOM;
17 let ReactDOMClient;
18 let ReactDOMFizzStatic;
19 let Suspense;
20 let textCache;
21 let document;
22 let writable;
23 let container;
24 let buffer = '';
25 let hasErrored = false;
26 let fatalError = undefined;
27
28 describe('ReactDOMFizzStatic', () => {
29 beforeEach(() => {
30 jest.resetModules();
31 JSDOM = require('jsdom').JSDOM;
32 React = require('react');
33 ReactDOM = require('react-dom');
34 ReactDOMClient = require('react-dom/client');
35 ReactDOMFizzStatic = require('react-dom/static');
36 Stream = require('stream');
37 Suspense = React.Suspense;
38
39 textCache = new Map();
40
41 // Test Environment
42 const jsdom = new JSDOM(
43 '<!DOCTYPE html><html><head></head><body><div id="container">',
44 {
45 runScripts: 'dangerously',
46 },
47 );
48 document = jsdom.window.document;
49 container = document.getElementById('container');
50
51 buffer = '';
52 hasErrored = false;
53
54 writable = new Stream.PassThrough();
55 writable.setEncoding('utf8');
56 writable.on('data', chunk => {
57 buffer += chunk;
58 });
59 writable.on('error', error => {
60 hasErrored = true;
61 fatalError = error;
62 });
63 });
64
65 async function act(callback) {
66 await callback();
67 // Await one turn around the event loop.
68 // This assumes that we'll flush everything we have so far.
69 await new Promise(resolve => {
70 setImmediate(resolve);
71 });
72 if (hasErrored) {
73 throw fatalError;
74 }
75 // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment.
76 // We also want to execute any scripts that are embedded.
77 // We assume that we have now received a proper fragment of HTML.
78 const bufferedContent = buffer;
79 buffer = '';
80 const fakeBody = document.createElement('body');
81 fakeBody.innerHTML = bufferedContent;
82 while (fakeBody.firstChild) {
83 const node = fakeBody.firstChild;
84 if (node.nodeName === 'SCRIPT') {
85 const script = document.createElement('script');
86 script.textContent = node.textContent;
87 for (let i = 0; i < node.attributes.length; i++) {
88 const attribute = node.attributes[i];
89 script.setAttribute(attribute.name, attribute.value);
90 }
91 fakeBody.removeChild(node);
92 container.appendChild(script);
93 } else {
94 container.appendChild(node);
95 }
96 }
97 }
98
99 function getVisibleChildren(element) {
100 const children = [];
101 let node = element.firstChild;
102 while (node) {
103 if (node.nodeType === 1) {
104 if (
105 (node.tagName !== 'SCRIPT' || node.hasAttribute('type')) &&
106 node.tagName !== 'TEMPLATE' &&
107 node.tagName !== 'template' &&
108 !node.hasAttribute('hidden') &&
109 !node.hasAttribute('aria-hidden') &&
110 // Ignore the render blocking expect
111 (node.getAttribute('rel') !== 'expect' ||
112 node.getAttribute('blocking') !== 'render')
113 ) {
114 const props = {};
115 const attributes = node.attributes;
116 for (let i = 0; i < attributes.length; i++) {
117 if (
118 attributes[i].name === 'id' &&
119 attributes[i].value.includes(':')
120 ) {
121 // We assume this is a React added ID that's a non-visual implementation detail.
122 continue;
123 }
124 props[attributes[i].name] = attributes[i].value;
125 }
126 props.children = getVisibleChildren(node);
127 children.push(React.createElement(node.tagName.toLowerCase(), props));
128 }
129 } else if (node.nodeType === 3) {
130 children.push(node.data);
131 }
132 node = node.nextSibling;
133 }
134 return children.length === 0
135 ? undefined
136 : children.length === 1
137 ? children[0]
138 : children;
139 }
140
141 function resolveText(text) {
142 const record = textCache.get(text);
143 if (record === undefined) {
144 const newRecord = {
145 status: 'resolved',
146 value: text,
147 };
148 textCache.set(text, newRecord);
149 } else if (record.status === 'pending') {
150 const thenable = record.value;
151 record.status = 'resolved';
152 record.value = text;
153 thenable.pings.forEach(t => t());
154 }
155 }
156
157 /*
158 function rejectText(text, error) {
159 const record = textCache.get(text);
160 if (record === undefined) {
161 const newRecord = {
162 status: 'rejected',
163 value: error,
164 };
165 textCache.set(text, newRecord);
166 } else if (record.status === 'pending') {
167 const thenable = record.value;
168 record.status = 'rejected';
169 record.value = error;
170 thenable.pings.forEach(t => t());
171 }
172 }
173 */
174
175 function readText(text) {
176 const record = textCache.get(text);
177 if (record !== undefined) {
178 switch (record.status) {
179 case 'pending':
180 throw record.value;
181 case 'rejected':
182 throw record.value;
183 case 'resolved':
184 return record.value;
185 }
186 } else {
187 const thenable = {
188 pings: [],
189 then(resolve) {
190 if (newRecord.status === 'pending') {
191 thenable.pings.push(resolve);
192 } else {
193 Promise.resolve().then(() => resolve(newRecord.value));
194 }
195 },
196 };
197
198 const newRecord = {
199 status: 'pending',
200 value: thenable,
201 };
202 textCache.set(text, newRecord);
203
204 throw thenable;
205 }
206 }
207
208 function Text({text}) {
209 return text;
210 }
211
212 function AsyncText({text}) {
213 return readText(text);
214 }
215
216 it('should render a fully static document, send it and then hydrate it', async () => {
217 function App() {
218 return (
219 <div>
220 <Suspense fallback={<Text text="Loading..." />}>
221 <AsyncText text="Hello" />
222 </Suspense>
223 </div>
224 );
225 }
226
227 const promise = ReactDOMFizzStatic.prerenderToNodeStream(<App />);
228
229 resolveText('Hello');
230
231 const result = await promise;
232
233 expect(result.postponed).toBe(null);
234
235 await act(async () => {
236 result.prelude.pipe(writable);
237 });
238 expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
239
240 await act(async () => {
241 ReactDOMClient.hydrateRoot(container, <App />);
242 });
243
244 expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
245 });
246
247 it('should support importMap option', async () => {
248 const importMap = {
249 foo: 'path/to/foo.js',
250 };
251 const result = await ReactDOMFizzStatic.prerenderToNodeStream(
252 <html>
253 <body>hello world</body>
254 </html>,
255 {importMap},
256 );
257
258 await act(async () => {
259 result.prelude.pipe(writable);
260 });
261 expect(getVisibleChildren(container)).toEqual([
262 <script type="importmap">{JSON.stringify(importMap)}</script>,
263 'hello world',
264 ]);
265 });
266
267 it('supports onHeaders', async () => {
268 let headers;
269 function onHeaders(x) {
270 headers = x;
271 }
272
273 function App() {
274 ReactDOM.preload('image', {as: 'image', fetchPriority: 'high'});
275 ReactDOM.preload('font', {as: 'font'});
276 return (
277 <html>
278 <body>hello</body>
279 </html>
280 );
281 }
282
283 const result = await ReactDOMFizzStatic.prerenderToNodeStream(<App />, {
284 onHeaders,
285 });
286 expect(headers).toEqual({
287 Link: `
288 <font>; rel=preload; as="font"; crossorigin="",
289 <image>; rel=preload; as="image"; fetchpriority="high"
290 `
291 .replaceAll('\n', '')
292 .trim(),
293 });
294
295 await act(async () => {
296 result.prelude.pipe(writable);
297 });
298 expect(getVisibleChildren(container)).toEqual('hello');
299 });
300
301 it('will prerender Suspense fallbacks before children', async () => {
302 const values = [];
303 function Indirection({children}) {
304 values.push(children);
305 return children;
306 }
307
308 function App() {
309 return (
310 <div>
311 <Suspense
312 fallback={
313 <div>
314 <Indirection>outer loading...</Indirection>
315 </div>
316 }>
317 <Suspense
318 fallback={
319 <div>
320 <Indirection>first inner loading...</Indirection>
321 </div>
322 }>
323 <div>
324 <Indirection>hello world</Indirection>
325 </div>
326 </Suspense>
327 <Suspense
328 fallback={
329 <div>
330 <Indirection>second inner loading...</Indirection>
331 </div>
332 }>
333 <div>
334 <Indirection>goodbye world</Indirection>
335 </div>
336 </Suspense>
337 </Suspense>
338 </div>
339 );
340 }
341
342 const result = await ReactDOMFizzStatic.prerenderToNodeStream(<App />);
343
344 expect(values).toEqual([
345 'outer loading...',
346 'first inner loading...',
347 'second inner loading...',
348 'hello world',
349 'goodbye world',
350 ]);
351
352 await act(async () => {
353 result.prelude.pipe(writable);
354 });
355 expect(getVisibleChildren(container)).toEqual(
356 <div>
357 <div>hello world</div>
358 <div>goodbye world</div>
359 </div>,
360 );
361 });
362
363 it('will halt a prerender when aborting with an error during a render', async () => {
364 const controller = new AbortController();
365 function App() {
366 controller.abort('sync');
367 return <div>hello world</div>;
368 }
369
370 const errors = [];
371 const result = await ReactDOMFizzStatic.prerenderToNodeStream(<App />, {
372 signal: controller.signal,
373 onError(error) {
374 errors.push(error);
375 },
376 });
377 await act(async () => {
378 result.prelude.pipe(writable);
379 });
380 expect(errors).toEqual(['sync']);
381 expect(getVisibleChildren(container)).toEqual(undefined);
382 });
383
384 it('will halt a prerender when aborting with an error in a microtask', async () => {
385 const errors = [];
386
387 const controller = new AbortController();
388 function App() {
389 React.use(
390 new Promise(() => {
391 Promise.resolve().then(() => {
392 controller.abort('async');
393 });
394 }),
395 );
396 return <div>hello world</div>;
397 }
398
399 errors.length = 0;
400 const result = await ReactDOMFizzStatic.prerenderToNodeStream(<App />, {
401 signal: controller.signal,
402 onError(error) {
403 errors.push(error);
404 },
405 });
406 await act(async () => {
407 result.prelude.pipe(writable);
408 });
409 expect(errors).toEqual(['async']);
410 expect(getVisibleChildren(container)).toEqual(undefined);
411 });
412 });