Skip to content

Commit 38cd020

Browse files
authored
Don't outline Suspense boundaries with suspensey CSS during shell flush (facebook#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.
1 parent f247eba commit 38cd020

File tree

6 files changed

+216
-6
lines changed

6 files changed

+216
-6
lines changed

packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7041,7 +7041,17 @@ export function hoistHoistables(
70417041
}
70427042
}
70437043

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+
}
70457055
return hoistableState.stylesheets.size > 0 || hoistableState.suspenseyImages;
70467056
}
70477057

packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,10 @@ export function writePreambleStart(
326326
);
327327
}
328328

329-
export function hasSuspenseyContent(hoistableState: HoistableState): boolean {
329+
export function hasSuspenseyContent(
330+
hoistableState: HoistableState,
331+
flushingInShell: boolean,
332+
): boolean {
330333
// Never outline.
331334
return false;
332335
}

packages/react-dom/src/__tests__/ReactDOMFloat-test.js

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9449,4 +9449,192 @@ background-color: green;
94499449
);
94509450
});
94519451
});
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+
});
94529640
});

packages/react-markup/src/ReactFizzConfigMarkup.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,10 @@ export function writeCompletedRoot(
242242
return true;
243243
}
244244

245-
export function hasSuspenseyContent(hoistableState: HoistableState): boolean {
245+
export function hasSuspenseyContent(
246+
hoistableState: HoistableState,
247+
flushingInShell: boolean,
248+
): boolean {
246249
// Never outline.
247250
return false;
248251
}

packages/react-noop-renderer/src/ReactNoopServer.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,10 @@ const ReactNoopServer = ReactFizzServer({
324324
writeHoistablesForBoundary() {},
325325
writePostamble() {},
326326
hoistHoistables(parent: HoistableState, child: HoistableState) {},
327-
hasSuspenseyContent(hoistableState: HoistableState): boolean {
327+
hasSuspenseyContent(
328+
hoistableState: HoistableState,
329+
flushingInShell: boolean,
330+
): boolean {
328331
return false;
329332
},
330333
createHoistableState(): HoistableState {

packages/react-server/src/ReactFizzServer.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -479,7 +479,7 @@ function isEligibleForOutlining(
479479
// outlining.
480480
return (
481481
(boundary.byteSize > 500 ||
482-
hasSuspenseyContent(boundary.contentState) ||
482+
hasSuspenseyContent(boundary.contentState, /* flushingInShell */ false) ||
483483
boundary.defer) &&
484484
// For boundaries that can possibly contribute to the preamble we don't want to outline
485485
// them regardless of their size since the fallbacks should only be emitted if we've
@@ -5593,7 +5593,7 @@ function flushSegment(
55935593
!flushingPartialBoundaries &&
55945594
isEligibleForOutlining(request, boundary) &&
55955595
(flushedByteSize + boundary.byteSize > request.progressiveChunkSize ||
5596-
hasSuspenseyContent(boundary.contentState) ||
5596+
hasSuspenseyContent(boundary.contentState, flushingShell) ||
55975597
boundary.defer)
55985598
) {
55995599
// Inlining this boundary would make the current sequence being written too large
@@ -5826,6 +5826,7 @@ function flushPartiallyCompletedSegment(
58265826
}
58275827

58285828
let flushingPartialBoundaries = false;
5829+
let flushingShell = false;
58295830

58305831
function flushCompletedQueues(
58315832
request: Request,
@@ -5885,7 +5886,9 @@ function flushCompletedQueues(
58855886
completedPreambleSegments,
58865887
skipBlockingShell,
58875888
);
5889+
flushingShell = true;
58885890
flushSegment(request, destination, completedRootSegment, null);
5891+
flushingShell = false;
58895892
request.completedRootSegment = null;
58905893
const isComplete =
58915894
request.allPendingTasks === 0 &&

0 commit comments

Comments
 (0)