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(
+