From 6372c1e6286b394a8c6da4277865fd0cbbba0ea6 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 9 Apr 2025 00:28:59 -0400 Subject: [PATCH 1/2] Emit Activity boundaries as comments in Fizz Uses & for Activity as opposed to $ for Suspense. --- .../src/server/ReactFizzConfigDOM.js | 39 ++++++++++++++ .../src/server/ReactFizzConfigDOMLegacy.js | 25 +++++++++ .../react-markup/src/ReactFizzConfigMarkup.js | 18 +++++++ .../src/ReactNoopServer.js | 51 +++++++++++++++--- packages/react-server/src/ReactFizzServer.js | 53 ++++++++++++++----- .../src/forks/ReactFizzConfig.custom.js | 2 + 6 files changed, 170 insertions(+), 18 deletions(-) diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index 40acf5b3c611..0022820465a4 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -4087,6 +4087,28 @@ export function writePlaceholder( return writeChunkAndReturn(destination, placeholder2); } +// Activity boundaries are encoded as comments. +const startActivityBoundary = stringToPrecomputedChunk(''); +const endActivityBoundary = stringToPrecomputedChunk(''); + +export function pushStartActivityBoundary( + target: Array, + renderState: RenderState, +): void { + target.push(startActivityBoundary); +} + +export function pushEndActivityBoundary( + target: Array, + renderState: RenderState, + preambleState: null | PreambleState, +): void { + if (preambleState) { + pushPreambleContribution(target, preambleState); + } + target.push(endActivityBoundary); +} + // Suspense boundaries are encoded as comments. const startCompletedSuspenseBoundary = stringToPrecomputedChunk(''); const startPendingSuspenseBoundary1 = stringToPrecomputedChunk( @@ -4225,6 +4247,23 @@ export function writeEndClientRenderedSuspenseBoundary( const boundaryPreambleContributionChunkStart = stringToPrecomputedChunk(''); +function pushPreambleContribution( + target: Array, + preambleState: PreambleState, +) { + // Same as writePreambleContribution but for the render phase. + const contribution = preambleState.contribution; + if (contribution !== NoContribution) { + target.push( + boundaryPreambleContributionChunkStart, + // This is a number type so we can do the fast path without coercion checking + // eslint-disable-next-line react-internal/safe-string-coercion + stringToChunk('' + contribution), + boundaryPreambleContributionChunkEnd, + ); + } +} + function writePreambleContribution( destination: Destination, preambleState: PreambleState, diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js index ecd12d2ac5fd..78615d806324 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js @@ -20,6 +20,8 @@ import { createRenderState as createRenderStateImpl, pushTextInstance as pushTextInstanceImpl, pushSegmentFinale as pushSegmentFinaleImpl, + pushStartActivityBoundary as pushStartActivityBoundaryImpl, + pushEndActivityBoundary as pushEndActivityBoundaryImpl, writeStartCompletedSuspenseBoundary as writeStartCompletedSuspenseBoundaryImpl, writeStartClientRenderedSuspenseBoundary as writeStartClientRenderedSuspenseBoundaryImpl, writeEndCompletedSuspenseBoundary as writeEndCompletedSuspenseBoundaryImpl, @@ -207,6 +209,29 @@ export function pushSegmentFinale( } } +export function pushStartActivityBoundary( + target: Array, + renderState: RenderState, +): void { + if (renderState.generateStaticMarkup) { + // A completed boundary is done and doesn't need a representation in the HTML + // if we're not going to be hydrating it. + return; + } + pushStartActivityBoundaryImpl(target, renderState); +} + +export function pushEndActivityBoundary( + target: Array, + renderState: RenderState, + preambleState: null | PreambleState, +): void { + if (renderState.generateStaticMarkup) { + return; + } + pushEndActivityBoundaryImpl(target, renderState, preambleState); +} + export function writeStartCompletedSuspenseBoundary( destination: Destination, renderState: RenderState, diff --git a/packages/react-markup/src/ReactFizzConfigMarkup.js b/packages/react-markup/src/ReactFizzConfigMarkup.js index 358a08e7c54b..7e95c0ce3904 100644 --- a/packages/react-markup/src/ReactFizzConfigMarkup.js +++ b/packages/react-markup/src/ReactFizzConfigMarkup.js @@ -151,6 +151,23 @@ export function pushSegmentFinale( return; } +export function pushStartActivityBoundary( + target: Array, + renderState: RenderState, +): void { + // Markup doesn't have any instructions. + return; +} + +export function pushEndActivityBoundary( + target: Array, + renderState: RenderState, + preambleState: null | PreambleState, +): void { + // Markup doesn't have any instructions. + return; +} + export function writeStartCompletedSuspenseBoundary( destination: Destination, renderState: RenderState, @@ -158,6 +175,7 @@ export function writeStartCompletedSuspenseBoundary( // Markup doesn't have any instructions. return true; } + export function writeStartClientRenderedSuspenseBoundary( destination: Destination, renderState: RenderState, diff --git a/packages/react-noop-renderer/src/ReactNoopServer.js b/packages/react-noop-renderer/src/ReactNoopServer.js index 964cf4509df6..5af393f3317e 100644 --- a/packages/react-noop-renderer/src/ReactNoopServer.js +++ b/packages/react-noop-renderer/src/ReactNoopServer.js @@ -30,6 +30,10 @@ type TextInstance = { hidden: boolean, }; +type ActivityInstance = { + children: Array, +}; + type SuspenseInstance = { state: 'pending' | 'complete' | 'client-render', children: Array, @@ -164,44 +168,74 @@ const ReactNoopServer = ReactFizzServer({ }); }, + pushStartActivityBoundary( + target: Array, + renderState: RenderState, + ): void { + const activityInstance: ActivityInstance = { + children: [], + }; + target.push(Buffer.from(JSON.stringify(activityInstance), 'utf8')); + }, + + pushEndActivityBoundary( + target: Array, + renderState: RenderState, + preambleState: null | PreambleState, + ): void { + target.push(POP); + }, + writeStartCompletedSuspenseBoundary( destination: Destination, renderState: RenderState, - suspenseInstance: SuspenseInstance, ): boolean { - suspenseInstance.state = 'complete'; + const suspenseInstance: SuspenseInstance = { + state: 'complete', + children: [], + }; const parent = destination.stack[destination.stack.length - 1]; parent.children.push(suspenseInstance); destination.stack.push(suspenseInstance); + return true; }, writeStartPendingSuspenseBoundary( destination: Destination, renderState: RenderState, - suspenseInstance: SuspenseInstance, ): boolean { - suspenseInstance.state = 'pending'; + const suspenseInstance: SuspenseInstance = { + state: 'pending', + children: [], + }; const parent = destination.stack[destination.stack.length - 1]; parent.children.push(suspenseInstance); destination.stack.push(suspenseInstance); + return true; }, writeStartClientRenderedSuspenseBoundary( destination: Destination, renderState: RenderState, - suspenseInstance: SuspenseInstance, ): boolean { - suspenseInstance.state = 'client-render'; + const suspenseInstance: SuspenseInstance = { + state: 'client-render', + children: [], + }; const parent = destination.stack[destination.stack.length - 1]; parent.children.push(suspenseInstance); destination.stack.push(suspenseInstance); + return true; }, writeEndCompletedSuspenseBoundary(destination: Destination): boolean { destination.stack.pop(); + return true; }, writeEndPendingSuspenseBoundary(destination: Destination): boolean { destination.stack.pop(); + return true; }, writeEndClientRenderedSuspenseBoundary(destination: Destination): boolean { destination.stack.pop(); + return true; }, writeStartSegment( @@ -218,9 +252,11 @@ const ReactNoopServer = ReactFizzServer({ throw new Error('Segments are only expected at the root of the stack.'); } destination.stack.push(segment); + return true; }, writeEndSegment(destination: Destination, formatContext: null): boolean { destination.stack.pop(); + return true; }, writeCompletedSegmentInstruction( @@ -241,6 +277,7 @@ const ReactNoopServer = ReactFizzServer({ 0, ...segment.children, ); + return true; }, writeCompletedBoundaryInstruction( @@ -255,6 +292,7 @@ const ReactNoopServer = ReactFizzServer({ } boundary.children = segment.children; boundary.state = 'complete'; + return true; }, writeClientRenderBoundaryInstruction( @@ -263,6 +301,7 @@ const ReactNoopServer = ReactFizzServer({ boundary: SuspenseInstance, ): boolean { boundary.status = 'client-render'; + return true; }, writePreambleStart() {}, diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index daa77239bad4..b0580aa1a174 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -51,6 +51,8 @@ import { import { writeCompletedRoot, writePlaceholder, + pushStartActivityBoundary, + pushEndActivityBoundary, writeStartCompletedSuspenseBoundary, writeStartPendingSuspenseBoundary, writeStartClientRenderedSuspenseBoundary, @@ -2200,23 +2202,50 @@ function renderLazyComponent( renderElement(request, task, keyPath, Component, resolvedProps, ref); } -function renderOffscreen( +function renderActivity( request: Request, task: Task, keyPath: KeyNode, props: Object, ): void { - const mode: ?OffscreenMode = (props.mode: any); - if (mode === 'hidden') { - // A hidden Offscreen boundary is not server rendered. Prerendering happens - // on the client. + const segment = task.blockedSegment; + if (segment === null) { + // Replay + const mode: ?OffscreenMode = (props.mode: any); + if (mode === 'hidden') { + // A hidden Activity boundary is not server rendered. Prerendering happens + // on the client. + } else { + // A visible Activity boundary has its children rendered inside the boundary. + const prevKeyPath = task.keyPath; + task.keyPath = keyPath; + renderNode(request, task, props.children, -1); + task.keyPath = prevKeyPath; + } } else { - // A visible Offscreen boundary is treated exactly like a fragment: a - // pure indirection. - const prevKeyPath = task.keyPath; - task.keyPath = keyPath; - renderNodeDestructive(request, task, props.children, -1); - task.keyPath = prevKeyPath; + // Render + // An Activity boundary is delimited so that we can hydrate it separately. + pushStartActivityBoundary(segment.chunks, request.renderState); + segment.lastPushedText = false; + const mode: ?OffscreenMode = (props.mode: any); + if (mode === 'hidden') { + // A hidden Activity boundary is not server rendered. Prerendering happens + // on the client. + } else { + // A visible Activity boundary has its children rendered inside the boundary. + const prevKeyPath = task.keyPath; + task.keyPath = keyPath; + // We use the non-destructive form because if something suspends, we still + // need to pop back up and finish the end comment. + renderNode(request, task, props.children, -1); + task.keyPath = prevKeyPath; + } + pushEndActivityBoundary( + segment.chunks, + request.renderState, + task.blockedPreamble, + ); + segment.lastPushedText = false; } } @@ -2291,7 +2320,7 @@ function renderElement( return; } case REACT_ACTIVITY_TYPE: { - renderOffscreen(request, task, keyPath, props); + renderActivity(request, task, keyPath, props); return; } case REACT_SUSPENSE_LIST_TYPE: { diff --git a/packages/react-server/src/forks/ReactFizzConfig.custom.js b/packages/react-server/src/forks/ReactFizzConfig.custom.js index d386d04e3981..ad33d1fc72ba 100644 --- a/packages/react-server/src/forks/ReactFizzConfig.custom.js +++ b/packages/react-server/src/forks/ReactFizzConfig.custom.js @@ -59,6 +59,8 @@ export const pushFormStateMarkerIsNotMatching = $$$config.pushFormStateMarkerIsNotMatching; export const writeCompletedRoot = $$$config.writeCompletedRoot; export const writePlaceholder = $$$config.writePlaceholder; +export const pushStartActivityBoundary = $$$config.pushStartActivityBoundary; +export const pushEndActivityBoundary = $$$config.pushEndActivityBoundary; export const writeStartCompletedSuspenseBoundary = $$$config.writeStartCompletedSuspenseBoundary; export const writeStartPendingSuspenseBoundary = From 8a21d0dfb7aa9d5e977299a38bb6886ecdf5c89f Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 9 Apr 2025 01:17:51 -0400 Subject: [PATCH 2/2] Update test --- .../ReactDOMServerPartialHydration-test.internal.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js index 94d672cef42a..86a0ceb42e84 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js @@ -3669,7 +3669,7 @@ describe('ReactDOMServerPartialHydration', () => { }); // @gate enableActivity - it('a visible Activity component acts like a fragment', async () => { + it('a visible Activity component is surrounded by comment markers', async () => { const ref = React.createRef(); function App() { @@ -3690,9 +3690,11 @@ describe('ReactDOMServerPartialHydration', () => { // pure indirection. expect(container).toMatchInlineSnapshot(`
+ Child +
`); @@ -3739,6 +3741,8 @@ describe('ReactDOMServerPartialHydration', () => { Visible + + `); @@ -3760,6 +3764,8 @@ describe('ReactDOMServerPartialHydration', () => { Visible + +