diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
index a93c32a947f1..e654ea88007d 100644
--- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
+++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
@@ -7041,7 +7041,17 @@ export function hoistHoistables(
}
}
-export function hasSuspenseyContent(hoistableState: HoistableState): boolean {
+export function hasSuspenseyContent(
+ hoistableState: HoistableState,
+ flushingInShell: boolean,
+): boolean {
+ if (flushingInShell) {
+ // When flushing the shell, stylesheets with precedence are already emitted
+ // in the
which blocks paint. There's no benefit to outlining for CSS
+ // alone during the shell flush. However, suspensey images (for ViewTransition
+ // animation reveals) should still trigger outlining even during the shell.
+ return hoistableState.suspenseyImages;
+ }
return hoistableState.stylesheets.size > 0 || hoistableState.suspenseyImages;
}
diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js
index d48e9a8dd932..46fad3c39bf4 100644
--- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js
+++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js
@@ -326,7 +326,10 @@ export function writePreambleStart(
);
}
-export function hasSuspenseyContent(hoistableState: HoistableState): boolean {
+export function hasSuspenseyContent(
+ hoistableState: HoistableState,
+ flushingInShell: boolean,
+): boolean {
// Never outline.
return false;
}
diff --git a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js
index 1caa5ed8d6e7..21bf9684b285 100644
--- a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js
@@ -9449,4 +9449,192 @@ background-color: green;
);
});
});
+
+ it('does not outline a boundary with suspensey CSS when flushing the shell', async () => {
+ // When flushing the shell, stylesheets with precedence are emitted in the
+ // which blocks paint anyway. So there's no benefit to outlining the
+ // boundary — it would just show a higher-level fallback unnecessarily.
+ // Instead, the boundary should be inlined so the innermost fallback is shown.
+ let streamedContent = '';
+ writable.on('data', chunk => (streamedContent += chunk));
+
+ await act(() => {
+ renderToPipeableStream(
+
+
+
+
+
+
+ Async Content
+
+
+
+
+ ,
+ ).pipe(writable);
+ });
+
+ // The middle boundary should have been inlined (not outlined) so the
+ // middle fallback text should never appear in the streamed HTML.
+ expect(streamedContent).not.toContain('Middle Fallback');
+
+ // The stylesheet is in the head (blocks paint), and the innermost
+ // fallback is visible.
+ expect(getMeaningfulChildren(document)).toEqual(
+
+
+
+
+ Inner Fallback
+ ,
+ );
+
+ // Resolve the async content — streams in without needing to load CSS
+ // since the stylesheet was already in the head.
+ await act(() => {
+ resolveText('content');
+ });
+
+ expect(getMeaningfulChildren(document)).toEqual(
+
+
+
+
+ Async Content
+ ,
+ );
+ });
+
+ it('outlines a boundary with suspensey CSS when flushing a streamed completion', async () => {
+ // When a boundary completes via streaming (not as part of the shell),
+ // suspensey CSS should cause the boundary to be outlined. The parent
+ // content can show sooner while the CSS loads separately.
+ let streamedContent = '';
+ writable.on('data', chunk => (streamedContent += chunk));
+
+ await act(() => {
+ renderToPipeableStream(
+
+
+
+
+
+
+
+
+ Async Content
+
+
+
+
+
+
+ ,
+ ).pipe(writable);
+ });
+
+ // Shell is showing root fallback
+ expect(getMeaningfulChildren(document)).toEqual(
+
+ Root Fallback
+ ,
+ );
+
+ // Unblock the shell — content streams in. The middle boundary should
+ // be outlined because the CSS arrived via streaming, not in the shell head.
+ streamedContent = '';
+ await act(() => {
+ resolveText('shell');
+ });
+
+ // The middle fallback should appear in the streamed HTML because the
+ // boundary was outlined.
+ expect(streamedContent).toContain('Middle Fallback');
+
+ // The CSS needs to load before the boundary reveals. Until then
+ // the middle fallback is visible.
+ expect(getMeaningfulChildren(document)).toEqual(
+
+
+
+
+
+ {'Middle Fallback'}
+
+
+ ,
+ );
+
+ // Load the stylesheet — now the middle boundary can reveal
+ await act(() => {
+ loadStylesheets();
+ });
+ assertLog(['load stylesheet: style.css']);
+
+ expect(getMeaningfulChildren(document)).toEqual(
+
+
+
+
+
+ {'Inner Fallback'}
+
+
+ ,
+ );
+
+ // Resolve the async content
+ await act(() => {
+ resolveText('content');
+ });
+
+ expect(getMeaningfulChildren(document)).toEqual(
+
+
+
+
+
+ {'Async Content'}
+
+
+ ,
+ );
+ });
+
+ // @gate enableViewTransition
+ it('still outlines a boundary with a suspensey image inside a ViewTransition when flushing the shell', async () => {
+ // Unlike stylesheets (which block paint from the anyway), images
+ // inside ViewTransitions are outlined to enable animation reveals. This
+ // should happen even during the shell flush.
+ const ViewTransition = React.ViewTransition;
+
+ let streamedContent = '';
+ writable.on('data', chunk => (streamedContent += chunk));
+
+ await act(() => {
+ renderToPipeableStream(
+
+
+
+
+
+
+
Content
+
+
+
+ ,
+ ).pipe(writable);
+ });
+
+ // The boundary should be outlined because the suspensey image motivates
+ // outlining for animation reveals, even during the shell flush.
+ expect(streamedContent).toContain('Image Fallback');
+ });
});
diff --git a/packages/react-markup/src/ReactFizzConfigMarkup.js b/packages/react-markup/src/ReactFizzConfigMarkup.js
index 7dbe5592f337..d12d72e69e02 100644
--- a/packages/react-markup/src/ReactFizzConfigMarkup.js
+++ b/packages/react-markup/src/ReactFizzConfigMarkup.js
@@ -242,7 +242,10 @@ export function writeCompletedRoot(
return true;
}
-export function hasSuspenseyContent(hoistableState: HoistableState): boolean {
+export function hasSuspenseyContent(
+ hoistableState: HoistableState,
+ flushingInShell: boolean,
+): boolean {
// Never outline.
return false;
}
diff --git a/packages/react-noop-renderer/src/ReactNoopServer.js b/packages/react-noop-renderer/src/ReactNoopServer.js
index 1793180cc765..913e72d7fc4f 100644
--- a/packages/react-noop-renderer/src/ReactNoopServer.js
+++ b/packages/react-noop-renderer/src/ReactNoopServer.js
@@ -324,7 +324,10 @@ const ReactNoopServer = ReactFizzServer({
writeHoistablesForBoundary() {},
writePostamble() {},
hoistHoistables(parent: HoistableState, child: HoistableState) {},
- hasSuspenseyContent(hoistableState: HoistableState): boolean {
+ hasSuspenseyContent(
+ hoistableState: HoistableState,
+ flushingInShell: boolean,
+ ): boolean {
return false;
},
createHoistableState(): HoistableState {
diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js
index d06d967b1f87..989f9184637d 100644
--- a/packages/react-server/src/ReactFizzServer.js
+++ b/packages/react-server/src/ReactFizzServer.js
@@ -479,7 +479,7 @@ function isEligibleForOutlining(
// outlining.
return (
(boundary.byteSize > 500 ||
- hasSuspenseyContent(boundary.contentState) ||
+ hasSuspenseyContent(boundary.contentState, /* flushingInShell */ false) ||
boundary.defer) &&
// For boundaries that can possibly contribute to the preamble we don't want to outline
// them regardless of their size since the fallbacks should only be emitted if we've
@@ -5593,7 +5593,7 @@ function flushSegment(
!flushingPartialBoundaries &&
isEligibleForOutlining(request, boundary) &&
(flushedByteSize + boundary.byteSize > request.progressiveChunkSize ||
- hasSuspenseyContent(boundary.contentState) ||
+ hasSuspenseyContent(boundary.contentState, flushingShell) ||
boundary.defer)
) {
// Inlining this boundary would make the current sequence being written too large
@@ -5826,6 +5826,7 @@ function flushPartiallyCompletedSegment(
}
let flushingPartialBoundaries = false;
+let flushingShell = false;
function flushCompletedQueues(
request: Request,
@@ -5885,7 +5886,9 @@ function flushCompletedQueues(
completedPreambleSegments,
skipBlockingShell,
);
+ flushingShell = true;
flushSegment(request, destination, completedRootSegment, null);
+ flushingShell = false;
request.completedRootSegment = null;
const isComplete =
request.allPendingTasks === 0 &&