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);
}
// 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.
const startCompletedSuspenseBoundary = stringToPrecomputedChunk('<!--$-->');
const startPendingSuspenseBoundary1 = stringToPrecomputedChunk(
@ -4225,6 +4247,23 @@ export function writeEndClientRenderedSuspenseBoundary(
const boundaryPreambleContributionChunkStart = 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(
destination: Destination,
preambleState: PreambleState,

View File

@ -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<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(
destination: Destination,
renderState: RenderState,

View File

@ -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(`
<div>
<!--&-->
<span>
Child
</span>
<!--/&-->
</div>
`);
@ -3739,6 +3741,8 @@ describe('ReactDOMServerPartialHydration', () => {
<span>
Visible
</span>
<!--&-->
<!--/&-->
</div>
`);
@ -3760,6 +3764,8 @@ describe('ReactDOMServerPartialHydration', () => {
<span>
Visible
</span>
<!--&-->
<!--/&-->
<span
style="display: none;"
>

View File

@ -151,6 +151,23 @@ export function pushSegmentFinale(
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(
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,

View File

@ -30,6 +30,10 @@ type TextInstance = {
hidden: boolean,
};
type ActivityInstance = {
children: Array<Instance | TextInstance | SuspenseInstance>,
};
type SuspenseInstance = {
state: 'pending' | 'complete' | 'client-render',
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(
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() {},

View File

@ -51,6 +51,8 @@ import {
import {
writeCompletedRoot,
writePlaceholder,
pushStartActivityBoundary,
pushEndActivityBoundary,
writeStartCompletedSuspenseBoundary,
writeStartPendingSuspenseBoundary,
writeStartClientRenderedSuspenseBoundary,
@ -2200,24 +2202,51 @@ function renderLazyComponent(
renderElement(request, task, keyPath, Component, resolvedProps, ref);
}
function renderOffscreen(
function renderActivity(
request: Request,
task: Task,
keyPath: KeyNode,
props: Object,
): void {
const segment = task.blockedSegment;
if (segment === null) {
// Replay
const mode: ?OffscreenMode = (props.mode: any);
if (mode === 'hidden') {
// A hidden Offscreen boundary is not server rendered. Prerendering happens
// A hidden Activity boundary is not server rendered. Prerendering happens
// on the client.
} else {
// A visible Offscreen boundary is treated exactly like a fragment: a
// pure indirection.
// A visible Activity boundary has its children rendered inside the boundary.
const prevKeyPath = task.keyPath;
task.keyPath = keyPath;
renderNodeDestructive(request, task, props.children, -1);
renderNode(request, task, props.children, -1);
task.keyPath = prevKeyPath;
}
} else {
// 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;
}
}
function renderViewTransition(
@ -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: {

View File

@ -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 =