From 57488c6fd732606809487d0eb0cb8c5713effa08 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Tue, 29 Aug 2023 12:19:24 -0400 Subject: [PATCH 01/10] Fix Node Server export names --- packages/react-dom/npm/server.node.js | 4 ++-- packages/react-dom/server.node.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) 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, ); From d814510d8496e95ffe63da65ba46914f3fc033d0 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Mon, 28 Aug 2023 11:37:55 -0400 Subject: [PATCH 02/10] Add trackedPostpones field I renamed startWork to startRender and added a startPrerender. Later, we'll probably add a startResume. This just adds a non-null Map. This is a signal to the runtime to track postponed holes. Ideally this would be static but it's not worth the forking that would require. Especially since this is just a cost when you postpone other than a bit extra code. --- .../src/server/ReactDOMFizzServerBrowser.js | 6 ++--- .../src/server/ReactDOMFizzServerBun.js | 4 +-- .../src/server/ReactDOMFizzServerEdge.js | 6 ++--- .../src/server/ReactDOMFizzServerNode.js | 6 ++--- .../src/server/ReactDOMFizzStaticBrowser.js | 4 +-- .../src/server/ReactDOMFizzStaticEdge.js | 4 +-- .../src/server/ReactDOMFizzStaticNode.js | 4 +-- .../src/server/ReactDOMLegacyServerImpl.js | 4 +-- .../server/ReactDOMLegacyServerNodeStream.js | 4 +-- .../src/ReactNoopFlightServer.js | 2 +- .../src/ReactNoopServer.js | 2 +- .../src/ReactFlightDOMServerNode.js | 4 +-- .../src/ReactDOMServerFB.js | 4 +-- .../src/ReactFlightDOMServerBrowser.js | 4 +-- .../src/ReactFlightDOMServerEdge.js | 4 +-- .../src/ReactFlightDOMServerNode.js | 4 +-- packages/react-server/src/ReactFizzServer.js | 26 +++++++++++++++++-- .../react-server/src/ReactFlightServer.js | 2 +- 18 files changed, 58 insertions(+), 36 deletions(-) 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-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..7fccae26980cf 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -158,6 +158,12 @@ type KeyNode = | null | [KeyNode /* parent */, string | null /* name */, string | number /* key */]; +type PostponedBoundary = { + boundary: SuspenseBoundary, + segments: Map, +}; +type PostponedHoles = Map; + type LegacyContext = { [key: string]: any, }; @@ -237,6 +243,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 @@ -317,6 +324,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, @@ -2522,7 +2530,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.size === 0 + ) { + writePostamble(destination, request.resumableState); + } } completeWriting(destination); flushBuffered(destination); @@ -2542,7 +2558,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 +2567,12 @@ export function startWork(request: Request): void { } } +export function startPrerender(request: Request): void { + // Start tracking postponed holes during this render. + request.trackedPostpones = new Map(); + startRender(request); +} + function enqueueFlush(request: Request): void { if ( request.flushScheduled === false && 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)); From 9286b09fa63686b24a3579f274eb963307bf94c0 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Mon, 28 Aug 2023 11:45:28 -0400 Subject: [PATCH 03/10] Return postponed state when some postponed were tracked Todo this we need to stash the rootFormatContext on the request. It's unfortunate that this is state that's not needed for regular rendering. --- packages/react-server/src/ReactFizzServer.js | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 7fccae26980cf..8983c05339285 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -230,6 +230,7 @@ export opaque type Request = { flushScheduled: boolean, +resumableState: ResumableState, +renderState: RenderState, + +rootFormatContext: FormatContext, +progressiveChunkSize: number, status: 0 | 1 | 2, fatalError: mixed, @@ -309,6 +310,7 @@ export function createRequest( flushScheduled: false, resumableState, renderState, + rootFormatContext, progressiveChunkSize: progressiveChunkSize === undefined ? DEFAULT_PROGRESSIVE_CHUNK_SIZE @@ -2648,5 +2650,16 @@ export type PostponedState = { // Returns the state of a postponed request or null if nothing was postponed. export function getPostponedState(request: Request): null | PostponedState { - return null; + if ( + request.trackedPostpones === null || + request.trackedPostpones.size === 0 + ) { + return null; + } + return { + nextSegmentId: request.nextSegmentId, + rootFormatContext: request.rootFormatContext, + progressiveChunkSize: request.progressiveChunkSize, + resumableState: request.resumableState, + }; } From 5e4869fe5d7243b50321dd42ec8cc87d66c8704e Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Tue, 29 Aug 2023 11:49:44 -0400 Subject: [PATCH 04/10] Add basic tests --- .../src/__tests__/ReactDOMFizzServer-test.js | 101 ++++++++----- .../ReactDOMFizzStaticBrowser-test.js | 137 ++++++++++++++++++ .../react-dom/src/test-utils/FizzTestUtils.js | 43 ++++++ 3 files changed, 241 insertions(+), 40 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index fb3a2ae86ee5a..dec63231edb28 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 experimental + 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..8c5b2573232a6 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 experimental + 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 experimental + 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/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, }; From d58d26d4782eee4b8280a2d26614f8955eaa20b5 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Tue, 29 Aug 2023 12:34:55 -0400 Subject: [PATCH 05/10] Track the key path on the SuspenseBoundary This is unfortunate that we need to add this to the render path in general but this doesn't really cost anything extra in terms of allocations since this object would live anyway so it's an extra field. --- packages/react-server/src/ReactFizzServer.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 8983c05339285..1c76b9eaea846 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -181,6 +181,7 @@ type SuspenseBoundary = { 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: KeyNode, }; export type Task = { @@ -385,6 +386,7 @@ function pingTask(request: Request, task: Task): void { function createSuspenseBoundary( request: Request, fallbackAbortableTasks: Set, + keyPath: KeyNode, ): SuspenseBoundary { return { id: UNINITIALIZED_SUSPENSE_BOUNDARY_ID, @@ -397,6 +399,7 @@ function createSuspenseBoundary( fallbackAbortableTasks, errorDigest: null, resources: createBoundaryResources(), + keyPath, }; } @@ -590,7 +593,7 @@ 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( From 3f0874ace415993b6e3560f826e249c22987f4bd Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Tue, 29 Aug 2023 15:09:30 -0400 Subject: [PATCH 06/10] Track postponed holes For now this only tracks inside boundaries but it's set up to handle holes in the root too. We now need a status field on the boundary because pendingTasks === 0 no longer implies that a boundary is complete. --- packages/react-server/src/ReactFizzServer.js | 232 ++++++++++++++----- 1 file changed, 172 insertions(+), 60 deletions(-) diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 1c76b9eaea846..5a29408b61136 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -158,23 +158,26 @@ type KeyNode = | null | [KeyNode /* parent */, string | null /* name */, string | number /* key */]; -type PostponedBoundary = { - boundary: SuspenseBoundary, - segments: Map, +type PostponedSegment = { + keyPath: KeyNode, + segment: Segment, }; -type PostponedHoles = Map; + +type PostponedHoles = Map>; 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. @@ -203,11 +206,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 @@ -389,11 +393,11 @@ function createSuspenseBoundary( keyPath: KeyNode, ): SuspenseBoundary { return { + status: PENDING, id: UNINITIALIZED_SUSPENSE_BOUNDARY_ID, rootSegmentID: -1, parentFlushed: false, pendingTasks: 0, - forceClientRender: false, completedSegments: [], byteSize: 0, fallbackAbortableTasks, @@ -593,7 +597,11 @@ function renderSuspenseBoundary( const content: ReactNodeList = props.children; const fallbackAbortSet: Set = new Set(); - const newBoundary = createSuspenseBoundary(request, fallbackAbortSet, task.keyPath); + 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( @@ -650,7 +658,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. @@ -659,7 +668,7 @@ function renderSuspenseBoundary( } } catch (error) { contentRootSegment.status = ERRORED; - newBoundary.forceClientRender = true; + newBoundary.status = CLIENT_RENDERED; let errorDigest; if ( enablePostpone && @@ -1637,6 +1646,53 @@ function renderChildrenArray( } } +function trackPostpone( + trackedPostpones: PostponedHoles, + task: Task, + segment: Segment, +): void { + const boundary = task.blockedBoundary; + if (boundary !== null && boundary.status === PENDING) { + boundary.status = POSTPONED; + } + let postponedSegments = trackedPostpones.get(task.blockedBoundary); + if (postponedSegments === undefined) { + // This is the first time we've postponed this boundary. + postponedSegments = []; + trackedPostpones.set(task.blockedBoundary, postponedSegments); + } + postponedSegments.push({ + keyPath: task.keyPath, + segment, + }); +} + +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, + ); + newSegment.status = POSTPONED; + 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, @@ -1726,40 +1782,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(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; } } @@ -1788,8 +1877,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); @@ -1842,9 +1931,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 = @@ -1931,9 +2019,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. @@ -2047,17 +2138,36 @@ 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); + segment.status = POSTPONED; + const postponeInstance: Postpone = (x: any); + logPostpone(request, postponeInstance.message); + trackPostpone(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); @@ -2136,7 +2246,8 @@ function flushSubtree( ): boolean { segment.parentFlushed = true; switch (segment.status) { - case PENDING: { + case PENDING: + case POSTPONED: { // 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++); @@ -2187,10 +2298,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. @@ -2208,7 +2320,7 @@ function flushSegment( destination, request.renderState, ); - } else if (boundary.pendingTasks > 0) { + } else if (boundary.status !== COMPLETED) { // This boundary is still loading. Emit a pending suspense boundary wrapper. // Assign an ID to refer to the future content by. From e8c8152ccecb961f4536a65233544165b0672b60 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 30 Aug 2023 13:33:03 -0400 Subject: [PATCH 07/10] Encode postponed holes into a resumable path tree This creates a tree that we can follow when replaying to find those holes. This flips the tree and encodes the ids at this point. --- packages/react-server/src/ReactFizzServer.js | 119 +++++++++++++++++-- scripts/error-codes/codes.json | 3 +- 2 files changed, 110 insertions(+), 12 deletions(-) diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 5a29408b61136..923dcd696e403 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -154,9 +154,38 @@ 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 PostponedSegment = { keyPath: KeyNode, @@ -184,7 +213,7 @@ type SuspenseBoundary = { 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: KeyNode, + keyPath: Root | KeyNode, }; export type Task = { @@ -193,7 +222,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 @@ -390,7 +419,7 @@ function pingTask(request: Request, task: Task): void { function createSuspenseBoundary( request: Request, fallbackAbortableTasks: Set, - keyPath: KeyNode, + keyPath: Root | KeyNode, ): SuspenseBoundary { return { status: PENDING, @@ -414,7 +443,7 @@ function createTask( blockedBoundary: Root | SuspenseBoundary, blockedSegment: Segment, abortSet: Set, - keyPath: KeyNode, + keyPath: Root | KeyNode, legacyContext: LegacyContext, context: ContextSnapshot, treeContext: TreeContext, @@ -1661,6 +1690,11 @@ function trackPostpone( postponedSegments = []; trackedPostpones.set(task.blockedBoundary, postponedSegments); } + if (task.keyPath === null) { + throw new Error( + 'It should not be possible to postpone at the root. This is a bug in React.', + ); + } postponedSegments.push({ keyPath: task.keyPath, segment, @@ -2756,25 +2790,88 @@ export function getResumableState(request: Request): ResumableState { return request.resumableState; } +function addToResumableParent( + node: ResumableNode, + keyPath: KeyNode, + root: Array, + workingMap: Map, +): void { + const parentKeyPath = keyPath[0]; + if (parentKeyPath === null) { + root.push(node); + } else { + let parentNode = workingMap.get(parentKeyPath); + if (parentNode === undefined) { + parentNode = ([ + REPLAY_NODE, + parentKeyPath[1], + parentKeyPath[2], + ([]: Array), + ]: ResumableParentNode); + workingMap.set(parentKeyPath, parentNode); + addToResumableParent(parentNode, parentKeyPath, root, workingMap); + } + 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 { - if ( - request.trackedPostpones === null || - request.trackedPostpones.size === 0 - ) { + const trackedPostpones = request.trackedPostpones; + if (trackedPostpones === null || trackedPostpones.size === 0) { return null; } + + // Next we build up a traversal tree of the resumable key paths and their + // paths through suspense boundaries. We do this after the fact as a second + // pass because the IDs aren't assigned until things are flushed so we don't + // have them at the time they're added to the map. + const workingMap: Map = new Map(); + const root: Array = []; + + trackedPostpones.forEach(function ( + segments: Array, + boundary: Root | SuspenseBoundary, + ) { + if (boundary !== null && boundary.keyPath !== null) { + const keyPath = boundary.keyPath; + const children: Array = []; + const boundaryNode: ResumableParentNode = [ + REPLAY_SUSPENSE_BOUNDARY, + keyPath[1], + keyPath[2], + children, + boundary.id, + ]; + workingMap.set(boundary.keyPath, boundaryNode); + addToResumableParent(boundaryNode, keyPath, root, workingMap); + } + for (let i = 0; i < segments.length; i++) { + const postponedSegment = segments[i]; + const keyPath = postponedSegment.keyPath; + const segmentNode: ResumableNode = [ + RESUME_SEGMENT, + keyPath[1], + keyPath[2], + postponedSegment.segment.id, + ]; + addToResumableParent(segmentNode, keyPath, root, workingMap); + } + }); + return { nextSegmentId: request.nextSegmentId, rootFormatContext: request.rootFormatContext, progressiveChunkSize: request.progressiveChunkSize, resumableState: request.resumableState, + resumablePath: root, }; } 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 From e96bb0979d687eba0099f0006ff1070da1f1de92 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 30 Aug 2023 13:46:52 -0400 Subject: [PATCH 08/10] Eagerly assign ids to postponed segments or boundaries Because we don't flush the stream until later after we've returned we need to assign these points an ID early so that we can write them in the resume payload. --- packages/react-server/src/ReactFizzServer.js | 39 ++++++++++++++------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 923dcd696e403..b73a2f7b5db9b 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -1676,6 +1676,7 @@ function renderChildrenArray( } function trackPostpone( + request: Request, trackedPostpones: PostponedHoles, task: Task, segment: Segment, @@ -1683,6 +1684,12 @@ function trackPostpone( 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, + ); } let postponedSegments = trackedPostpones.get(task.blockedBoundary); if (postponedSegments === undefined) { @@ -1721,6 +1728,8 @@ function injectPostponedHole( true, ); newSegment.status = POSTPONED; + // We know that this will leave a hole so we might as well assign an ID now. + segment.id = request.nextSegmentId++; segment.children.push(newSegment); // Reset lastPushedText for current Segment since the new Segment "consumed" it segment.lastPushedText = false; @@ -1853,7 +1862,7 @@ function renderNode( task, postponeInstance.message, ); - trackPostpone(trackedPostpones, task, postponedSegment); + 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. @@ -2192,9 +2201,11 @@ function retryTask(request: Request, task: Task): void { const trackedPostpones = request.trackedPostpones; task.abortSet.delete(task); 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 postponeInstance: Postpone = (x: any); logPostpone(request, postponeInstance.message); - trackPostpone(trackedPostpones, task, segment); + trackPostpone(request, trackedPostpones, task, segment); finishedTask(request, task.blockedBoundary, segment); } } @@ -2280,11 +2291,14 @@ function flushSubtree( ): boolean { segment.parentFlushed = true; switch (segment.status) { - case PENDING: - case POSTPONED: { + 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; @@ -2355,6 +2369,12 @@ function flushSegment( request.renderState, ); } 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. @@ -2365,10 +2385,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); @@ -2831,9 +2848,7 @@ export function getPostponedState(request: Request): null | PostponedState { } // Next we build up a traversal tree of the resumable key paths and their - // paths through suspense boundaries. We do this after the fact as a second - // pass because the IDs aren't assigned until things are flushed so we don't - // have them at the time they're added to the map. + // paths through suspense boundaries. const workingMap: Map = new Map(); const root: Array = []; From 019dd90d73ad6f21f152f18ffe553312ca52c5e3 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 30 Aug 2023 14:12:29 -0400 Subject: [PATCH 09/10] Eagerly encode the serializable nodes Since we're now assigning IDs eagerly to postponed boundaries and segments we can encode them into their serialized form eagerly. Instead of doing a second pass at the end. --- packages/react-server/src/ReactFizzServer.js | 109 ++++++++----------- 1 file changed, 43 insertions(+), 66 deletions(-) diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index b73a2f7b5db9b..914fb152a4f48 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -187,13 +187,11 @@ type ResumableNode = number /* segment id */, ]; -type PostponedSegment = { - keyPath: KeyNode, - segment: Segment, +type PostponedHoles = { + workingMap: Map, + root: Array, }; -type PostponedHoles = Map>; - type LegacyContext = { [key: string]: any, }; @@ -1681,6 +1679,10 @@ function trackPostpone( 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; @@ -1690,22 +1692,39 @@ function trackPostpone( 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); } - let postponedSegments = trackedPostpones.get(task.blockedBoundary); - if (postponedSegments === undefined) { - // This is the first time we've postponed this boundary. - postponedSegments = []; - trackedPostpones.set(task.blockedBoundary, postponedSegments); - } - if (task.keyPath === null) { + + 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.', ); } - postponedSegments.push({ - keyPath: task.keyPath, - segment, - }); + + const segmentNode: ResumableNode = [ + RESUME_SEGMENT, + keyPath[1], + keyPath[2], + segment.id, + ]; + addToResumableParent(segmentNode, keyPath, trackedPostpones); } function injectPostponedHole( @@ -1727,9 +1746,6 @@ function injectPostponedHole( // Assume we are text embedded at the trailing edge true, ); - newSegment.status = POSTPONED; - // We know that this will leave a hole so we might as well assign an ID now. - segment.id = request.nextSegmentId++; segment.children.push(newSegment); // Reset lastPushedText for current Segment since the new Segment "consumed" it segment.lastPushedText = false; @@ -2200,9 +2216,6 @@ function retryTask(request: Request, task: Task): void { // an error. const trackedPostpones = request.trackedPostpones; task.abortSet.delete(task); - 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 postponeInstance: Postpone = (x: any); logPostpone(request, postponeInstance.message); trackPostpone(request, trackedPostpones, task, segment); @@ -2703,7 +2716,7 @@ function flushCompletedQueues( if ( !enablePostpone || request.trackedPostpones === null || - request.trackedPostpones.size === 0 + request.trackedPostpones.root.length === 0 ) { writePostamble(destination, request.resumableState); } @@ -2737,7 +2750,7 @@ export function startRender(request: Request): void { export function startPrerender(request: Request): void { // Start tracking postponed holes during this render. - request.trackedPostpones = new Map(); + request.trackedPostpones = {workingMap: new Map(), root: []}; startRender(request); } @@ -2810,13 +2823,13 @@ export function getResumableState(request: Request): ResumableState { function addToResumableParent( node: ResumableNode, keyPath: KeyNode, - root: Array, - workingMap: Map, + trackedPostpones: PostponedHoles, ): void { const parentKeyPath = keyPath[0]; if (parentKeyPath === null) { - root.push(node); + trackedPostpones.root.push(node); } else { + const workingMap = trackedPostpones.workingMap; let parentNode = workingMap.get(parentKeyPath); if (parentNode === undefined) { parentNode = ([ @@ -2826,7 +2839,7 @@ function addToResumableParent( ([]: Array), ]: ResumableParentNode); workingMap.set(parentKeyPath, parentNode); - addToResumableParent(parentNode, parentKeyPath, root, workingMap); + addToResumableParent(parentNode, parentKeyPath, trackedPostpones); } parentNode[3].push(node); } @@ -2843,50 +2856,14 @@ export type PostponedState = { // Returns the state of a postponed request or null if nothing was postponed. export function getPostponedState(request: Request): null | PostponedState { const trackedPostpones = request.trackedPostpones; - if (trackedPostpones === null || trackedPostpones.size === 0) { + if (trackedPostpones === null || trackedPostpones.root.length === 0) { return null; } - - // Next we build up a traversal tree of the resumable key paths and their - // paths through suspense boundaries. - const workingMap: Map = new Map(); - const root: Array = []; - - trackedPostpones.forEach(function ( - segments: Array, - boundary: Root | SuspenseBoundary, - ) { - if (boundary !== null && boundary.keyPath !== null) { - const keyPath = boundary.keyPath; - const children: Array = []; - const boundaryNode: ResumableParentNode = [ - REPLAY_SUSPENSE_BOUNDARY, - keyPath[1], - keyPath[2], - children, - boundary.id, - ]; - workingMap.set(boundary.keyPath, boundaryNode); - addToResumableParent(boundaryNode, keyPath, root, workingMap); - } - for (let i = 0; i < segments.length; i++) { - const postponedSegment = segments[i]; - const keyPath = postponedSegment.keyPath; - const segmentNode: ResumableNode = [ - RESUME_SEGMENT, - keyPath[1], - keyPath[2], - postponedSegment.segment.id, - ]; - addToResumableParent(segmentNode, keyPath, root, workingMap); - } - }); - return { nextSegmentId: request.nextSegmentId, rootFormatContext: request.rootFormatContext, progressiveChunkSize: request.progressiveChunkSize, resumableState: request.resumableState, - resumablePath: root, + resumablePath: trackedPostpones.root, }; } From 7733a1570f97ea4cc6cefc00a868ddd2de7c3ca7 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Thu, 31 Aug 2023 11:27:00 -0400 Subject: [PATCH 10/10] Gate test --- packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js | 2 +- .../react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index dec63231edb28..8357b8304d8dd 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -6193,7 +6193,7 @@ describe('ReactDOMFizzServer', () => { }, ); - // @gate experimental + // @gate enablePostpone it('supports postponing in prerender and resuming later', async () => { let prerendering = true; function Postpone() { diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js index 8c5b2573232a6..6873031207fd5 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js @@ -454,7 +454,7 @@ describe('ReactDOMFizzStaticBrowser', () => { expect(errors).toEqual(['uh oh', 'uh oh']); }); - // @gate experimental + // @gate enablePostpone it('supports postponing in prerender and resuming later', async () => { let prerendering = true; function Postpone() { @@ -493,7 +493,7 @@ describe('ReactDOMFizzStaticBrowser', () => { // TODO: expect(getVisibleChildren(container)).toEqual(
Hello
); }); - // @gate experimental + // @gate enablePostpone it('only emits end tags once when resuming', async () => { let prerendering = true; function Postpone() {