From 0dd76e797425d76301e0099fe8e6a14031603f5b Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sun, 21 Sep 2025 17:07:01 -0400 Subject: [PATCH 1/5] Outline a boundary if it has client suspensey content even if the byte size is not exceeded In this case stylesheets. --- .../react-dom-bindings/src/server/ReactFizzConfigDOM.js | 4 ++++ .../src/server/ReactFizzConfigDOMLegacy.js | 6 ++++++ packages/react-markup/src/ReactFizzConfigMarkup.js | 5 +++++ packages/react-noop-renderer/src/ReactNoopServer.js | 3 +++ packages/react-server/src/ReactFizzServer.js | 6 ++++-- packages/react-server/src/forks/ReactFizzConfig.custom.js | 1 + 6 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index eb8c2786152d8..d1e6ad1d625b7 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -6997,6 +6997,10 @@ export function hoistHoistables( childState.stylesheets.forEach(hoistStylesheetDependency, parentState); } +export function hasSuspenseyContent(hoistableState: HoistableState): boolean { + return hoistableState.stylesheets.size > 0; +} + // This function is called at various times depending on whether we are rendering // or prerendering. In this implementation we only actually emit headers once and // subsequent calls are ignored. We track whether the request has a completed shell diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js index 6ab54af00f7b7..d48e9a8dd932e 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js @@ -10,6 +10,7 @@ import type { RenderState as BaseRenderState, ResumableState, + HoistableState, StyleQueue, Resource, HeadersDescriptor, @@ -325,5 +326,10 @@ export function writePreambleStart( ); } +export function hasSuspenseyContent(hoistableState: HoistableState): boolean { + // Never outline. + return false; +} + export type TransitionStatus = FormStatus; export const NotPendingTransition: TransitionStatus = NotPending; diff --git a/packages/react-markup/src/ReactFizzConfigMarkup.js b/packages/react-markup/src/ReactFizzConfigMarkup.js index fee02f320fcb5..7dbe5592f3372 100644 --- a/packages/react-markup/src/ReactFizzConfigMarkup.js +++ b/packages/react-markup/src/ReactFizzConfigMarkup.js @@ -242,5 +242,10 @@ export function writeCompletedRoot( return true; } +export function hasSuspenseyContent(hoistableState: HoistableState): boolean { + // Never outline. + return false; +} + export type TransitionStatus = FormStatus; export const NotPendingTransition: TransitionStatus = NotPending; diff --git a/packages/react-noop-renderer/src/ReactNoopServer.js b/packages/react-noop-renderer/src/ReactNoopServer.js index 59f0fafa5d21f..1793180cc7658 100644 --- a/packages/react-noop-renderer/src/ReactNoopServer.js +++ b/packages/react-noop-renderer/src/ReactNoopServer.js @@ -324,6 +324,9 @@ const ReactNoopServer = ReactFizzServer({ writeHoistablesForBoundary() {}, writePostamble() {}, hoistHoistables(parent: HoistableState, child: HoistableState) {}, + hasSuspenseyContent(hoistableState: HoistableState): boolean { + return false; + }, createHoistableState(): HoistableState { return null; }, diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 7d54dff3d080a..16ba483627a6c 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -99,6 +99,7 @@ import { hoistPreambleState, isPreambleReady, isPreambleContext, + hasSuspenseyContent, } from './ReactFizzConfig'; import { constructClassInstance, @@ -461,7 +462,7 @@ function isEligibleForOutlining( // The larger this limit is, the more we can save on preparing fallbacks in case we end up // outlining. return ( - boundary.byteSize > 500 && + (boundary.byteSize > 500 || hasSuspenseyContent(boundary.contentState)) && // 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 // errored the boundary. @@ -5749,7 +5750,8 @@ function flushSegment( return writeEndPendingSuspenseBoundary(destination, request.renderState); } else if ( isEligibleForOutlining(request, boundary) && - flushedByteSize + boundary.byteSize > request.progressiveChunkSize + (flushedByteSize + boundary.byteSize > request.progressiveChunkSize || + hasSuspenseyContent(boundary.contentState)) ) { // Inlining this boundary would make the current sequence being written too large // and block the parent for too long. Instead, it will be emitted separately so that we diff --git a/packages/react-server/src/forks/ReactFizzConfig.custom.js b/packages/react-server/src/forks/ReactFizzConfig.custom.js index 981e390ea4dc6..aa8ea94b57917 100644 --- a/packages/react-server/src/forks/ReactFizzConfig.custom.js +++ b/packages/react-server/src/forks/ReactFizzConfig.custom.js @@ -104,4 +104,5 @@ export const writeHoistablesForBoundary = $$$config.writeHoistablesForBoundary; export const writePostamble = $$$config.writePostamble; export const hoistHoistables = $$$config.hoistHoistables; export const createHoistableState = $$$config.createHoistableState; +export const hasSuspenseyContent = $$$config.hasSuspenseyContent; export const emitEarlyPreloads = $$$config.emitEarlyPreloads; From 9e7ec301e9defe1ab4db3221105be5145a7614e8 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sun, 21 Sep 2025 20:38:28 -0400 Subject: [PATCH 2/5] Outline boundaries with suspensey images in them --- .../src/server/ReactFizzConfigDOM.js | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index d1e6ad1d625b7..b4404789abe52 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -3289,6 +3289,7 @@ function pushImg( props: Object, resumableState: ResumableState, renderState: RenderState, + hoistableState: null | HoistableState, formatContext: FormatContext, ): null { const pictureOrNoScriptTagInScope = @@ -3321,6 +3322,12 @@ function pushImg( ) { // We have a suspensey image and ought to preload it to optimize the loading of display blocking // resumableState. + + if (hoistableState !== null) { + // Mark this boundary's state as having suspensey images. + hoistableState.suspenseyImages = true; + } + const sizes = typeof props.sizes === 'string' ? props.sizes : undefined; const key = getImageResourceKey(src, srcSet, sizes); @@ -4255,7 +4262,14 @@ export function pushStartInstance( return pushStartPreformattedElement(target, props, type, formatContext); } case 'img': { - return pushImg(target, props, resumableState, renderState, formatContext); + return pushImg( + target, + props, + resumableState, + renderState, + hoistableState, + formatContext, + ); } // Omitted close tags case 'base': @@ -6125,6 +6139,7 @@ type StylesheetResource = { export type HoistableState = { styles: Set, stylesheets: Set, + suspenseyImages: boolean, }; export type StyleQueue = { @@ -6138,6 +6153,7 @@ export function createHoistableState(): HoistableState { return { styles: new Set(), stylesheets: new Set(), + suspenseyImages: false, }; } @@ -6995,10 +7011,18 @@ export function hoistHoistables( ): void { childState.styles.forEach(hoistStyleQueueDependency, parentState); childState.stylesheets.forEach(hoistStylesheetDependency, parentState); + if (childState.suspenseyImages) { + // If the child has suspensey images, the parent now does too if it's inlined. + // Similarly, if a SuspenseList row has a suspensey image then effectively + // the next row should be blocked on it as well since the next row can't show + // earlier. In practice, since the child will be outlined this transferring + // may never matter but is conceptually correct. + parentState.suspenseyImages = true; + } } export function hasSuspenseyContent(hoistableState: HoistableState): boolean { - return hoistableState.stylesheets.size > 0; + return hoistableState.stylesheets.size > 0 || hoistableState.suspenseyImages; } // This function is called at various times depending on whether we are rendering From 73bf8915f0dc7fa9c5ecca0e2d4965bba2805198 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sun, 21 Sep 2025 21:45:49 -0400 Subject: [PATCH 3/5] Only treat an img as suspensey if it's inside a Suspense boundary which might animate Since the client runtime doesn't implement suspensey image semantics unless we're animating, there's no point in outlining unless it's inside a Suspense boundary with an enter animation or a around the boundary. --- .../src/server/ReactFizzConfigDOM.js | 40 ++++++++++++++----- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index b4404789abe52..a93c32a947f10 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -782,13 +782,14 @@ const HTML_COLGROUP_MODE = 9; type InsertionMode = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; -const NO_SCOPE = /* */ 0b000000; -const NOSCRIPT_SCOPE = /* */ 0b000001; -const PICTURE_SCOPE = /* */ 0b000010; -const FALLBACK_SCOPE = /* */ 0b000100; -const EXIT_SCOPE = /* */ 0b001000; // A direct Instance below a Suspense fallback is the only thing that can "exit" -const ENTER_SCOPE = /* */ 0b010000; // A direct Instance below Suspense content is the only thing that can "enter" -const UPDATE_SCOPE = /* */ 0b100000; // Inside a scope that applies "update" ViewTransitions if anything mutates here. +const NO_SCOPE = /* */ 0b0000000; +const NOSCRIPT_SCOPE = /* */ 0b0000001; +const PICTURE_SCOPE = /* */ 0b0000010; +const FALLBACK_SCOPE = /* */ 0b0000100; +const EXIT_SCOPE = /* */ 0b0001000; // A direct Instance below a Suspense fallback is the only thing that can "exit" +const ENTER_SCOPE = /* */ 0b0010000; // A direct Instance below Suspense content is the only thing that can "enter" +const UPDATE_SCOPE = /* */ 0b0100000; // Inside a scope that applies "update" ViewTransitions if anything mutates here. +const APPEARING_SCOPE = /* */ 0b1000000; // Below Suspense content subtree which might appear in an "enter" animation or "shared" animation. // Everything not listed here are tracked for the whole subtree as opposed to just // until the next Instance. @@ -987,11 +988,20 @@ export function getSuspenseContentFormatContext( resumableState: ResumableState, parentContext: FormatContext, ): FormatContext { + const viewTransition = getSuspenseViewTransition( + parentContext.viewTransition, + ); + let subtreeScope = parentContext.tagScope | ENTER_SCOPE; + if (viewTransition !== null && viewTransition.share !== 'none') { + // If we have a ViewTransition wrapping Suspense then the appearing animation + // will be applied just like an "enter" below. Mark it as animating. + subtreeScope |= APPEARING_SCOPE; + } return createFormatContext( parentContext.insertionMode, parentContext.selectedValue, - parentContext.tagScope | ENTER_SCOPE, - getSuspenseViewTransition(parentContext.viewTransition), + subtreeScope, + viewTransition, ); } @@ -1063,6 +1073,9 @@ export function getViewTransitionFormatContext( } else { subtreeScope &= ~UPDATE_SCOPE; } + if (enter !== 'none') { + subtreeScope |= APPEARING_SCOPE; + } return createFormatContext( parentContext.insertionMode, parentContext.selectedValue, @@ -3325,7 +3338,14 @@ function pushImg( if (hoistableState !== null) { // Mark this boundary's state as having suspensey images. - hoistableState.suspenseyImages = true; + // Only do that if we have a ViewTransition that might trigger a parent Suspense boundary + // to animate its appearing. Since that's the only case we'd actually apply suspensey images + // for SSR reveals. + const isInSuspenseWithEnterViewTransition = + formatContext.tagScope & APPEARING_SCOPE; + if (isInSuspenseWithEnterViewTransition) { + hoistableState.suspenseyImages = true; + } } const sizes = typeof props.sizes === 'string' ? props.sizes : undefined; From 5f441130d2479b9fbc27b4edffc4e0de4508e357 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sun, 21 Sep 2025 21:46:15 -0400 Subject: [PATCH 4/5] Update fixture to test this case --- fixtures/view-transition/src/components/Page.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fixtures/view-transition/src/components/Page.js b/fixtures/view-transition/src/components/Page.js index 658ed686293c7..d411bb2453069 100644 --- a/fixtures/view-transition/src/components/Page.js +++ b/fixtures/view-transition/src/components/Page.js @@ -238,8 +238,8 @@ export default function Page({url, navigate}) { + {show ? : null} - {show ? : null} From a9f232464cee4508be1d653e91b497e54d1e833e Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sun, 21 Sep 2025 22:06:44 -0400 Subject: [PATCH 5/5] Don't outline if we're flushing partially completed boundaries We don't outline when we're emitting partially completed boundaries optimistically because it doesn't make sense to outline something if its parent is going to be blocked on something later in the stream anyway. We won't be able to show the parent anyway so we might as well inline. This is not completely true for when we outline for CSS or images because the reveal of the inner boundary could be still pending after the parent finishes but there's a trade off there. --- packages/react-server/src/ReactFizzServer.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 16ba483627a6c..b8184a1983708 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -5749,6 +5749,10 @@ function flushSegment( return writeEndPendingSuspenseBoundary(destination, request.renderState); } else if ( + // We don't outline when we're emitting partially completed boundaries optimistically + // because it doesn't make sense to outline something if its parent is going to be + // blocked on something later in the stream anyway. + !flushingPartialBoundaries && isEligibleForOutlining(request, boundary) && (flushedByteSize + boundary.byteSize > request.progressiveChunkSize || hasSuspenseyContent(boundary.contentState)) @@ -5982,6 +5986,8 @@ function flushPartiallyCompletedSegment( } } +let flushingPartialBoundaries = false; + function flushCompletedQueues( request: Request, destination: Destination, @@ -6097,6 +6103,7 @@ function flushCompletedQueues( // Next we emit any segments of any boundaries that are partially complete // but not deeply complete. + flushingPartialBoundaries = true; const partialBoundaries = request.partialBoundaries; for (i = 0; i < partialBoundaries.length; i++) { const boundary = partialBoundaries[i]; @@ -6108,6 +6115,7 @@ function flushCompletedQueues( } } partialBoundaries.splice(0, i); + flushingPartialBoundaries = false; // Next we check the completed boundaries again. This may have had // boundaries added to it in case they were too larged to be inlined. @@ -6125,6 +6133,7 @@ function flushCompletedQueues( } largeBoundaries.splice(0, i); } finally { + flushingPartialBoundaries = false; if ( request.allPendingTasks === 0 && request.clientRenderedBoundaries.length === 0 &&