From 0f216ae31d91f882134707af99d5da9c01e1f603 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Sat, 18 Jun 2022 22:34:31 -0400 Subject: [PATCH] Add entry points for "static" server rendering passes (#24752) This will be used to add optimizations for static server rendering. --- packages/react-dom/npm/static.browser.js | 7 + packages/react-dom/npm/static.js | 3 + packages/react-dom/npm/static.node.js | 7 + packages/react-dom/package.json | 14 +- .../src/__tests__/ReactDOMFizzStatic-test.js | 237 ++++++++++ .../ReactDOMFizzStaticBrowser-test.js | 412 +++++++++++++++++ .../__tests__/ReactDOMFizzStaticNode-test.js | 421 ++++++++++++++++++ .../src/server/ReactDOMFizzStaticBrowser.js | 98 ++++ .../src/server/ReactDOMFizzStaticNode.js | 112 +++++ packages/react-dom/static.browser.js | 10 + packages/react-dom/static.js | 10 + packages/react-dom/static.node.js | 13 + scripts/rollup/bundles.js | 21 + scripts/rollup/modules.js | 1 + scripts/rollup/packaging.js | 4 + scripts/shared/inlinedHostConfigs.js | 10 + 16 files changed, 1379 insertions(+), 1 deletion(-) create mode 100644 packages/react-dom/npm/static.browser.js create mode 100644 packages/react-dom/npm/static.js create mode 100644 packages/react-dom/npm/static.node.js create mode 100644 packages/react-dom/src/__tests__/ReactDOMFizzStatic-test.js create mode 100644 packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js create mode 100644 packages/react-dom/src/__tests__/ReactDOMFizzStaticNode-test.js create mode 100644 packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js create mode 100644 packages/react-dom/src/server/ReactDOMFizzStaticNode.js create mode 100644 packages/react-dom/static.browser.js create mode 100644 packages/react-dom/static.js create mode 100644 packages/react-dom/static.node.js diff --git a/packages/react-dom/npm/static.browser.js b/packages/react-dom/npm/static.browser.js new file mode 100644 index 0000000000000..7212cd1f6a00c --- /dev/null +++ b/packages/react-dom/npm/static.browser.js @@ -0,0 +1,7 @@ +'use strict'; + +if (process.env.NODE_ENV === 'production') { + module.exports = require('./cjs/react-dom-static.browser.production.min.js'); +} else { + module.exports = require('./cjs/react-dom-static.browser.development.js'); +} diff --git a/packages/react-dom/npm/static.js b/packages/react-dom/npm/static.js new file mode 100644 index 0000000000000..2e39a9b183d06 --- /dev/null +++ b/packages/react-dom/npm/static.js @@ -0,0 +1,3 @@ +'use strict'; + +module.exports = require('./static.node'); diff --git a/packages/react-dom/npm/static.node.js b/packages/react-dom/npm/static.node.js new file mode 100644 index 0000000000000..266e8c9116707 --- /dev/null +++ b/packages/react-dom/npm/static.node.js @@ -0,0 +1,7 @@ +'use strict'; + +if (process.env.NODE_ENV === 'production') { + module.exports = require('./cjs/react-dom-static.node.production.min.js'); +} else { + module.exports = require('./cjs/react-dom-static.node.development.js'); +} diff --git a/packages/react-dom/package.json b/packages/react-dom/package.json index 86993240c72ce..ca84673501ffd 100644 --- a/packages/react-dom/package.json +++ b/packages/react-dom/package.json @@ -32,6 +32,9 @@ "server.js", "server.browser.js", "server.node.js", + "static.js", + "static.browser.js", + "static.node.js", "test-utils.js", "unstable_testing.js", "cjs/", @@ -48,6 +51,14 @@ }, "./server.browser": "./server.browser.js", "./server.node": "./server.node.js", + "./static": { + "deno": "./static.browser.js", + "worker": "./static.browser.js", + "browser": "./static.browser.js", + "default": "./static.node.js" + }, + "./static.browser": "./static.browser.js", + "./static.node": "./static.node.js", "./profiling": "./profiling.js", "./test-utils": "./test-utils.js", "./unstable_testing": "./unstable_testing.js", @@ -55,7 +66,8 @@ "./package.json": "./package.json" }, "browser": { - "./server.js": "./server.browser.js" + "./server.js": "./server.browser.js", + "./static.js": "./static.browser.js" }, "browserify": { "transform": [ diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzStatic-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzStatic-test.js new file mode 100644 index 0000000000000..b4d0158f9ae6f --- /dev/null +++ b/packages/react-dom/src/__tests__/ReactDOMFizzStatic-test.js @@ -0,0 +1,237 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + */ + +'use strict'; + +let JSDOM; +let Stream; +let React; +let ReactDOMClient; +let ReactDOMFizzStatic; +let Suspense; +let textCache; +let document; +let writable; +let container; +let buffer = ''; +let hasErrored = false; +let fatalError = undefined; + +describe('ReactDOMFizzStatic', () => { + beforeEach(() => { + jest.resetModules(); + JSDOM = require('jsdom').JSDOM; + React = require('react'); + ReactDOMClient = require('react-dom/client'); + if (__EXPERIMENTAL__) { + ReactDOMFizzStatic = require('react-dom/static'); + } + Stream = require('stream'); + Suspense = React.Suspense; + + textCache = new Map(); + + // Test Environment + const jsdom = new JSDOM( + '
', + { + runScripts: 'dangerously', + }, + ); + document = jsdom.window.document; + container = document.getElementById('container'); + + buffer = ''; + hasErrored = false; + + writable = new Stream.PassThrough(); + writable.setEncoding('utf8'); + writable.on('data', chunk => { + buffer += chunk; + }); + writable.on('error', error => { + hasErrored = true; + fatalError = error; + }); + }); + + async function act(callback) { + await callback(); + // Await one turn around the event loop. + // This assumes that we'll flush everything we have so far. + await new Promise(resolve => { + setImmediate(resolve); + }); + if (hasErrored) { + throw fatalError; + } + // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment. + // We also want to execute any scripts that are embedded. + // We assume that we have now received a proper fragment of HTML. + const bufferedContent = buffer; + buffer = ''; + const fakeBody = document.createElement('body'); + fakeBody.innerHTML = bufferedContent; + while (fakeBody.firstChild) { + const node = fakeBody.firstChild; + if (node.nodeName === 'SCRIPT') { + const script = document.createElement('script'); + script.textContent = node.textContent; + fakeBody.removeChild(node); + container.appendChild(script); + } else { + container.appendChild(node); + } + } + } + + function getVisibleChildren(element) { + const children = []; + let node = element.firstChild; + while (node) { + if (node.nodeType === 1) { + if ( + node.tagName !== 'SCRIPT' && + node.tagName !== 'TEMPLATE' && + node.tagName !== 'template' && + !node.hasAttribute('hidden') && + !node.hasAttribute('aria-hidden') + ) { + const props = {}; + const attributes = node.attributes; + for (let i = 0; i < attributes.length; i++) { + if ( + attributes[i].name === 'id' && + attributes[i].value.includes(':') + ) { + // We assume this is a React added ID that's a non-visual implementation detail. + continue; + } + props[attributes[i].name] = attributes[i].value; + } + props.children = getVisibleChildren(node); + children.push(React.createElement(node.tagName.toLowerCase(), props)); + } + } else if (node.nodeType === 3) { + children.push(node.data); + } + node = node.nextSibling; + } + return children.length === 0 + ? undefined + : children.length === 1 + ? children[0] + : children; + } + + function resolveText(text) { + const record = textCache.get(text); + if (record === undefined) { + const newRecord = { + status: 'resolved', + value: text, + }; + textCache.set(text, newRecord); + } else if (record.status === 'pending') { + const thenable = record.value; + record.status = 'resolved'; + record.value = text; + thenable.pings.forEach(t => t()); + } + } + + /* + function rejectText(text, error) { + const record = textCache.get(text); + if (record === undefined) { + const newRecord = { + status: 'rejected', + value: error, + }; + textCache.set(text, newRecord); + } else if (record.status === 'pending') { + const thenable = record.value; + record.status = 'rejected'; + record.value = error; + thenable.pings.forEach(t => t()); + } + } + */ + + function readText(text) { + const record = textCache.get(text); + if (record !== undefined) { + switch (record.status) { + case 'pending': + throw record.value; + case 'rejected': + throw record.value; + case 'resolved': + return record.value; + } + } else { + const thenable = { + pings: [], + then(resolve) { + if (newRecord.status === 'pending') { + thenable.pings.push(resolve); + } else { + Promise.resolve().then(() => resolve(newRecord.value)); + } + }, + }; + + const newRecord = { + status: 'pending', + value: thenable, + }; + textCache.set(text, newRecord); + + throw thenable; + } + } + + function Text({text}) { + return text; + } + + function AsyncText({text}) { + return readText(text); + } + + // @gate experimental + it('should render a fully static document, send it and then hydrate it', async () => { + function App() { + return ( +
+ }> + + +
+ ); + } + + const promise = ReactDOMFizzStatic.prerenderToNodeStreams(); + + resolveText('Hello'); + + const result = await promise; + + await act(async () => { + result.prelude.pipe(writable); + }); + expect(getVisibleChildren(container)).toEqual(
Hello
); + + await act(async () => { + ReactDOMClient.hydrateRoot(container, ); + }); + + expect(getVisibleChildren(container)).toEqual(
Hello
); + }); +}); diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js new file mode 100644 index 0000000000000..bd180ae7ce9c1 --- /dev/null +++ b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js @@ -0,0 +1,412 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + */ + +'use strict'; + +// Polyfills for test environment +global.ReadableStream = require('web-streams-polyfill/ponyfill/es6').ReadableStream; +global.TextEncoder = require('util').TextEncoder; + +let React; +let ReactDOMFizzStatic; +let Suspense; + +describe('ReactDOMFizzStaticBrowser', () => { + beforeEach(() => { + jest.resetModules(); + React = require('react'); + if (__EXPERIMENTAL__) { + ReactDOMFizzStatic = require('react-dom/static.browser'); + } + Suspense = React.Suspense; + }); + + const theError = new Error('This is an error'); + function Throw() { + throw theError; + } + const theInfinitePromise = new Promise(() => {}); + function InfiniteSuspend() { + throw theInfinitePromise; + } + + async function readContent(stream) { + const reader = stream.getReader(); + let content = ''; + while (true) { + const {done, value} = await reader.read(); + if (done) { + return content; + } + content += Buffer.from(value).toString('utf8'); + } + } + + // @gate experimental + it('should call prerender', async () => { + const result = await ReactDOMFizzStatic.prerender(
hello world
); + const prelude = await readContent(result.prelude); + expect(prelude).toMatchInlineSnapshot(`"
hello world
"`); + }); + + // @gate experimental + it('should emit DOCTYPE at the root of the document', async () => { + const result = await ReactDOMFizzStatic.prerender( + + hello world + , + ); + const prelude = await readContent(result.prelude); + expect(prelude).toMatchInlineSnapshot( + `"hello world"`, + ); + }); + + // @gate experimental + it('should emit bootstrap script src at the end', async () => { + const result = await ReactDOMFizzStatic.prerender(
hello world
, { + bootstrapScriptContent: 'INIT();', + bootstrapScripts: ['init.js'], + bootstrapModules: ['init.mjs'], + }); + const prelude = await readContent(result.prelude); + expect(prelude).toMatchInlineSnapshot( + `"
hello world
"`, + ); + }); + + // @gate experimental + it('emits all HTML as one unit', async () => { + let hasLoaded = false; + let resolve; + const promise = new Promise(r => (resolve = r)); + function Wait() { + if (!hasLoaded) { + throw promise; + } + return 'Done'; + } + const resultPromise = ReactDOMFizzStatic.prerender( +
+ + + +
, + ); + + await jest.runAllTimers(); + + // Resolve the loading. + hasLoaded = true; + await resolve(); + + const result = await resultPromise; + const prelude = await readContent(result.prelude); + expect(prelude).toMatchInlineSnapshot( + `"
Done
"`, + ); + }); + + // @gate experimental + it('should reject the promise when an error is thrown at the root', async () => { + const reportedErrors = []; + let caughtError = null; + try { + await ReactDOMFizzStatic.prerender( +
+ +
, + { + onError(x) { + reportedErrors.push(x); + }, + }, + ); + } catch (error) { + caughtError = error; + } + expect(caughtError).toBe(theError); + expect(reportedErrors).toEqual([theError]); + }); + + // @gate experimental + it('should reject the promise when an error is thrown inside a fallback', async () => { + const reportedErrors = []; + let caughtError = null; + try { + await ReactDOMFizzStatic.prerender( +
+ }> + + +
, + { + onError(x) { + reportedErrors.push(x); + }, + }, + ); + } catch (error) { + caughtError = error; + } + expect(caughtError).toBe(theError); + expect(reportedErrors).toEqual([theError]); + }); + + // @gate experimental + it('should not error the stream when an error is thrown inside suspense boundary', async () => { + const reportedErrors = []; + const result = await ReactDOMFizzStatic.prerender( +
+ Loading
}> + + +
, + { + onError(x) { + reportedErrors.push(x); + }, + }, + ); + + const prelude = await readContent(result.prelude); + expect(prelude).toContain('Loading'); + expect(reportedErrors).toEqual([theError]); + }); + + // @gate experimental + it('should be able to complete by aborting even if the promise never resolves', async () => { + const errors = []; + const controller = new AbortController(); + const resultPromise = ReactDOMFizzStatic.prerender( +
+ Loading
}> + + + , + { + signal: controller.signal, + onError(x) { + errors.push(x.message); + }, + }, + ); + + await jest.runAllTimers(); + + controller.abort(); + + const result = await resultPromise; + + const prelude = await readContent(result.prelude); + expect(prelude).toContain('Loading'); + + expect(errors).toEqual([ + 'The render was aborted by the server without a reason.', + ]); + }); + + // @gate experimental + it('should reject if aborting before the shell is complete', async () => { + const errors = []; + const controller = new AbortController(); + const promise = ReactDOMFizzStatic.prerender( +
+ +
, + { + signal: controller.signal, + onError(x) { + errors.push(x.message); + }, + }, + ); + + await jest.runAllTimers(); + + const theReason = new Error('aborted for reasons'); + // @TODO this is a hack to work around lack of support for abortSignal.reason in node + // The abort call itself should set this property but since we are testing in node we + // set it here manually + controller.signal.reason = theReason; + controller.abort(theReason); + + let caughtError = null; + try { + await promise; + } catch (error) { + caughtError = error; + } + expect(caughtError).toBe(theReason); + expect(errors).toEqual(['aborted for reasons']); + }); + + // @gate experimental + it('should be able to abort before something suspends', async () => { + const errors = []; + const controller = new AbortController(); + function App() { + controller.abort(); + return ( + Loading}> + + + ); + } + const streamPromise = ReactDOMFizzStatic.prerender( +
+ +
, + { + signal: controller.signal, + onError(x) { + errors.push(x.message); + }, + }, + ); + + let caughtError = null; + try { + await streamPromise; + } catch (error) { + caughtError = error; + } + expect(caughtError.message).toBe( + 'The render was aborted by the server without a reason.', + ); + expect(errors).toEqual([ + 'The render was aborted by the server without a reason.', + ]); + }); + + // @gate experimental + it('should reject if passing an already aborted signal', async () => { + const errors = []; + const controller = new AbortController(); + const theReason = new Error('aborted for reasons'); + // @TODO this is a hack to work around lack of support for abortSignal.reason in node + // The abort call itself should set this property but since we are testing in node we + // set it here manually + controller.signal.reason = theReason; + controller.abort(theReason); + + const promise = ReactDOMFizzStatic.prerender( +
+ Loading
}> + + + , + { + signal: controller.signal, + onError(x) { + errors.push(x.message); + }, + }, + ); + + // Technically we could still continue rendering the shell but currently the + // semantics mean that we also abort any pending CPU work. + let caughtError = null; + try { + await promise; + } catch (error) { + caughtError = error; + } + expect(caughtError).toBe(theReason); + expect(errors).toEqual(['aborted for reasons']); + }); + + // @gate experimental + it('supports custom abort reasons with a string', async () => { + const promise = new Promise(r => {}); + function Wait() { + throw promise; + } + function App() { + return ( +
+

