@samitouri / QOS-React-1 / commits / 3fbfb9baaf

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 =