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(''); -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;