[Fizz] Wrap revealCompletedBoundaries in a ViewTransitions aware version (#33293)

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.

This doesn't yet do anything useful with it, that's coming in a follow
up. This is just the mechanism for how it gets installed.
This commit is contained in:
Sebastian Markbåge 2025-05-17 18:18:24 -04:00 committed by GitHub
parent c250b7d980
commit 6060367ef8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 259 additions and 109 deletions

View File

@ -23,6 +23,8 @@ export default function render(url, res) {
const {pipe, abort} = renderToPipeableStream( const {pipe, abort} = renderToPipeableStream(
<App assets={assets} initialURL={url} />, <App assets={assets} initialURL={url} />,
{ {
// TODO: Temporary hack. Detect from attributes instead.
bootstrapScriptContent: 'window._useVT = true;',
bootstrapScripts: [assets['main.js']], bootstrapScripts: [assets['main.js']],
onShellReady() { onShellReady() {
// If something errored before we started streaming, we set the error code appropriately. // If something errored before we started streaming, we set the error code appropriately.

View File

@ -8,6 +8,7 @@ import React, {
useId, useId,
useOptimistic, useOptimistic,
startTransition, startTransition,
Suspense,
} from 'react'; } from 'react';
import {createPortal} from 'react-dom'; import {createPortal} from 'react-dom';
@ -60,6 +61,12 @@ function Id() {
return <span id={useId()} />; return <span id={useId()} />;
} }
let wait;
function Suspend() {
if (!wait) wait = sleep(500);
return React.use(wait);
}
export default function Page({url, navigate}) { export default function Page({url, navigate}) {
const [renderedUrl, optimisticNavigate] = useOptimistic( const [renderedUrl, optimisticNavigate] = useOptimistic(
url, url,
@ -93,7 +100,7 @@ export default function Page({url, navigate}) {
// a flushSync will. // a flushSync will.
// Promise.resolve().then(() => { // Promise.resolve().then(() => {
// flushSync(() => { // flushSync(() => {
setCounter(c => c + 10); // setCounter(c => c + 10);
// }); // });
// }); // });
}, [show]); }, [show]);
@ -193,18 +200,23 @@ export default function Page({url, navigate}) {
<div>!!</div> <div>!!</div>
</ViewTransition> </ViewTransition>
</Activity> </Activity>
<p>these</p> <Suspense fallback="Loading">
<p>rows</p> <ViewTransition>
<p>exist</p> <p>these</p>
<p>to</p> <p>rows</p>
<p>test</p> <p>exist</p>
<p>scrolling</p> <p>to</p>
<p>content</p> <p>test</p>
<p>out</p> <p>scrolling</p>
<p>of</p> <p>content</p>
{portal} <p>out</p>
<p>the</p> <p>of</p>
<p>viewport</p> {portal}
<p>the</p>
<p>viewport</p>
<Suspend />
</ViewTransition>
</Suspense>
{show ? <Component /> : null} {show ? <Component /> : null}
</div> </div>
</ViewTransition> </ViewTransition>

View File

@ -80,6 +80,7 @@ import isArray from 'shared/isArray';
import { import {
clientRenderBoundary as clientRenderFunction, clientRenderBoundary as clientRenderFunction,
completeBoundary as completeBoundaryFunction, completeBoundary as completeBoundaryFunction,
completeBoundaryUpgradeToViewTransitions as upgradeToViewTransitionsInstruction,
completeBoundaryWithStyles as styleInsertionFunction, completeBoundaryWithStyles as styleInsertionFunction,
completeSegment as completeSegmentFunction, completeSegment as completeSegmentFunction,
formReplaying as formReplayingRuntime, formReplaying as formReplayingRuntime,
@ -123,14 +124,16 @@ const ScriptStreamingFormat: StreamingFormat = 0;
const DataStreamingFormat: StreamingFormat = 1; const DataStreamingFormat: StreamingFormat = 1;
export type InstructionState = number; export type InstructionState = number;
const NothingSent /* */ = 0b0000000; const NothingSent /* */ = 0b000000000;
const SentCompleteSegmentFunction /* */ = 0b0000001; const SentCompleteSegmentFunction /* */ = 0b000000001;
const SentCompleteBoundaryFunction /* */ = 0b0000010; const SentCompleteBoundaryFunction /* */ = 0b000000010;
const SentClientRenderFunction /* */ = 0b0000100; const SentClientRenderFunction /* */ = 0b000000100;
const SentStyleInsertionFunction /* */ = 0b0001000; const SentStyleInsertionFunction /* */ = 0b000001000;
const SentFormReplayingRuntime /* */ = 0b0010000; const SentFormReplayingRuntime /* */ = 0b000010000;
const SentCompletedShellId /* */ = 0b0100000; const SentCompletedShellId /* */ = 0b000100000;
const SentMarkShellTime /* */ = 0b1000000; const SentMarkShellTime /* */ = 0b001000000;
const NeedUpgradeToViewTransitions /* */ = 0b010000000;
const SentUpgradeToViewTransitions /* */ = 0b100000000;
// Per request, global state that is not contextual to the rendering subtree. // Per request, global state that is not contextual to the rendering subtree.
// This cannot be resumed and therefore should only contain things that are // This cannot be resumed and therefore should only contain things that are
@ -742,12 +745,13 @@ const HTML_COLGROUP_MODE = 9;
type InsertionMode = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; type InsertionMode = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
const NO_SCOPE = /* */ 0b00000; const NO_SCOPE = /* */ 0b000000;
const NOSCRIPT_SCOPE = /* */ 0b00001; const NOSCRIPT_SCOPE = /* */ 0b000001;
const PICTURE_SCOPE = /* */ 0b00010; const PICTURE_SCOPE = /* */ 0b000010;
const FALLBACK_SCOPE = /* */ 0b00100; const FALLBACK_SCOPE = /* */ 0b000100;
const EXIT_SCOPE = /* */ 0b01000; // A direct Instance below a Suspense fallback is the only thing that can "exit" const EXIT_SCOPE = /* */ 0b001000; // 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 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 // Everything not listed here are tracked for the whole subtree as opposed to just
// until the next Instance. // until the next Instance.
@ -926,8 +930,15 @@ function getSuspenseViewTransition(
} }
export function getSuspenseFallbackFormatContext( export function getSuspenseFallbackFormatContext(
resumableState: ResumableState,
parentContext: FormatContext, parentContext: FormatContext,
): FormatContext { ): FormatContext {
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.
resumableState.instructions |= NeedUpgradeToViewTransitions;
}
return createFormatContext( return createFormatContext(
parentContext.insertionMode, parentContext.insertionMode,
parentContext.selectedValue, parentContext.selectedValue,
@ -937,6 +948,7 @@ export function getSuspenseFallbackFormatContext(
} }
export function getSuspenseContentFormatContext( export function getSuspenseContentFormatContext(
resumableState: ResumableState,
parentContext: FormatContext, parentContext: FormatContext,
): FormatContext { ): FormatContext {
return createFormatContext( return createFormatContext(
@ -948,6 +960,7 @@ export function getSuspenseContentFormatContext(
} }
export function getViewTransitionFormatContext( export function getViewTransitionFormatContext(
resumableState: ResumableState,
parentContext: FormatContext, parentContext: FormatContext,
update: ?string, update: ?string,
enter: ?string, enter: ?string,
@ -983,14 +996,26 @@ export function getViewTransitionFormatContext(
// exit because enter/exit will take precedence and if it's deeply nested // exit because enter/exit will take precedence and if it's deeply nested
// it just animates along whatever the parent does when disabled. // it just animates along whatever the parent does when disabled.
share = null; share = null;
} else if (share == null) { } else {
share = 'auto'; 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)) { if (!(parentContext.tagScope & EXIT_SCOPE)) {
exit = null; // exit is only relevant for the first ViewTransition inside fallback exit = null; // exit is only relevant for the first ViewTransition inside fallback
} else {
resumableState.instructions |= NeedUpgradeToViewTransitions;
} }
if (!(parentContext.tagScope & ENTER_SCOPE)) { if (!(parentContext.tagScope & ENTER_SCOPE)) {
enter = null; // enter is only relevant for the first ViewTransition inside content enter = null; // enter is only relevant for the first ViewTransition inside content
} else {
resumableState.instructions |= NeedUpgradeToViewTransitions;
} }
const viewTransition: ViewTransitionContext = { const viewTransition: ViewTransitionContext = {
update, update,
@ -1001,7 +1026,12 @@ export function getViewTransitionFormatContext(
autoName, autoName,
nameIdx: 0, 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( return createFormatContext(
parentContext.insertionMode, parentContext.insertionMode,
parentContext.selectedValue, parentContext.selectedValue,
@ -4780,9 +4810,8 @@ export function writeCompletedSegmentInstruction(
const completeBoundaryScriptFunctionOnly = stringToPrecomputedChunk( const completeBoundaryScriptFunctionOnly = stringToPrecomputedChunk(
completeBoundaryFunction, completeBoundaryFunction,
); );
const completeBoundaryScript1Full = stringToPrecomputedChunk( const completeBoundaryUpgradeToViewTransitionsInstruction =
completeBoundaryFunction + '$RC("', stringToPrecomputedChunk(upgradeToViewTransitionsInstruction);
);
const completeBoundaryScript1Partial = stringToPrecomputedChunk('$RC("'); const completeBoundaryScript1Partial = stringToPrecomputedChunk('$RC("');
const completeBoundaryWithStylesScript1FullPartial = stringToPrecomputedChunk( const completeBoundaryWithStylesScript1FullPartial = stringToPrecomputedChunk(
@ -4814,6 +4843,10 @@ export function writeCompletedBoundaryInstruction(
hoistableState: HoistableState, hoistableState: HoistableState,
): boolean { ): boolean {
const requiresStyleInsertion = renderState.stylesToHoist; const requiresStyleInsertion = renderState.stylesToHoist;
const requiresViewTransitions =
enableViewTransition &&
(resumableState.instructions & NeedUpgradeToViewTransitions) !==
NothingSent;
// If necessary stylesheets will be flushed with this instruction. // If necessary stylesheets will be flushed with this instruction.
// Any style tags not yet hoisted in the Document will also be hoisted. // Any style tags not yet hoisted in the Document will also be hoisted.
// We reset this state since after this instruction executes all styles // We reset this state since after this instruction executes all styles
@ -4842,6 +4875,17 @@ export function writeCompletedBoundaryInstruction(
resumableState.instructions |= SentCompleteBoundaryFunction; resumableState.instructions |= SentCompleteBoundaryFunction;
writeChunk(destination, completeBoundaryScriptFunctionOnly); writeChunk(destination, completeBoundaryScriptFunctionOnly);
} }
if (
requiresViewTransitions &&
(resumableState.instructions & SentUpgradeToViewTransitions) ===
NothingSent
) {
resumableState.instructions |= SentUpgradeToViewTransitions;
writeChunk(
destination,
completeBoundaryUpgradeToViewTransitionsInstruction,
);
}
if ( if (
(resumableState.instructions & SentStyleInsertionFunction) === (resumableState.instructions & SentStyleInsertionFunction) ===
NothingSent NothingSent
@ -4857,10 +4901,20 @@ export function writeCompletedBoundaryInstruction(
NothingSent NothingSent
) { ) {
resumableState.instructions |= SentCompleteBoundaryFunction; resumableState.instructions |= SentCompleteBoundaryFunction;
writeChunk(destination, completeBoundaryScript1Full); writeChunk(destination, completeBoundaryScriptFunctionOnly);
} else {
writeChunk(destination, completeBoundaryScript1Partial);
} }
if (
requiresViewTransitions &&
(resumableState.instructions & SentUpgradeToViewTransitions) ===
NothingSent
) {
resumableState.instructions |= SentUpgradeToViewTransitions;
writeChunk(
destination,
completeBoundaryUpgradeToViewTransitionsInstruction,
);
}
writeChunk(destination, completeBoundaryScript1Partial);
} }
} else { } else {
if (requiresStyleInsertion) { if (requiresStyleInsertion) {

View File

@ -181,6 +181,7 @@ export {
import escapeTextForBrowser from './escapeTextForBrowser'; import escapeTextForBrowser from './escapeTextForBrowser';
export function getViewTransitionFormatContext( export function getViewTransitionFormatContext(
resumableState: ResumableState,
parentContext: FormatContext, parentContext: FormatContext,
update: void | null | 'none' | 'auto' | string, update: void | null | 'none' | 'auto' | string,
enter: void | null | 'none' | 'auto' | string, enter: void | null | 'none' | 'auto' | string,

View File

@ -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. // This is a string so Closure's advanced compilation mode doesn't mangle it.
// eslint-disable-next-line dot-notation // eslint-disable-next-line dot-notation
window['$RB'] = []; window['$RB'] = [];
// eslint-disable-next-line dot-notation // eslint-disable-next-line dot-notation
window['$RV'] = revealCompletedBoundaries;
// eslint-disable-next-line dot-notation
window['$RC'] = completeBoundary; window['$RC'] = completeBoundary;

View File

@ -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'],
);

View File

@ -8,6 +8,8 @@ import {
completeBoundaryWithStyles, completeBoundaryWithStyles,
completeSegment, completeSegment,
listenToFormSubmissionsForReplaying, listenToFormSubmissionsForReplaying,
revealCompletedBoundaries,
revealCompletedBoundariesWithViewTransitions,
} from './ReactDOMFizzInstructionSetShared'; } from './ReactDOMFizzInstructionSetShared';
// This is a string so Closure's advanced compilation mode doesn't mangle it. // This is a string so Closure's advanced compilation mode doesn't mangle it.
@ -15,6 +17,10 @@ import {
window['$RM'] = new Map(); window['$RM'] = new Map();
window['$RB'] = []; window['$RB'] = [];
window['$RX'] = clientRenderBoundary; window['$RX'] = clientRenderBoundary;
window['$RV'] = revealCompletedBoundariesWithViewTransitions.bind(
null,
revealCompletedBoundaries,
);
window['$RC'] = completeBoundary; window['$RC'] = completeBoundary;
window['$RR'] = completeBoundaryWithStyles; window['$RR'] = completeBoundaryWithStyles;
window['$RS'] = completeSegment; window['$RS'] = completeSegment;

View File

@ -6,7 +6,9 @@ export const markShellTime =
export const clientRenderBoundary = 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())};'; '$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 = export const completeBoundary =
'$RB=[];$RC=function(d,c){function m(){$RT=performance.now();var f=$RB;$RB=[];for(var e=0;e<f.length;e+=2){var a=f[e],l=f[e+1],g=a.parentNode;if(g){var h=a.previousSibling,k=0;do{if(a&&8===a.nodeType){var b=a.data;if("/$"===b||"/&"===b)if(0===k)break;else k--;else"$"!==b&&"$?"!==b&&"$~"!==b&&"$!"!==b&&"&"!==b||k++}b=a.nextSibling;g.removeChild(a);a=b}while(a);for(;l.firstChild;)g.insertBefore(l.firstChild,a);h.data="$";h._reactRetry&&h._reactRetry()}}}if(c=document.getElementById(c))if(c.parentNode.removeChild(c),d=\ndocument.getElementById(d))d.previousSibling.data="$~",$RB.push(d,c),2===$RB.length&&setTimeout(m,("number"!==typeof $RT?0:$RT)+300-performance.now())};'; '$RB=[];$RV=function(){$RT=performance.now();var d=$RB;$RB=[];for(var a=0;a<d.length;a+=2){var b=d[a],h=d[a+1],e=b.parentNode;if(e){var f=b.previousSibling,g=0;do{if(b&&8===b.nodeType){var c=b.data;if("/$"===c||"/&"===c)if(0===g)break;else g--;else"$"!==c&&"$?"!==c&&"$~"!==c&&"$!"!==c&&"&"!==c||g++}c=b.nextSibling;e.removeChild(b);b=c}while(b);for(;h.firstChild;)e.insertBefore(h.firstChild,b);f.data="$";f._reactRetry&&f._reactRetry()}}};$RC=function(d,a){if(a=document.getElementById(a))if(a.parentNode.removeChild(a),d=document.getElementById(d))d.previousSibling.data="$~",$RB.push(d,a),2===$RB.length&&setTimeout($RV,("number"!==typeof $RT?0:$RT)+300-performance.now())};';
export const completeBoundaryUpgradeToViewTransitions =
'$RV=function(a){try{var b=document.__reactViewTransition;if(b){b.finished.then($RV,$RV);return}if(window._useVT){var c=document.__reactViewTransition=document.startViewTransition({update:a,types:[]});c.finished.finally(function(){document.__reactViewTransition===c&&(document.__reactViewTransition=null)});return}}catch(d){}a()}.bind(null,$RV);';
export const completeBoundaryWithStyles = export const completeBoundaryWithStyles =
'$RM=new Map;$RR=function(n,w,p){function u(q){this._p=null;q()}for(var r=new Map,t=document,h,b,e=t.querySelectorAll("link[data-precedence],style[data-precedence]"),v=[],k=0;b=e[k++];)"not all"===b.getAttribute("media")?v.push(b):("LINK"===b.tagName&&$RM.set(b.getAttribute("href"),b),r.set(b.dataset.precedence,h=b));e=0;b=[];var l,a;for(k=!0;;){if(k){var f=p[e++];if(!f){k=!1;e=0;continue}var c=!1,m=0;var d=f[m++];if(a=$RM.get(d)){var g=a._p;c=!0}else{a=t.createElement("link");a.href=d;a.rel=\n"stylesheet";for(a.dataset.precedence=l=f[m++];g=f[m++];)a.setAttribute(g,f[m++]);g=a._p=new Promise(function(q,x){a.onload=u.bind(a,q);a.onerror=u.bind(a,x)});$RM.set(d,a)}d=a.getAttribute("media");!g||d&&!matchMedia(d).matches||b.push(g);if(c)continue}else{a=v[e++];if(!a)break;l=a.getAttribute("data-precedence");a.removeAttribute("media")}c=r.get(l)||h;c===h&&(h=a);r.set(l,a);c?c.parentNode.insertBefore(a,c.nextSibling):(c=t.head,c.insertBefore(a,c.firstChild))}if(p=document.getElementById(n))p.previousSibling.data=\n"$~";Promise.all(b).then($RC.bind(null,n,w),$RX.bind(null,n,"CSS failed to load"))};'; '$RM=new Map;$RR=function(n,w,p){function u(q){this._p=null;q()}for(var r=new Map,t=document,h,b,e=t.querySelectorAll("link[data-precedence],style[data-precedence]"),v=[],k=0;b=e[k++];)"not all"===b.getAttribute("media")?v.push(b):("LINK"===b.tagName&&$RM.set(b.getAttribute("href"),b),r.set(b.dataset.precedence,h=b));e=0;b=[];var l,a;for(k=!0;;){if(k){var f=p[e++];if(!f){k=!1;e=0;continue}var c=!1,m=0;var d=f[m++];if(a=$RM.get(d)){var g=a._p;c=!0}else{a=t.createElement("link");a.href=d;a.rel=\n"stylesheet";for(a.dataset.precedence=l=f[m++];g=f[m++];)a.setAttribute(g,f[m++]);g=a._p=new Promise(function(q,x){a.onload=u.bind(a,q);a.onerror=u.bind(a,x)});$RM.set(d,a)}d=a.getAttribute("media");!g||d&&!matchMedia(d).matches||b.push(g);if(c)continue}else{a=v[e++];if(!a)break;l=a.getAttribute("data-precedence");a.removeAttribute("media")}c=r.get(l)||h;c===h&&(h=a);r.set(l,a);c?c.parentNode.insertBefore(a,c.nextSibling):(c=t.head,c.insertBefore(a,c.firstChild))}if(p=document.getElementById(n))p.previousSibling.data=\n"$~";Promise.all(b).then($RC.bind(null,n,w),$RX.bind(null,n,"CSS failed to load"))};';
export const completeSegment = export const completeSegment =

View File

@ -18,6 +18,100 @@ const SUSPENSE_FALLBACK_START_DATA = '$!';
// working. Closure converts it to a dot access anyway, though, so it's not an // working. Closure converts it to a dot access anyway, though, so it's not an
// urgent issue. // urgent issue.
export 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']();
}
}
}
export function revealCompletedBoundariesWithViewTransitions(revealBoundaries) {
try {
const existingTransition = document['__reactViewTransition'];
if (existingTransition) {
// Retry after the previous ViewTransition finishes.
existingTransition.finished.then(window['$RV'], window['$RV']);
return;
}
const shouldStartViewTransition = window['_useVT']; // TODO: Detect.
if (shouldStartViewTransition) {
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;
}
});
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( export function clientRenderBoundary(
suspenseBoundaryID, suspenseBoundaryID,
errorDigest, errorDigest,
@ -71,69 +165,6 @@ export function completeBoundary(suspenseBoundaryID, contentID) {
return; 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 // Mark this Suspense boundary as queued so we know not to client render it
// at the end of document load. // at the end of document load.
const suspenseNodeOuter = suspenseIdNodeOuter.previousSibling; const suspenseNodeOuter = suspenseIdNodeOuter.previousSibling;
@ -151,7 +182,7 @@ export function completeBoundary(suspenseBoundaryID, contentID) {
// We always schedule the flush in a timer even if it's very low or negative to allow // 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 // for multiple completeBoundary calls that are already queued to have a chance to
// make the batch. // make the batch.
setTimeout(revealCompletedBoundaries, msUntilTimeout); setTimeout(window['$RV'], msUntilTimeout);
} }
} }

View File

@ -89,6 +89,7 @@ export {
import escapeTextForBrowser from 'react-dom-bindings/src/server/escapeTextForBrowser'; import escapeTextForBrowser from 'react-dom-bindings/src/server/escapeTextForBrowser';
export function getViewTransitionFormatContext( export function getViewTransitionFormatContext(
resumableState: ResumableState,
parentContext: FormatContext, parentContext: FormatContext,
update: void | null | 'none' | 'auto' | string, update: void | null | 'none' | 'auto' | string,
enter: void | null | 'none' | 'auto' | string, enter: void | null | 'none' | 'auto' | string,

View File

@ -1146,7 +1146,10 @@ function renderSuspenseBoundary(
const prevKeyPath = someTask.keyPath; const prevKeyPath = someTask.keyPath;
const prevContext = someTask.formatContext; const prevContext = someTask.formatContext;
someTask.keyPath = keyPath; someTask.keyPath = keyPath;
someTask.formatContext = getSuspenseContentFormatContext(prevContext); someTask.formatContext = getSuspenseContentFormatContext(
request.resumableState,
prevContext,
);
const content: ReactNodeList = props.children; const content: ReactNodeList = props.children;
try { try {
renderNode(request, someTask, content, -1); renderNode(request, someTask, content, -1);
@ -1239,7 +1242,10 @@ function renderSuspenseBoundary(
task.blockedSegment = boundarySegment; task.blockedSegment = boundarySegment;
task.blockedPreamble = newBoundary.fallbackPreamble; task.blockedPreamble = newBoundary.fallbackPreamble;
task.keyPath = fallbackKeyPath; task.keyPath = fallbackKeyPath;
task.formatContext = getSuspenseFallbackFormatContext(prevContext); task.formatContext = getSuspenseFallbackFormatContext(
request.resumableState,
prevContext,
);
boundarySegment.status = RENDERING; boundarySegment.status = RENDERING;
try { try {
renderNode(request, task, fallback, -1); renderNode(request, task, fallback, -1);
@ -1278,7 +1284,10 @@ function renderSuspenseBoundary(
newBoundary.contentState, newBoundary.contentState,
task.abortSet, task.abortSet,
keyPath, keyPath,
getSuspenseContentFormatContext(task.formatContext), getSuspenseContentFormatContext(
request.resumableState,
task.formatContext,
),
task.context, task.context,
task.treeContext, task.treeContext,
task.componentStack, task.componentStack,
@ -1305,7 +1314,10 @@ function renderSuspenseBoundary(
task.hoistableState = newBoundary.contentState; task.hoistableState = newBoundary.contentState;
task.blockedSegment = contentRootSegment; task.blockedSegment = contentRootSegment;
task.keyPath = keyPath; task.keyPath = keyPath;
task.formatContext = getSuspenseContentFormatContext(prevContext); task.formatContext = getSuspenseContentFormatContext(
request.resumableState,
prevContext,
);
contentRootSegment.status = RENDERING; contentRootSegment.status = RENDERING;
try { try {
@ -1409,7 +1421,10 @@ function renderSuspenseBoundary(
newBoundary.fallbackState, newBoundary.fallbackState,
fallbackAbortSet, fallbackAbortSet,
fallbackKeyPath, fallbackKeyPath,
getSuspenseFallbackFormatContext(task.formatContext), getSuspenseFallbackFormatContext(
request.resumableState,
task.formatContext,
),
task.context, task.context,
task.treeContext, task.treeContext,
task.componentStack, task.componentStack,
@ -1471,7 +1486,10 @@ function replaySuspenseBoundary(
task.blockedBoundary = resumedBoundary; task.blockedBoundary = resumedBoundary;
task.hoistableState = resumedBoundary.contentState; task.hoistableState = resumedBoundary.contentState;
task.keyPath = keyPath; task.keyPath = keyPath;
task.formatContext = getSuspenseContentFormatContext(prevContext); task.formatContext = getSuspenseContentFormatContext(
request.resumableState,
prevContext,
);
task.replay = {nodes: childNodes, slots: childSlots, pendingTasks: 1}; task.replay = {nodes: childNodes, slots: childSlots, pendingTasks: 1};
try { try {
@ -1569,7 +1587,10 @@ function replaySuspenseBoundary(
resumedBoundary.fallbackState, resumedBoundary.fallbackState,
fallbackAbortSet, fallbackAbortSet,
fallbackKeyPath, fallbackKeyPath,
getSuspenseFallbackFormatContext(task.formatContext), getSuspenseFallbackFormatContext(
request.resumableState,
task.formatContext,
),
task.context, task.context,
task.treeContext, task.treeContext,
task.componentStack, task.componentStack,
@ -2284,6 +2305,7 @@ function renderViewTransition(
request.resumableState, request.resumableState,
); );
task.formatContext = getViewTransitionFormatContext( task.formatContext = getViewTransitionFormatContext(
request.resumableState,
prevContext, prevContext,
getViewTransitionClassName(props.default, props.update), getViewTransitionClassName(props.default, props.update),
getViewTransitionClassName(props.default, props.enter), getViewTransitionClassName(props.default, props.enter),

View File

@ -25,6 +25,10 @@ const config = [
entry: 'ReactDOMFizzInlineCompleteBoundary.js', entry: 'ReactDOMFizzInlineCompleteBoundary.js',
exportName: 'completeBoundary', exportName: 'completeBoundary',
}, },
{
entry: 'ReactDOMFizzInlineCompleteBoundaryUpgradeToViewTransitions.js',
exportName: 'completeBoundaryUpgradeToViewTransitions',
},
{ {
entry: 'ReactDOMFizzInlineCompleteBoundaryWithStyles.js', entry: 'ReactDOMFizzInlineCompleteBoundaryWithStyles.js',
exportName: 'completeBoundaryWithStyles', exportName: 'completeBoundaryWithStyles',