@samitouri / QOS-React / commits / 38cd020c1f

Don't outline Suspense boundaries with suspensey CSS during shell flush (#35824)

When flushing the shell, stylesheets with precedence are emitted in the `<head>` which blocks paint regardless. Outlining a boundary solely because it has suspensey CSS provides no benefit during the shell flush and causes a higher-level fallback to be shown unnecessarily (e.g. "Middle Fallback" instead of "Inner Fallback"). This change passes a flushingInShell flag to hasSuspenseyContent so the host config can skip stylesheet-only suspensey content when flushing the shell. Suspensey images (used for ViewTransition animation reveals) still trigger outlining during the shell since their motivation is different. When flushing streamed completions the behavior is unchanged — suspensey CSS still causes outlining so the parent content can display sooner while the stylesheet loads.

Josh Story committed Feb 19, 2026 at 12:29 UTC 38cd020c1fb8a1e88b7852160796f411926a6fac
6 files changed +216 -6
packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
+11 -1
@@ -7041,7 +7041,17 @@ export function hoistHoistables(
7041 }
7042 }
7043
7044 -export function hasSuspenseyContent(hoistableState: HoistableState): boolean {
7044 +export function hasSuspenseyContent(
7045 + hoistableState: HoistableState,
7046 + flushingInShell: boolean,
7047 +): boolean {
7048 + if (flushingInShell) {
7049 + // When flushing the shell, stylesheets with precedence are already emitted
7050 + // in the <head> which blocks paint. There's no benefit to outlining for CSS
7051 + // alone during the shell flush. However, suspensey images (for ViewTransition
7052 + // animation reveals) should still trigger outlining even during the shell.
7053 + return hoistableState.suspenseyImages;
7054 + }
7055 return hoistableState.stylesheets.size > 0 || hoistableState.suspenseyImages;
7056 }
7057
packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js
+4 -1
@@ -326,7 +326,10 @@ export function writePreambleStart(
326 );
327 }
328
329 -export function hasSuspenseyContent(hoistableState: HoistableState): boolean {
329 +export function hasSuspenseyContent(
330 + hoistableState: HoistableState,
331 + flushingInShell: boolean,
332 +): boolean {
333 // Never outline.
334 return false;
335 }
packages/react-dom/src/__tests__/ReactDOMFloat-test.js
+188
@@ -9449,4 +9449,192 @@ background-color: green;
9449 );
9450 });
9451 });
9452 +
9453 + it('does not outline a boundary with suspensey CSS when flushing the shell', async () => {
9454 + // When flushing the shell, stylesheets with precedence are emitted in the
9455 + // <head> which blocks paint anyway. So there's no benefit to outlining the
9456 + // boundary — it would just show a higher-level fallback unnecessarily.
9457 + // Instead, the boundary should be inlined so the innermost fallback is shown.
9458 + let streamedContent = '';
9459 + writable.on('data', chunk => (streamedContent += chunk));
9460 +
9461 + await act(() => {
9462 + renderToPipeableStream(
9463 + <html>
9464 + <body>
9465 + <Suspense fallback="Outer Fallback">
9466 + <Suspense fallback="Middle Fallback">
9467 + <link rel="stylesheet" href="style.css" precedence="default" />
9468 + <Suspense fallback="Inner Fallback">
9469 + <BlockedOn value="content">Async Content</BlockedOn>
9470 + </Suspense>
9471 + </Suspense>
9472 + </Suspense>
9473 + </body>
9474 + </html>,
9475 + ).pipe(writable);
9476 + });
9477 +
9478 + // The middle boundary should have been inlined (not outlined) so the
9479 + // middle fallback text should never appear in the streamed HTML.
9480 + expect(streamedContent).not.toContain('Middle Fallback');
9481 +
9482 + // The stylesheet is in the head (blocks paint), and the innermost
9483 + // fallback is visible.
9484 + expect(getMeaningfulChildren(document)).toEqual(
9485 + <html>
9486 + <head>
9487 + <link rel="stylesheet" href="style.css" data-precedence="default" />
9488 + </head>
9489 + <body>Inner Fallback</body>
9490 + </html>,
9491 + );
9492 +
9493 + // Resolve the async content — streams in without needing to load CSS
9494 + // since the stylesheet was already in the head.
9495 + await act(() => {
9496 + resolveText('content');
9497 + });
9498 +
9499 + expect(getMeaningfulChildren(document)).toEqual(
9500 + <html>
9501 + <head>
9502 + <link rel="stylesheet" href="style.css" data-precedence="default" />
9503 + </head>
9504 + <body>Async Content</body>
9505 + </html>,
9506 + );
9507 + });
9508 +
9509 + it('outlines a boundary with suspensey CSS when flushing a streamed completion', async () => {
9510 + // When a boundary completes via streaming (not as part of the shell),
9511 + // suspensey CSS should cause the boundary to be outlined. The parent
9512 + // content can show sooner while the CSS loads separately.
9513 + let streamedContent = '';
9514 + writable.on('data', chunk => (streamedContent += chunk));
9515 +
9516 + await act(() => {
9517 + renderToPipeableStream(
9518 + <html>
9519 + <body>
9520 + <Suspense fallback="Root Fallback">
9521 + <BlockedOn value="shell">
9522 + <Suspense fallback="Outer Fallback">
9523 + <Suspense fallback="Middle Fallback">
9524 + <link
9525 + rel="stylesheet"
9526 + href="style.css"
9527 + precedence="default"
9528 + />
9529 + <Suspense fallback="Inner Fallback">
9530 + <BlockedOn value="content">Async Content</BlockedOn>
9531 + </Suspense>
9532 + </Suspense>
9533 + </Suspense>
9534 + </BlockedOn>
9535 + </Suspense>
9536 + </body>
9537 + </html>,
9538 + ).pipe(writable);
9539 + });
9540 +
9541 + // Shell is showing root fallback
9542 + expect(getMeaningfulChildren(document)).toEqual(
9543 + <html>
9544 + <head />
9545 + <body>Root Fallback</body>
9546 + </html>,
9547 + );
9548 +
9549 + // Unblock the shell — content streams in. The middle boundary should
9550 + // be outlined because the CSS arrived via streaming, not in the shell head.
9551 + streamedContent = '';
9552 + await act(() => {
9553 + resolveText('shell');
9554 + });
9555 +
9556 + // The middle fallback should appear in the streamed HTML because the
9557 + // boundary was outlined.
9558 + expect(streamedContent).toContain('Middle Fallback');
9559 +
9560 + // The CSS needs to load before the boundary reveals. Until then
9561 + // the middle fallback is visible.
9562 + expect(getMeaningfulChildren(document)).toEqual(
9563 + <html>
9564 + <head>
9565 + <link rel="stylesheet" href="style.css" data-precedence="default" />
9566 + </head>
9567 + <body>
9568 + {'Middle Fallback'}
9569 + <link rel="preload" href="style.css" as="style" />
9570 + </body>
9571 + </html>,
9572 + );
9573 +
9574 + // Load the stylesheet — now the middle boundary can reveal
9575 + await act(() => {
9576 + loadStylesheets();
9577 + });
9578 + assertLog(['load stylesheet: style.css']);
9579 +
9580 + expect(getMeaningfulChildren(document)).toEqual(
9581 + <html>
9582 + <head>
9583 + <link rel="stylesheet" href="style.css" data-precedence="default" />
9584 + </head>
9585 + <body>
9586 + {'Inner Fallback'}
9587 + <link rel="preload" href="style.css" as="style" />
9588 + </body>
9589 + </html>,
9590 + );
9591 +
9592 + // Resolve the async content
9593 + await act(() => {
9594 + resolveText('content');
9595 + });
9596 +
9597 + expect(getMeaningfulChildren(document)).toEqual(
9598 + <html>
9599 + <head>
9600 + <link rel="stylesheet" href="style.css" data-precedence="default" />
9601 + </head>
9602 + <body>
9603 + {'Async Content'}
9604 + <link rel="preload" href="style.css" as="style" />
9605 + </body>
9606 + </html>,
9607 + );
9608 + });
9609 +
9610 + // @gate enableViewTransition
9611 + it('still outlines a boundary with a suspensey image inside a ViewTransition when flushing the shell', async () => {
9612 + // Unlike stylesheets (which block paint from the <head> anyway), images
9613 + // inside ViewTransitions are outlined to enable animation reveals. This
9614 + // should happen even during the shell flush.
9615 + const ViewTransition = React.ViewTransition;
9616 +
9617 + let streamedContent = '';
9618 + writable.on('data', chunk => (streamedContent += chunk));
9619 +
9620 + await act(() => {
9621 + renderToPipeableStream(
9622 + <html>
9623 + <body>
9624 + <ViewTransition>
9625 + <Suspense fallback="Image Fallback">
9626 + <link rel="stylesheet" href="style.css" precedence="default" />
9627 + <img src="large-image.jpg" />
9628 + <div>Content</div>
9629 + </Suspense>
9630 + </ViewTransition>
9631 + </body>
9632 + </html>,
9633 + ).pipe(writable);
9634 + });
9635 +
9636 + // The boundary should be outlined because the suspensey image motivates
9637 + // outlining for animation reveals, even during the shell flush.
9638 + expect(streamedContent).toContain('Image Fallback');
9639 + });
9640 });
packages/react-markup/src/ReactFizzConfigMarkup.js
+4 -1
@@ -242,7 +242,10 @@ export function writeCompletedRoot(
242 return true;
243 }
244
245 -export function hasSuspenseyContent(hoistableState: HoistableState): boolean {
245 +export function hasSuspenseyContent(
246 + hoistableState: HoistableState,
247 + flushingInShell: boolean,
248 +): boolean {
249 // Never outline.
250 return false;
251 }
packages/react-noop-renderer/src/ReactNoopServer.js
+4 -1
@@ -324,7 +324,10 @@ const ReactNoopServer = ReactFizzServer({
324 writeHoistablesForBoundary() {},
325 writePostamble() {},
326 hoistHoistables(parent: HoistableState, child: HoistableState) {},
327 - hasSuspenseyContent(hoistableState: HoistableState): boolean {
327 + hasSuspenseyContent(
328 + hoistableState: HoistableState,
329 + flushingInShell: boolean,
330 + ): boolean {
331 return false;
332 },
333 createHoistableState(): HoistableState {
packages/react-server/src/ReactFizzServer.js
+5 -2
@@ -479,7 +479,7 @@ function isEligibleForOutlining(
479 // outlining.
480 return (
481 (boundary.byteSize > 500 ||
482 - hasSuspenseyContent(boundary.contentState) ||
482 + hasSuspenseyContent(boundary.contentState, /* flushingInShell */ false) ||
483 boundary.defer) &&
484 // For boundaries that can possibly contribute to the preamble we don't want to outline
485 // them regardless of their size since the fallbacks should only be emitted if we've
@@ -5593,7 +5593,7 @@ function flushSegment(
5593 !flushingPartialBoundaries &&
5594 isEligibleForOutlining(request, boundary) &&
5595 (flushedByteSize + boundary.byteSize > request.progressiveChunkSize ||
5596 - hasSuspenseyContent(boundary.contentState) ||
5596 + hasSuspenseyContent(boundary.contentState, flushingShell) ||
5597 boundary.defer)
5598 ) {
5599 // Inlining this boundary would make the current sequence being written too large
@@ -5826,6 +5826,7 @@ function flushPartiallyCompletedSegment(
5826 }
5827
5828 let flushingPartialBoundaries = false;
5829 +let flushingShell = false;
5830
5831 function flushCompletedQueues(
5832 request: Request,
@@ -5885,7 +5886,9 @@ function flushCompletedQueues(
5886 completedPreambleSegments,
5887 skipBlockingShell,
5888 );
5889 + flushingShell = true;
5890 flushSegment(request, destination, completedRootSegment, null);
5891 + flushingShell = false;
5892 request.completedRootSegment = null;
5893 const isComplete =
5894 request.allPendingTasks === 0 &&