diff --git a/fixtures/view-transition/src/components/Page.js b/fixtures/view-transition/src/components/Page.js
index 658ed686293..d411bb24530 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}
diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
index eb8c2786152..a93c32a947f 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,
@@ -3289,6 +3302,7 @@ function pushImg(
props: Object,
resumableState: ResumableState,
renderState: RenderState,
+ hoistableState: null | HoistableState,
formatContext: FormatContext,
): null {
const pictureOrNoScriptTagInScope =
@@ -3321,6 +3335,19 @@ 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.
+ // 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;
const key = getImageResourceKey(src, srcSet, sizes);
@@ -4255,7 +4282,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 +6159,7 @@ type StylesheetResource = {
export type HoistableState = {
styles: Set,
stylesheets: Set,
+ suspenseyImages: boolean,
};
export type StyleQueue = {
@@ -6138,6 +6173,7 @@ export function createHoistableState(): HoistableState {
return {
styles: new Set(),
stylesheets: new Set(),
+ suspenseyImages: false,
};
}
@@ -6995,6 +7031,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 || hoistableState.suspenseyImages;
}
// This function is called at various times depending on whether we are rendering
diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js
index 6ab54af00f7..d48e9a8dd93 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 fee02f320fc..7dbe5592f33 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 59f0fafa5d2..1793180cc76 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 7d54dff3d08..b8184a19837 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.
@@ -5748,8 +5749,13 @@ 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
+ (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
@@ -5980,6 +5986,8 @@ function flushPartiallyCompletedSegment(
}
}
+let flushingPartialBoundaries = false;
+
function flushCompletedQueues(
request: Request,
destination: Destination,
@@ -6095,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];
@@ -6106,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.
@@ -6123,6 +6133,7 @@ function flushCompletedQueues(
}
largeBoundaries.splice(0, i);
} finally {
+ flushingPartialBoundaries = false;
if (
request.allPendingTasks === 0 &&
request.clientRenderedBoundaries.length === 0 &&
diff --git a/packages/react-server/src/forks/ReactFizzConfig.custom.js b/packages/react-server/src/forks/ReactFizzConfig.custom.js
index 981e390ea4d..aa8ea94b579 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;