@samitouri / QOS-React / commits / 6eb5d67e9c

[Fizz] Outline a Suspense Boundary if it has Suspensey CSS or Images (#34552)

We should favor outlining a boundary if it contains Suspensey CSS or Suspensey Images since then we can load that content separately and not block the main content. This also allows us to animate the reveal. For example this should be able to animate the reveal even though the actual HTML content isn't large in this case it's worth outlining so that the JS runtime can choose to animate this reveal. ```js <ViewTransition> <Suspense> <img src="..." /> </Suspense> </ViewTransition> ``` For Suspensey Images, in Fizz, we currently only implement the suspensey semantics when a View Transition is running. Therefore the outlining only applies if it appears inside a Suspense boundary which might animate. Otherwise there's no point in outlining. It is also only if the Suspense boundary itself might animate its appear and not just any ViewTransition. So the effect is very conservative. For CSS it applies even without ViewTransition though, since it can help unblock the main content faster.

Sebastian Markbåge committed Sep 25, 2025 at 09:38 UTC 6eb5d67e9c4c5c456783dbbd454d79016c43b07d
7 files changed +87 -13
fixtures/view-transition/src/components/Page.js
+1 -1
@@ -238,8 +238,8 @@ export default function Page({url, navigate}) {
238 <Suspend />
239 </div>
240 </ViewTransition>
241 + {show ? <Component /> : null}
242 </Suspense>
242 - {show ? <Component /> : null}
243 </div>
244 </ViewTransition>
245 </SwipeRecognizer>
packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
+58 -10
@@ -782,13 +782,14 @@ const HTML_COLGROUP_MODE = 9;
782
783 type InsertionMode = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
784
785 -const NO_SCOPE = /* */ 0b000000;
786 -const NOSCRIPT_SCOPE = /* */ 0b000001;
787 -const PICTURE_SCOPE = /* */ 0b000010;
788 -const FALLBACK_SCOPE = /* */ 0b000100;
789 -const EXIT_SCOPE = /* */ 0b001000; // A direct Instance below a Suspense fallback is the only thing that can "exit"
790 -const ENTER_SCOPE = /* */ 0b010000; // A direct Instance below Suspense content is the only thing that can "enter"
791 -const UPDATE_SCOPE = /* */ 0b100000; // Inside a scope that applies "update" ViewTransitions if anything mutates here.
785 +const NO_SCOPE = /* */ 0b0000000;
786 +const NOSCRIPT_SCOPE = /* */ 0b0000001;
787 +const PICTURE_SCOPE = /* */ 0b0000010;
788 +const FALLBACK_SCOPE = /* */ 0b0000100;
789 +const EXIT_SCOPE = /* */ 0b0001000; // A direct Instance below a Suspense fallback is the only thing that can "exit"
790 +const ENTER_SCOPE = /* */ 0b0010000; // A direct Instance below Suspense content is the only thing that can "enter"
791 +const UPDATE_SCOPE = /* */ 0b0100000; // Inside a scope that applies "update" ViewTransitions if anything mutates here.
792 +const APPEARING_SCOPE = /* */ 0b1000000; // Below Suspense content subtree which might appear in an "enter" animation or "shared" animation.
793
794 // Everything not listed here are tracked for the whole subtree as opposed to just
795 // until the next Instance.
@@ -987,11 +988,20 @@ export function getSuspenseContentFormatContext(
988 resumableState: ResumableState,
989 parentContext: FormatContext,
990 ): FormatContext {
991 + const viewTransition = getSuspenseViewTransition(
992 + parentContext.viewTransition,
993 + );
994 + let subtreeScope = parentContext.tagScope | ENTER_SCOPE;
995 + if (viewTransition !== null && viewTransition.share !== 'none') {
996 + // If we have a ViewTransition wrapping Suspense then the appearing animation
997 + // will be applied just like an "enter" below. Mark it as animating.
998 + subtreeScope |= APPEARING_SCOPE;
999 + }
1000 return createFormatContext(
1001 parentContext.insertionMode,
1002 parentContext.selectedValue,
993 - parentContext.tagScope | ENTER_SCOPE,
994 - getSuspenseViewTransition(parentContext.viewTransition),
1003 + subtreeScope,
1004 + viewTransition,
1005 );
1006 }
1007
@@ -1063,6 +1073,9 @@ export function getViewTransitionFormatContext(
1073 } else {
1074 subtreeScope &= ~UPDATE_SCOPE;
1075 }
1076 + if (enter !== 'none') {
1077 + subtreeScope |= APPEARING_SCOPE;
1078 + }
1079 return createFormatContext(
1080 parentContext.insertionMode,
1081 parentContext.selectedValue,
@@ -3289,6 +3302,7 @@ function pushImg(
3302 props: Object,
3303 resumableState: ResumableState,
3304 renderState: RenderState,
3305 + hoistableState: null | HoistableState,
3306 formatContext: FormatContext,
3307 ): null {
3308 const pictureOrNoScriptTagInScope =
@@ -3321,6 +3335,19 @@ function pushImg(
3335 ) {
3336 // We have a suspensey image and ought to preload it to optimize the loading of display blocking
3337 // resumableState.
3338 +
3339 + if (hoistableState !== null) {
3340 + // Mark this boundary's state as having suspensey images.
3341 + // Only do that if we have a ViewTransition that might trigger a parent Suspense boundary
3342 + // to animate its appearing. Since that's the only case we'd actually apply suspensey images
3343 + // for SSR reveals.
3344 + const isInSuspenseWithEnterViewTransition =
3345 + formatContext.tagScope & APPEARING_SCOPE;
3346 + if (isInSuspenseWithEnterViewTransition) {
3347 + hoistableState.suspenseyImages = true;
3348 + }
3349 + }
3350 +
3351 const sizes = typeof props.sizes === 'string' ? props.sizes : undefined;
3352 const key = getImageResourceKey(src, srcSet, sizes);
3353
@@ -4255,7 +4282,14 @@ export function pushStartInstance(
4282 return pushStartPreformattedElement(target, props, type, formatContext);
4283 }
4284 case 'img': {
4258 - return pushImg(target, props, resumableState, renderState, formatContext);
4285 + return pushImg(
4286 + target,
4287 + props,
4288 + resumableState,
4289 + renderState,
4290 + hoistableState,
4291 + formatContext,
4292 + );
4293 }
4294 // Omitted close tags
4295 case 'base':
@@ -6125,6 +6159,7 @@ type StylesheetResource = {
6159 export type HoistableState = {
6160 styles: Set<StyleQueue>,
6161 stylesheets: Set<StylesheetResource>,
6162 + suspenseyImages: boolean,
6163 };
6164
6165 export type StyleQueue = {
@@ -6138,6 +6173,7 @@ export function createHoistableState(): HoistableState {
6173 return {
6174 styles: new Set(),
6175 stylesheets: new Set(),
6176 + suspenseyImages: false,
6177 };
6178 }
6179
@@ -6995,6 +7031,18 @@ export function hoistHoistables(
7031 ): void {
7032 childState.styles.forEach(hoistStyleQueueDependency, parentState);
7033 childState.stylesheets.forEach(hoistStylesheetDependency, parentState);
7034 + if (childState.suspenseyImages) {
7035 + // If the child has suspensey images, the parent now does too if it's inlined.
7036 + // Similarly, if a SuspenseList row has a suspensey image then effectively
7037 + // the next row should be blocked on it as well since the next row can't show
7038 + // earlier. In practice, since the child will be outlined this transferring
7039 + // may never matter but is conceptually correct.
7040 + parentState.suspenseyImages = true;
7041 + }
7042 +}
7043 +
7044 +export function hasSuspenseyContent(hoistableState: HoistableState): boolean {
7045 + return hoistableState.stylesheets.size > 0 || hoistableState.suspenseyImages;
7046 }
7047
7048 // This function is called at various times depending on whether we are rendering
packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js
+6
@@ -10,6 +10,7 @@
10 import type {
11 RenderState as BaseRenderState,
12 ResumableState,
13 + HoistableState,
14 StyleQueue,
15 Resource,
16 HeadersDescriptor,
@@ -325,5 +326,10 @@ export function writePreambleStart(
326 );
327 }
328
329 +export function hasSuspenseyContent(hoistableState: HoistableState): boolean {
330 + // Never outline.
331 + return false;
332 +}
333 +
334 export type TransitionStatus = FormStatus;
335 export const NotPendingTransition: TransitionStatus = NotPending;
packages/react-markup/src/ReactFizzConfigMarkup.js
+5
@@ -242,5 +242,10 @@ export function writeCompletedRoot(
242 return true;
243 }
244
245 +export function hasSuspenseyContent(hoistableState: HoistableState): boolean {
246 + // Never outline.
247 + return false;
248 +}
249 +
250 export type TransitionStatus = FormStatus;
251 export const NotPendingTransition: TransitionStatus = NotPending;
packages/react-noop-renderer/src/ReactNoopServer.js
+3
@@ -324,6 +324,9 @@ const ReactNoopServer = ReactFizzServer({
324 writeHoistablesForBoundary() {},
325 writePostamble() {},
326 hoistHoistables(parent: HoistableState, child: HoistableState) {},
327 + hasSuspenseyContent(hoistableState: HoistableState): boolean {
328 + return false;
329 + },
330 createHoistableState(): HoistableState {
331 return null;
332 },
packages/react-server/src/ReactFizzServer.js
+13 -2
@@ -99,6 +99,7 @@ import {
99 hoistPreambleState,
100 isPreambleReady,
101 isPreambleContext,
102 + hasSuspenseyContent,
103 } from './ReactFizzConfig';
104 import {
105 constructClassInstance,
@@ -461,7 +462,7 @@ function isEligibleForOutlining(
462 // The larger this limit is, the more we can save on preparing fallbacks in case we end up
463 // outlining.
464 return (
464 - boundary.byteSize > 500 &&
465 + (boundary.byteSize > 500 || hasSuspenseyContent(boundary.contentState)) &&
466 // For boundaries that can possibly contribute to the preamble we don't want to outline
467 // them regardless of their size since the fallbacks should only be emitted if we've
468 // errored the boundary.
@@ -5748,8 +5749,13 @@ function flushSegment(
5749
5750 return writeEndPendingSuspenseBoundary(destination, request.renderState);
5751 } else if (
5752 + // We don't outline when we're emitting partially completed boundaries optimistically
5753 + // because it doesn't make sense to outline something if its parent is going to be
5754 + // blocked on something later in the stream anyway.
5755 + !flushingPartialBoundaries &&
5756 isEligibleForOutlining(request, boundary) &&
5752 - flushedByteSize + boundary.byteSize > request.progressiveChunkSize
5757 + (flushedByteSize + boundary.byteSize > request.progressiveChunkSize ||
5758 + hasSuspenseyContent(boundary.contentState))
5759 ) {
5760 // Inlining this boundary would make the current sequence being written too large
5761 // and block the parent for too long. Instead, it will be emitted separately so that we
@@ -5980,6 +5986,8 @@ function flushPartiallyCompletedSegment(
5986 }
5987 }
5988
5989 +let flushingPartialBoundaries = false;
5990 +
5991 function flushCompletedQueues(
5992 request: Request,
5993 destination: Destination,
@@ -6095,6 +6103,7 @@ function flushCompletedQueues(
6103
6104 // Next we emit any segments of any boundaries that are partially complete
6105 // but not deeply complete.
6106 + flushingPartialBoundaries = true;
6107 const partialBoundaries = request.partialBoundaries;
6108 for (i = 0; i < partialBoundaries.length; i++) {
6109 const boundary = partialBoundaries[i];
@@ -6106,6 +6115,7 @@ function flushCompletedQueues(
6115 }
6116 }
6117 partialBoundaries.splice(0, i);
6118 + flushingPartialBoundaries = false;
6119
6120 // Next we check the completed boundaries again. This may have had
6121 // boundaries added to it in case they were too larged to be inlined.
@@ -6123,6 +6133,7 @@ function flushCompletedQueues(
6133 }
6134 largeBoundaries.splice(0, i);
6135 } finally {
6136 + flushingPartialBoundaries = false;
6137 if (
6138 request.allPendingTasks === 0 &&
6139 request.clientRenderedBoundaries.length === 0 &&
packages/react-server/src/forks/ReactFizzConfig.custom.js
+1
@@ -104,4 +104,5 @@ export const writeHoistablesForBoundary = $$$config.writeHoistablesForBoundary;
104 export const writePostamble = $$$config.writePostamble;
105 export const hoistHoistables = $$$config.hoistHoistables;
106 export const createHoistableState = $$$config.createHoistableState;
107 +export const hasSuspenseyContent = $$$config.hasSuspenseyContent;
108 export const emitEarlyPreloads = $$$config.emitEarlyPreloads;