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 &&