From 542555671cdebe3d9bf019f87f623471f38fa34d Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Fri, 16 May 2025 11:20:15 -0400 Subject: [PATCH 1/4] Wrap revealCompletedBoundaries in a ViewTransitions aware version when needed For the external runtime we always include this wrapper. For others, we only include it if we have an ViewTransitions affecting. If we discover the ViewTransitions late, then we can upgrade an already emitted instruction. --- .../src/server/ReactFizzConfigDOM.js | 51 ++++-- .../ReactDOMFizzInlineCompleteBoundary.js | 7 +- ...ompleteBoundaryUpgradeToViewTransitions.js | 10 ++ ...actDOMFizzInstructionSetExternalRuntime.js | 6 + ...tDOMFizzInstructionSetInlineCodeStrings.js | 4 +- .../ReactDOMFizzInstructionSetShared.js | 158 +++++++++++------- .../rollup/generate-inline-fizz-runtime.js | 4 + 7 files changed, 160 insertions(+), 80 deletions(-) create mode 100644 packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInlineCompleteBoundaryUpgradeToViewTransitions.js diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index 8dbc6831e1fca..3cb1c5e9029bb 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -80,6 +80,7 @@ import isArray from 'shared/isArray'; import { clientRenderBoundary as clientRenderFunction, completeBoundary as completeBoundaryFunction, + completeBoundaryUpgradeToViewTransitions as upgradeToViewTransitionsInstruction, completeBoundaryWithStyles as styleInsertionFunction, completeSegment as completeSegmentFunction, formReplaying as formReplayingRuntime, @@ -123,14 +124,15 @@ const ScriptStreamingFormat: StreamingFormat = 0; const DataStreamingFormat: StreamingFormat = 1; export type InstructionState = number; -const NothingSent /* */ = 0b0000000; -const SentCompleteSegmentFunction /* */ = 0b0000001; -const SentCompleteBoundaryFunction /* */ = 0b0000010; -const SentClientRenderFunction /* */ = 0b0000100; -const SentStyleInsertionFunction /* */ = 0b0001000; -const SentFormReplayingRuntime /* */ = 0b0010000; -const SentCompletedShellId /* */ = 0b0100000; -const SentMarkShellTime /* */ = 0b1000000; +const NothingSent /* */ = 0b00000000; +const SentCompleteSegmentFunction /* */ = 0b00000001; +const SentCompleteBoundaryFunction /* */ = 0b00000010; +const SentClientRenderFunction /* */ = 0b00000100; +const SentStyleInsertionFunction /* */ = 0b00001000; +const SentFormReplayingRuntime /* */ = 0b00010000; +const SentCompletedShellId /* */ = 0b00100000; +const SentMarkShellTime /* */ = 0b01000000; +const SentUpgradeToViewTransitions /* */ = 0b10000000; // Per request, global state that is not contextual to the rendering subtree. // This cannot be resumed and therefore should only contain things that are @@ -4780,9 +4782,8 @@ export function writeCompletedSegmentInstruction( const completeBoundaryScriptFunctionOnly = stringToPrecomputedChunk( completeBoundaryFunction, ); -const completeBoundaryScript1Full = stringToPrecomputedChunk( - completeBoundaryFunction + '$RC("', -); +const completeBoundaryUpgradeToViewTransitionsInstruction = + stringToPrecomputedChunk(upgradeToViewTransitionsInstruction); const completeBoundaryScript1Partial = stringToPrecomputedChunk('$RC("'); const completeBoundaryWithStylesScript1FullPartial = stringToPrecomputedChunk( @@ -4814,6 +4815,7 @@ export function writeCompletedBoundaryInstruction( hoistableState: HoistableState, ): boolean { const requiresStyleInsertion = renderState.stylesToHoist; + const requiresViewTransitions = enableViewTransition; // If necessary stylesheets will be flushed with this instruction. // Any style tags not yet hoisted in the Document will also be hoisted. // We reset this state since after this instruction executes all styles @@ -4842,6 +4844,17 @@ export function writeCompletedBoundaryInstruction( resumableState.instructions |= SentCompleteBoundaryFunction; writeChunk(destination, completeBoundaryScriptFunctionOnly); } + if ( + requiresViewTransitions && + (resumableState.instructions & SentUpgradeToViewTransitions) === + NothingSent + ) { + resumableState.instructions |= SentUpgradeToViewTransitions; + writeChunk( + destination, + completeBoundaryUpgradeToViewTransitionsInstruction, + ); + } if ( (resumableState.instructions & SentStyleInsertionFunction) === NothingSent @@ -4857,10 +4870,20 @@ export function writeCompletedBoundaryInstruction( NothingSent ) { resumableState.instructions |= SentCompleteBoundaryFunction; - writeChunk(destination, completeBoundaryScript1Full); - } else { - writeChunk(destination, completeBoundaryScript1Partial); + writeChunk(destination, completeBoundaryScriptFunctionOnly); + } + if ( + requiresViewTransitions && + (resumableState.instructions & SentUpgradeToViewTransitions) === + NothingSent + ) { + resumableState.instructions |= SentUpgradeToViewTransitions; + writeChunk( + destination, + completeBoundaryUpgradeToViewTransitionsInstruction, + ); } + writeChunk(destination, completeBoundaryScript1Partial); } } else { if (requiresStyleInsertion) { diff --git a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInlineCompleteBoundary.js b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInlineCompleteBoundary.js index 44a81c64468e7..761a8a18a958f 100644 --- a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInlineCompleteBoundary.js +++ b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInlineCompleteBoundary.js @@ -1,7 +1,12 @@ -import {completeBoundary} from './ReactDOMFizzInstructionSetShared'; +import { + revealCompletedBoundaries, + completeBoundary, +} from './ReactDOMFizzInstructionSetShared'; // This is a string so Closure's advanced compilation mode doesn't mangle it. // eslint-disable-next-line dot-notation window['$RB'] = []; // eslint-disable-next-line dot-notation +window['$RV'] = revealCompletedBoundaries; +// eslint-disable-next-line dot-notation window['$RC'] = completeBoundary; diff --git a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInlineCompleteBoundaryUpgradeToViewTransitions.js b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInlineCompleteBoundaryUpgradeToViewTransitions.js new file mode 100644 index 0000000000000..e033b9cc9e82b --- /dev/null +++ b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInlineCompleteBoundaryUpgradeToViewTransitions.js @@ -0,0 +1,10 @@ +import {revealCompletedBoundariesWithViewTransitions} from './ReactDOMFizzInstructionSetShared'; + +// Upgrade the revealCompletedBoundaries instruction to support ViewTransitions. +// This is a string so Closure's advanced compilation mode doesn't mangle it. +// eslint-disable-next-line dot-notation +window['$RV'] = revealCompletedBoundariesWithViewTransitions.bind( + null, + // eslint-disable-next-line dot-notation + window['$RV'], +); diff --git a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetExternalRuntime.js b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetExternalRuntime.js index 845ea5b5666e3..ce503f815d168 100644 --- a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetExternalRuntime.js +++ b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetExternalRuntime.js @@ -8,6 +8,8 @@ import { completeBoundaryWithStyles, completeSegment, listenToFormSubmissionsForReplaying, + revealCompletedBoundaries, + revealCompletedBoundariesWithViewTransitions, } from './ReactDOMFizzInstructionSetShared'; // This is a string so Closure's advanced compilation mode doesn't mangle it. @@ -15,6 +17,10 @@ import { window['$RM'] = new Map(); window['$RB'] = []; window['$RX'] = clientRenderBoundary; +window['$RV'] = revealCompletedBoundariesWithViewTransitions.bind( + null, + revealCompletedBoundaries, +); window['$RC'] = completeBoundary; window['$RR'] = completeBoundaryWithStyles; window['$RS'] = completeSegment; diff --git a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js index 470698b27c0c5..06e57c0c67caa 100644 --- a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js +++ b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js @@ -6,7 +6,9 @@ export const markShellTime = export const clientRenderBoundary = '$RX=function(b,c,d,e,f){var a=document.getElementById(b);a&&(b=a.previousSibling,b.data="$!",a=a.dataset,c&&(a.dgst=c),d&&(a.msg=d),e&&(a.stck=e),f&&(a.cstck=f),b._reactRetry&&b._reactRetry())};'; export const completeBoundary = - '$RB=[];$RC=function(d,c){function m(){$RT=performance.now();var f=$RB;$RB=[];for(var e=0;e { + if (document['__reactViewTransition'] === transition) { + document['__reactViewTransition'] = null; + } + }); + return; + } + // Fall through to reveal. + } catch (x) { + // Fall through to reveal. + } + // ViewTransitions v2 not supported or no ViewTransitions found. Reveal immediately. + revealBoundaries(); +} + export function clientRenderBoundary( suspenseBoundaryID, errorDigest, @@ -71,69 +164,6 @@ export function completeBoundary(suspenseBoundaryID, contentID) { return; } - function revealCompletedBoundaries() { - window['$RT'] = performance.now(); - const batch = window['$RB']; - window['$RB'] = []; - for (let i = 0; i < batch.length; i += 2) { - const suspenseIdNode = batch[i]; - const contentNode = batch[i + 1]; - - // Clear all the existing children. This is complicated because - // there can be embedded Suspense boundaries in the fallback. - // This is similar to clearSuspenseBoundary in ReactFiberConfigDOM. - // TODO: We could avoid this if we never emitted suspense boundaries in fallback trees. - // They never hydrate anyway. However, currently we support incrementally loading the fallback. - const parentInstance = suspenseIdNode.parentNode; - if (!parentInstance) { - // We may have client-rendered this boundary already. Skip it. - continue; - } - - // Find the boundary around the fallback. This is always the previous node. - const suspenseNode = suspenseIdNode.previousSibling; - - let node = suspenseIdNode; - let depth = 0; - do { - if (node && node.nodeType === COMMENT_NODE) { - const data = node.data; - if (data === SUSPENSE_END_DATA || data === ACTIVITY_END_DATA) { - if (depth === 0) { - break; - } else { - depth--; - } - } else if ( - data === SUSPENSE_START_DATA || - data === SUSPENSE_PENDING_START_DATA || - data === SUSPENSE_QUEUED_START_DATA || - data === SUSPENSE_FALLBACK_START_DATA || - data === ACTIVITY_START_DATA - ) { - depth++; - } - } - - const nextNode = node.nextSibling; - parentInstance.removeChild(node); - node = nextNode; - } while (node); - - const endOfBoundary = node; - - // Insert all the children from the contentNode between the start and end of suspense boundary. - while (contentNode.firstChild) { - parentInstance.insertBefore(contentNode.firstChild, endOfBoundary); - } - - suspenseNode.data = SUSPENSE_START_DATA; - if (suspenseNode['_reactRetry']) { - suspenseNode['_reactRetry'](); - } - } - } - // Mark this Suspense boundary as queued so we know not to client render it // at the end of document load. const suspenseNodeOuter = suspenseIdNodeOuter.previousSibling; @@ -151,7 +181,7 @@ export function completeBoundary(suspenseBoundaryID, contentID) { // We always schedule the flush in a timer even if it's very low or negative to allow // for multiple completeBoundary calls that are already queued to have a chance to // make the batch. - setTimeout(revealCompletedBoundaries, msUntilTimeout); + setTimeout(window['$RV'], msUntilTimeout); } } diff --git a/scripts/rollup/generate-inline-fizz-runtime.js b/scripts/rollup/generate-inline-fizz-runtime.js index 3d097f63e216a..c531240e0c586 100644 --- a/scripts/rollup/generate-inline-fizz-runtime.js +++ b/scripts/rollup/generate-inline-fizz-runtime.js @@ -25,6 +25,10 @@ const config = [ entry: 'ReactDOMFizzInlineCompleteBoundary.js', exportName: 'completeBoundary', }, + { + entry: 'ReactDOMFizzInlineCompleteBoundaryUpgradeToViewTransitions.js', + exportName: 'completeBoundaryUpgradeToViewTransitions', + }, { entry: 'ReactDOMFizzInlineCompleteBoundaryWithStyles.js', exportName: 'completeBoundaryWithStyles', From 4bbbfe72844c1890669ec833bccf5918f6b1195d Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Fri, 16 May 2025 15:39:49 -0400 Subject: [PATCH 2/4] Only conditionally include the runtime if we're rendering some scenario that might need it We don't know if we'll actually outline a boundary to need this but just in case. --- .../src/server/ReactFizzConfigDOM.js | 74 +++++++++++++------ .../src/server/ReactFizzConfigDOMLegacy.js | 1 + .../react-markup/src/ReactFizzConfigMarkup.js | 1 + packages/react-server/src/ReactFizzServer.js | 36 +++++++-- 4 files changed, 84 insertions(+), 28 deletions(-) diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index 3cb1c5e9029bb..e305c7a09265c 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -124,15 +124,16 @@ const ScriptStreamingFormat: StreamingFormat = 0; const DataStreamingFormat: StreamingFormat = 1; export type InstructionState = number; -const NothingSent /* */ = 0b00000000; -const SentCompleteSegmentFunction /* */ = 0b00000001; -const SentCompleteBoundaryFunction /* */ = 0b00000010; -const SentClientRenderFunction /* */ = 0b00000100; -const SentStyleInsertionFunction /* */ = 0b00001000; -const SentFormReplayingRuntime /* */ = 0b00010000; -const SentCompletedShellId /* */ = 0b00100000; -const SentMarkShellTime /* */ = 0b01000000; -const SentUpgradeToViewTransitions /* */ = 0b10000000; +const NothingSent /* */ = 0b000000000; +const SentCompleteSegmentFunction /* */ = 0b000000001; +const SentCompleteBoundaryFunction /* */ = 0b000000010; +const SentClientRenderFunction /* */ = 0b000000100; +const SentStyleInsertionFunction /* */ = 0b000001000; +const SentFormReplayingRuntime /* */ = 0b000010000; +const SentCompletedShellId /* */ = 0b000100000; +const SentMarkShellTime /* */ = 0b001000000; +const NeedUpgradeToViewTransitions /* */ = 0b010000000; +const SentUpgradeToViewTransitions /* */ = 0b100000000; // Per request, global state that is not contextual to the rendering subtree. // This cannot be resumed and therefore should only contain things that are @@ -744,12 +745,13 @@ const HTML_COLGROUP_MODE = 9; type InsertionMode = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; -const NO_SCOPE = /* */ 0b00000; -const NOSCRIPT_SCOPE = /* */ 0b00001; -const PICTURE_SCOPE = /* */ 0b00010; -const FALLBACK_SCOPE = /* */ 0b00100; -const EXIT_SCOPE = /* */ 0b01000; // A direct Instance below a Suspense fallback is the only thing that can "exit" -const ENTER_SCOPE = /* */ 0b10000; // A direct Instance below Suspense content is the only thing that can "enter" +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. // Everything not listed here are tracked for the whole subtree as opposed to just // until the next Instance. @@ -898,6 +900,7 @@ export function getChildFormatContext( } function getSuspenseViewTransition( + resumableState: ResumableState, parentViewTransition: null | ViewTransitionContext, ): null | ViewTransitionContext { if (parentViewTransition === null) { @@ -928,28 +931,37 @@ function getSuspenseViewTransition( } export function getSuspenseFallbackFormatContext( + resumableState: ResumableState, parentContext: FormatContext, ): FormatContext { + if (parentContext & UPDATE_SCOPE) { + // If we're rendering a Suspense in fallback mode and that is inside a ViewTransition, + // which hasn't disabled updates, then revealing it might animate the parent so we need + // the ViewTransition instructions. + resumableState.instructions |= NeedUpgradeToViewTransitions; + } return createFormatContext( parentContext.insertionMode, parentContext.selectedValue, parentContext.tagScope | FALLBACK_SCOPE | EXIT_SCOPE, - getSuspenseViewTransition(parentContext.viewTransition), + getSuspenseViewTransition(resumableState, parentContext.viewTransition), ); } export function getSuspenseContentFormatContext( + resumableState: ResumableState, parentContext: FormatContext, ): FormatContext { return createFormatContext( parentContext.insertionMode, parentContext.selectedValue, parentContext.tagScope | ENTER_SCOPE, - getSuspenseViewTransition(parentContext.viewTransition), + getSuspenseViewTransition(resumableState, parentContext.viewTransition), ); } export function getViewTransitionFormatContext( + resumableState: ResumableState, parentContext: FormatContext, update: ?string, enter: ?string, @@ -985,14 +997,26 @@ export function getViewTransitionFormatContext( // exit because enter/exit will take precedence and if it's deeply nested // it just animates along whatever the parent does when disabled. share = null; - } else if (share == null) { - share = 'auto'; + } else { + if (share == null) { + share = 'auto'; + } + if (parentContext.tagScope & FALLBACK_SCOPE) { + // If we have an explicit name and share is not disabled, and we're inside + // a fallback, then that fallback might pair with content and so we might need + // the ViewTransition instructions to animate between them. + resumableState.instructions |= NeedUpgradeToViewTransitions; + } } if (!(parentContext.tagScope & EXIT_SCOPE)) { exit = null; // exit is only relevant for the first ViewTransition inside fallback + } else { + resumableState.instructions |= NeedUpgradeToViewTransitions; } if (!(parentContext.tagScope & ENTER_SCOPE)) { enter = null; // enter is only relevant for the first ViewTransition inside content + } else { + resumableState.instructions |= NeedUpgradeToViewTransitions; } const viewTransition: ViewTransitionContext = { update, @@ -1003,7 +1027,12 @@ export function getViewTransitionFormatContext( autoName, nameIdx: 0, }; - const subtreeScope = parentContext.tagScope & SUBTREE_SCOPE; + let subtreeScope = parentContext.tagScope & SUBTREE_SCOPE; + if (update !== 'none') { + subtreeScope |= UPDATE_SCOPE; + } else { + subtreeScope &= ~UPDATE_SCOPE; + } return createFormatContext( parentContext.insertionMode, parentContext.selectedValue, @@ -4815,7 +4844,10 @@ export function writeCompletedBoundaryInstruction( hoistableState: HoistableState, ): boolean { const requiresStyleInsertion = renderState.stylesToHoist; - const requiresViewTransitions = enableViewTransition; + const requiresViewTransitions = + enableViewTransition && + (resumableState.instructions & NeedUpgradeToViewTransitions) !== + NothingSent; // If necessary stylesheets will be flushed with this instruction. // Any style tags not yet hoisted in the Document will also be hoisted. // We reset this state since after this instruction executes all styles diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js index 74787f9ee26b5..fd1791ff2c8a7 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js @@ -181,6 +181,7 @@ export { import escapeTextForBrowser from './escapeTextForBrowser'; export function getViewTransitionFormatContext( + resumableState: ResumableState, parentContext: FormatContext, update: void | null | 'none' | 'auto' | string, enter: void | null | 'none' | 'auto' | string, diff --git a/packages/react-markup/src/ReactFizzConfigMarkup.js b/packages/react-markup/src/ReactFizzConfigMarkup.js index 3ed4e61190a00..bbca0d4ddf2b9 100644 --- a/packages/react-markup/src/ReactFizzConfigMarkup.js +++ b/packages/react-markup/src/ReactFizzConfigMarkup.js @@ -89,6 +89,7 @@ export { import escapeTextForBrowser from 'react-dom-bindings/src/server/escapeTextForBrowser'; export function getViewTransitionFormatContext( + resumableState: ResumableState, parentContext: FormatContext, update: void | null | 'none' | 'auto' | string, enter: void | null | 'none' | 'auto' | string, diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index a159771421b7d..247e21076e5b6 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -1146,7 +1146,10 @@ function renderSuspenseBoundary( const prevKeyPath = someTask.keyPath; const prevContext = someTask.formatContext; someTask.keyPath = keyPath; - someTask.formatContext = getSuspenseContentFormatContext(prevContext); + someTask.formatContext = getSuspenseContentFormatContext( + request.resumableState, + prevContext, + ); const content: ReactNodeList = props.children; try { renderNode(request, someTask, content, -1); @@ -1239,7 +1242,10 @@ function renderSuspenseBoundary( task.blockedSegment = boundarySegment; task.blockedPreamble = newBoundary.fallbackPreamble; task.keyPath = fallbackKeyPath; - task.formatContext = getSuspenseFallbackFormatContext(prevContext); + task.formatContext = getSuspenseFallbackFormatContext( + request.resumableState, + prevContext, + ); boundarySegment.status = RENDERING; try { renderNode(request, task, fallback, -1); @@ -1278,7 +1284,10 @@ function renderSuspenseBoundary( newBoundary.contentState, task.abortSet, keyPath, - getSuspenseContentFormatContext(task.formatContext), + getSuspenseContentFormatContext( + request.resumableState, + task.formatContext, + ), task.context, task.treeContext, task.componentStack, @@ -1305,7 +1314,10 @@ function renderSuspenseBoundary( task.hoistableState = newBoundary.contentState; task.blockedSegment = contentRootSegment; task.keyPath = keyPath; - task.formatContext = getSuspenseContentFormatContext(prevContext); + task.formatContext = getSuspenseContentFormatContext( + request.resumableState, + prevContext, + ); contentRootSegment.status = RENDERING; try { @@ -1409,7 +1421,10 @@ function renderSuspenseBoundary( newBoundary.fallbackState, fallbackAbortSet, fallbackKeyPath, - getSuspenseFallbackFormatContext(task.formatContext), + getSuspenseFallbackFormatContext( + request.resumableState, + task.formatContext, + ), task.context, task.treeContext, task.componentStack, @@ -1471,7 +1486,10 @@ function replaySuspenseBoundary( task.blockedBoundary = resumedBoundary; task.hoistableState = resumedBoundary.contentState; task.keyPath = keyPath; - task.formatContext = getSuspenseContentFormatContext(prevContext); + task.formatContext = getSuspenseContentFormatContext( + request.resumableState, + prevContext, + ); task.replay = {nodes: childNodes, slots: childSlots, pendingTasks: 1}; try { @@ -1569,7 +1587,10 @@ function replaySuspenseBoundary( resumedBoundary.fallbackState, fallbackAbortSet, fallbackKeyPath, - getSuspenseFallbackFormatContext(task.formatContext), + getSuspenseFallbackFormatContext( + request.resumableState, + task.formatContext, + ), task.context, task.treeContext, task.componentStack, @@ -2284,6 +2305,7 @@ function renderViewTransition( request.resumableState, ); task.formatContext = getViewTransitionFormatContext( + request.resumableState, prevContext, getViewTransitionClassName(props.default, props.update), getViewTransitionClassName(props.default, props.enter), From fd248c9f5b0feaf5188ff61d52caa850bef0320a Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Fri, 16 May 2025 15:48:34 -0400 Subject: [PATCH 3/4] Try in fixture --- fixtures/view-transition/server/render.js | 2 + .../view-transition/src/components/Page.js | 38 ++++++++++++------- .../src/server/ReactFizzConfigDOM.js | 2 +- ...tDOMFizzInstructionSetInlineCodeStrings.js | 2 +- .../ReactDOMFizzInstructionSetShared.js | 15 ++++---- 5 files changed, 37 insertions(+), 22 deletions(-) diff --git a/fixtures/view-transition/server/render.js b/fixtures/view-transition/server/render.js index 11d352eabdd72..716290212e5a2 100644 --- a/fixtures/view-transition/server/render.js +++ b/fixtures/view-transition/server/render.js @@ -23,6 +23,8 @@ export default function render(url, res) { const {pipe, abort} = renderToPipeableStream( , { + // TODO: Temporary hack. Detect from attributes instead. + bootstrapScriptContent: 'window._useVT = true;', bootstrapScripts: [assets['main.js']], onShellReady() { // If something errored before we started streaming, we set the error code appropriately. diff --git a/fixtures/view-transition/src/components/Page.js b/fixtures/view-transition/src/components/Page.js index db2cd0aff08c7..061b1edb2cbf6 100644 --- a/fixtures/view-transition/src/components/Page.js +++ b/fixtures/view-transition/src/components/Page.js @@ -8,6 +8,7 @@ import React, { useId, useOptimistic, startTransition, + Suspense, } from 'react'; import {createPortal} from 'react-dom'; @@ -60,6 +61,12 @@ function Id() { return ; } +let wait; +function Suspend() { + if (!wait) wait = sleep(500); + return React.use(wait); +} + export default function Page({url, navigate}) { const [renderedUrl, optimisticNavigate] = useOptimistic( url, @@ -93,7 +100,7 @@ export default function Page({url, navigate}) { // a flushSync will. // Promise.resolve().then(() => { // flushSync(() => { - setCounter(c => c + 10); + // setCounter(c => c + 10); // }); // }); }, [show]); @@ -193,18 +200,23 @@ export default function Page({url, navigate}) {
!!
-

these

-

rows

-

exist

-

to

-

test

-

scrolling

-

content

-

out

-

of

- {portal} -

the

-

viewport

+ + +

these

+

rows

+

exist

+

to

+

test

+

scrolling

+

content

+

out

+

of

+ {portal} +

the

+

viewport

+ +
+
{show ? : null} diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index e305c7a09265c..6197af4da2236 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -934,7 +934,7 @@ export function getSuspenseFallbackFormatContext( resumableState: ResumableState, parentContext: FormatContext, ): FormatContext { - if (parentContext & UPDATE_SCOPE) { + if (parentContext.tagScope & UPDATE_SCOPE) { // If we're rendering a Suspense in fallback mode and that is inside a ViewTransition, // which hasn't disabled updates, then revealing it might animate the parent so we need // the ViewTransition instructions. diff --git a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js index 06e57c0c67caa..aa7209fadac1d 100644 --- a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js +++ b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js @@ -8,7 +8,7 @@ export const clientRenderBoundary = export const completeBoundary = '$RB=[];$RV=function(){$RT=performance.now();var d=$RB;$RB=[];for(var a=0;a { + const transition = (document['__reactViewTransition'] = document[ + 'startViewTransition' + ]({ + update: revealBoundaries, + types: [], // TODO: Add a hard coded type for Suspense reveals. + })); + transition.finished.finally(() => { if (document['__reactViewTransition'] === transition) { document['__reactViewTransition'] = null; } From e8e9fc4b84cd9d65e8b352fde2acd0cf7b0b1f79 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sat, 17 May 2025 18:18:00 -0400 Subject: [PATCH 4/4] Remove unused arg --- packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index 6197af4da2236..ef69bd4582a87 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -900,7 +900,6 @@ export function getChildFormatContext( } function getSuspenseViewTransition( - resumableState: ResumableState, parentViewTransition: null | ViewTransitionContext, ): null | ViewTransitionContext { if (parentViewTransition === null) { @@ -944,7 +943,7 @@ export function getSuspenseFallbackFormatContext( parentContext.insertionMode, parentContext.selectedValue, parentContext.tagScope | FALLBACK_SCOPE | EXIT_SCOPE, - getSuspenseViewTransition(resumableState, parentContext.viewTransition), + getSuspenseViewTransition(parentContext.viewTransition), ); } @@ -956,7 +955,7 @@ export function getSuspenseContentFormatContext( parentContext.insertionMode, parentContext.selectedValue, parentContext.tagScope | ENTER_SCOPE, - getSuspenseViewTransition(resumableState, parentContext.viewTransition), + getSuspenseViewTransition(parentContext.viewTransition), ); }