@samitouri / QOS-React-1 / commits / 99aa685cef

[Fizz] Support SuspenseList revealOrder="together" (#33311)

Stacked on #33308. For "together" mode, we can be a self-blocking row that adds all its boundaries to the blocked set, but there's no parent row that unblocks it. A particular quirk of this mode is that it's not enough to just unblock them all on the server together. Because if one boundary downloads all its html and then issues a complete instruction it'll appear before the others while streaming in. What we actually want is to reveal them all in a single batch. This implementation takes a short cut by unblocking the rows in `flushPartialBoundary`. That ensures that all the segments of every boundary has a chance to flush before we start emitting any of the complete boundary instructions. Once the last one unblocks, all the complete boundary instructions are queued. Ideally this would be a single `<script>` tag so that they can't be split up even if we get a chunk containing some of them. ~A downside of this approach is that we always outline these boundaries. We could inline them if they all complete before the parent flushes. E.g. by checking if the row is blocked only by its own boundaries and if all the boundaries would fit without getting outlined, then we can inline them all at once.~ I went ahead and did this because it solves an issue with `renderToString` where it doesn't support the script runtime so it can only handle this if inlined.

Sebastian Markbåge committed May 20, 2025 at 14:42 UTC 99aa685cefc3c828d4523da04d7830c1e494a6bd
3 files changed +404 -3
packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js
+4 -1
@@ -172,7 +172,6 @@ export {
172 completeResumableState,
173 emitEarlyPreloads,
174 supportsClientAPIs,
175 - canHavePreamble,
175 hoistPreambleState,
176 isPreambleReady,
177 isPreambleContext,
@@ -194,6 +193,10 @@ export function getViewTransitionFormatContext(
193 return parentContext;
194 }
195
196 +export function canHavePreamble(formatContext: FormatContext): boolean {
197 + return false;
198 +}
199 +
200 export function pushTextInstance(
201 target: Array<Chunk | PrecomputedChunk>,
202 text: string,
packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js
+317
@@ -183,6 +183,323 @@ describe('ReactDOMFizSuspenseList', () => {
183 );
184 });
185
186 + // @gate enableSuspenseList
187 + it('displays all "together"', async () => {
188 + const A = createAsyncText('A');
189 + const B = createAsyncText('B');
190 + const C = createAsyncText('C');
191 +
192 + function Foo() {
193 + return (
194 + <div>
195 + <SuspenseList revealOrder="together">
196 + <Suspense fallback={<Text text="Loading A" />}>
197 + <A />
198 + </Suspense>
199 + <Suspense fallback={<Text text="Loading B" />}>
200 + <B />
201 + </Suspense>
202 + <Suspense fallback={<Text text="Loading C" />}>
203 + <C />
204 + </Suspense>
205 + </SuspenseList>
206 + </div>
207 + );
208 + }
209 +
210 + await A.resolve();
211 +
212 + await serverAct(async () => {
213 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<Foo />);
214 + pipe(writable);
215 + });
216 +
217 + assertLog([
218 + 'A',
219 + 'Suspend! [B]',
220 + 'Suspend! [C]',
221 + 'Loading A',
222 + 'Loading B',
223 + 'Loading C',
224 + ]);
225 +
226 + expect(getVisibleChildren(container)).toEqual(
227 + <div>
228 + <span>Loading A</span>
229 + <span>Loading B</span>
230 + <span>Loading C</span>
231 + </div>,
232 + );
233 +
234 + await serverAct(() => B.resolve());
235 + assertLog(['B']);
236 +
237 + expect(getVisibleChildren(container)).toEqual(
238 + <div>
239 + <span>Loading A</span>
240 + <span>Loading B</span>
241 + <span>Loading C</span>
242 + </div>,
243 + );
244 +
245 + await serverAct(() => C.resolve());
246 + assertLog(['C']);
247 +
248 + expect(getVisibleChildren(container)).toEqual(
249 + <div>
250 + <span>A</span>
251 + <span>B</span>
252 + <span>C</span>
253 + </div>,
254 + );
255 + });
256 +
257 + // @gate enableSuspenseList
258 + it('displays all "together" in a single pass', async () => {
259 + function Foo() {
260 + return (
261 + <div>
262 + <SuspenseList revealOrder="together">
263 + <Suspense fallback={<Text text="Loading A" />}>
264 + <Text text="A" />
265 + </Suspense>
266 + <Suspense fallback={<Text text="Loading B" />}>
267 + <Text text="B" />
268 + </Suspense>
269 + <Suspense fallback={<Text text="Loading C" />}>
270 + <Text text="C" />
271 + </Suspense>
272 + </SuspenseList>
273 + </div>
274 + );
275 + }
276 +
277 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<Foo />);
278 + pipe(writable);
279 + await 0;
280 + const bufferedContent = buffer;
281 + buffer = '';
282 +
283 + assertLog(['A', 'B', 'C', 'Loading A', 'Loading B', 'Loading C']);
284 +
285 + expect(bufferedContent).toMatchInlineSnapshot(
286 + `"<div><!--$--><span>A</span><!--/$--><!--$--><span>B</span><!--/$--><!--$--><span>C</span><!--/$--></div>"`,
287 + );
288 + });
289 +
290 + // @gate enableSuspenseList
291 + it('displays all "together" even when nested as siblings', async () => {
292 + const A = createAsyncText('A');
293 + const B = createAsyncText('B');
294 + const C = createAsyncText('C');
295 +
296 + function Foo() {
297 + return (
298 + <div>
299 + <SuspenseList revealOrder="together">
300 + <div>
301 + <Suspense fallback={<Text text="Loading A" />}>
302 + <A />
303 + </Suspense>
304 + <Suspense fallback={<Text text="Loading B" />}>
305 + <B />
306 + </Suspense>
307 + </div>
308 + <div>
309 + <Suspense fallback={<Text text="Loading C" />}>
310 + <C />
311 + </Suspense>
312 + </div>
313 + </SuspenseList>
314 + </div>
315 + );
316 + }
317 +
318 + await A.resolve();
319 +
320 + await serverAct(async () => {
321 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<Foo />);
322 + pipe(writable);
323 + });
324 +
325 + assertLog([
326 + 'A',
327 + 'Suspend! [B]',
328 + 'Suspend! [C]',
329 + 'Loading A',
330 + 'Loading B',
331 + 'Loading C',
332 + ]);
333 +
334 + expect(getVisibleChildren(container)).toEqual(
335 + <div>
336 + <div>
337 + <span>Loading A</span>
338 + <span>Loading B</span>
339 + </div>
340 + <div>
341 + <span>Loading C</span>
342 + </div>
343 + </div>,
344 + );
345 +
346 + await serverAct(() => B.resolve());
347 + assertLog(['B']);
348 +
349 + expect(getVisibleChildren(container)).toEqual(
350 + <div>
351 + <div>
352 + <span>Loading A</span>
353 + <span>Loading B</span>
354 + </div>
355 + <div>
356 + <span>Loading C</span>
357 + </div>
358 + </div>,
359 + );
360 +
361 + await serverAct(() => C.resolve());
362 + assertLog(['C']);
363 +
364 + expect(getVisibleChildren(container)).toEqual(
365 + <div>
366 + <div>
367 + <span>A</span>
368 + <span>B</span>
369 + </div>
370 + <div>
371 + <span>C</span>
372 + </div>
373 + </div>,
374 + );
375 + });
376 +
377 + // @gate enableSuspenseList
378 + it('displays all "together" in nested SuspenseLists', async () => {
379 + const A = createAsyncText('A');
380 + const B = createAsyncText('B');
381 + const C = createAsyncText('C');
382 +
383 + function Foo() {
384 + return (
385 + <div>
386 + <SuspenseList revealOrder="together">
387 + <Suspense fallback={<Text text="Loading A" />}>
388 + <A />
389 + </Suspense>
390 + <SuspenseList revealOrder="together">
391 + <Suspense fallback={<Text text="Loading B" />}>
392 + <B />
393 + </Suspense>
394 + <Suspense fallback={<Text text="Loading C" />}>
395 + <C />
396 + </Suspense>
397 + </SuspenseList>
398 + </SuspenseList>
399 + </div>
400 + );
401 + }
402 +
403 + await A.resolve();
404 + await B.resolve();
405 +
406 + await serverAct(async () => {
407 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<Foo />);
408 + pipe(writable);
409 + });
410 +
411 + assertLog([
412 + 'A',
413 + 'B',
414 + 'Suspend! [C]',
415 + 'Loading A',
416 + 'Loading B',
417 + 'Loading C',
418 + ]);
419 +
420 + expect(getVisibleChildren(container)).toEqual(
421 + <div>
422 + <span>Loading A</span>
423 + <span>Loading B</span>
424 + <span>Loading C</span>
425 + </div>,
426 + );
427 +
428 + await serverAct(() => C.resolve());
429 + assertLog(['C']);
430 +
431 + expect(getVisibleChildren(container)).toEqual(
432 + <div>
433 + <span>A</span>
434 + <span>B</span>
435 + <span>C</span>
436 + </div>,
437 + );
438 + });
439 +
440 + // @gate enableSuspenseList
441 + it('displays all "together" in nested SuspenseLists where the inner is default', async () => {
442 + const A = createAsyncText('A');
443 + const B = createAsyncText('B');
444 + const C = createAsyncText('C');
445 +
446 + function Foo() {
447 + return (
448 + <div>
449 + <SuspenseList revealOrder="together">
450 + <Suspense fallback={<Text text="Loading A" />}>
451 + <A />
452 + </Suspense>
453 + <SuspenseList>
454 + <Suspense fallback={<Text text="Loading B" />}>
455 + <B />
456 + </Suspense>
457 + <Suspense fallback={<Text text="Loading C" />}>
458 + <C />
459 + </Suspense>
460 + </SuspenseList>
461 + </SuspenseList>
462 + </div>
463 + );
464 + }
465 +
466 + await A.resolve();
467 + await B.resolve();
468 +
469 + await serverAct(async () => {
470 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<Foo />);
471 + pipe(writable);
472 + });
473 +
474 + assertLog([
475 + 'A',
476 + 'B',
477 + 'Suspend! [C]',
478 + 'Loading A',
479 + 'Loading B',
480 + 'Loading C',
481 + ]);
482 +
483 + expect(getVisibleChildren(container)).toEqual(
484 + <div>
485 + <span>Loading A</span>
486 + <span>Loading B</span>
487 + <span>Loading C</span>
488 + </div>,
489 + );
490 +
491 + await serverAct(() => C.resolve());
492 + assertLog(['C']);
493 +
494 + expect(getVisibleChildren(container)).toEqual(
495 + <div>
496 + <span>A</span>
497 + <span>B</span>
498 + <span>C</span>
499 + </div>,
500 + );
501 + });
502 +
503 // @gate enableSuspenseList
504 it('displays each items in "forwards" order', async () => {
505 const A = createAsyncText('A');
packages/react-server/src/ReactFizzServer.js
+83 -2
@@ -236,6 +236,7 @@ type LegacyContext = {
236 type SuspenseListRow = {
237 pendingTasks: number, // The number of tasks, previous rows and inner suspense boundaries blocking this row.
238 boundaries: null | Array<SuspenseBoundary>, // The boundaries in this row waiting to be unblocked by the previous row. (null means this row is not blocked)
239 + together: boolean, // All the boundaries within this row must be revealed together.
240 next: null | SuspenseListRow, // The next row blocked by this one.
241 };
242
@@ -1404,6 +1405,11 @@ function renderSuspenseBoundary(
1405 }
1406 return;
1407 }
1408 + } else {
1409 + const boundaryRow = prevRow;
1410 + if (boundaryRow !== null && boundaryRow.together) {
1411 + tryToResolveTogetherRow(request, boundaryRow);
1412 + }
1413 }
1414 } catch (thrownValue: mixed) {
1415 newBoundary.status = CLIENT_RENDERED;
@@ -1670,8 +1676,14 @@ function replaySuspenseBoundary(
1676
1677 function finishSuspenseListRow(request: Request, row: SuspenseListRow): void {
1678 // This row finished. Now we have to unblock all the next rows that were blocked on this.
1679 + unblockSuspenseListRow(request, row.next);
1680 +}
1681 +
1682 +function unblockSuspenseListRow(
1683 + request: Request,
1684 + unblockedRow: null | SuspenseListRow,
1685 +): void {
1686 // We do this in a loop to avoid stack overflow for very long lists that get unblocked.
1674 - let unblockedRow = row.next;
1687 while (unblockedRow !== null) {
1688 // Unblocking the boundaries will decrement the count of this row but we keep it above
1689 // zero so they never finish this row recursively.
@@ -1692,12 +1704,41 @@ function finishSuspenseListRow(request: Request, row: SuspenseListRow): void {
1704 }
1705 }
1706
1707 +function tryToResolveTogetherRow(
1708 + request: Request,
1709 + togetherRow: SuspenseListRow,
1710 +): void {
1711 + // If we have a "together" row and all the pendingTasks are really the boundaries themselves,
1712 + // and we won't outline any of them then we can unblock this row early so that we can inline
1713 + // all the boundaries at once.
1714 + const boundaries = togetherRow.boundaries;
1715 + if (boundaries === null || togetherRow.pendingTasks !== boundaries.length) {
1716 + return;
1717 + }
1718 + let allCompleteAndInlinable = true;
1719 + for (let i = 0; i < boundaries.length; i++) {
1720 + const rowBoundary = boundaries[i];
1721 + if (
1722 + rowBoundary.pendingTasks !== 1 ||
1723 + rowBoundary.parentFlushed ||
1724 + isEligibleForOutlining(request, rowBoundary)
1725 + ) {
1726 + allCompleteAndInlinable = false;
1727 + break;
1728 + }
1729 + }
1730 + if (allCompleteAndInlinable) {
1731 + unblockSuspenseListRow(request, togetherRow);
1732 + }
1733 +}
1734 +
1735 function createSuspenseListRow(
1736 previousRow: null | SuspenseListRow,
1737 ): SuspenseListRow {
1738 const newRow: SuspenseListRow = {
1739 pendingTasks: 1, // At first the row is blocked on attempting rendering itself.
1740 boundaries: null,
1741 + together: false,
1742 next: null,
1743 };
1744 if (previousRow !== null && previousRow.pendingTasks > 0) {
@@ -1978,7 +2019,27 @@ function renderSuspenseList(
2019 }
2020
2021 if (revealOrder === 'together') {
1981 - // TODO
2022 + const prevKeyPath = task.keyPath;
2023 + const prevRow = task.row;
2024 + const newRow = (task.row = createSuspenseListRow(null));
2025 + // This will cause boundaries to block on this row, but there's nothing to
2026 + // unblock them. We'll use the partial flushing pass to unblock them.
2027 + newRow.boundaries = [];
2028 + newRow.together = true;
2029 + task.keyPath = keyPath;
2030 + renderNodeDestructive(request, task, children, -1);
2031 + if (--newRow.pendingTasks === 0) {
2032 + finishSuspenseListRow(request, newRow);
2033 + }
2034 + task.keyPath = prevKeyPath;
2035 + task.row = prevRow;
2036 + if (prevRow !== null && newRow.pendingTasks > 0) {
2037 + // If we are part of an outer SuspenseList and our row is still pending, then that blocks
2038 + // the parent row from completing. We can continue the chain.
2039 + prevRow.pendingTasks++;
2040 + newRow.next = prevRow;
2041 + }
2042 + return;
2043 }
2044 // For other reveal order modes, we just render it as a fragment.
2045 const prevKeyPath = task.keyPath;
@@ -4759,6 +4820,8 @@ function finishedTask(
4820 if (row !== null) {
4821 if (--row.pendingTasks === 0) {
4822 finishSuspenseListRow(request, row);
4823 + } else if (row.together) {
4824 + tryToResolveTogetherRow(request, row);
4825 }
4826 }
4827 request.allPendingTasks--;
@@ -4848,6 +4911,10 @@ function finishedTask(
4911 }
4912 }
4913 }
4914 + const boundaryRow = boundary.row;
4915 + if (boundaryRow !== null && boundaryRow.together) {
4916 + tryToResolveTogetherRow(request, boundaryRow);
4917 + }
4918 }
4919 }
4920
@@ -5604,6 +5671,20 @@ function flushPartialBoundary(
5671 }
5672 completedSegments.splice(0, i);
5673
5674 + const row = boundary.row;
5675 + if (row !== null && row.together && boundary.pendingTasks === 1) {
5676 + // "together" rows are blocked on their own boundaries.
5677 + // We have now flushed all the boundary's segments as partials.
5678 + // We can now unblock it from blocking the row that will eventually
5679 + // unblock the boundary itself which can issue its complete instruction.
5680 + // TODO: Ideally the complete instruction would be in a single <script> tag.
5681 + if (row.pendingTasks === 1) {
5682 + unblockSuspenseListRow(request, row);
5683 + } else {
5684 + row.pendingTasks--;
5685 + }
5686 + }
5687 +
5688 return writeHoistablesForBoundary(
5689 destination,
5690 boundary.contentState,