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