diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
new file mode 100644
index 0000000000000..d90ea76892545
--- /dev/null
+++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
@@ -0,0 +1,269 @@
+/**
+ * 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 Scheduler;
+let React;
+let ReactDOM;
+let ReactDOMFizzServer;
+let Suspense;
+let textCache;
+let document;
+let writable;
+let buffer = '';
+let hasErrored = false;
+let fatalError = undefined;
+
+describe('ReactDOMFizzServer', () => {
+ beforeEach(() => {
+ jest.resetModules();
+ JSDOM = require('jsdom').JSDOM;
+ Scheduler = require('scheduler');
+ React = require('react');
+ ReactDOM = require('react-dom');
+ if (__EXPERIMENTAL__) {
+ ReactDOMFizzServer = require('react-dom/unstable-fizz');
+ }
+ Stream = require('stream');
+ Suspense = React.Suspense;
+ textCache = new Map();
+
+ // Test Environment
+ const jsdom = new JSDOM('
', {
+ runScripts: 'dangerously',
+ });
+ document = jsdom.window.document;
+
+ 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);
+ document.body.appendChild(script);
+ } else {
+ document.body.appendChild(node);
+ }
+ }
+ }
+
+ function getVisibleChildren(element) {
+ const children = [];
+ let node = element.firstChild;
+ while (node) {
+ if (node.nodeType === 1) {
+ if (node.tagName !== 'SCRIPT' && !node.hasAttribute('hidden')) {
+ const props = {};
+ const attributes = node.attributes;
+ for (let i = 0; i < attributes.length; i++) {
+ 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
+ ? null
+ : 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 asynchronously load the suspense boundary', async () => {
+ await act(async () => {
+ ReactDOMFizzServer.pipeToNodeWritable(
+ ,
+ writable,
+ );
+ });
+ expect(getVisibleChildren(document.body)).toEqual(Loading...
);
+ await act(async () => {
+ resolveText('Hello World');
+ });
+ expect(getVisibleChildren(document.body)).toEqual(Hello World
);
+ });
+
+ // @gate experimental
+ it('waits for pending content to come in from the server and then hydrates it', async () => {
+ const ref = React.createRef();
+
+ function App() {
+ return (
+
+ );
+ }
+
+ await act(async () => {
+ ReactDOMFizzServer.pipeToNodeWritable(
+ // We currently have to wrap the server node in a container because
+ // otherwise the Fizz nodes get deleted during hydration.
+ ,
+ writable,
+ );
+ });
+
+ // We're still showing a fallback.
+
+ // Attempt to hydrate the content.
+ const container = document.body.firstChild;
+ const root = ReactDOM.unstable_createRoot(container, {hydrate: true});
+ root.render();
+ Scheduler.unstable_flushAll();
+
+ // We're still loading because we're waiting for the server to stream more content.
+ expect(getVisibleChildren(container)).toEqual(Loading...
);
+
+ // The server now updates the content in place in the fallback.
+ await act(async () => {
+ resolveText('Hello');
+ });
+
+ // The final HTML is now in place.
+ expect(getVisibleChildren(container)).toEqual(
+
+
Hello
+ ,
+ );
+ const h1 = container.getElementsByTagName('h1')[0];
+
+ // But it is not yet hydrated.
+ expect(ref.current).toBe(null);
+
+ Scheduler.unstable_flushAll();
+
+ // Now it's hydrated.
+ expect(ref.current).toBe(h1);
+ });
+});
diff --git a/packages/react-dom/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom/src/server/ReactDOMServerFormatConfig.js
index 37ee8feae3096..250892b31d4a0 100644
--- a/packages/react-dom/src/server/ReactDOMServerFormatConfig.js
+++ b/packages/react-dom/src/server/ReactDOMServerFormatConfig.js
@@ -24,6 +24,7 @@ import invariant from 'shared/invariant';
// Per response,
export type ResponseState = {
+ nextSuspenseID: number,
sentCompleteSegmentFunction: boolean,
sentCompleteBoundaryFunction: boolean,
sentClientRenderFunction: boolean,
@@ -32,6 +33,7 @@ export type ResponseState = {
// Allows us to keep track of what we've already written so we can refer back to it.
export function createResponseState(): ResponseState {
return {
+ nextSuspenseID: 0,
sentCompleteSegmentFunction: false,
sentCompleteBoundaryFunction: false,
sentClientRenderFunction: false,
@@ -42,13 +44,13 @@ export function createResponseState(): ResponseState {
// We can't assign an ID up front because the node we're attaching it to might already
// have one. So we need to lazily use that if it's available.
export type SuspenseBoundaryID = {
- id: null | string,
+ formattedID: null | PrecomputedChunk,
};
export function createSuspenseBoundaryID(
responseState: ResponseState,
): SuspenseBoundaryID {
- return {id: null};
+ return {formattedID: null};
}
function encodeHTMLIDAttribute(value: string): string {
@@ -59,23 +61,86 @@ function encodeHTMLTextNode(text: string): string {
return escapeTextForBrowser(text);
}
+function assignAnID(
+ responseState: ResponseState,
+ id: SuspenseBoundaryID,
+): PrecomputedChunk {
+ // TODO: This approach doesn't yield deterministic results since this is assigned during render.
+ const generatedID = responseState.nextSuspenseID++;
+ return (id.formattedID = stringToPrecomputedChunk(
+ 'B:' + generatedID.toString(16),
+ ));
+}
+
+const dummyNode1 = stringToPrecomputedChunk('');
+
+function pushDummyNodeWithID(
+ target: Array,
+ responseState: ResponseState,
+ assignID: SuspenseBoundaryID,
+): void {
+ const id = assignAnID(responseState, assignID);
+ target.push(dummyNode1, id, dummyNode2);
+}
+
+export function pushEmpty(
+ target: Array,
+ responseState: ResponseState,
+ assignID: null | SuspenseBoundaryID,
+): void {
+ if (assignID !== null) {
+ pushDummyNodeWithID(target, responseState, assignID);
+ }
+}
+
export function pushTextInstance(
target: Array,
text: string,
+ responseState: ResponseState,
+ assignID: null | SuspenseBoundaryID,
): void {
+ if (assignID !== null) {
+ pushDummyNodeWithID(target, responseState, assignID);
+ }
target.push(stringToChunk(encodeHTMLTextNode(text)));
}
const startTag1 = stringToPrecomputedChunk('<');
const startTag2 = stringToPrecomputedChunk('>');
+const idAttr = stringToPrecomputedChunk(' id="');
+const attrEnd = stringToPrecomputedChunk('"');
+
export function pushStartInstance(
target: Array,
type: string,
props: Object,
+ responseState: ResponseState,
+ assignID: null | SuspenseBoundaryID,
): void {
// TODO: Figure out if it's self closing and everything else.
- target.push(startTag1, stringToChunk(type), startTag2);
+ if (assignID !== null) {
+ let encodedID;
+ if (typeof props.id === 'string') {
+ // We can reuse the existing ID for our purposes.
+ encodedID = assignID.formattedID = stringToPrecomputedChunk(
+ encodeHTMLIDAttribute(props.id),
+ );
+ } else {
+ encodedID = assignAnID(responseState, assignID);
+ }
+ target.push(
+ startTag1,
+ stringToChunk(type),
+ idAttr,
+ encodedID,
+ attrEnd,
+ startTag2,
+ );
+ } else {
+ target.push(startTag1, stringToChunk(type), startTag2);
+ }
}
const endTag1 = stringToPrecomputedChunk('');
@@ -144,7 +209,7 @@ export function writeEndSuspenseBoundary(destination: Destination): boolean {
const startSegment = stringToPrecomputedChunk('');
-const endSegment = stringToPrecomputedChunk('">
');
+const endSegment = stringToPrecomputedChunk('');
export function writeStartSegment(
destination: Destination,
id: number,
@@ -297,7 +362,7 @@ export function writeCompletedSegmentInstruction(
responseState: ResponseState,
contentSegmentID: number,
): boolean {
- if (responseState.sentCompleteSegmentFunction) {
+ if (!responseState.sentCompleteSegmentFunction) {
// The first time we write this, we'll need to include the full implementation.
responseState.sentCompleteSegmentFunction = true;
writeChunk(destination, completeSegmentScript1Full);
@@ -328,7 +393,7 @@ export function writeCompletedBoundaryInstruction(
boundaryID: SuspenseBoundaryID,
contentSegmentID: number,
): boolean {
- if (responseState.sentCompleteBoundaryFunction) {
+ if (!responseState.sentCompleteBoundaryFunction) {
// The first time we write this, we'll need to include the full implementation.
responseState.sentCompleteBoundaryFunction = true;
writeChunk(destination, completeBoundaryScript1Full);
@@ -337,13 +402,11 @@ export function writeCompletedBoundaryInstruction(
writeChunk(destination, completeBoundaryScript1Partial);
}
// TODO: Use the identifierPrefix option to make the prefix configurable.
+ const formattedBoundaryID = boundaryID.formattedID;
invariant(
- boundaryID.id !== null,
+ formattedBoundaryID !== null,
'An ID must have been assigned before we can complete the boundary.',
);
- const formattedBoundaryID = stringToChunk(
- encodeHTMLIDAttribute(boundaryID.id),
- );
const formattedContentID = stringToChunk(contentSegmentID.toString(16));
writeChunk(destination, formattedBoundaryID);
writeChunk(destination, completeBoundaryScript2);
@@ -362,7 +425,7 @@ export function writeClientRenderBoundaryInstruction(
responseState: ResponseState,
boundaryID: SuspenseBoundaryID,
): boolean {
- if (responseState.sentClientRenderFunction) {
+ if (!responseState.sentClientRenderFunction) {
// The first time we write this, we'll need to include the full implementation.
responseState.sentClientRenderFunction = true;
writeChunk(destination, clientRenderScript1Full);
@@ -370,13 +433,11 @@ export function writeClientRenderBoundaryInstruction(
// Future calls can just reuse the same function.
writeChunk(destination, clientRenderScript1Partial);
}
+ const formattedBoundaryID = boundaryID.formattedID;
invariant(
- boundaryID.id !== null,
+ formattedBoundaryID !== null,
'An ID must have been assigned before we can complete the boundary.',
);
- const formattedBoundaryID = stringToPrecomputedChunk(
- encodeHTMLIDAttribute(boundaryID.id),
- );
writeChunk(destination, formattedBoundaryID);
return writeChunk(destination, clientRenderScript2);
}
diff --git a/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js b/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js
index 224f28e4fa945..e8f6b9e14afff 100644
--- a/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js
+++ b/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js
@@ -73,14 +73,25 @@ export type SuspenseBoundaryID = number;
export function createSuspenseBoundaryID(
responseState: ResponseState,
): SuspenseBoundaryID {
+ // TODO: This is not deterministic since it's created during render.
return responseState.nextSuspenseID++;
}
const RAW_TEXT = stringToPrecomputedChunk('RCTRawText');
+export function pushEmpty(
+ target: Array,
+ responseState: ResponseState,
+ assignID: null | SuspenseBoundaryID,
+): void {
+ // This is not used since we don't need to assign any IDs.
+}
+
export function pushTextInstance(
target: Array,
text: string,
+ responseState: ResponseState,
+ assignID: null | SuspenseBoundaryID,
): void {
target.push(
INSTANCE,
@@ -95,6 +106,8 @@ export function pushStartInstance(
target: Array,
type: string,
props: Object,
+ responseState: ResponseState,
+ assignID: null | SuspenseBoundaryID,
): void {
target.push(
INSTANCE,
diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js
index 4731e965dab23..5eff77a57a29c 100644
--- a/packages/react-server/src/ReactFizzServer.js
+++ b/packages/react-server/src/ReactFizzServer.js
@@ -39,6 +39,7 @@ import {
writeClientRenderBoundaryInstruction,
writeCompletedBoundaryInstruction,
writeCompletedSegmentInstruction,
+ pushEmpty,
pushTextInstance,
pushStartInstance,
pushEndInstance,
@@ -218,11 +219,26 @@ function renderNode(
parentBoundary: Root | SuspenseBoundary,
segment: Segment,
node: ReactNodeList,
+ assignID: null | SuspenseBoundaryID,
): void {
if (typeof node === 'string') {
- pushTextInstance(segment.chunks, node);
+ pushTextInstance(segment.chunks, node, request.responseState, assignID);
+ return;
+ }
+
+ if (Array.isArray(node)) {
+ if (node.length > 0) {
+ // Only the first node gets assigned an ID.
+ renderNode(request, parentBoundary, segment, node[0], assignID);
+ for (let i = 1; i < node.length; i++) {
+ renderNode(request, parentBoundary, segment, node[i], null);
+ }
+ } else {
+ pushEmpty(segment.chunks, request.responseState, assignID);
+ }
return;
}
+
if (
typeof node !== 'object' ||
!node ||
@@ -236,7 +252,7 @@ function renderNode(
if (typeof type === 'function') {
try {
const result = type(props);
- renderNode(request, parentBoundary, segment, result);
+ renderNode(request, parentBoundary, segment, result, assignID);
} catch (x) {
if (typeof x === 'object' && x !== null && typeof x.then === 'function') {
// Something suspended, we'll need to create a new segment and resolve it later.
@@ -248,7 +264,7 @@ function renderNode(
node,
parentBoundary,
newSegment,
- null,
+ assignID,
);
const ping = suspendedWork.ping;
x.then(ping, ping);
@@ -259,10 +275,18 @@ function renderNode(
}
}
} else if (typeof type === 'string') {
- pushStartInstance(segment.chunks, type, props);
- renderNode(request, parentBoundary, segment, props.children);
+ pushStartInstance(
+ segment.chunks,
+ type,
+ props,
+ request.responseState,
+ assignID,
+ );
+ renderNode(request, parentBoundary, segment, props.children, null);
pushEndInstance(segment.chunks, type, props);
} else if (type === REACT_SUSPENSE_TYPE) {
+ // We need to push an "empty" thing here to identify the parent suspense boundary.
+ pushEmpty(segment.chunks, request.responseState, assignID);
// Each time we enter a suspense boundary, we split out into a new segment for
// the fallback so that we can later replace that segment with the content.
// This also lets us split out the main content even if it doesn't suspend,
@@ -418,7 +442,7 @@ function retryWork(request: Request, work: SuspendedWork): void {
node = element.type(element.props);
}
- renderNode(request, boundary, segment, node);
+ renderNode(request, boundary, segment, node, work.assignID);
completeWork(request, boundary, segment);
} catch (x) {
diff --git a/packages/react-server/src/forks/ReactServerFormatConfig.custom.js b/packages/react-server/src/forks/ReactServerFormatConfig.custom.js
index eb81d61a47b33..3f3688ad4fb88 100644
--- a/packages/react-server/src/forks/ReactServerFormatConfig.custom.js
+++ b/packages/react-server/src/forks/ReactServerFormatConfig.custom.js
@@ -30,6 +30,7 @@ export opaque type SuspenseBoundaryID = mixed;
export const createResponseState = $$$hostConfig.createResponseState;
export const createSuspenseBoundaryID = $$$hostConfig.createSuspenseBoundaryID;
+export const pushEmpty = $$$hostConfig.pushEmpty;
export const pushTextInstance = $$$hostConfig.pushTextInstance;
export const pushStartInstance = $$$hostConfig.pushStartInstance;
export const pushEndInstance = $$$hostConfig.pushEndInstance;