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.
Sebastian Markbåge committed
Apr 9, 2025 at 10:59 UTC
3fbfb9baaf38528349b86372bd7eff36c6a3261a
7 files changed
+177
-19
packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
+39
@@ -4087,6 +4087,28 @@ export function writePlaceholder(
4087
return writeChunkAndReturn(destination, placeholder2);
4088
}
4089
4090
+// Activity boundaries are encoded as comments.
4091
+const startActivityBoundary = stringToPrecomputedChunk('<!--&-->');
4092
+const endActivityBoundary = stringToPrecomputedChunk('<!--/&-->');
4093
+
4094
+export function pushStartActivityBoundary(
4095
+ target: Array<Chunk | PrecomputedChunk>,
4096
+ renderState: RenderState,
4097
+): void {
4098
+ target.push(startActivityBoundary);
4099
+}
4100
+
4101
+export function pushEndActivityBoundary(
4102
+ target: Array<Chunk | PrecomputedChunk>,
4103
+ renderState: RenderState,
4104
+ preambleState: null | PreambleState,
4105
+): void {
4106
+ if (preambleState) {
4107
+ pushPreambleContribution(target, preambleState);
4108
+ }
4109
+ target.push(endActivityBoundary);
4110
+}
4111
+
4112
// Suspense boundaries are encoded as comments.
4113
const startCompletedSuspenseBoundary = stringToPrecomputedChunk('<!--$-->');
4114
const startPendingSuspenseBoundary1 = stringToPrecomputedChunk(
@@ -4225,6 +4247,23 @@ export function writeEndClientRenderedSuspenseBoundary(
4247
const boundaryPreambleContributionChunkStart = stringToPrecomputedChunk('<!--');
4248
const boundaryPreambleContributionChunkEnd = stringToPrecomputedChunk('-->');
4249
4250
+function pushPreambleContribution(
4251
+ target: Array<Chunk | PrecomputedChunk>,
4252
+ preambleState: PreambleState,
4253
+) {
4254
+ // Same as writePreambleContribution but for the render phase.
4255
+ const contribution = preambleState.contribution;
4256
+ if (contribution !== NoContribution) {
4257
+ target.push(
4258
+ boundaryPreambleContributionChunkStart,
4259
+ // This is a number type so we can do the fast path without coercion checking
4260
+ // eslint-disable-next-line react-internal/safe-string-coercion
4261
+ stringToChunk('' + contribution),
4262
+ boundaryPreambleContributionChunkEnd,
4263
+ );
4264
+ }
4265
+}
4266
+
4267
function writePreambleContribution(
4268
destination: Destination,
4269
preambleState: PreambleState,
packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js
+25
@@ -20,6 +20,8 @@ import {
20
createRenderState as createRenderStateImpl,
21
pushTextInstance as pushTextInstanceImpl,
22
pushSegmentFinale as pushSegmentFinaleImpl,
23
+ pushStartActivityBoundary as pushStartActivityBoundaryImpl,
24
+ pushEndActivityBoundary as pushEndActivityBoundaryImpl,
25
writeStartCompletedSuspenseBoundary as writeStartCompletedSuspenseBoundaryImpl,
26
writeStartClientRenderedSuspenseBoundary as writeStartClientRenderedSuspenseBoundaryImpl,
27
writeEndCompletedSuspenseBoundary as writeEndCompletedSuspenseBoundaryImpl,
@@ -207,6 +209,29 @@ export function pushSegmentFinale(
209
}
210
}
211
212
+export function pushStartActivityBoundary(
213
+ target: Array<Chunk | PrecomputedChunk>,
214
+ renderState: RenderState,
215
+): void {
216
+ if (renderState.generateStaticMarkup) {
217
+ // A completed boundary is done and doesn't need a representation in the HTML
218
+ // if we're not going to be hydrating it.
219
+ return;
220
+ }
221
+ pushStartActivityBoundaryImpl(target, renderState);
222
+}
223
+
224
+export function pushEndActivityBoundary(
225
+ target: Array<Chunk | PrecomputedChunk>,
226
+ renderState: RenderState,
227
+ preambleState: null | PreambleState,
228
+): void {
229
+ if (renderState.generateStaticMarkup) {
230
+ return;
231
+ }
232
+ pushEndActivityBoundaryImpl(target, renderState, preambleState);
233
+}
234
+
235
export function writeStartCompletedSuspenseBoundary(
236
destination: Destination,
237
renderState: RenderState,
packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js
+7
-1
@@ -3669,7 +3669,7 @@ describe('ReactDOMServerPartialHydration', () => {
3669
});
3670
3671
// @gate enableActivity
3672
- it('a visible Activity component acts like a fragment', async () => {
3672
+ it('a visible Activity component is surrounded by comment markers', async () => {
3673
const ref = React.createRef();
3674
3675
function App() {
@@ -3690,9 +3690,11 @@ describe('ReactDOMServerPartialHydration', () => {
3690
// pure indirection.
3691
expect(container).toMatchInlineSnapshot(`
3692
<div>
3693
+ <!--&-->
3694
<span>
3695
Child
3696
</span>
3697
+ <!--/&-->
3698
</div>
3699
`);
3700
@@ -3739,6 +3741,8 @@ describe('ReactDOMServerPartialHydration', () => {
3741
<span>
3742
Visible
3743
</span>
3744
+ <!--&-->
3745
+ <!--/&-->
3746
</div>
3747
`);
3748
@@ -3760,6 +3764,8 @@ describe('ReactDOMServerPartialHydration', () => {
3764
<span>
3765
Visible
3766
</span>
3767
+ <!--&-->
3768
+ <!--/&-->
3769
<span
3770
style="display: none;"
3771
>
packages/react-markup/src/ReactFizzConfigMarkup.js
+18
@@ -151,6 +151,23 @@ export function pushSegmentFinale(
151
return;
152
}
153
154
+export function pushStartActivityBoundary(
155
+ target: Array<Chunk | PrecomputedChunk>,
156
+ renderState: RenderState,
157
+): void {
158
+ // Markup doesn't have any instructions.
159
+ return;
160
+}
161
+
162
+export function pushEndActivityBoundary(
163
+ target: Array<Chunk | PrecomputedChunk>,
164
+ renderState: RenderState,
165
+ preambleState: null | PreambleState,
166
+): void {
167
+ // Markup doesn't have any instructions.
168
+ return;
169
+}
170
+
171
export function writeStartCompletedSuspenseBoundary(
172
destination: Destination,
173
renderState: RenderState,
@@ -158,6 +175,7 @@ export function writeStartCompletedSuspenseBoundary(
175
// Markup doesn't have any instructions.
176
return true;
177
}
178
+
179
export function writeStartClientRenderedSuspenseBoundary(
180
destination: Destination,
181
renderState: RenderState,
packages/react-noop-renderer/src/ReactNoopServer.js
+45
-6
@@ -30,6 +30,10 @@ type TextInstance = {
30
hidden: boolean,
31
};
32
33
+type ActivityInstance = {
34
+ children: Array<Instance | TextInstance | SuspenseInstance>,
35
+};
36
+
37
type SuspenseInstance = {
38
state: 'pending' | 'complete' | 'client-render',
39
children: Array<Instance | TextInstance | SuspenseInstance>,
@@ -164,44 +168,74 @@ const ReactNoopServer = ReactFizzServer({
168
});
169
},
170
171
+ pushStartActivityBoundary(
172
+ target: Array<Uint8Array>,
173
+ renderState: RenderState,
174
+ ): void {
175
+ const activityInstance: ActivityInstance = {
176
+ children: [],
177
+ };
178
+ target.push(Buffer.from(JSON.stringify(activityInstance), 'utf8'));
179
+ },
180
+
181
+ pushEndActivityBoundary(
182
+ target: Array<Uint8Array>,
183
+ renderState: RenderState,
184
+ preambleState: null | PreambleState,
185
+ ): void {
186
+ target.push(POP);
187
+ },
188
+
189
writeStartCompletedSuspenseBoundary(
190
destination: Destination,
191
renderState: RenderState,
170
- suspenseInstance: SuspenseInstance,
192
): boolean {
172
- suspenseInstance.state = 'complete';
193
+ const suspenseInstance: SuspenseInstance = {
194
+ state: 'complete',
195
+ children: [],
196
+ };
197
const parent = destination.stack[destination.stack.length - 1];
198
parent.children.push(suspenseInstance);
199
destination.stack.push(suspenseInstance);
200
+ return true;
201
},
202
writeStartPendingSuspenseBoundary(
203
destination: Destination,
204
renderState: RenderState,
180
- suspenseInstance: SuspenseInstance,
205
): boolean {
182
- suspenseInstance.state = 'pending';
206
+ const suspenseInstance: SuspenseInstance = {
207
+ state: 'pending',
208
+ children: [],
209
+ };
210
const parent = destination.stack[destination.stack.length - 1];
211
parent.children.push(suspenseInstance);
212
destination.stack.push(suspenseInstance);
213
+ return true;
214
},
215
writeStartClientRenderedSuspenseBoundary(
216
destination: Destination,
217
renderState: RenderState,
190
- suspenseInstance: SuspenseInstance,
218
): boolean {
192
- suspenseInstance.state = 'client-render';
219
+ const suspenseInstance: SuspenseInstance = {
220
+ state: 'client-render',
221
+ children: [],
222
+ };
223
const parent = destination.stack[destination.stack.length - 1];
224
parent.children.push(suspenseInstance);
225
destination.stack.push(suspenseInstance);
226
+ return true;
227
},
228
writeEndCompletedSuspenseBoundary(destination: Destination): boolean {
229
destination.stack.pop();
230
+ return true;
231
},
232
writeEndPendingSuspenseBoundary(destination: Destination): boolean {
233
destination.stack.pop();
234
+ return true;
235
},
236
writeEndClientRenderedSuspenseBoundary(destination: Destination): boolean {
237
destination.stack.pop();
238
+ return true;
239
},
240
241
writeStartSegment(
@@ -218,9 +252,11 @@ const ReactNoopServer = ReactFizzServer({
252
throw new Error('Segments are only expected at the root of the stack.');
253
}
254
destination.stack.push(segment);
255
+ return true;
256
},
257
writeEndSegment(destination: Destination, formatContext: null): boolean {
258
destination.stack.pop();
259
+ return true;
260
},
261
262
writeCompletedSegmentInstruction(
@@ -241,6 +277,7 @@ const ReactNoopServer = ReactFizzServer({
277
0,
278
...segment.children,
279
);
280
+ return true;
281
},
282
283
writeCompletedBoundaryInstruction(
@@ -255,6 +292,7 @@ const ReactNoopServer = ReactFizzServer({
292
}
293
boundary.children = segment.children;
294
boundary.state = 'complete';
295
+ return true;
296
},
297
298
writeClientRenderBoundaryInstruction(
@@ -263,6 +301,7 @@ const ReactNoopServer = ReactFizzServer({
301
boundary: SuspenseInstance,
302
): boolean {
303
boundary.status = 'client-render';
304
+ return true;
305
},
306
307
writePreambleStart() {},
packages/react-server/src/ReactFizzServer.js
+41
-12
@@ -51,6 +51,8 @@ import {
51
import {
52
writeCompletedRoot,
53
writePlaceholder,
54
+ pushStartActivityBoundary,
55
+ pushEndActivityBoundary,
56
writeStartCompletedSuspenseBoundary,
57
writeStartPendingSuspenseBoundary,
58
writeStartClientRenderedSuspenseBoundary,
@@ -2200,23 +2202,50 @@ function renderLazyComponent(
2202
renderElement(request, task, keyPath, Component, resolvedProps, ref);
2203
}
2204
2203
-function renderOffscreen(
2205
+function renderActivity(
2206
request: Request,
2207
task: Task,
2208
keyPath: KeyNode,
2209
props: Object,
2210
): void {
2209
- const mode: ?OffscreenMode = (props.mode: any);
2210
- if (mode === 'hidden') {
2211
- // A hidden Offscreen boundary is not server rendered. Prerendering happens
2212
- // on the client.
2211
+ const segment = task.blockedSegment;
2212
+ if (segment === null) {
2213
+ // Replay
2214
+ const mode: ?OffscreenMode = (props.mode: any);
2215
+ if (mode === 'hidden') {
2216
+ // A hidden Activity boundary is not server rendered. Prerendering happens
2217
+ // on the client.
2218
+ } else {
2219
+ // A visible Activity boundary has its children rendered inside the boundary.
2220
+ const prevKeyPath = task.keyPath;
2221
+ task.keyPath = keyPath;
2222
+ renderNode(request, task, props.children, -1);
2223
+ task.keyPath = prevKeyPath;
2224
+ }
2225
} else {
2214
- // A visible Offscreen boundary is treated exactly like a fragment: a
2215
- // pure indirection.
2216
- const prevKeyPath = task.keyPath;
2217
- task.keyPath = keyPath;
2218
- renderNodeDestructive(request, task, props.children, -1);
2219
- task.keyPath = prevKeyPath;
2226
+ // Render
2227
+ // An Activity boundary is delimited so that we can hydrate it separately.
2228
+ pushStartActivityBoundary(segment.chunks, request.renderState);
2229
+ segment.lastPushedText = false;
2230
+ const mode: ?OffscreenMode = (props.mode: any);
2231
+ if (mode === 'hidden') {
2232
+ // A hidden Activity boundary is not server rendered. Prerendering happens
2233
+ // on the client.
2234
+ } else {
2235
+ // A visible Activity boundary has its children rendered inside the boundary.
2236
+ const prevKeyPath = task.keyPath;
2237
+ task.keyPath = keyPath;
2238
+ // We use the non-destructive form because if something suspends, we still
2239
+ // need to pop back up and finish the end comment.
2240
+ renderNode(request, task, props.children, -1);
2241
+ task.keyPath = prevKeyPath;
2242
+ }
2243
+ pushEndActivityBoundary(
2244
+ segment.chunks,
2245
+ request.renderState,
2246
+ task.blockedPreamble,
2247
+ );
2248
+ segment.lastPushedText = false;
2249
}
2250
}
2251
@@ -2291,7 +2320,7 @@ function renderElement(
2320
return;
2321
}
2322
case REACT_ACTIVITY_TYPE: {
2294
- renderOffscreen(request, task, keyPath, props);
2323
+ renderActivity(request, task, keyPath, props);
2324
return;
2325
}
2326
case REACT_SUSPENSE_LIST_TYPE: {
packages/react-server/src/forks/ReactFizzConfig.custom.js
+2
@@ -59,6 +59,8 @@ export const pushFormStateMarkerIsNotMatching =
59
$$$config.pushFormStateMarkerIsNotMatching;
60
export const writeCompletedRoot = $$$config.writeCompletedRoot;
61
export const writePlaceholder = $$$config.writePlaceholder;
62
+export const pushStartActivityBoundary = $$$config.pushStartActivityBoundary;
63
+export const pushEndActivityBoundary = $$$config.pushEndActivityBoundary;
64
export const writeStartCompletedSuspenseBoundary =
65
$$$config.writeStartCompletedSuspenseBoundary;
66
export const writeStartPendingSuspenseBoundary =