+ + + +

+ + + + + +
+ ); + } + + const errors = []; + const controller = new AbortController(); + const resultPromise = ReactDOMFizzStatic.prerender(, { + signal: controller.signal, + onError(x) { + errors.push(x); + return 'a digest'; + }, + }); + + // @TODO this is a hack to work around lack of support for abortSignal.reason in node + // The abort call itself should set this property but since we are testing in node we + // set it here manually + controller.signal.reason = 'foobar'; + controller.abort('foobar'); + + await resultPromise; + + expect(errors).toEqual(['foobar', 'foobar']); + }); + + // @gate experimental + it('supports custom abort reasons with an Error', async () => { + const promise = new Promise(r => {}); + function Wait() { + throw promise; + } + function App() { + return ( +
+

+ + + +

+ + + + + +
+ ); + } + + const errors = []; + const controller = new AbortController(); + const resultPromise = ReactDOMFizzStatic.prerender(, { + signal: controller.signal, + onError(x) { + errors.push(x.message); + return 'a digest'; + }, + }); + + // @TODO this is a hack to work around lack of support for abortSignal.reason in node + // The abort call itself should set this property but since we are testing in node we + // set it here manually + controller.signal.reason = new Error('uh oh'); + controller.abort(new Error('uh oh')); + + await resultPromise; + + expect(errors).toEqual(['uh oh', 'uh oh']); + }); +}); diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzStaticNode-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzStaticNode-test.js new file mode 100644 index 0000000000000..dfcbf16186f87 --- /dev/null +++ b/packages/react-dom/src/__tests__/ReactDOMFizzStaticNode-test.js @@ -0,0 +1,421 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + */ + +// TODO: This should actually run in `@jest-environment node` but we currently +// run an old jest that doesn't support AbortController so we use DOM for now. + +'use strict'; + +let React; +let ReactDOMFizzStatic; +let Suspense; + +describe('ReactDOMFizzStaticNode', () => { + beforeEach(() => { + jest.resetModules(); + React = require('react'); + if (__EXPERIMENTAL__) { + ReactDOMFizzStatic = require('react-dom/static'); + } + Suspense = React.Suspense; + }); + + const theError = new Error('This is an error'); + function Throw() { + throw theError; + } + const theInfinitePromise = new Promise(() => {}); + function InfiniteSuspend() { + throw theInfinitePromise; + } + + function readContent(readable) { + return new Promise((resolve, reject) => { + let content = ''; + readable.on('data', chunk => { + content += Buffer.from(chunk).toString('utf8'); + }); + readable.on('error', error => { + reject(error); + }); + readable.on('end', () => resolve(content)); + }); + } + + // @gate experimental + it('should call prerenderToNodeStreams', async () => { + const result = await ReactDOMFizzStatic.prerenderToNodeStreams( +
hello world
, + ); + const prelude = await readContent(result.prelude); + expect(prelude).toMatchInlineSnapshot(`"
hello world
"`); + }); + + // @gate experimental + it('should emit DOCTYPE at the root of the document', async () => { + const result = await ReactDOMFizzStatic.prerenderToNodeStreams( + + hello world + , + ); + const prelude = await readContent(result.prelude); + expect(prelude).toMatchInlineSnapshot( + `"hello world"`, + ); + }); + + // @gate experimental + it('should emit bootstrap script src at the end', async () => { + const result = await ReactDOMFizzStatic.prerenderToNodeStreams( +
hello world
, + { + bootstrapScriptContent: 'INIT();', + bootstrapScripts: ['init.js'], + bootstrapModules: ['init.mjs'], + }, + ); + const prelude = await readContent(result.prelude); + expect(prelude).toMatchInlineSnapshot( + `"
hello world
"`, + ); + }); + + // @gate experimental + it('emits all HTML as one unit', async () => { + let hasLoaded = false; + let resolve; + const promise = new Promise(r => (resolve = r)); + function Wait() { + if (!hasLoaded) { + throw promise; + } + return 'Done'; + } + const resultPromise = ReactDOMFizzStatic.prerenderToNodeStreams( +
+ + + +
, + ); + + await jest.runAllTimers(); + + // Resolve the loading. + hasLoaded = true; + await resolve(); + + const result = await resultPromise; + const prelude = await readContent(result.prelude); + expect(prelude).toMatchInlineSnapshot( + `"
Done
"`, + ); + }); + + // @gate experimental + it('should reject the promise when an error is thrown at the root', async () => { + const reportedErrors = []; + let caughtError = null; + try { + await ReactDOMFizzStatic.prerenderToNodeStreams( +
+ +
, + { + onError(x) { + reportedErrors.push(x); + }, + }, + ); + } catch (error) { + caughtError = error; + } + expect(caughtError).toBe(theError); + expect(reportedErrors).toEqual([theError]); + }); + + // @gate experimental + it('should reject the promise when an error is thrown inside a fallback', async () => { + const reportedErrors = []; + let caughtError = null; + try { + await ReactDOMFizzStatic.prerenderToNodeStreams( +
+ }> + + +
, + { + onError(x) { + reportedErrors.push(x); + }, + }, + ); + } catch (error) { + caughtError = error; + } + expect(caughtError).toBe(theError); + expect(reportedErrors).toEqual([theError]); + }); + + // @gate experimental + it('should not error the stream when an error is thrown inside suspense boundary', async () => { + const reportedErrors = []; + const result = await ReactDOMFizzStatic.prerenderToNodeStreams( +
+ Loading
}> + + + , + { + onError(x) { + reportedErrors.push(x); + }, + }, + ); + + const prelude = await readContent(result.prelude); + expect(prelude).toContain('Loading'); + expect(reportedErrors).toEqual([theError]); + }); + + // @gate experimental + it('should be able to complete by aborting even if the promise never resolves', async () => { + const errors = []; + const controller = new AbortController(); + const resultPromise = ReactDOMFizzStatic.prerenderToNodeStreams( +
+ Loading
}> + + + , + { + signal: controller.signal, + onError(x) { + errors.push(x.message); + }, + }, + ); + + await jest.runAllTimers(); + + controller.abort(); + + const result = await resultPromise; + + const prelude = await readContent(result.prelude); + expect(prelude).toContain('Loading'); + + expect(errors).toEqual([ + 'The render was aborted by the server without a reason.', + ]); + }); + + // @gate experimental + it('should reject if aborting before the shell is complete', async () => { + const errors = []; + const controller = new AbortController(); + const promise = ReactDOMFizzStatic.prerenderToNodeStreams( +
+ +
, + { + signal: controller.signal, + onError(x) { + errors.push(x.message); + }, + }, + ); + + await jest.runAllTimers(); + + const theReason = new Error('aborted for reasons'); + // @TODO this is a hack to work around lack of support for abortSignal.reason in node + // The abort call itself should set this property but since we are testing in node we + // set it here manually + controller.signal.reason = theReason; + controller.abort(theReason); + + let caughtError = null; + try { + await promise; + } catch (error) { + caughtError = error; + } + expect(caughtError).toBe(theReason); + expect(errors).toEqual(['aborted for reasons']); + }); + + // @gate experimental + it('should be able to abort before something suspends', async () => { + const errors = []; + const controller = new AbortController(); + function App() { + controller.abort(); + return ( + Loading}> + + + ); + } + const streamPromise = ReactDOMFizzStatic.prerenderToNodeStreams( +
+ +
, + { + signal: controller.signal, + onError(x) { + errors.push(x.message); + }, + }, + ); + + let caughtError = null; + try { + await streamPromise; + } catch (error) { + caughtError = error; + } + expect(caughtError.message).toBe( + 'The render was aborted by the server without a reason.', + ); + expect(errors).toEqual([ + 'The render was aborted by the server without a reason.', + ]); + }); + + // @gate experimental + it('should reject if passing an already aborted signal', async () => { + const errors = []; + const controller = new AbortController(); + const theReason = new Error('aborted for reasons'); + // @TODO this is a hack to work around lack of support for abortSignal.reason in node + // The abort call itself should set this property but since we are testing in node we + // set it here manually + controller.signal.reason = theReason; + controller.abort(theReason); + + const promise = ReactDOMFizzStatic.prerenderToNodeStreams( +
+ Loading
}> + + + , + { + signal: controller.signal, + onError(x) { + errors.push(x.message); + }, + }, + ); + + // Technically we could still continue rendering the shell but currently the + // semantics mean that we also abort any pending CPU work. + let caughtError = null; + try { + await promise; + } catch (error) { + caughtError = error; + } + expect(caughtError).toBe(theReason); + expect(errors).toEqual(['aborted for reasons']); + }); + + // @gate experimental + it('supports custom abort reasons with a string', async () => { + const promise = new Promise(r => {}); + function Wait() { + throw promise; + } + function App() { + return ( +
+

