@samitouri / QOS-React-2 / commits / 0f216ae31d

Add entry points for "static" server rendering passes (#24752)

This will be used to add optimizations for static server rendering.

Sebastian Markbåge committed Jun 18, 2022 at 22:34 UTC 0f216ae31d91f882134707af99d5da9c01e1f603
16 files changed +1379 -1
packages/react-dom/npm/static.browser.js new
+7
@@ -0,0 +1,7 @@
1 +'use strict';
2 +
3 +if (process.env.NODE_ENV === 'production') {
4 + module.exports = require('./cjs/react-dom-static.browser.production.min.js');
5 +} else {
6 + module.exports = require('./cjs/react-dom-static.browser.development.js');
7 +}
packages/react-dom/npm/static.js new
+3
@@ -0,0 +1,3 @@
1 +'use strict';
2 +
3 +module.exports = require('./static.node');
packages/react-dom/npm/static.node.js new
+7
@@ -0,0 +1,7 @@
1 +'use strict';
2 +
3 +if (process.env.NODE_ENV === 'production') {
4 + module.exports = require('./cjs/react-dom-static.node.production.min.js');
5 +} else {
6 + module.exports = require('./cjs/react-dom-static.node.development.js');
7 +}
packages/react-dom/package.json
+13 -1
@@ -32,6 +32,9 @@
32 "server.js",
33 "server.browser.js",
34 "server.node.js",
35 + "static.js",
36 + "static.browser.js",
37 + "static.node.js",
38 "test-utils.js",
39 "unstable_testing.js",
40 "cjs/",
@@ -48,6 +51,14 @@
51 },
52 "./server.browser": "./server.browser.js",
53 "./server.node": "./server.node.js",
54 + "./static": {
55 + "deno": "./static.browser.js",
56 + "worker": "./static.browser.js",
57 + "browser": "./static.browser.js",
58 + "default": "./static.node.js"
59 + },
60 + "./static.browser": "./static.browser.js",
61 + "./static.node": "./static.node.js",
62 "./profiling": "./profiling.js",
63 "./test-utils": "./test-utils.js",
64 "./unstable_testing": "./unstable_testing.js",
@@ -55,7 +66,8 @@
66 "./package.json": "./package.json"
67 },
68 "browser": {
58 - "./server.js": "./server.browser.js"
69 + "./server.js": "./server.browser.js",
70 + "./static.js": "./static.browser.js"
71 },
72 "browserify": {
73 "transform": [
packages/react-dom/src/__tests__/ReactDOMFizzStatic-test.js new
+237
@@ -0,0 +1,237 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its 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 +let JSDOM;
13 +let Stream;
14 +let React;
15 +let ReactDOMClient;
16 +let ReactDOMFizzStatic;
17 +let Suspense;
18 +let textCache;
19 +let document;
20 +let writable;
21 +let container;
22 +let buffer = '';
23 +let hasErrored = false;
24 +let fatalError = undefined;
25 +
26 +describe('ReactDOMFizzStatic', () => {
27 + beforeEach(() => {
28 + jest.resetModules();
29 + JSDOM = require('jsdom').JSDOM;
30 + React = require('react');
31 + ReactDOMClient = require('react-dom/client');
32 + if (__EXPERIMENTAL__) {
33 + ReactDOMFizzStatic = require('react-dom/static');
34 + }
35 + Stream = require('stream');
36 + Suspense = React.Suspense;
37 +
38 + textCache = new Map();
39 +
40 + // Test Environment
41 + const jsdom = new JSDOM(
42 + '<!DOCTYPE html><html><head></head><body><div id="container">',
43 + {
44 + runScripts: 'dangerously',
45 + },
46 + );
47 + document = jsdom.window.document;
48 + container = document.getElementById('container');
49 +
50 + buffer = '';
51 + hasErrored = false;
52 +
53 + writable = new Stream.PassThrough();
54 + writable.setEncoding('utf8');
55 + writable.on('data', chunk => {
56 + buffer += chunk;
57 + });
58 + writable.on('error', error => {
59 + hasErrored = true;
60 + fatalError = error;
61 + });
62 + });
63 +
64 + async function act(callback) {
65 + await callback();
66 + // Await one turn around the event loop.
67 + // This assumes that we'll flush everything we have so far.
68 + await new Promise(resolve => {
69 + setImmediate(resolve);
70 + });
71 + if (hasErrored) {
72 + throw fatalError;
73 + }
74 + // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment.
75 + // We also want to execute any scripts that are embedded.
76 + // We assume that we have now received a proper fragment of HTML.
77 + const bufferedContent = buffer;
78 + buffer = '';
79 + const fakeBody = document.createElement('body');
80 + fakeBody.innerHTML = bufferedContent;
81 + while (fakeBody.firstChild) {
82 + const node = fakeBody.firstChild;
83 + if (node.nodeName === 'SCRIPT') {
84 + const script = document.createElement('script');
85 + script.textContent = node.textContent;
86 + fakeBody.removeChild(node);
87 + container.appendChild(script);
88 + } else {
89 + container.appendChild(node);
90 + }
91 + }
92 + }
93 +
94 + function getVisibleChildren(element) {
95 + const children = [];
96 + let node = element.firstChild;
97 + while (node) {
98 + if (node.nodeType === 1) {
99 + if (
100 + node.tagName !== 'SCRIPT' &&
101 + node.tagName !== 'TEMPLATE' &&
102 + node.tagName !== 'template' &&
103 + !node.hasAttribute('hidden') &&
104 + !node.hasAttribute('aria-hidden')
105 + ) {
106 + const props = {};
107 + const attributes = node.attributes;
108 + for (let i = 0; i < attributes.length; i++) {
109 + if (
110 + attributes[i].name === 'id' &&
111 + attributes[i].value.includes(':')
112 + ) {
113 + // We assume this is a React added ID that's a non-visual implementation detail.
114 + continue;
115 + }
116 + props[attributes[i].name] = attributes[i].value;
117 + }
118 + props.children = getVisibleChildren(node);
119 + children.push(React.createElement(node.tagName.toLowerCase(), props));
120 + }
121 + } else if (node.nodeType === 3) {
122 + children.push(node.data);
123 + }
124 + node = node.nextSibling;
125 + }
126 + return children.length === 0
127 + ? undefined
128 + : children.length === 1
129 + ? children[0]
130 + : children;
131 + }
132 +
133 + function resolveText(text) {
134 + const record = textCache.get(text);
135 + if (record === undefined) {
136 + const newRecord = {
137 + status: 'resolved',
138 + value: text,
139 + };
140 + textCache.set(text, newRecord);
141 + } else if (record.status === 'pending') {
142 + const thenable = record.value;
143 + record.status = 'resolved';
144 + record.value = text;
145 + thenable.pings.forEach(t => t());
146 + }
147 + }
148 +
149 + /*
150 + function rejectText(text, error) {
151 + const record = textCache.get(text);
152 + if (record === undefined) {
153 + const newRecord = {
154 + status: 'rejected',
155 + value: error,
156 + };
157 + textCache.set(text, newRecord);
158 + } else if (record.status === 'pending') {
159 + const thenable = record.value;
160 + record.status = 'rejected';
161 + record.value = error;
162 + thenable.pings.forEach(t => t());
163 + }
164 + }
165 + */
166 +
167 + function readText(text) {
168 + const record = textCache.get(text);
169 + if (record !== undefined) {
170 + switch (record.status) {
171 + case 'pending':
172 + throw record.value;
173 + case 'rejected':
174 + throw record.value;
175 + case 'resolved':
176 + return record.value;
177 + }
178 + } else {
179 + const thenable = {
180 + pings: [],
181 + then(resolve) {
182 + if (newRecord.status === 'pending') {
183 + thenable.pings.push(resolve);
184 + } else {
185 + Promise.resolve().then(() => resolve(newRecord.value));
186 + }
187 + },
188 + };
189 +
190 + const newRecord = {
191 + status: 'pending',
192 + value: thenable,
193 + };
194 + textCache.set(text, newRecord);
195 +
196 + throw thenable;
197 + }
198 + }
199 +
200 + function Text({text}) {
201 + return text;
202 + }
203 +
204 + function AsyncText({text}) {
205 + return readText(text);
206 + }
207 +
208 + // @gate experimental
209 + it('should render a fully static document, send it and then hydrate it', async () => {
210 + function App() {
211 + return (
212 + <div>
213 + <Suspense fallback={<Text text="Loading..." />}>
214 + <AsyncText text="Hello" />
215 + </Suspense>
216 + </div>
217 + );
218 + }
219 +
220 + const promise = ReactDOMFizzStatic.prerenderToNodeStreams(<App />);
221 +
222 + resolveText('Hello');
223 +
224 + const result = await promise;
225 +
226 + await act(async () => {
227 + result.prelude.pipe(writable);
228 + });
229 + expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
230 +
231 + await act(async () => {
232 + ReactDOMClient.hydrateRoot(container, <App />);
233 + });
234 +
235 + expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
236 + });
237 +});
packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js new
+412
@@ -0,0 +1,412 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its 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 +// Polyfills for test environment
13 +global.ReadableStream = require('web-streams-polyfill/ponyfill/es6').ReadableStream;
14 +global.TextEncoder = require('util').TextEncoder;
15 +
16 +let React;
17 +let ReactDOMFizzStatic;
18 +let Suspense;
19 +
20 +describe('ReactDOMFizzStaticBrowser', () => {
21 + beforeEach(() => {
22 + jest.resetModules();
23 + React = require('react');
24 + if (__EXPERIMENTAL__) {
25 + ReactDOMFizzStatic = require('react-dom/static.browser');
26 + }
27 + Suspense = React.Suspense;
28 + });
29 +
30 + const theError = new Error('This is an error');
31 + function Throw() {
32 + throw theError;
33 + }
34 + const theInfinitePromise = new Promise(() => {});
35 + function InfiniteSuspend() {
36 + throw theInfinitePromise;
37 + }
38 +
39 + async function readContent(stream) {
40 + const reader = stream.getReader();
41 + let content = '';
42 + while (true) {
43 + const {done, value} = await reader.read();
44 + if (done) {
45 + return content;
46 + }
47 + content += Buffer.from(value).toString('utf8');
48 + }
49 + }
50 +
51 + // @gate experimental
52 + it('should call prerender', async () => {
53 + const result = await ReactDOMFizzStatic.prerender(<div>hello world</div>);
54 + const prelude = await readContent(result.prelude);
55 + expect(prelude).toMatchInlineSnapshot(`"<div>hello world</div>"`);
56 + });
57 +
58 + // @gate experimental
59 + it('should emit DOCTYPE at the root of the document', async () => {
60 + const result = await ReactDOMFizzStatic.prerender(
61 + <html>
62 + <body>hello world</body>
63 + </html>,
64 + );
65 + const prelude = await readContent(result.prelude);
66 + expect(prelude).toMatchInlineSnapshot(
67 + `"<!DOCTYPE html><html><body>hello world</body></html>"`,
68 + );
69 + });
70 +
71 + // @gate experimental
72 + it('should emit bootstrap script src at the end', async () => {
73 + const result = await ReactDOMFizzStatic.prerender(<div>hello world</div>, {
74 + bootstrapScriptContent: 'INIT();',
75 + bootstrapScripts: ['init.js'],
76 + bootstrapModules: ['init.mjs'],
77 + });
78 + const prelude = await readContent(result.prelude);
79 + expect(prelude).toMatchInlineSnapshot(
80 + `"<div>hello world</div><script>INIT();</script><script src=\\"init.js\\" async=\\"\\"></script><script type=\\"module\\" src=\\"init.mjs\\" async=\\"\\"></script>"`,
81 + );
82 + });
83 +
84 + // @gate experimental
85 + it('emits all HTML as one unit', async () => {
86 + let hasLoaded = false;
87 + let resolve;
88 + const promise = new Promise(r => (resolve = r));
89 + function Wait() {
90 + if (!hasLoaded) {
91 + throw promise;
92 + }
93 + return 'Done';
94 + }
95 + const resultPromise = ReactDOMFizzStatic.prerender(
96 + <div>
97 + <Suspense fallback="Loading">
98 + <Wait />
99 + </Suspense>
100 + </div>,
101 + );
102 +
103 + await jest.runAllTimers();
104 +
105 + // Resolve the loading.
106 + hasLoaded = true;
107 + await resolve();
108 +
109 + const result = await resultPromise;
110 + const prelude = await readContent(result.prelude);
111 + expect(prelude).toMatchInlineSnapshot(
112 + `"<div><!--$-->Done<!-- --><!--/$--></div>"`,
113 + );
114 + });
115 +
116 + // @gate experimental
117 + it('should reject the promise when an error is thrown at the root', async () => {
118 + const reportedErrors = [];
119 + let caughtError = null;
120 + try {
121 + await ReactDOMFizzStatic.prerender(
122 + <div>
123 + <Throw />
124 + </div>,
125 + {
126 + onError(x) {
127 + reportedErrors.push(x);
128 + },
129 + },
130 + );
131 + } catch (error) {
132 + caughtError = error;
133 + }
134 + expect(caughtError).toBe(theError);
135 + expect(reportedErrors).toEqual([theError]);
136 + });
137 +
138 + // @gate experimental
139 + it('should reject the promise when an error is thrown inside a fallback', async () => {
140 + const reportedErrors = [];
141 + let caughtError = null;
142 + try {
143 + await ReactDOMFizzStatic.prerender(
144 + <div>
145 + <Suspense fallback={<Throw />}>
146 + <InfiniteSuspend />
147 + </Suspense>
148 + </div>,
149 + {
150 + onError(x) {
151 + reportedErrors.push(x);
152 + },
153 + },
154 + );
155 + } catch (error) {
156 + caughtError = error;
157 + }
158 + expect(caughtError).toBe(theError);
159 + expect(reportedErrors).toEqual([theError]);
160 + });
161 +
162 + // @gate experimental
163 + it('should not error the stream when an error is thrown inside suspense boundary', async () => {
164 + const reportedErrors = [];
165 + const result = await ReactDOMFizzStatic.prerender(
166 + <div>
167 + <Suspense fallback={<div>Loading</div>}>
168 + <Throw />
169 + </Suspense>
170 + </div>,
171 + {
172 + onError(x) {
173 + reportedErrors.push(x);
174 + },
175 + },
176 + );
177 +
178 + const prelude = await readContent(result.prelude);
179 + expect(prelude).toContain('Loading');
180 + expect(reportedErrors).toEqual([theError]);
181 + });
182 +
183 + // @gate experimental
184 + it('should be able to complete by aborting even if the promise never resolves', async () => {
185 + const errors = [];
186 + const controller = new AbortController();
187 + const resultPromise = ReactDOMFizzStatic.prerender(
188 + <div>
189 + <Suspense fallback={<div>Loading</div>}>
190 + <InfiniteSuspend />
191 + </Suspense>
192 + </div>,
193 + {
194 + signal: controller.signal,
195 + onError(x) {
196 + errors.push(x.message);
197 + },
198 + },
199 + );
200 +
201 + await jest.runAllTimers();
202 +
203 + controller.abort();
204 +
205 + const result = await resultPromise;
206 +
207 + const prelude = await readContent(result.prelude);
208 + expect(prelude).toContain('Loading');
209 +
210 + expect(errors).toEqual([
211 + 'The render was aborted by the server without a reason.',
212 + ]);
213 + });
214 +
215 + // @gate experimental
216 + it('should reject if aborting before the shell is complete', async () => {
217 + const errors = [];
218 + const controller = new AbortController();
219 + const promise = ReactDOMFizzStatic.prerender(
220 + <div>
221 + <InfiniteSuspend />
222 + </div>,
223 + {
224 + signal: controller.signal,
225 + onError(x) {
226 + errors.push(x.message);
227 + },
228 + },
229 + );
230 +
231 + await jest.runAllTimers();
232 +
233 + const theReason = new Error('aborted for reasons');
234 + // @TODO this is a hack to work around lack of support for abortSignal.reason in node
235 + // The abort call itself should set this property but since we are testing in node we
236 + // set it here manually
237 + controller.signal.reason = theReason;
238 + controller.abort(theReason);
239 +
240 + let caughtError = null;
241 + try {
242 + await promise;
243 + } catch (error) {
244 + caughtError = error;
245 + }
246 + expect(caughtError).toBe(theReason);
247 + expect(errors).toEqual(['aborted for reasons']);
248 + });
249 +
250 + // @gate experimental
251 + it('should be able to abort before something suspends', async () => {
252 + const errors = [];
253 + const controller = new AbortController();
254 + function App() {
255 + controller.abort();
256 + return (
257 + <Suspense fallback={<div>Loading</div>}>
258 + <InfiniteSuspend />
259 + </Suspense>
260 + );
261 + }
262 + const streamPromise = ReactDOMFizzStatic.prerender(
263 + <div>
264 + <App />
265 + </div>,
266 + {
267 + signal: controller.signal,
268 + onError(x) {
269 + errors.push(x.message);
270 + },
271 + },
272 + );
273 +
274 + let caughtError = null;
275 + try {
276 + await streamPromise;
277 + } catch (error) {
278 + caughtError = error;
279 + }
280 + expect(caughtError.message).toBe(
281 + 'The render was aborted by the server without a reason.',
282 + );
283 + expect(errors).toEqual([
284 + 'The render was aborted by the server without a reason.',
285 + ]);
286 + });
287 +
288 + // @gate experimental
289 + it('should reject if passing an already aborted signal', async () => {
290 + const errors = [];
291 + const controller = new AbortController();
292 + const theReason = new Error('aborted for reasons');
293 + // @TODO this is a hack to work around lack of support for abortSignal.reason in node
294 + // The abort call itself should set this property but since we are testing in node we
295 + // set it here manually
296 + controller.signal.reason = theReason;
297 + controller.abort(theReason);
298 +
299 + const promise = ReactDOMFizzStatic.prerender(
300 + <div>
301 + <Suspense fallback={<div>Loading</div>}>
302 + <InfiniteSuspend />
303 + </Suspense>
304 + </div>,
305 + {
306 + signal: controller.signal,
307 + onError(x) {
308 + errors.push(x.message);
309 + },
310 + },
311 + );
312 +
313 + // Technically we could still continue rendering the shell but currently the
314 + // semantics mean that we also abort any pending CPU work.
315 + let caughtError = null;
316 + try {
317 + await promise;
318 + } catch (error) {
319 + caughtError = error;
320 + }
321 + expect(caughtError).toBe(theReason);
322 + expect(errors).toEqual(['aborted for reasons']);
323 + });
324 +
325 + // @gate experimental
326 + it('supports custom abort reasons with a string', async () => {
327 + const promise = new Promise(r => {});
328 + function Wait() {
329 + throw promise;
330 + }
331 + function App() {
332 + return (
333 + <div>
334 + <p>
335 + <Suspense fallback={'p'}>
336 + <Wait />
337 + </Suspense>
338 + </p>
339 + <span>
340 + <Suspense fallback={'span'}>
341 + <Wait />
342 + </Suspense>
343 + </span>
344 + </div>
345 + );
346 + }
347 +
348 + const errors = [];
349 + const controller = new AbortController();
350 + const resultPromise = ReactDOMFizzStatic.prerender(<App />, {
351 + signal: controller.signal,
352 + onError(x) {
353 + errors.push(x);
354 + return 'a digest';
355 + },
356 + });
357 +
358 + // @TODO this is a hack to work around lack of support for abortSignal.reason in node
359 + // The abort call itself should set this property but since we are testing in node we
360 + // set it here manually
361 + controller.signal.reason = 'foobar';
362 + controller.abort('foobar');
363 +
364 + await resultPromise;
365 +
366 + expect(errors).toEqual(['foobar', 'foobar']);
367 + });
368 +
369 + // @gate experimental
370 + it('supports custom abort reasons with an Error', async () => {
371 + const promise = new Promise(r => {});
372 + function Wait() {
373 + throw promise;
374 + }
375 + function App() {
376 + return (
377 + <div>
378 + <p>
379 + <Suspense fallback={'p'}>
380 + <Wait />
381 + </Suspense>
382 + </p>
383 + <span>
384 + <Suspense fallback={'span'}>
385 + <Wait />
386 + </Suspense>
387 + </span>
388 + </div>
389 + );
390 + }
391 +
392 + const errors = [];
393 + const controller = new AbortController();
394 + const resultPromise = ReactDOMFizzStatic.prerender(<App />, {
395 + signal: controller.signal,
396 + onError(x) {
397 + errors.push(x.message);
398 + return 'a digest';
399 + },
400 + });
401 +
402 + // @TODO this is a hack to work around lack of support for abortSignal.reason in node
403 + // The abort call itself should set this property but since we are testing in node we
404 + // set it here manually
405 + controller.signal.reason = new Error('uh oh');
406 + controller.abort(new Error('uh oh'));
407 +
408 + await resultPromise;
409 +
410 + expect(errors).toEqual(['uh oh', 'uh oh']);
411 + });
412 +});
packages/react-dom/src/__tests__/ReactDOMFizzStaticNode-test.js new
+421
@@ -0,0 +1,421 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its 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 +// TODO: This should actually run in `@jest-environment node` but we currently
11 +// run an old jest that doesn't support AbortController so we use DOM for now.
12 +
13 +'use strict';
14 +
15 +let React;
16 +let ReactDOMFizzStatic;
17 +let Suspense;
18 +
19 +describe('ReactDOMFizzStaticNode', () => {
20 + beforeEach(() => {
21 + jest.resetModules();
22 + React = require('react');
23 + if (__EXPERIMENTAL__) {
24 + ReactDOMFizzStatic = require('react-dom/static');
25 + }
26 + Suspense = React.Suspense;
27 + });
28 +
29 + const theError = new Error('This is an error');
30 + function Throw() {
31 + throw theError;
32 + }
33 + const theInfinitePromise = new Promise(() => {});
34 + function InfiniteSuspend() {
35 + throw theInfinitePromise;
36 + }
37 +
38 + function readContent(readable) {
39 + return new Promise((resolve, reject) => {
40 + let content = '';
41 + readable.on('data', chunk => {
42 + content += Buffer.from(chunk).toString('utf8');
43 + });
44 + readable.on('error', error => {
45 + reject(error);
46 + });
47 + readable.on('end', () => resolve(content));
48 + });
49 + }
50 +
51 + // @gate experimental
52 + it('should call prerenderToNodeStreams', async () => {
53 + const result = await ReactDOMFizzStatic.prerenderToNodeStreams(
54 + <div>hello world</div>,
55 + );
56 + const prelude = await readContent(result.prelude);
57 + expect(prelude).toMatchInlineSnapshot(`"<div>hello world</div>"`);
58 + });
59 +
60 + // @gate experimental
61 + it('should emit DOCTYPE at the root of the document', async () => {
62 + const result = await ReactDOMFizzStatic.prerenderToNodeStreams(
63 + <html>
64 + <body>hello world</body>
65 + </html>,
66 + );
67 + const prelude = await readContent(result.prelude);
68 + expect(prelude).toMatchInlineSnapshot(
69 + `"<!DOCTYPE html><html><body>hello world</body></html>"`,
70 + );
71 + });
72 +
73 + // @gate experimental
74 + it('should emit bootstrap script src at the end', async () => {
75 + const result = await ReactDOMFizzStatic.prerenderToNodeStreams(
76 + <div>hello world</div>,
77 + {
78 + bootstrapScriptContent: 'INIT();',
79 + bootstrapScripts: ['init.js'],
80 + bootstrapModules: ['init.mjs'],
81 + },
82 + );
83 + const prelude = await readContent(result.prelude);
84 + expect(prelude).toMatchInlineSnapshot(
85 + `"<div>hello world</div><script>INIT();</script><script src=\\"init.js\\" async=\\"\\"></script><script type=\\"module\\" src=\\"init.mjs\\" async=\\"\\"></script>"`,
86 + );
87 + });
88 +
89 + // @gate experimental
90 + it('emits all HTML as one unit', async () => {
91 + let hasLoaded = false;
92 + let resolve;
93 + const promise = new Promise(r => (resolve = r));
94 + function Wait() {
95 + if (!hasLoaded) {
96 + throw promise;
97 + }
98 + return 'Done';
99 + }
100 + const resultPromise = ReactDOMFizzStatic.prerenderToNodeStreams(
101 + <div>
102 + <Suspense fallback="Loading">
103 + <Wait />
104 + </Suspense>
105 + </div>,
106 + );
107 +
108 + await jest.runAllTimers();
109 +
110 + // Resolve the loading.
111 + hasLoaded = true;
112 + await resolve();
113 +
114 + const result = await resultPromise;
115 + const prelude = await readContent(result.prelude);
116 + expect(prelude).toMatchInlineSnapshot(
117 + `"<div><!--$-->Done<!-- --><!--/$--></div>"`,
118 + );
119 + });
120 +
121 + // @gate experimental
122 + it('should reject the promise when an error is thrown at the root', async () => {
123 + const reportedErrors = [];
124 + let caughtError = null;
125 + try {
126 + await ReactDOMFizzStatic.prerenderToNodeStreams(
127 + <div>
128 + <Throw />
129 + </div>,
130 + {
131 + onError(x) {
132 + reportedErrors.push(x);
133 + },
134 + },
135 + );
136 + } catch (error) {
137 + caughtError = error;
138 + }
139 + expect(caughtError).toBe(theError);
140 + expect(reportedErrors).toEqual([theError]);
141 + });
142 +
143 + // @gate experimental
144 + it('should reject the promise when an error is thrown inside a fallback', async () => {
145 + const reportedErrors = [];
146 + let caughtError = null;
147 + try {
148 + await ReactDOMFizzStatic.prerenderToNodeStreams(
149 + <div>
150 + <Suspense fallback={<Throw />}>
151 + <InfiniteSuspend />
152 + </Suspense>
153 + </div>,
154 + {
155 + onError(x) {
156 + reportedErrors.push(x);
157 + },
158 + },
159 + );
160 + } catch (error) {
161 + caughtError = error;
162 + }
163 + expect(caughtError).toBe(theError);
164 + expect(reportedErrors).toEqual([theError]);
165 + });
166 +
167 + // @gate experimental
168 + it('should not error the stream when an error is thrown inside suspense boundary', async () => {
169 + const reportedErrors = [];
170 + const result = await ReactDOMFizzStatic.prerenderToNodeStreams(
171 + <div>
172 + <Suspense fallback={<div>Loading</div>}>
173 + <Throw />
174 + </Suspense>
175 + </div>,
176 + {
177 + onError(x) {
178 + reportedErrors.push(x);
179 + },
180 + },
181 + );
182 +
183 + const prelude = await readContent(result.prelude);
184 + expect(prelude).toContain('Loading');
185 + expect(reportedErrors).toEqual([theError]);
186 + });
187 +
188 + // @gate experimental
189 + it('should be able to complete by aborting even if the promise never resolves', async () => {
190 + const errors = [];
191 + const controller = new AbortController();
192 + const resultPromise = ReactDOMFizzStatic.prerenderToNodeStreams(
193 + <div>
194 + <Suspense fallback={<div>Loading</div>}>
195 + <InfiniteSuspend />
196 + </Suspense>
197 + </div>,
198 + {
199 + signal: controller.signal,
200 + onError(x) {
201 + errors.push(x.message);
202 + },
203 + },
204 + );
205 +
206 + await jest.runAllTimers();
207 +
208 + controller.abort();
209 +
210 + const result = await resultPromise;
211 +
212 + const prelude = await readContent(result.prelude);
213 + expect(prelude).toContain('Loading');
214 +
215 + expect(errors).toEqual([
216 + 'The render was aborted by the server without a reason.',
217 + ]);
218 + });
219 +
220 + // @gate experimental
221 + it('should reject if aborting before the shell is complete', async () => {
222 + const errors = [];
223 + const controller = new AbortController();
224 + const promise = ReactDOMFizzStatic.prerenderToNodeStreams(
225 + <div>
226 + <InfiniteSuspend />
227 + </div>,
228 + {
229 + signal: controller.signal,
230 + onError(x) {
231 + errors.push(x.message);
232 + },
233 + },
234 + );
235 +
236 + await jest.runAllTimers();
237 +
238 + const theReason = new Error('aborted for reasons');
239 + // @TODO this is a hack to work around lack of support for abortSignal.reason in node
240 + // The abort call itself should set this property but since we are testing in node we
241 + // set it here manually
242 + controller.signal.reason = theReason;
243 + controller.abort(theReason);
244 +
245 + let caughtError = null;
246 + try {
247 + await promise;
248 + } catch (error) {
249 + caughtError = error;
250 + }
251 + expect(caughtError).toBe(theReason);
252 + expect(errors).toEqual(['aborted for reasons']);
253 + });
254 +
255 + // @gate experimental
256 + it('should be able to abort before something suspends', async () => {
257 + const errors = [];
258 + const controller = new AbortController();
259 + function App() {
260 + controller.abort();
261 + return (
262 + <Suspense fallback={<div>Loading</div>}>
263 + <InfiniteSuspend />
264 + </Suspense>
265 + );
266 + }
267 + const streamPromise = ReactDOMFizzStatic.prerenderToNodeStreams(
268 + <div>
269 + <App />
270 + </div>,
271 + {
272 + signal: controller.signal,
273 + onError(x) {
274 + errors.push(x.message);
275 + },
276 + },
277 + );
278 +
279 + let caughtError = null;
280 + try {
281 + await streamPromise;
282 + } catch (error) {
283 + caughtError = error;
284 + }
285 + expect(caughtError.message).toBe(
286 + 'The render was aborted by the server without a reason.',
287 + );
288 + expect(errors).toEqual([
289 + 'The render was aborted by the server without a reason.',
290 + ]);
291 + });
292 +
293 + // @gate experimental
294 + it('should reject if passing an already aborted signal', async () => {
295 + const errors = [];
296 + const controller = new AbortController();
297 + const theReason = new Error('aborted for reasons');
298 + // @TODO this is a hack to work around lack of support for abortSignal.reason in node
299 + // The abort call itself should set this property but since we are testing in node we
300 + // set it here manually
301 + controller.signal.reason = theReason;
302 + controller.abort(theReason);
303 +
304 + const promise = ReactDOMFizzStatic.prerenderToNodeStreams(
305 + <div>
306 + <Suspense fallback={<div>Loading</div>}>
307 + <InfiniteSuspend />
308 + </Suspense>
309 + </div>,
310 + {
311 + signal: controller.signal,
312 + onError(x) {
313 + errors.push(x.message);
314 + },
315 + },
316 + );
317 +
318 + // Technically we could still continue rendering the shell but currently the
319 + // semantics mean that we also abort any pending CPU work.
320 + let caughtError = null;
321 + try {
322 + await promise;
323 + } catch (error) {
324 + caughtError = error;
325 + }
326 + expect(caughtError).toBe(theReason);
327 + expect(errors).toEqual(['aborted for reasons']);
328 + });
329 +
330 + // @gate experimental
331 + it('supports custom abort reasons with a string', async () => {
332 + const promise = new Promise(r => {});
333 + function Wait() {
334 + throw promise;
335 + }
336 + function App() {
337 + return (
338 + <div>
339 + <p>
340 + <Suspense fallback={'p'}>
341 + <Wait />
342 + </Suspense>
343 + </p>
344 + <span>
345 + <Suspense fallback={'span'}>
346 + <Wait />
347 + </Suspense>
348 + </span>
349 + </div>
350 + );
351 + }
352 +
353 + const errors = [];
354 + const controller = new AbortController();
355 + const resultPromise = ReactDOMFizzStatic.prerenderToNodeStreams(<App />, {
356 + signal: controller.signal,
357 + onError(x) {
358 + errors.push(x);
359 + return 'a digest';
360 + },
361 + });
362 +
363 + await jest.runAllTimers();
364 +
365 + // @TODO this is a hack to work around lack of support for abortSignal.reason in node
366 + // The abort call itself should set this property but since we are testing in node we
367 + // set it here manually
368 + controller.signal.reason = 'foobar';
369 + controller.abort('foobar');
370 +
371 + await resultPromise;
372 +
373 + expect(errors).toEqual(['foobar', 'foobar']);
374 + });
375 +
376 + // @gate experimental
377 + it('supports custom abort reasons with an Error', async () => {
378 + const promise = new Promise(r => {});
379 + function Wait() {
380 + throw promise;
381 + }
382 + function App() {
383 + return (
384 + <div>
385 + <p>
386 + <Suspense fallback={'p'}>
387 + <Wait />
388 + </Suspense>
389 + </p>
390 + <span>
391 + <Suspense fallback={'span'}>
392 + <Wait />
393 + </Suspense>
394 + </span>
395 + </div>
396 + );
397 + }
398 +
399 + const errors = [];
400 + const controller = new AbortController();
401 + const resultPromise = ReactDOMFizzStatic.prerenderToNodeStreams(<App />, {
402 + signal: controller.signal,
403 + onError(x) {
404 + errors.push(x.message);
405 + return 'a digest';
406 + },
407 + });
408 +
409 + await jest.runAllTimers();
410 +
411 + // @TODO this is a hack to work around lack of support for abortSignal.reason in node
412 + // The abort call itself should set this property but since we are testing in node we
413 + // set it here manually
414 + controller.signal.reason = new Error('uh oh');
415 + controller.abort(new Error('uh oh'));
416 +
417 + await resultPromise;
418 +
419 + expect(errors).toEqual(['uh oh', 'uh oh']);
420 + });
421 +});
packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js new
+98
@@ -0,0 +1,98 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its 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 + * @flow
8 + */
9 +
10 +import type {ReactNodeList} from 'shared/ReactTypes';
11 +
12 +import ReactVersion from 'shared/ReactVersion';
13 +
14 +import {
15 + createRequest,
16 + startWork,
17 + startFlowing,
18 + abort,
19 +} from 'react-server/src/ReactFizzServer';
20 +
21 +import {
22 + createResponseState,
23 + createRootFormatContext,
24 +} from './ReactDOMServerFormatConfig';
25 +
26 +type Options = {|
27 + identifierPrefix?: string,
28 + namespaceURI?: string,
29 + bootstrapScriptContent?: string,
30 + bootstrapScripts?: Array<string>,
31 + bootstrapModules?: Array<string>,
32 + progressiveChunkSize?: number,
33 + signal?: AbortSignal,
34 + onError?: (error: mixed) => ?string,
35 +|};
36 +
37 +type StaticResult = {|
38 + prelude: ReadableStream,
39 +|};
40 +
41 +function prerender(
42 + children: ReactNodeList,
43 + options?: Options,
44 +): Promise<StaticResult> {
45 + return new Promise((resolve, reject) => {
46 + const onFatalError = reject;
47 +
48 + function onAllReady() {
49 + const stream = new ReadableStream(
50 + {
51 + type: 'bytes',
52 + pull(controller) {
53 + startFlowing(request, controller);
54 + },
55 + },
56 + // $FlowFixMe size() methods are not allowed on byte streams.
57 + {highWaterMark: 0},
58 + );
59 +
60 + const result = {
61 + prelude: stream,
62 + };
63 + resolve(result);
64 + }
65 + const request = createRequest(
66 + children,
67 + createResponseState(
68 + options ? options.identifierPrefix : undefined,
69 + undefined,
70 + options ? options.bootstrapScriptContent : undefined,
71 + options ? options.bootstrapScripts : undefined,
72 + options ? options.bootstrapModules : undefined,
73 + ),
74 + createRootFormatContext(options ? options.namespaceURI : undefined),
75 + options ? options.progressiveChunkSize : undefined,
76 + options ? options.onError : undefined,
77 + onAllReady,
78 + undefined,
79 + undefined,
80 + onFatalError,
81 + );
82 + if (options && options.signal) {
83 + const signal = options.signal;
84 + if (signal.aborted) {
85 + abort(request, (signal: any).reason);
86 + } else {
87 + const listener = () => {
88 + abort(request, (signal: any).reason);
89 + signal.removeEventListener('abort', listener);
90 + };
91 + signal.addEventListener('abort', listener);
92 + }
93 + }
94 + startWork(request);
95 + });
96 +}
97 +
98 +export {prerender, ReactVersion as version};
packages/react-dom/src/server/ReactDOMFizzStaticNode.js new
+112
@@ -0,0 +1,112 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its 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 + * @flow
8 + */
9 +
10 +import type {ReactNodeList} from 'shared/ReactTypes';
11 +import {Writable, Readable} from 'stream';
12 +
13 +import ReactVersion from 'shared/ReactVersion';
14 +
15 +import {
16 + createRequest,
17 + startWork,
18 + startFlowing,
19 + abort,
20 +} from 'react-server/src/ReactFizzServer';
21 +
22 +import {
23 + createResponseState,
24 + createRootFormatContext,
25 +} from './ReactDOMServerFormatConfig';
26 +
27 +type Options = {|
28 + identifierPrefix?: string,
29 + namespaceURI?: string,
30 + bootstrapScriptContent?: string,
31 + bootstrapScripts?: Array<string>,
32 + bootstrapModules?: Array<string>,
33 + progressiveChunkSize?: number,
34 + signal?: AbortSignal,
35 + onError?: (error: mixed) => ?string,
36 +|};
37 +
38 +type StaticResult = {|
39 + prelude: Readable,
40 +|};
41 +
42 +function createFakeWritable(readable): Writable {
43 + // The current host config expects a Writable so we create
44 + // a fake writable for now to push into the Readable.
45 + return ({
46 + write(chunk) {
47 + return readable.push(chunk);
48 + },
49 + end() {
50 + readable.push(null);
51 + },
52 + destroy(error) {
53 + readable.destroy(error);
54 + },
55 + }: any);
56 +}
57 +
58 +function prerenderToNodeStreams(
59 + children: ReactNodeList,
60 + options?: Options,
61 +): Promise<StaticResult> {
62 + return new Promise((resolve, reject) => {
63 + const onFatalError = reject;
64 +
65 + function onAllReady() {
66 + const readable = new Readable({
67 + read() {
68 + startFlowing(request, writable);
69 + },
70 + });
71 + const writable = createFakeWritable(readable);
72 +
73 + const result = {
74 + prelude: readable,
75 + };
76 + resolve(result);
77 + }
78 +
79 + const request = createRequest(
80 + children,
81 + createResponseState(
82 + options ? options.identifierPrefix : undefined,
83 + undefined,
84 + options ? options.bootstrapScriptContent : undefined,
85 + options ? options.bootstrapScripts : undefined,
86 + options ? options.bootstrapModules : undefined,
87 + ),
88 + createRootFormatContext(options ? options.namespaceURI : undefined),
89 + options ? options.progressiveChunkSize : undefined,
90 + options ? options.onError : undefined,
91 + onAllReady,
92 + undefined,
93 + undefined,
94 + onFatalError,
95 + );
96 + if (options && options.signal) {
97 + const signal = options.signal;
98 + if (signal.aborted) {
99 + abort(request, (signal: any).reason);
100 + } else {
101 + const listener = () => {
102 + abort(request, (signal: any).reason);
103 + signal.removeEventListener('abort', listener);
104 + };
105 + signal.addEventListener('abort', listener);
106 + }
107 + }
108 + startWork(request);
109 + });
110 +}
111 +
112 +export {prerenderToNodeStreams, ReactVersion as version};
packages/react-dom/static.browser.js new
+10
@@ -0,0 +1,10 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its 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 + * @flow
8 + */
9 +
10 +export {prerender, version} from './src/server/ReactDOMFizzStaticBrowser';
packages/react-dom/static.js new
+10
@@ -0,0 +1,10 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its 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 + * @flow
8 + */
9 +
10 +export * from './static.node';
packages/react-dom/static.node.js new
+13
@@ -0,0 +1,13 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its 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 + * @flow
8 + */
9 +
10 +export {
11 + prerenderToNodeStreams,
12 + version,
13 +} from './src/server/ReactDOMFizzStaticNode';
scripts/rollup/bundles.js
+21
@@ -330,6 +330,27 @@ const bundles = [
330 externals: ['react'],
331 },
332
333 + /******* React DOM Fizz Static *******/
334 + {
335 + bundleTypes: __EXPERIMENTAL__ ? [NODE_DEV, NODE_PROD] : [],
336 + moduleType: RENDERER,
337 + entry: 'react-dom/static.browser',
338 + global: 'ReactDOMStatic',
339 + minifyWithProdErrorCodes: true,
340 + wrapWithModuleBoundaries: false,
341 + externals: ['react'],
342 + },
343 + {
344 + bundleTypes: __EXPERIMENTAL__ ? [NODE_DEV, NODE_PROD] : [],
345 + moduleType: RENDERER,
346 + entry: 'react-dom/static.node',
347 + name: 'react-dom-static.node',
348 + global: 'ReactDOMStatic',
349 + minifyWithProdErrorCodes: false,
350 + wrapWithModuleBoundaries: false,
351 + externals: ['react', 'util', 'stream'],
352 + },
353 +
354 /******* React Server DOM Webpack Writer *******/
355 {
356 bundleTypes: [NODE_DEV, NODE_PROD, UMD_DEV, UMD_PROD],
scripts/rollup/modules.js
+1
@@ -12,6 +12,7 @@ const importSideEffects = Object.freeze({
12 fs: HAS_NO_SIDE_EFFECTS_ON_IMPORT,
13 'fs/promises': HAS_NO_SIDE_EFFECTS_ON_IMPORT,
14 path: HAS_NO_SIDE_EFFECTS_ON_IMPORT,
15 + stream: HAS_NO_SIDE_EFFECTS_ON_IMPORT,
16 'prop-types/checkPropTypes': HAS_NO_SIDE_EFFECTS_ON_IMPORT,
17 'react-native/Libraries/ReactPrivate/ReactNativePrivateInterface': HAS_NO_SIDE_EFFECTS_ON_IMPORT,
18 scheduler: HAS_NO_SIDE_EFFECTS_ON_IMPORT,
scripts/rollup/packaging.js
+4
@@ -153,6 +153,7 @@ function filterOutEntrypoints(name) {
153 let packageJSON = JSON.parse(readFileSync(jsonPath));
154 let files = packageJSON.files;
155 let exportsJSON = packageJSON.exports;
156 + let browserJSON = packageJSON.browser;
157 if (!Array.isArray(files)) {
158 throw new Error('expected all package.json files to contain a files field');
159 }
@@ -189,6 +190,9 @@ function filterOutEntrypoints(name) {
190 delete exportsJSON['./' + filename.replace(/\.js$/, '')];
191 }
192 }
193 + if (browserJSON) {
194 + delete browserJSON['./' + filename];
195 + }
196 }
197
198 // We only export the source directory so Jest and Rollup can access them
scripts/shared/inlinedHostConfigs.js
+10
@@ -13,13 +13,19 @@ module.exports = [
13 'react-dom',
14 'react-dom/unstable_testing',
15 'react-dom/src/server/ReactDOMFizzServerNode.js',
16 + 'react-dom/static.node',
17 'react-server-dom-webpack/writer.node.server',
18 'react-server-dom-webpack',
19 ],
20 paths: [
21 'react-dom',
22 'react-dom/client',
23 + 'react-dom/server',
24 + 'react-dom/server.node',
25 + 'react-dom/static',
26 + 'react-dom/static.node',
27 'react-dom/src/server/ReactDOMFizzServerNode.js', // react-dom/server.node
28 + 'react-dom/src/server/ReactDOMFizzStaticNode.js',
29 'react-server-dom-webpack',
30 'react-server-dom-webpack/writer',
31 'react-server-dom-webpack/writer.node.server',
@@ -40,14 +46,18 @@ module.exports = [
46 'react-dom',
47 'react-dom/unstable_testing',
48 'react-dom/src/server/ReactDOMFizzServerBrowser.js',
49 + 'react-dom/static.browser',
50 'react-server-dom-webpack/writer.browser.server',
51 'react-server-dom-webpack',
52 ],
53 paths: [
54 'react-dom',
55 'react-dom/client',
56 + 'react-dom/server.browser',
57 + 'react-dom/static.browser',
58 'react-dom/unstable_testing',
59 'react-dom/src/server/ReactDOMFizzServerBrowser.js', // react-dom/server.browser
60 + 'react-dom/src/server/ReactDOMFizzStaticBrowser.js',
61 'react-server-dom-webpack',
62 'react-server-dom-webpack/writer.browser.server',
63 'react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js', // react-server-dom-webpack/writer.browser.server