Emit Activity boundaries as comments in Fizz (#32834)

Uses `&` for Activity as opposed to `$` for Suspense. This will be used
to delimitate which nodes we can skip hydrating.

This isn't used on the client yet. It's just a noop on the client
because it's just an unknown comment. This just adds the SSR parts.
This commit is contained in:
Sebastian Markbåge 2025-04-09 10:59:52 -04:00 committed by GitHub
parent 8571249eb8
commit 3fbfb9baaf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 177 additions and 19 deletions

View File

@ -4087,6 +4087,28 @@ export function writePlaceholder(
return writeChunkAndReturn(destination, placeholder2); return writeChunkAndReturn(destination, placeholder2);
} }
// Activity boundaries are encoded as comments.
const startActivityBoundary = stringToPrecomputedChunk('<!--&-->');
const endActivityBoundary = stringToPrecomputedChunk('<!--/&-->');
export function pushStartActivityBoundary(
target: Array<Chunk | PrecomputedChunk>,
renderState: RenderState,
): void {
target.push(startActivityBoundary);
}
export function pushEndActivityBoundary(
target: Array<Chunk | PrecomputedChunk>,
renderState: RenderState,
preambleState: null | PreambleState,
): void {
if (preambleState) {
pushPreambleContribution(target, preambleState);
}
target.push(endActivityBoundary);
}
// Suspense boundaries are encoded as comments. // Suspense boundaries are encoded as comments.
const startCompletedSuspenseBoundary = stringToPrecomputedChunk('<!--$-->'); const startCompletedSuspenseBoundary = stringToPrecomputedChunk('<!--$-->');
const startPendingSuspenseBoundary1 = stringToPrecomputedChunk( const startPendingSuspenseBoundary1 = stringToPrecomputedChunk(
@ -4225,6 +4247,23 @@ export function writeEndClientRenderedSuspenseBoundary(
const boundaryPreambleContributionChunkStart = stringToPrecomputedChunk('<!--'); const boundaryPreambleContributionChunkStart = stringToPrecomputedChunk('<!--');
const boundaryPreambleContributionChunkEnd = stringToPrecomputedChunk('-->'); const boundaryPreambleContributionChunkEnd = stringToPrecomputedChunk('-->');
function pushPreambleContribution(
target: Array<Chunk | PrecomputedChunk>,
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( function writePreambleContribution(
destination: Destination, destination: Destination,
preambleState: PreambleState, preambleState: PreambleState,

View File

@ -20,6 +20,8 @@ import {
createRenderState as createRenderStateImpl, createRenderState as createRenderStateImpl,
pushTextInstance as pushTextInstanceImpl, pushTextInstance as pushTextInstanceImpl,
pushSegmentFinale as pushSegmentFinaleImpl, pushSegmentFinale as pushSegmentFinaleImpl,
pushStartActivityBoundary as pushStartActivityBoundaryImpl,
pushEndActivityBoundary as pushEndActivityBoundaryImpl,
writeStartCompletedSuspenseBoundary as writeStartCompletedSuspenseBoundaryImpl, writeStartCompletedSuspenseBoundary as writeStartCompletedSuspenseBoundaryImpl,
writeStartClientRenderedSuspenseBoundary as writeStartClientRenderedSuspenseBoundaryImpl, writeStartClientRenderedSuspenseBoundary as writeStartClientRenderedSuspenseBoundaryImpl,
writeEndCompletedSuspenseBoundary as writeEndCompletedSuspenseBoundaryImpl, writeEndCompletedSuspenseBoundary as writeEndCompletedSuspenseBoundaryImpl,
@ -207,6 +209,29 @@ export function pushSegmentFinale(
} }
} }
export function pushStartActivityBoundary(
target: Array<Chunk | PrecomputedChunk>,
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<Chunk | PrecomputedChunk>,
renderState: RenderState,
preambleState: null | PreambleState,
): void {
if (renderState.generateStaticMarkup) {
return;
}
pushEndActivityBoundaryImpl(target, renderState, preambleState);
}
export function writeStartCompletedSuspenseBoundary( export function writeStartCompletedSuspenseBoundary(
destination: Destination, destination: Destination,
renderState: RenderState, renderState: RenderState,

View File

@ -3669,7 +3669,7 @@ describe('ReactDOMServerPartialHydration', () => {
}); });
// @gate enableActivity // @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(); const ref = React.createRef();
function App() { function App() {
@ -3690,9 +3690,11 @@ describe('ReactDOMServerPartialHydration', () => {
// pure indirection. // pure indirection.
expect(container).toMatchInlineSnapshot(` expect(container).toMatchInlineSnapshot(`
<div> <div>
<!--&-->
<span> <span>
Child Child
</span> </span>
<!--/&-->
</div> </div>
`); `);
@ -3739,6 +3741,8 @@ describe('ReactDOMServerPartialHydration', () => {
<span> <span>
Visible Visible
</span> </span>
<!--&-->
<!--/&-->
</div> </div>
`); `);
@ -3760,6 +3764,8 @@ describe('ReactDOMServerPartialHydration', () => {
<span> <span>
Visible Visible
</span> </span>
<!--&-->
<!--/&-->
<span <span
style="display: none;" style="display: none;"
> >

View File

@ -151,6 +151,23 @@ export function pushSegmentFinale(
return; return;
} }
export function pushStartActivityBoundary(
target: Array<Chunk | PrecomputedChunk>,
renderState: RenderState,
): void {
// Markup doesn't have any instructions.
return;
}
export function pushEndActivityBoundary(
target: Array<Chunk | PrecomputedChunk>,
renderState: RenderState,
preambleState: null | PreambleState,
): void {
// Markup doesn't have any instructions.
return;
}
export function writeStartCompletedSuspenseBoundary( export function writeStartCompletedSuspenseBoundary(
destination: Destination, destination: Destination,
renderState: RenderState, renderState: RenderState,
@ -158,6 +175,7 @@ export function writeStartCompletedSuspenseBoundary(
// Markup doesn't have any instructions. // Markup doesn't have any instructions.
return true; return true;
} }
export function writeStartClientRenderedSuspenseBoundary( export function writeStartClientRenderedSuspenseBoundary(
destination: Destination, destination: Destination,
renderState: RenderState, renderState: RenderState,

View File

@ -30,6 +30,10 @@ type TextInstance = {
hidden: boolean, hidden: boolean,
}; };
type ActivityInstance = {
children: Array<Instance | TextInstance | SuspenseInstance>,
};
type SuspenseInstance = { type SuspenseInstance = {
state: 'pending' | 'complete' | 'client-render', state: 'pending' | 'complete' | 'client-render',
children: Array<Instance | TextInstance | SuspenseInstance>, children: Array<Instance | TextInstance | SuspenseInstance>,
@ -164,44 +168,74 @@ const ReactNoopServer = ReactFizzServer({
}); });
}, },
pushStartActivityBoundary(
target: Array<Uint8Array>,
renderState: RenderState,
): void {
const activityInstance: ActivityInstance = {
children: [],
};
target.push(Buffer.from(JSON.stringify(activityInstance), 'utf8'));
},
pushEndActivityBoundary(
target: Array<Uint8Array>,
renderState: RenderState,
preambleState: null | PreambleState,
): void {
target.push(POP);
},
writeStartCompletedSuspenseBoundary( writeStartCompletedSuspenseBoundary(
destination: Destination, destination: Destination,
renderState: RenderState, renderState: RenderState,
suspenseInstance: SuspenseInstance,
): boolean { ): boolean {
suspenseInstance.state = 'complete'; const suspenseInstance: SuspenseInstance = {
state: 'complete',
children: [],
};
const parent = destination.stack[destination.stack.length - 1]; const parent = destination.stack[destination.stack.length - 1];
parent.children.push(suspenseInstance); parent.children.push(suspenseInstance);
destination.stack.push(suspenseInstance); destination.stack.push(suspenseInstance);
return true;
}, },
writeStartPendingSuspenseBoundary( writeStartPendingSuspenseBoundary(
destination: Destination, destination: Destination,
renderState: RenderState, renderState: RenderState,
suspenseInstance: SuspenseInstance,
): boolean { ): boolean {
suspenseInstance.state = 'pending'; const suspenseInstance: SuspenseInstance = {
state: 'pending',
children: [],
};
const parent = destination.stack[destination.stack.length - 1]; const parent = destination.stack[destination.stack.length - 1];
parent.children.push(suspenseInstance); parent.children.push(suspenseInstance);
destination.stack.push(suspenseInstance); destination.stack.push(suspenseInstance);
return true;
}, },
writeStartClientRenderedSuspenseBoundary( writeStartClientRenderedSuspenseBoundary(
destination: Destination, destination: Destination,
renderState: RenderState, renderState: RenderState,
suspenseInstance: SuspenseInstance,
): boolean { ): boolean {
suspenseInstance.state = 'client-render'; const suspenseInstance: SuspenseInstance = {
state: 'client-render',
children: [],
};
const parent = destination.stack[destination.stack.length - 1]; const parent = destination.stack[destination.stack.length - 1];
parent.children.push(suspenseInstance); parent.children.push(suspenseInstance);
destination.stack.push(suspenseInstance); destination.stack.push(suspenseInstance);
return true;
}, },
writeEndCompletedSuspenseBoundary(destination: Destination): boolean { writeEndCompletedSuspenseBoundary(destination: Destination): boolean {
destination.stack.pop(); destination.stack.pop();
return true;
}, },
writeEndPendingSuspenseBoundary(destination: Destination): boolean { writeEndPendingSuspenseBoundary(destination: Destination): boolean {
destination.stack.pop(); destination.stack.pop();
return true;
}, },
writeEndClientRenderedSuspenseBoundary(destination: Destination): boolean { writeEndClientRenderedSuspenseBoundary(destination: Destination): boolean {
destination.stack.pop(); destination.stack.pop();
return true;
}, },
writeStartSegment( writeStartSegment(
@ -218,9 +252,11 @@ const ReactNoopServer = ReactFizzServer({
throw new Error('Segments are only expected at the root of the stack.'); throw new Error('Segments are only expected at the root of the stack.');
} }
destination.stack.push(segment); destination.stack.push(segment);
return true;
}, },
writeEndSegment(destination: Destination, formatContext: null): boolean { writeEndSegment(destination: Destination, formatContext: null): boolean {
destination.stack.pop(); destination.stack.pop();
return true;
}, },
writeCompletedSegmentInstruction( writeCompletedSegmentInstruction(
@ -241,6 +277,7 @@ const ReactNoopServer = ReactFizzServer({
0, 0,
...segment.children, ...segment.children,
); );
return true;
}, },
writeCompletedBoundaryInstruction( writeCompletedBoundaryInstruction(
@ -255,6 +292,7 @@ const ReactNoopServer = ReactFizzServer({
} }
boundary.children = segment.children; boundary.children = segment.children;
boundary.state = 'complete'; boundary.state = 'complete';
return true;
}, },
writeClientRenderBoundaryInstruction( writeClientRenderBoundaryInstruction(
@ -263,6 +301,7 @@ const ReactNoopServer = ReactFizzServer({
boundary: SuspenseInstance, boundary: SuspenseInstance,
): boolean { ): boolean {
boundary.status = 'client-render'; boundary.status = 'client-render';
return true;
}, },
writePreambleStart() {}, writePreambleStart() {},

View File

@ -51,6 +51,8 @@ import {
import { import {
writeCompletedRoot, writeCompletedRoot,
writePlaceholder, writePlaceholder,
pushStartActivityBoundary,
pushEndActivityBoundary,
writeStartCompletedSuspenseBoundary, writeStartCompletedSuspenseBoundary,
writeStartPendingSuspenseBoundary, writeStartPendingSuspenseBoundary,
writeStartClientRenderedSuspenseBoundary, writeStartClientRenderedSuspenseBoundary,
@ -2200,23 +2202,50 @@ function renderLazyComponent(
renderElement(request, task, keyPath, Component, resolvedProps, ref); renderElement(request, task, keyPath, Component, resolvedProps, ref);
} }
function renderOffscreen( function renderActivity(
request: Request, request: Request,
task: Task, task: Task,
keyPath: KeyNode, keyPath: KeyNode,
props: Object, props: Object,
): void { ): void {
const mode: ?OffscreenMode = (props.mode: any); const segment = task.blockedSegment;
if (mode === 'hidden') { if (segment === null) {
// A hidden Offscreen boundary is not server rendered. Prerendering happens // Replay
// on the client. 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 { } else {
// A visible Offscreen boundary is treated exactly like a fragment: a // Render
// pure indirection. // An Activity boundary is delimited so that we can hydrate it separately.
const prevKeyPath = task.keyPath; pushStartActivityBoundary(segment.chunks, request.renderState);
task.keyPath = keyPath; segment.lastPushedText = false;
renderNodeDestructive(request, task, props.children, -1); const mode: ?OffscreenMode = (props.mode: any);
task.keyPath = prevKeyPath; 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; return;
} }
case REACT_ACTIVITY_TYPE: { case REACT_ACTIVITY_TYPE: {
renderOffscreen(request, task, keyPath, props); renderActivity(request, task, keyPath, props);
return; return;
} }
case REACT_SUSPENSE_LIST_TYPE: { case REACT_SUSPENSE_LIST_TYPE: {

View File

@ -59,6 +59,8 @@ export const pushFormStateMarkerIsNotMatching =
$$$config.pushFormStateMarkerIsNotMatching; $$$config.pushFormStateMarkerIsNotMatching;
export const writeCompletedRoot = $$$config.writeCompletedRoot; export const writeCompletedRoot = $$$config.writeCompletedRoot;
export const writePlaceholder = $$$config.writePlaceholder; export const writePlaceholder = $$$config.writePlaceholder;
export const pushStartActivityBoundary = $$$config.pushStartActivityBoundary;
export const pushEndActivityBoundary = $$$config.pushEndActivityBoundary;
export const writeStartCompletedSuspenseBoundary = export const writeStartCompletedSuspenseBoundary =
$$$config.writeStartCompletedSuspenseBoundary; $$$config.writeStartCompletedSuspenseBoundary;
export const writeStartPendingSuspenseBoundary = export const writeStartPendingSuspenseBoundary =