+ + + +

+ + + + + +
+ ); + } + + const errors = []; + const controller = new AbortController(); + const resultPromise = ReactDOMFizzStatic.prerenderToNodeStreams(, { + signal: controller.signal, + onError(x) { + errors.push(x); + return 'a digest'; + }, + }); + + await jest.runAllTimers(); + + // @TODO this is a hack to work around lack of support for abortSignal.reason in node + // The abort call itself should set this property but since we are testing in node we + // set it here manually + controller.signal.reason = 'foobar'; + controller.abort('foobar'); + + await resultPromise; + + expect(errors).toEqual(['foobar', 'foobar']); + }); + + // @gate experimental + it('supports custom abort reasons with an Error', async () => { + const promise = new Promise(r => {}); + function Wait() { + throw promise; + } + function App() { + return ( +
+

+ + + +

+ + + + + +
+ ); + } + + const errors = []; + const controller = new AbortController(); + const resultPromise = ReactDOMFizzStatic.prerenderToNodeStreams(, { + signal: controller.signal, + onError(x) { + errors.push(x.message); + return 'a digest'; + }, + }); + + await jest.runAllTimers(); + + // @TODO this is a hack to work around lack of support for abortSignal.reason in node + // The abort call itself should set this property but since we are testing in node we + // set it here manually + controller.signal.reason = new Error('uh oh'); + controller.abort(new Error('uh oh')); + + await resultPromise; + + expect(errors).toEqual(['uh oh', 'uh oh']); + }); +}); diff --git a/packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js b/packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js new file mode 100644 index 0000000000000..989d2de31ba0d --- /dev/null +++ b/packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js @@ -0,0 +1,98 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {ReactNodeList} from 'shared/ReactTypes'; + +import ReactVersion from 'shared/ReactVersion'; + +import { + createRequest, + startWork, + startFlowing, + abort, +} from 'react-server/src/ReactFizzServer'; + +import { + createResponseState, + createRootFormatContext, +} from './ReactDOMServerFormatConfig'; + +type Options = {| + identifierPrefix?: string, + namespaceURI?: string, + bootstrapScriptContent?: string, + bootstrapScripts?: Array, + bootstrapModules?: Array, + progressiveChunkSize?: number, + signal?: AbortSignal, + onError?: (error: mixed) => ?string, +|}; + +type StaticResult = {| + prelude: ReadableStream, +|}; + +function prerender( + children: ReactNodeList, + options?: Options, +): Promise { + return new Promise((resolve, reject) => { + const onFatalError = reject; + + function onAllReady() { + const stream = new ReadableStream( + { + type: 'bytes', + pull(controller) { + startFlowing(request, controller); + }, + }, + // $FlowFixMe size() methods are not allowed on byte streams. + {highWaterMark: 0}, + ); + + const result = { + prelude: stream, + }; + resolve(result); + } + const request = createRequest( + children, + createResponseState( + options ? options.identifierPrefix : undefined, + undefined, + options ? options.bootstrapScriptContent : undefined, + options ? options.bootstrapScripts : undefined, + options ? options.bootstrapModules : undefined, + ), + createRootFormatContext(options ? options.namespaceURI : undefined), + options ? options.progressiveChunkSize : undefined, + options ? options.onError : undefined, + onAllReady, + undefined, + undefined, + onFatalError, + ); + if (options && options.signal) { + const signal = options.signal; + if (signal.aborted) { + abort(request, (signal: any).reason); + } else { + const listener = () => { + abort(request, (signal: any).reason); + signal.removeEventListener('abort', listener); + }; + signal.addEventListener('abort', listener); + } + } + startWork(request); + }); +} + +export {prerender, ReactVersion as version}; diff --git a/packages/react-dom/src/server/ReactDOMFizzStaticNode.js b/packages/react-dom/src/server/ReactDOMFizzStaticNode.js new file mode 100644 index 0000000000000..e799c21a8b78c --- /dev/null +++ b/packages/react-dom/src/server/ReactDOMFizzStaticNode.js @@ -0,0 +1,112 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {ReactNodeList} from 'shared/ReactTypes'; +import {Writable, Readable} from 'stream'; + +import ReactVersion from 'shared/ReactVersion'; + +import { + createRequest, + startWork, + startFlowing, + abort, +} from 'react-server/src/ReactFizzServer'; + +import { + createResponseState, + createRootFormatContext, +} from './ReactDOMServerFormatConfig'; + +type Options = {| + identifierPrefix?: string, + namespaceURI?: string, + bootstrapScriptContent?: string, + bootstrapScripts?: Array, + bootstrapModules?: Array, + progressiveChunkSize?: number, + signal?: AbortSignal, + onError?: (error: mixed) => ?string, +|}; + +type StaticResult = {| + prelude: Readable, +|}; + +function createFakeWritable(readable): Writable { + // The current host config expects a Writable so we create + // a fake writable for now to push into the Readable. + return ({ + write(chunk) { + return readable.push(chunk); + }, + end() { + readable.push(null); + }, + destroy(error) { + readable.destroy(error); + }, + }: any); +} + +function prerenderToNodeStreams( + children: ReactNodeList, + options?: Options, +): Promise { + return new Promise((resolve, reject) => { + const onFatalError = reject; + + function onAllReady() { + const readable = new Readable({ + read() { + startFlowing(request, writable); + }, + }); + const writable = createFakeWritable(readable); + + const result = { + prelude: readable, + }; + resolve(result); + } + + const request = createRequest( + children, + createResponseState( + options ? options.identifierPrefix : undefined, + undefined, + options ? options.bootstrapScriptContent : undefined, + options ? options.bootstrapScripts : undefined, + options ? options.bootstrapModules : undefined, + ), + createRootFormatContext(options ? options.namespaceURI : undefined), + options ? options.progressiveChunkSize : undefined, + options ? options.onError : undefined, + onAllReady, + undefined, + undefined, + onFatalError, + ); + if (options && options.signal) { + const signal = options.signal; + if (signal.aborted) { + abort(request, (signal: any).reason); + } else { + const listener = () => { + abort(request, (signal: any).reason); + signal.removeEventListener('abort', listener); + }; + signal.addEventListener('abort', listener); + } + } + startWork(request); + }); +} + +export {prerenderToNodeStreams, ReactVersion as version}; diff --git a/packages/react-dom/static.browser.js b/packages/react-dom/static.browser.js new file mode 100644 index 0000000000000..440740105f2f0 --- /dev/null +++ b/packages/react-dom/static.browser.js @@ -0,0 +1,10 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export {prerender, version} from './src/server/ReactDOMFizzStaticBrowser'; diff --git a/packages/react-dom/static.js b/packages/react-dom/static.js new file mode 100644 index 0000000000000..21f347feb164a --- /dev/null +++ b/packages/react-dom/static.js @@ -0,0 +1,10 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export * from './static.node'; diff --git a/packages/react-dom/static.node.js b/packages/react-dom/static.node.js new file mode 100644 index 0000000000000..00bcfa89c02cd --- /dev/null +++ b/packages/react-dom/static.node.js @@ -0,0 +1,13 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export { + prerenderToNodeStreams, + version, +} from './src/server/ReactDOMFizzStaticNode'; diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index 0c8a3b64030c3..68407ef01183d 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -330,6 +330,27 @@ const bundles = [ externals: ['react'], }, + /******* React DOM Fizz Static *******/ + { + bundleTypes: __EXPERIMENTAL__ ? [NODE_DEV, NODE_PROD] : [], + moduleType: RENDERER, + entry: 'react-dom/static.browser', + global: 'ReactDOMStatic', + minifyWithProdErrorCodes: true, + wrapWithModuleBoundaries: false, + externals: ['react'], + }, + { + bundleTypes: __EXPERIMENTAL__ ? [NODE_DEV, NODE_PROD] : [], + moduleType: RENDERER, + entry: 'react-dom/static.node', + name: 'react-dom-static.node', + global: 'ReactDOMStatic', + minifyWithProdErrorCodes: false, + wrapWithModuleBoundaries: false, + externals: ['react', 'util', 'stream'], + }, + /******* React Server DOM Webpack Writer *******/ { bundleTypes: [NODE_DEV, NODE_PROD, UMD_DEV, UMD_PROD], diff --git a/scripts/rollup/modules.js b/scripts/rollup/modules.js index 2461e28850cc0..68fa90bc3b91d 100644 --- a/scripts/rollup/modules.js +++ b/scripts/rollup/modules.js @@ -12,6 +12,7 @@ const importSideEffects = Object.freeze({ fs: HAS_NO_SIDE_EFFECTS_ON_IMPORT, 'fs/promises': HAS_NO_SIDE_EFFECTS_ON_IMPORT, path: HAS_NO_SIDE_EFFECTS_ON_IMPORT, + stream: HAS_NO_SIDE_EFFECTS_ON_IMPORT, 'prop-types/checkPropTypes': HAS_NO_SIDE_EFFECTS_ON_IMPORT, 'react-native/Libraries/ReactPrivate/ReactNativePrivateInterface': HAS_NO_SIDE_EFFECTS_ON_IMPORT, scheduler: HAS_NO_SIDE_EFFECTS_ON_IMPORT, diff --git a/scripts/rollup/packaging.js b/scripts/rollup/packaging.js index 592314c394240..bd66d0b634730 100644 --- a/scripts/rollup/packaging.js +++ b/scripts/rollup/packaging.js @@ -153,6 +153,7 @@ function filterOutEntrypoints(name) { let packageJSON = JSON.parse(readFileSync(jsonPath)); let files = packageJSON.files; let exportsJSON = packageJSON.exports; + let browserJSON = packageJSON.browser; if (!Array.isArray(files)) { throw new Error('expected all package.json files to contain a files field'); } @@ -189,6 +190,9 @@ function filterOutEntrypoints(name) { delete exportsJSON['./' + filename.replace(/\.js$/, '')]; } } + if (browserJSON) { + delete browserJSON['./' + filename]; + } } // We only export the source directory so Jest and Rollup can access them diff --git a/scripts/shared/inlinedHostConfigs.js b/scripts/shared/inlinedHostConfigs.js index eb0eef9109837..458c5410eaf18 100644 --- a/scripts/shared/inlinedHostConfigs.js +++ b/scripts/shared/inlinedHostConfigs.js @@ -13,13 +13,19 @@ module.exports = [ 'react-dom', 'react-dom/unstable_testing', 'react-dom/src/server/ReactDOMFizzServerNode.js', + 'react-dom/static.node', 'react-server-dom-webpack/writer.node.server', 'react-server-dom-webpack', ], paths: [ 'react-dom', 'react-dom/client', + 'react-dom/server', + 'react-dom/server.node', + 'react-dom/static', + 'react-dom/static.node', 'react-dom/src/server/ReactDOMFizzServerNode.js', // react-dom/server.node + 'react-dom/src/server/ReactDOMFizzStaticNode.js', 'react-server-dom-webpack', 'react-server-dom-webpack/writer', 'react-server-dom-webpack/writer.node.server', @@ -40,14 +46,18 @@ module.exports = [ 'react-dom', 'react-dom/unstable_testing', 'react-dom/src/server/ReactDOMFizzServerBrowser.js', + 'react-dom/static.browser', 'react-server-dom-webpack/writer.browser.server', 'react-server-dom-webpack', ], paths: [ 'react-dom', 'react-dom/client', + 'react-dom/server.browser', + 'react-dom/static.browser', 'react-dom/unstable_testing', 'react-dom/src/server/ReactDOMFizzServerBrowser.js', // react-dom/server.browser + 'react-dom/src/server/ReactDOMFizzStaticBrowser.js', 'react-server-dom-webpack', 'react-server-dom-webpack/writer.browser.server', 'react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js', // react-server-dom-webpack/writer.browser.server