diff --git a/packages/react-dom/npm/server.node.js b/packages/react-dom/npm/server.node.js index 44dc715efdd92..6e48652e4103f 100644 --- a/packages/react-dom/npm/server.node.js +++ b/packages/react-dom/npm/server.node.js @@ -15,6 +15,6 @@ exports.renderToStaticMarkup = l.renderToStaticMarkup; exports.renderToNodeStream = l.renderToNodeStream; exports.renderToStaticNodeStream = l.renderToStaticNodeStream; exports.renderToPipeableStream = s.renderToPipeableStream; -if (s.resume) { - exports.resume = s.resume; +if (s.resumeToPipeableStream) { + exports.resumeToPipeableStream = s.resumeToPipeableStream; } diff --git a/packages/react-dom/server.node.js b/packages/react-dom/server.node.js index 713f6ea322e6a..d0e403eaf89b6 100644 --- a/packages/react-dom/server.node.js +++ b/packages/react-dom/server.node.js @@ -43,8 +43,8 @@ export function renderToPipeableStream() { ); } -export function resume() { - return require('./src/server/react-dom-server.node').resume.apply( +export function resumeToPipeableStream() { + return require('./src/server/react-dom-server.node').resumeToPipeableStream.apply( this, arguments, ); diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index fb3a2ae86ee5a..8357b8304d8dd 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -14,6 +14,7 @@ import { mergeOptions, stripExternalRuntimeInNodes, withLoadingReadyState, + getVisibleChildren, } from '../test-utils/FizzTestUtils'; let JSDOM; @@ -23,6 +24,7 @@ let React; let ReactDOM; let ReactDOMClient; let ReactDOMFizzServer; +let ReactDOMFizzStatic; let Suspense; let SuspenseList; let useSyncExternalStore; @@ -77,6 +79,9 @@ describe('ReactDOMFizzServer', () => { ReactDOM = require('react-dom'); ReactDOMClient = require('react-dom/client'); ReactDOMFizzServer = require('react-dom/server'); + if (__EXPERIMENTAL__) { + ReactDOMFizzStatic = require('react-dom/static'); + } Stream = require('stream'); Suspense = React.Suspense; use = React.use; @@ -289,46 +294,6 @@ describe('ReactDOMFizzServer', () => { }, document); } - function getVisibleChildren(element) { - const children = []; - let node = element.firstChild; - while (node) { - if (node.nodeType === 1) { - if ( - node.tagName !== 'SCRIPT' && - 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) { @@ -6227,4 +6192,60 @@ describe('ReactDOMFizzServer', () => { ); }, ); + + // @gate enablePostpone + it('supports postponing in prerender and resuming later', async () => { + let prerendering = true; + function Postpone() { + if (prerendering) { + React.unstable_postpone(); + } + return 'Hello'; + } + + function App() { + return ( +
+ + + +
+ ); + } + + const prerendered = await ReactDOMFizzStatic.prerenderToNodeStream(); + expect(prerendered.postponed).not.toBe(null); + + prerendering = false; + + const resumed = ReactDOMFizzServer.resumeToPipeableStream( + , + prerendered.postponed, + ); + + // Create a separate stream so it doesn't close the writable. I.e. simple concat. + const preludeWritable = new Stream.PassThrough(); + preludeWritable.setEncoding('utf8'); + preludeWritable.on('data', chunk => { + writable.write(chunk); + }); + + await act(() => { + prerendered.prelude.pipe(preludeWritable); + }); + + expect(getVisibleChildren(container)).toEqual(
Loading...
); + + const b = new Stream.PassThrough(); + b.setEncoding('utf8'); + b.on('data', chunk => { + writable.write(chunk); + }); + + await act(() => { + resumed.pipe(writable); + }); + + // TODO: 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 index 12febb303c304..6873031207fd5 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js @@ -9,23 +9,37 @@ 'use strict'; +import { + getVisibleChildren, + insertNodesAndExecuteScripts, +} from '../test-utils/FizzTestUtils'; + // Polyfills for test environment global.ReadableStream = require('web-streams-polyfill/ponyfill/es6').ReadableStream; global.TextEncoder = require('util').TextEncoder; let React; +let ReactDOMFizzServer; let ReactDOMFizzStatic; let Suspense; +let container; describe('ReactDOMFizzStaticBrowser', () => { beforeEach(() => { jest.resetModules(); React = require('react'); + ReactDOMFizzServer = require('react-dom/server.browser'); if (__EXPERIMENTAL__) { ReactDOMFizzStatic = require('react-dom/static.browser'); } Suspense = React.Suspense; + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + document.body.removeChild(container); }); const theError = new Error('This is an error'); @@ -37,6 +51,36 @@ describe('ReactDOMFizzStaticBrowser', () => { throw theInfinitePromise; } + function concat(streamA, streamB) { + const readerA = streamA.getReader(); + const readerB = streamB.getReader(); + return new ReadableStream({ + start(controller) { + function readA() { + readerA.read().then(({done, value}) => { + if (done) { + readB(); + return; + } + controller.enqueue(value); + readA(); + }); + } + function readB() { + readerB.read().then(({done, value}) => { + if (done) { + controller.close(); + return; + } + controller.enqueue(value); + readB(); + }); + } + readA(); + }, + }); + } + async function readContent(stream) { const reader = stream.getReader(); let content = ''; @@ -49,6 +93,21 @@ describe('ReactDOMFizzStaticBrowser', () => { } } + async function readIntoContainer(stream) { + const reader = stream.getReader(); + let result = ''; + while (true) { + const {done, value} = await reader.read(); + if (done) { + break; + } + result += Buffer.from(value).toString('utf8'); + } + const temp = document.createElement('div'); + temp.innerHTML = result; + insertNodesAndExecuteScripts(temp, container, null); + } + // @gate experimental it('should call prerender', async () => { const result = await ReactDOMFizzStatic.prerender(
hello world
); @@ -394,4 +453,82 @@ describe('ReactDOMFizzStaticBrowser', () => { expect(errors).toEqual(['uh oh', 'uh oh']); }); + + // @gate enablePostpone + it('supports postponing in prerender and resuming later', async () => { + let prerendering = true; + function Postpone() { + if (prerendering) { + React.unstable_postpone(); + } + return 'Hello'; + } + + function App() { + return ( +
+ + + +
+ ); + } + + const prerendered = await ReactDOMFizzStatic.prerender(); + expect(prerendered.postponed).not.toBe(null); + + prerendering = false; + + const resumed = await ReactDOMFizzServer.resume( + , + prerendered.postponed, + ); + + await readIntoContainer(prerendered.prelude); + + expect(getVisibleChildren(container)).toEqual(
Loading...
); + + await readIntoContainer(resumed); + + // TODO: expect(getVisibleChildren(container)).toEqual(
Hello
); + }); + + // @gate enablePostpone + it('only emits end tags once when resuming', async () => { + let prerendering = true; + function Postpone() { + if (prerendering) { + React.unstable_postpone(); + } + return 'Hello'; + } + + function App() { + return ( + + + + + + + + ); + } + + const prerendered = await ReactDOMFizzStatic.prerender(); + expect(prerendered.postponed).not.toBe(null); + + prerendering = false; + + const content = await ReactDOMFizzServer.resume( + , + prerendered.postponed, + ); + + const html = await readContent(concat(prerendered.prelude, content)); + const htmlEndTags = /<\/html\s*>/gi; + const bodyEndTags = /<\/body\s*>/gi; + expect(Array.from(html.matchAll(htmlEndTags)).length).toBe(1); + expect(Array.from(html.matchAll(bodyEndTags)).length).toBe(1); + }); }); diff --git a/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js b/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js index 0de26739b5696..71631f9573086 100644 --- a/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js +++ b/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js @@ -16,7 +16,7 @@ import ReactVersion from 'shared/ReactVersion'; import { createRequest, - startWork, + startRender, startFlowing, abort, } from 'react-server/src/ReactFizzServer'; @@ -129,7 +129,7 @@ function renderToReadableStream( signal.addEventListener('abort', listener); } } - startWork(request); + startRender(request); }); } @@ -200,7 +200,7 @@ function resume( signal.addEventListener('abort', listener); } } - startWork(request); + startRender(request); }); } diff --git a/packages/react-dom/src/server/ReactDOMFizzServerBun.js b/packages/react-dom/src/server/ReactDOMFizzServerBun.js index 997934e1a3d1a..4464b95551271 100644 --- a/packages/react-dom/src/server/ReactDOMFizzServerBun.js +++ b/packages/react-dom/src/server/ReactDOMFizzServerBun.js @@ -15,7 +15,7 @@ import ReactVersion from 'shared/ReactVersion'; import { createRequest, - startWork, + startRender, startFlowing, abort, } from 'react-server/src/ReactFizzServer'; @@ -121,7 +121,7 @@ function renderToReadableStream( signal.addEventListener('abort', listener); } } - startWork(request); + startRender(request); }); } diff --git a/packages/react-dom/src/server/ReactDOMFizzServerEdge.js b/packages/react-dom/src/server/ReactDOMFizzServerEdge.js index 0de26739b5696..71631f9573086 100644 --- a/packages/react-dom/src/server/ReactDOMFizzServerEdge.js +++ b/packages/react-dom/src/server/ReactDOMFizzServerEdge.js @@ -16,7 +16,7 @@ import ReactVersion from 'shared/ReactVersion'; import { createRequest, - startWork, + startRender, startFlowing, abort, } from 'react-server/src/ReactFizzServer'; @@ -129,7 +129,7 @@ function renderToReadableStream( signal.addEventListener('abort', listener); } } - startWork(request); + startRender(request); }); } @@ -200,7 +200,7 @@ function resume( signal.addEventListener('abort', listener); } } - startWork(request); + startRender(request); }); } diff --git a/packages/react-dom/src/server/ReactDOMFizzServerNode.js b/packages/react-dom/src/server/ReactDOMFizzServerNode.js index 89332ef5dc7de..c948d4f3996d8 100644 --- a/packages/react-dom/src/server/ReactDOMFizzServerNode.js +++ b/packages/react-dom/src/server/ReactDOMFizzServerNode.js @@ -18,7 +18,7 @@ import ReactVersion from 'shared/ReactVersion'; import { createRequest, - startWork, + startRender, startFlowing, abort, } from 'react-server/src/ReactFizzServer'; @@ -105,7 +105,7 @@ function renderToPipeableStream( ): PipeableStream { const request = createRequestImpl(children, options); let hasStartedFlowing = false; - startWork(request); + startRender(request); return { pipe(destination: T): T { if (hasStartedFlowing) { @@ -166,7 +166,7 @@ function resumeToPipeableStream( ): PipeableStream { const request = resumeRequestImpl(children, postponedState, options); let hasStartedFlowing = false; - startWork(request); + startRender(request); return { pipe(destination: T): T { if (hasStartedFlowing) { diff --git a/packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js b/packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js index 0e03530b2d885..64c5cf4ae28fd 100644 --- a/packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js +++ b/packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js @@ -16,7 +16,7 @@ import ReactVersion from 'shared/ReactVersion'; import { createRequest, - startWork, + startPrerender, startFlowing, abort, getPostponedState, @@ -109,7 +109,7 @@ function prerender( signal.addEventListener('abort', listener); } } - startWork(request); + startPrerender(request); }); } diff --git a/packages/react-dom/src/server/ReactDOMFizzStaticEdge.js b/packages/react-dom/src/server/ReactDOMFizzStaticEdge.js index 0e03530b2d885..64c5cf4ae28fd 100644 --- a/packages/react-dom/src/server/ReactDOMFizzStaticEdge.js +++ b/packages/react-dom/src/server/ReactDOMFizzStaticEdge.js @@ -16,7 +16,7 @@ import ReactVersion from 'shared/ReactVersion'; import { createRequest, - startWork, + startPrerender, startFlowing, abort, getPostponedState, @@ -109,7 +109,7 @@ function prerender( signal.addEventListener('abort', listener); } } - startWork(request); + startPrerender(request); }); } diff --git a/packages/react-dom/src/server/ReactDOMFizzStaticNode.js b/packages/react-dom/src/server/ReactDOMFizzStaticNode.js index d538eb9f81ce9..ee138ef5a3f6b 100644 --- a/packages/react-dom/src/server/ReactDOMFizzStaticNode.js +++ b/packages/react-dom/src/server/ReactDOMFizzStaticNode.js @@ -18,7 +18,7 @@ import ReactVersion from 'shared/ReactVersion'; import { createRequest, - startWork, + startPrerender, startFlowing, abort, getPostponedState, @@ -123,7 +123,7 @@ function prerenderToNodeStream( signal.addEventListener('abort', listener); } } - startWork(request); + startPrerender(request); }); } diff --git a/packages/react-dom/src/server/ReactDOMLegacyServerImpl.js b/packages/react-dom/src/server/ReactDOMLegacyServerImpl.js index b08b51de19890..24a61cb40f68f 100644 --- a/packages/react-dom/src/server/ReactDOMLegacyServerImpl.js +++ b/packages/react-dom/src/server/ReactDOMLegacyServerImpl.js @@ -13,7 +13,7 @@ import type {ReactNodeList} from 'shared/ReactTypes'; import { createRequest, - startWork, + startRender, startFlowing, abort, } from 'react-server/src/ReactFizzServer'; @@ -81,7 +81,7 @@ function renderToStringImpl( undefined, undefined, ); - startWork(request); + startRender(request); // If anything suspended and is still pending, we'll abort it before writing. // That way we write only client-rendered boundaries from the start. abort(request, abortReason); diff --git a/packages/react-dom/src/server/ReactDOMLegacyServerNodeStream.js b/packages/react-dom/src/server/ReactDOMLegacyServerNodeStream.js index a3e39def4ef4c..d01b063bc421f 100644 --- a/packages/react-dom/src/server/ReactDOMLegacyServerNodeStream.js +++ b/packages/react-dom/src/server/ReactDOMLegacyServerNodeStream.js @@ -13,7 +13,7 @@ import type {Request} from 'react-server/src/ReactFizzServer'; import { createRequest, - startWork, + startRender, startFlowing, abort, } from 'react-server/src/ReactFizzServer'; @@ -92,7 +92,7 @@ function renderToNodeStreamImpl( undefined, ); destination.request = request; - startWork(request); + startRender(request); return destination; } diff --git a/packages/react-dom/src/test-utils/FizzTestUtils.js b/packages/react-dom/src/test-utils/FizzTestUtils.js index 545743a6445b7..96654d8ccb21e 100644 --- a/packages/react-dom/src/test-utils/FizzTestUtils.js +++ b/packages/react-dom/src/test-utils/FizzTestUtils.js @@ -171,9 +171,52 @@ async function withLoadingReadyState( return result; } +function getVisibleChildren(element: Element): React$Node { + const children = []; + let node: any = element.firstChild; + while (node) { + if (node.nodeType === 1) { + if ( + node.tagName !== 'SCRIPT' && + node.tagName !== 'script' && + node.tagName !== 'TEMPLATE' && + node.tagName !== 'template' && + !node.hasAttribute('hidden') && + !node.hasAttribute('aria-hidden') + ) { + const props: any = {}; + 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( + require('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; +} + export { insertNodesAndExecuteScripts, mergeOptions, stripExternalRuntimeInNodes, withLoadingReadyState, + getVisibleChildren, }; diff --git a/packages/react-noop-renderer/src/ReactNoopFlightServer.js b/packages/react-noop-renderer/src/ReactNoopFlightServer.js index 9faab3cdfa110..560305d539f71 100644 --- a/packages/react-noop-renderer/src/ReactNoopFlightServer.js +++ b/packages/react-noop-renderer/src/ReactNoopFlightServer.js @@ -84,7 +84,7 @@ function render(model: ReactClientValue, options?: Options): Destination { options ? options.context : undefined, options ? options.identifierPrefix : undefined, ); - ReactNoopFlightServer.startWork(request); + ReactNoopFlightServer.startRender(request); ReactNoopFlightServer.startFlowing(request, destination); return destination; } diff --git a/packages/react-noop-renderer/src/ReactNoopServer.js b/packages/react-noop-renderer/src/ReactNoopServer.js index 34a2dac5592a2..44c9559d1ad56 100644 --- a/packages/react-noop-renderer/src/ReactNoopServer.js +++ b/packages/react-noop-renderer/src/ReactNoopServer.js @@ -304,7 +304,7 @@ function render(children: React$Element, options?: Options): Destination { options ? options.onAllReady : undefined, options ? options.onShellReady : undefined, ); - ReactNoopServer.startWork(request); + ReactNoopServer.startRender(request); ReactNoopServer.startFlowing(request, destination); return destination; } diff --git a/packages/react-server-dom-esm/src/ReactFlightDOMServerNode.js b/packages/react-server-dom-esm/src/ReactFlightDOMServerNode.js index def3a58478e57..4d44fa01a3821 100644 --- a/packages/react-server-dom-esm/src/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-esm/src/ReactFlightDOMServerNode.js @@ -20,7 +20,7 @@ import type {ServerContextJSONValue, Thenable} from 'shared/ReactTypes'; import { createRequest, - startWork, + startRender, startFlowing, abort, } from 'react-server/src/ReactFlightServer'; @@ -73,7 +73,7 @@ function renderToPipeableStream( options ? options.onPostpone : undefined, ); let hasStartedFlowing = false; - startWork(request); + startRender(request); return { pipe(destination: T): T { if (hasStartedFlowing) { diff --git a/packages/react-server-dom-fb/src/ReactDOMServerFB.js b/packages/react-server-dom-fb/src/ReactDOMServerFB.js index eec7404055e26..91c99cead07c5 100644 --- a/packages/react-server-dom-fb/src/ReactDOMServerFB.js +++ b/packages/react-server-dom-fb/src/ReactDOMServerFB.js @@ -16,7 +16,7 @@ import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/Reac import { createRequest, - startWork, + startRender, performWork, startFlowing, abort, @@ -68,7 +68,7 @@ function renderToStream(children: ReactNodeList, options: Options): Stream { undefined, undefined, ); - startWork(request); + startRender(request); if (destination.fatal) { throw destination.error; } diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js b/packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js index 08214a4182ab2..b445a4be15323 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js @@ -14,7 +14,7 @@ import type {ServerManifest} from 'react-client/src/ReactFlightClientConfig'; import { createRequest, - startWork, + startRender, startFlowing, abort, } from 'react-server/src/ReactFlightServer'; @@ -70,7 +70,7 @@ function renderToReadableStream( { type: 'bytes', start: (controller): ?Promise => { - startWork(request); + startRender(request); }, pull: (controller): ?Promise => { startFlowing(request, controller); diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js b/packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js index 08214a4182ab2..b445a4be15323 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js @@ -14,7 +14,7 @@ import type {ServerManifest} from 'react-client/src/ReactFlightClientConfig'; import { createRequest, - startWork, + startRender, startFlowing, abort, } from 'react-server/src/ReactFlightServer'; @@ -70,7 +70,7 @@ function renderToReadableStream( { type: 'bytes', start: (controller): ?Promise => { - startWork(request); + startRender(request); }, pull: (controller): ?Promise => { startFlowing(request, controller); diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js b/packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js index 1e39d000ffef4..17dd769cdecb8 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js @@ -20,7 +20,7 @@ import type {ServerContextJSONValue, Thenable} from 'shared/ReactTypes'; import { createRequest, - startWork, + startRender, startFlowing, abort, } from 'react-server/src/ReactFlightServer'; @@ -74,7 +74,7 @@ function renderToPipeableStream( options ? options.onPostpone : undefined, ); let hasStartedFlowing = false; - startWork(request); + startRender(request); return { pipe(destination: T): T { if (hasStartedFlowing) { diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 12901e29a4021..914fb152a4f48 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -154,27 +154,64 @@ const ReactDebugCurrentFrame = ReactSharedInternals.ReactDebugCurrentFrame; // Linked list representing the identity of a component given the component/tag name and key. // The name might be minified but we assume that it's going to be the same generated name. Typically // because it's just the same compiled output in practice. -type KeyNode = - | null - | [KeyNode /* parent */, string | null /* name */, string | number /* key */]; +type KeyNode = [ + Root | KeyNode /* parent */, + string | null /* name */, + string | number /* key */, +]; + +const REPLAY_NODE = 0; +const REPLAY_SUSPENSE_BOUNDARY = 1; +const RESUME_SEGMENT = 2; + +type ResumableParentNode = + | [ + 0, // REPLAY_NODE + string | null /* name */, + string | number /* key */, + Array /* children */, + ] + | [ + 1, // REPLAY_SUSPENSE_BOUNDARY + string | null /* name */, + string | number /* key */, + Array /* children */, + SuspenseBoundaryID, + ]; +type ResumableNode = + | ResumableParentNode + | [ + 2, // RESUME_SEGMENT + string | null /* name */, + string | number /* key */, + number /* segment id */, + ]; + +type PostponedHoles = { + workingMap: Map, + root: Array, +}; type LegacyContext = { [key: string]: any, }; +const CLIENT_RENDERED = 4; // if it errors or infinitely suspends + type SuspenseBoundary = { + status: 0 | 1 | 4 | 5, id: SuspenseBoundaryID, rootSegmentID: number, errorDigest: ?string, // the error hash if it errors errorMessage?: string, // the error string if it errors errorComponentStack?: string, // the error component stack if it errors - forceClientRender: boolean, // if it errors or infinitely suspends parentFlushed: boolean, pendingTasks: number, // when it reaches zero we can show this boundary's content completedSegments: Array, // completed but not yet flushed segments. byteSize: number, // used to determine whether to inline children boundaries. fallbackAbortableTasks: Set, // used to cancel task on the fallback if the boundary completes or gets canceled. resources: BoundaryResources, + keyPath: Root | KeyNode, }; export type Task = { @@ -183,7 +220,7 @@ export type Task = { blockedBoundary: Root | SuspenseBoundary, blockedSegment: Segment, // the segment we'll write to abortSet: Set, // the abortable set that this task belongs to - keyPath: KeyNode, // the path of all parent keys currently rendering + keyPath: Root | KeyNode, // the path of all parent keys currently rendering legacyContext: LegacyContext, // the current legacy context that this task is executing in context: ContextSnapshot, // the current new context that this task is executing in treeContext: TreeContext, // the current tree context that this task is executing in @@ -196,11 +233,12 @@ const COMPLETED = 1; const FLUSHED = 2; const ABORTED = 3; const ERRORED = 4; +const POSTPONED = 5; type Root = null; type Segment = { - status: 0 | 1 | 2 | 3 | 4, + status: 0 | 1 | 2 | 3 | 4 | 5, parentFlushed: boolean, // typically a segment will be flushed by its parent, except if its parent was already flushed id: number, // starts as 0 and is lazily assigned if the parent flushes early +index: number, // the index within the parent's chunks or 0 at the root @@ -224,6 +262,7 @@ export opaque type Request = { flushScheduled: boolean, +resumableState: ResumableState, +renderState: RenderState, + +rootFormatContext: FormatContext, +progressiveChunkSize: number, status: 0 | 1 | 2, fatalError: mixed, @@ -237,6 +276,7 @@ export opaque type Request = { clientRenderedBoundaries: Array, // Errored or client rendered but not yet flushed. completedBoundaries: Array, // Completed but not yet fully flushed boundaries to show. partialBoundaries: Array, // Partially completed boundaries that can flush its segments early. + trackedPostpones: null | PostponedHoles, // Gets set to non-null while we want to track postponed holes. I.e. during a prerender. // onError is called when an error happens anywhere in the tree. It might recover. // The return string is used in production primarily to avoid leaking internals, secondarily to save bytes. // Returning null/undefined will cause a defualt error message in production @@ -302,6 +342,7 @@ export function createRequest( flushScheduled: false, resumableState, renderState, + rootFormatContext, progressiveChunkSize: progressiveChunkSize === undefined ? DEFAULT_PROGRESSIVE_CHUNK_SIZE @@ -317,6 +358,7 @@ export function createRequest( clientRenderedBoundaries: ([]: Array), completedBoundaries: ([]: Array), partialBoundaries: ([]: Array), + trackedPostpones: null, onError: onError === undefined ? defaultErrorHandler : onError, onPostpone: onPostpone === undefined ? noop : onPostpone, onAllReady: onAllReady === undefined ? noop : onAllReady, @@ -375,18 +417,20 @@ function pingTask(request: Request, task: Task): void { function createSuspenseBoundary( request: Request, fallbackAbortableTasks: Set, + keyPath: Root | KeyNode, ): SuspenseBoundary { return { + status: PENDING, id: UNINITIALIZED_SUSPENSE_BOUNDARY_ID, rootSegmentID: -1, parentFlushed: false, pendingTasks: 0, - forceClientRender: false, completedSegments: [], byteSize: 0, fallbackAbortableTasks, errorDigest: null, resources: createBoundaryResources(), + keyPath, }; } @@ -397,7 +441,7 @@ function createTask( blockedBoundary: Root | SuspenseBoundary, blockedSegment: Segment, abortSet: Set, - keyPath: KeyNode, + keyPath: Root | KeyNode, legacyContext: LegacyContext, context: ContextSnapshot, treeContext: TreeContext, @@ -580,7 +624,11 @@ function renderSuspenseBoundary( const content: ReactNodeList = props.children; const fallbackAbortSet: Set = new Set(); - const newBoundary = createSuspenseBoundary(request, fallbackAbortSet); + const newBoundary = createSuspenseBoundary( + request, + fallbackAbortSet, + task.keyPath, + ); const insertionIndex = parentSegment.chunks.length; // The children of the boundary segment is actually the fallback. const boundarySegment = createPendingSegment( @@ -637,7 +685,8 @@ function renderSuspenseBoundary( ); contentRootSegment.status = COMPLETED; queueCompletedSegment(newBoundary, contentRootSegment); - if (newBoundary.pendingTasks === 0) { + if (newBoundary.pendingTasks === 0 && newBoundary.status === PENDING) { + newBoundary.status = COMPLETED; // This must have been the last segment we were waiting on. This boundary is now complete. // Therefore we won't need the fallback. We early return so that we don't have to create // the fallback. @@ -646,7 +695,7 @@ function renderSuspenseBoundary( } } catch (error) { contentRootSegment.status = ERRORED; - newBoundary.forceClientRender = true; + newBoundary.status = CLIENT_RENDERED; let errorDigest; if ( enablePostpone && @@ -1624,6 +1673,85 @@ function renderChildrenArray( } } +function trackPostpone( + request: Request, + trackedPostpones: PostponedHoles, + task: Task, + segment: Segment, +): void { + segment.status = POSTPONED; + // We know that this will leave a hole so we might as well assign an ID now. + segment.id = request.nextSegmentId++; + + const boundary = task.blockedBoundary; + if (boundary !== null && boundary.status === PENDING) { + boundary.status = POSTPONED; + // We need to eagerly assign it an ID because we'll need to refer to + // it before flushing and we know that we can't inline it. + boundary.id = assignSuspenseBoundaryID( + request.renderState, + request.resumableState, + ); + + const boundaryKeyPath = boundary.keyPath; + if (boundaryKeyPath === null) { + throw new Error( + 'It should not be possible to postpone at the root. This is a bug in React.', + ); + } + const children: Array = []; + const boundaryNode: ResumableParentNode = [ + REPLAY_SUSPENSE_BOUNDARY, + boundaryKeyPath[1], + boundaryKeyPath[2], + children, + boundary.id, + ]; + trackedPostpones.workingMap.set(boundaryKeyPath, boundaryNode); + addToResumableParent(boundaryNode, boundaryKeyPath, trackedPostpones); + } + + const keyPath = task.keyPath; + if (keyPath === null) { + throw new Error( + 'It should not be possible to postpone at the root. This is a bug in React.', + ); + } + + const segmentNode: ResumableNode = [ + RESUME_SEGMENT, + keyPath[1], + keyPath[2], + segment.id, + ]; + addToResumableParent(segmentNode, keyPath, trackedPostpones); +} + +function injectPostponedHole( + request: Request, + task: Task, + reason: string, +): Segment { + logPostpone(request, reason); + // Something suspended, we'll need to create a new segment and resolve it later. + const segment = task.blockedSegment; + const insertionIndex = segment.chunks.length; + const newSegment = createPendingSegment( + request, + insertionIndex, + null, + segment.formatContext, + // Adopt the parent segment's leading text embed + segment.lastPushedText, + // Assume we are text embedded at the trailing edge + true, + ); + segment.children.push(newSegment); + // Reset lastPushedText for current Segment since the new Segment "consumed" it + segment.lastPushedText = false; + return segment; +} + function spawnNewSuspendedTask( request: Request, task: Task, @@ -1713,40 +1841,73 @@ function renderNode( getSuspendedThenable() : thrownValue; - // $FlowFixMe[method-unbinding] - if (typeof x === 'object' && x !== null && typeof x.then === 'function') { - const wakeable: Wakeable = (x: any); - const thenableState = getThenableStateAfterSuspending(); - spawnNewSuspendedTask(request, task, thenableState, wakeable); - - // Restore the context. We assume that this will be restored by the inner - // functions in case nothing throws so we don't use "finally" here. - task.blockedSegment.formatContext = previousFormatContext; - task.legacyContext = previousLegacyContext; - task.context = previousContext; - task.keyPath = previousKeyPath; - // Restore all active ReactContexts to what they were before. - switchContext(previousContext); - if (__DEV__) { - task.componentStack = previousComponentStack; + if (typeof x === 'object' && x !== null) { + // $FlowFixMe[method-unbinding] + if (typeof x.then === 'function') { + const wakeable: Wakeable = (x: any); + const thenableState = getThenableStateAfterSuspending(); + spawnNewSuspendedTask(request, task, thenableState, wakeable); + + // Restore the context. We assume that this will be restored by the inner + // functions in case nothing throws so we don't use "finally" here. + task.blockedSegment.formatContext = previousFormatContext; + task.legacyContext = previousLegacyContext; + task.context = previousContext; + task.keyPath = previousKeyPath; + // Restore all active ReactContexts to what they were before. + switchContext(previousContext); + if (__DEV__) { + task.componentStack = previousComponentStack; + } + return; } - return; - } else { - // Restore the context. We assume that this will be restored by the inner - // functions in case nothing throws so we don't use "finally" here. - task.blockedSegment.formatContext = previousFormatContext; - task.legacyContext = previousLegacyContext; - task.context = previousContext; - task.keyPath = previousKeyPath; - // Restore all active ReactContexts to what they were before. - switchContext(previousContext); - if (__DEV__) { - task.componentStack = previousComponentStack; + if ( + enablePostpone && + request.trackedPostpones !== null && + x.$$typeof === REACT_POSTPONE_TYPE && + task.blockedBoundary !== null // TODO: Support holes in the shell + ) { + // If we're tracking postpones, we inject a hole here and continue rendering + // sibling. Similar to suspending. If we're not tracking, we treat it more like + // an error. Notably this doesn't spawn a new task since nothing will fill it + // in during this prerender. + const postponeInstance: Postpone = (x: any); + const trackedPostpones = request.trackedPostpones; + const postponedSegment = injectPostponedHole( + request, + task, + postponeInstance.message, + ); + trackPostpone(request, trackedPostpones, task, postponedSegment); + + // Restore the context. We assume that this will be restored by the inner + // functions in case nothing throws so we don't use "finally" here. + task.blockedSegment.formatContext = previousFormatContext; + task.legacyContext = previousLegacyContext; + task.context = previousContext; + task.keyPath = previousKeyPath; + // Restore all active ReactContexts to what they were before. + switchContext(previousContext); + if (__DEV__) { + task.componentStack = previousComponentStack; + } + return; } - // We assume that we don't need the correct context. - // Let's terminate the rest of the tree and don't render any siblings. - throw x; } + // Restore the context. We assume that this will be restored by the inner + // functions in case nothing throws so we don't use "finally" here. + task.blockedSegment.formatContext = previousFormatContext; + task.legacyContext = previousLegacyContext; + task.context = previousContext; + task.keyPath = previousKeyPath; + // Restore all active ReactContexts to what they were before. + switchContext(previousContext); + if (__DEV__) { + task.componentStack = previousComponentStack; + } + // We assume that we don't need the correct context. + // Let's terminate the rest of the tree and don't render any siblings. + throw x; } } @@ -1775,8 +1936,8 @@ function erroredTask( fatalError(request, error); } else { boundary.pendingTasks--; - if (!boundary.forceClientRender) { - boundary.forceClientRender = true; + if (boundary.status !== CLIENT_RENDERED) { + boundary.status = CLIENT_RENDERED; boundary.errorDigest = errorDigest; if (__DEV__) { captureBoundaryErrorDetailsDev(boundary, error); @@ -1829,9 +1990,8 @@ function abortTask(task: Task, request: Request, error: mixed): void { } } else { boundary.pendingTasks--; - - if (!boundary.forceClientRender) { - boundary.forceClientRender = true; + if (boundary.status !== CLIENT_RENDERED) { + boundary.status = CLIENT_RENDERED; boundary.errorDigest = request.onError(error); if (__DEV__) { const errorPrefix = @@ -1918,9 +2078,12 @@ function finishedTask( } } else { boundary.pendingTasks--; - if (boundary.forceClientRender) { + if (boundary.status === CLIENT_RENDERED) { // This already errored. } else if (boundary.pendingTasks === 0) { + if (boundary.status === PENDING) { + boundary.status = COMPLETED; + } // This must have been the last segment we were waiting on. This boundary is now complete. if (segment.parentFlushed) { // Our parent segment already flushed, so we need to schedule this segment to be emitted. @@ -2034,17 +2197,35 @@ function retryTask(request: Request, task: Task): void { getSuspendedThenable() : thrownValue; - // $FlowFixMe[method-unbinding] - if (typeof x === 'object' && x !== null && typeof x.then === 'function') { - // Something suspended again, let's pick it back up later. - const ping = task.ping; - x.then(ping, ping); - task.thenableState = getThenableStateAfterSuspending(); - } else { - task.abortSet.delete(task); - segment.status = ERRORED; - erroredTask(request, task.blockedBoundary, segment, x); + if (typeof x === 'object' && x !== null) { + // $FlowFixMe[method-unbinding] + if (typeof x.then === 'function') { + // Something suspended again, let's pick it back up later. + const ping = task.ping; + x.then(ping, ping); + task.thenableState = getThenableStateAfterSuspending(); + return; + } else if ( + enablePostpone && + request.trackedPostpones !== null && + x.$$typeof === REACT_POSTPONE_TYPE && + task.blockedBoundary !== null // TODO: Support holes in the shell + ) { + // If we're tracking postpones, we mark this segment as postponed and finish + // the task without filling it in. If we're not tracking, we treat it more like + // an error. + const trackedPostpones = request.trackedPostpones; + task.abortSet.delete(task); + const postponeInstance: Postpone = (x: any); + logPostpone(request, postponeInstance.message); + trackPostpone(request, trackedPostpones, task, segment); + finishedTask(request, task.blockedBoundary, segment); + } } + task.abortSet.delete(task); + segment.status = ERRORED; + erroredTask(request, task.blockedBoundary, segment, x); + return; } finally { if (enableFloat) { setCurrentlyRenderingBoundaryResourcesTarget(request.renderState, null); @@ -2126,7 +2307,11 @@ function flushSubtree( case PENDING: { // We're emitting a placeholder for this segment to be filled in later. // Therefore we'll need to assign it an ID - to refer to it by. - const segmentID = (segment.id = request.nextSegmentId++); + segment.id = request.nextSegmentId++; + // Fallthrough + } + case POSTPONED: { + const segmentID = segment.id; // When this segment finally completes it won't be embedded in text since it will flush separately segment.lastPushedText = false; segment.textEmbedded = false; @@ -2174,10 +2359,11 @@ function flushSegment( // Not a suspense boundary. return flushSubtree(request, destination, segment); } + boundary.parentFlushed = true; // This segment is a Suspense boundary. We need to decide whether to // emit the content or the fallback now. - if (boundary.forceClientRender) { + if (boundary.status === CLIENT_RENDERED) { // Emit a client rendered suspense boundary wrapper. // We never queue the inner boundary so we'll never emit its content or partial segments. @@ -2195,7 +2381,13 @@ function flushSegment( destination, request.renderState, ); - } else if (boundary.pendingTasks > 0) { + } else if (boundary.status !== COMPLETED) { + if (boundary.status === PENDING) { + boundary.id = assignSuspenseBoundaryID( + request.renderState, + request.resumableState, + ); + } // This boundary is still loading. Emit a pending suspense boundary wrapper. // Assign an ID to refer to the future content by. @@ -2206,10 +2398,7 @@ function flushSegment( } /// This is the first time we should have referenced this ID. - const id = (boundary.id = assignSuspenseBoundaryID( - request.renderState, - request.resumableState, - )); + const id = boundary.id; writeStartPendingSuspenseBoundary(destination, request.renderState, id); @@ -2522,7 +2711,15 @@ function flushCompletedQueues( ) { request.flushScheduled = false; if (enableFloat) { - writePostamble(destination, request.resumableState); + // We write the trailing tags but only if don't have any data to resume. + // If we need to resume we'll write the postamble in the resume instead. + if ( + !enablePostpone || + request.trackedPostpones === null || + request.trackedPostpones.root.length === 0 + ) { + writePostamble(destination, request.resumableState); + } } completeWriting(destination); flushBuffered(destination); @@ -2542,7 +2739,7 @@ function flushCompletedQueues( } } -export function startWork(request: Request): void { +export function startRender(request: Request): void { request.flushScheduled = request.destination !== null; if (supportsRequestStorage) { scheduleWork(() => requestStorage.run(request, performWork, request)); @@ -2551,6 +2748,12 @@ export function startWork(request: Request): void { } } +export function startPrerender(request: Request): void { + // Start tracking postponed holes during this render. + request.trackedPostpones = {workingMap: new Map(), root: []}; + startRender(request); +} + function enqueueFlush(request: Request): void { if ( request.flushScheduled === false && @@ -2617,14 +2820,50 @@ export function getResumableState(request: Request): ResumableState { return request.resumableState; } +function addToResumableParent( + node: ResumableNode, + keyPath: KeyNode, + trackedPostpones: PostponedHoles, +): void { + const parentKeyPath = keyPath[0]; + if (parentKeyPath === null) { + trackedPostpones.root.push(node); + } else { + const workingMap = trackedPostpones.workingMap; + let parentNode = workingMap.get(parentKeyPath); + if (parentNode === undefined) { + parentNode = ([ + REPLAY_NODE, + parentKeyPath[1], + parentKeyPath[2], + ([]: Array), + ]: ResumableParentNode); + workingMap.set(parentKeyPath, parentNode); + addToResumableParent(parentNode, parentKeyPath, trackedPostpones); + } + parentNode[3].push(node); + } +} + export type PostponedState = { nextSegmentId: number, rootFormatContext: FormatContext, progressiveChunkSize: number, resumableState: ResumableState, + resumablePath: Array, }; // Returns the state of a postponed request or null if nothing was postponed. export function getPostponedState(request: Request): null | PostponedState { - return null; + const trackedPostpones = request.trackedPostpones; + if (trackedPostpones === null || trackedPostpones.root.length === 0) { + return null; + } + return { + nextSegmentId: request.nextSegmentId, + rootFormatContext: request.rootFormatContext, + progressiveChunkSize: request.progressiveChunkSize, + resumableState: request.resumableState, + resumablePath: trackedPostpones.root, + }; } diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index cb62cd0cb1f40..515b6aa870613 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -1519,7 +1519,7 @@ function flushCompletedChunks( } } -export function startWork(request: Request): void { +export function startRender(request: Request): void { request.flushScheduled = request.destination !== null; if (supportsRequestStorage) { scheduleWork(() => requestStorage.run(request, performWork, request)); diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index b87b82fafd316..4eaed23f7cc00 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -470,5 +470,6 @@ "482": "async/await is not yet supported in Client Components, only Server Components. This error is often caused by accidentally adding `'use client'` to a module that was originally written for the server.", "483": "Hooks are not supported inside an async component. This error is often caused by accidentally adding `'use client'` to a module that was originally written for the server.", "484": "A Server Component was postponed. The reason is omitted in production builds to avoid leaking sensitive details.", - "485": "Cannot update form state while rendering." + "485": "Cannot update form state while rendering.", + "486": "It should not be possible to postpone at the root. This is a bug in React." } \ No newline at end of file