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;