[Fizz] Suspensey Images for View Transition Reveals (#33433)

Block the view transition on suspensey images Up to 500ms just like the
client.

We can't use `decode()` because a bug in Chrome where those are blocked
on `startViewTransition` finishing we instead rely on sync decoding but
also that the image is live when it's animating in and we assume it
doesn't start visible.

However, we can block the View Transition from starting on the `"load"`
or `"error"` events.

The nice thing about blocking inside `startViewTransition` is that we
have already done the layout so we can only wait on images that are
within the viewport at this point. We might want to do that in Fiber
too. If many image doesn't have fixed size but need to load first, they
can all end up in the viewport. We might consider only doing this for
images that have a fixed size or only a max number that doesn't have a
fixed size.
This commit is contained in:
Sebastian Markbåge
2025-06-06 10:14:13 -04:00
committed by GitHub
parent a3be6829c6
commit 22b929156c
3 changed files with 41 additions and 10 deletions

View File

@@ -4860,8 +4860,9 @@ export function writeCompletedSegmentInstruction(
const completeBoundaryScriptFunctionOnly = stringToPrecomputedChunk(
completeBoundaryFunction,
);
const completeBoundaryUpgradeToViewTransitionsInstruction =
stringToPrecomputedChunk(upgradeToViewTransitionsInstruction);
const completeBoundaryUpgradeToViewTransitionsInstruction = stringToChunk(
upgradeToViewTransitionsInstruction,
);
const completeBoundaryScript1Partial = stringToPrecomputedChunk('$RC("');
const completeBoundaryWithStylesScript1FullPartial = stringToPrecomputedChunk(

View File

@@ -8,7 +8,7 @@ export const clientRenderBoundary =
export const completeBoundary =
'$RB=[];$RV=function(c){$RT=performance.now();for(var a=0;a<c.length;a+=2){var b=c[a],h=c[a+1],e=b.parentNode;if(e){var f=b.previousSibling,g=0;do{if(b&&8===b.nodeType){var d=b.data;if("/$"===d||"/&"===d)if(0===g)break;else g--;else"$"!==d&&"$?"!==d&&"$~"!==d&&"$!"!==d&&"&"!==d||g++}d=b.nextSibling;e.removeChild(b);b=d}while(b);for(;h.firstChild;)e.insertBefore(h.firstChild,b);f.data="$";f._reactRetry&&f._reactRetry()}}c.length=0};$RC=function(c,a){if(a=document.getElementById(a))if(a.parentNode.removeChild(a),c=document.getElementById(c))c.previousSibling.data="$~",$RB.push(c,a),2===$RB.length&&setTimeout($RV.bind(null,$RB),("number"!==typeof $RT?0:$RT)+300-performance.now())};';
export const completeBoundaryUpgradeToViewTransitions =
'$RV=function(w,f){function h(a,d){var k=a.getAttribute(d);k&&(d=a.style,l.push(a,d.viewTransitionName,d.viewTransitionClass),"auto"!==k&&(d.viewTransitionClass=k),(a=a.getAttribute("vt-name"))||(a="_T_"+F++ +"_"),d.viewTransitionName=a,x=!0)}var x=!1,F=0,l=[];try{var e=document.__reactViewTransition;if(e){e.finished.finally($RV.bind(null,f));return}var m=new Map;for(e=1;e<f.length;e+=2)for(var g=f[e].querySelectorAll("[vt-share]"),c=0;c<g.length;c++){var b=g[c];m.set(b.getAttribute("vt-name"),b)}for(g=0;g<f.length;g+=2){var y=f[g],t=y.parentNode;if(t){var r=t.getBoundingClientRect();if(r.left||r.top||r.width||r.height){b=y;for(e=0;b;){if(8===b.nodeType){var p=b.data;if("/$"===p)if(0===e)break;else e--;else"$"!==p&&"$?"!==p&&"$~"!==p&&"$!"!==p||e++}else if(1===b.nodeType){c=b;var z=c.getAttribute("vt-name"),u=m.get(z);h(c,u?"vt-share":"vt-exit");u&&(h(u,"vt-share"),m.set(z,null));var A=c.querySelectorAll("[vt-share]");for(c=0;c<A.length;c++){var B=A[c],C=B.getAttribute("vt-name"),D=m.get(C);\nD&&(h(B,"vt-share"),h(D,"vt-share"),m.set(C,null))}}b=b.nextSibling}for(var q=f[g+1].firstElementChild;q;)null!==m.get(q.getAttribute("vt-name"))&&h(q,"vt-enter"),q=q.nextElementSibling;b=t;do for(var n=b.firstElementChild;n;){var E=n.getAttribute("vt-update");E&&"none"!==E&&!l.includes(n)&&h(n,"vt-update");n=n.nextElementSibling}while((b=b.parentNode)&&1===b.nodeType&&"none"!==b.getAttribute("vt-update"))}}}if(x){var v=document.__reactViewTransition=document.startViewTransition({update:function(){w(f,\ndocument.documentElement.clientHeight);return Promise.race([document.fonts.ready,new Promise(function(a){return setTimeout(a,500)})])},types:[]});v.ready.finally(function(){for(var a=l.length-3;0<=a;a-=3){var d=l[a],k=d.style;k.viewTransitionName=l[a+1];k.viewTransitionClass=l[a+1];""===d.getAttribute("style")&&d.removeAttribute("style")}});v.finished.finally(function(){document.__reactViewTransition===v&&(document.__reactViewTransition=null)});$RB=[];return}}catch(a){}w(f)}.bind(null,$RV);';
'$RV=function(z,g){function k(a,b){var e=a.getAttribute(b);e&&(b=a.style,l.push(a,b.viewTransitionName,b.viewTransitionClass),"auto"!==e&&(b.viewTransitionClass=e),(a=a.getAttribute("vt-name"))||(a="_T_"+K++ +"_"),b.viewTransitionName=a,A=!0)}var A=!1,K=0,l=[];try{var f=document.__reactViewTransition;if(f){f.finished.finally($RV.bind(null,g));return}var m=new Map;for(f=1;f<g.length;f+=2)for(var h=g[f].querySelectorAll("[vt-share]"),d=0;d<h.length;d++){var c=h[d];m.set(c.getAttribute("vt-name"),c)}var t=[];for(h=0;h<g.length;h+=2){var B=g[h],w=B.parentNode;if(w){var u=w.getBoundingClientRect();if(u.left||u.top||u.width||u.height){c=B;for(f=0;c;){if(8===c.nodeType){var q=c.data;if("/$"===q)if(0===f)break;else f--;else"$"!==q&&"$?"!==q&&"$~"!==q&&"$!"!==q||f++}else if(1===c.nodeType){d=c;var C=d.getAttribute("vt-name"),x=m.get(C);k(d,x?"vt-share":"vt-exit");x&&(k(x,"vt-share"),m.set(C,null));var D=d.querySelectorAll("[vt-share]");for(d=0;d<D.length;d++){var E=D[d],F=E.getAttribute("vt-name"),\nG=m.get(F);G&&(k(E,"vt-share"),k(G,"vt-share"),m.set(F,null))}}c=c.nextSibling}for(var H=g[h+1],r=H.firstElementChild;r;)null!==m.get(r.getAttribute("vt-name"))&&k(r,"vt-enter"),r=r.nextElementSibling;c=w;do for(var n=c.firstElementChild;n;){var I=n.getAttribute("vt-update");I&&"none"!==I&&!l.includes(n)&&k(n,"vt-update");n=n.nextElementSibling}while((c=c.parentNode)&&1===c.nodeType&&"none"!==c.getAttribute("vt-update"));t.push.apply(t,H.querySelectorAll(\'img[src]:not([loading="lazy"])\'))}}}if(A){var y=\ndocument.__reactViewTransition=document.startViewTransition({update:function(){z(g);for(var a=[document.documentElement.clientHeight,document.fonts.ready],b={},e=0;e<t.length;b={g:b.g},e++)if(b.g=t[e],!b.g.complete){var p=b.g.getBoundingClientRect();0<p.bottom&&0<p.right&&p.top<window.innerHeight&&p.left<window.innerWidth&&(p=new Promise(function(v){return function(J){v.g.addEventListener("load",J);v.g.addEventListener("error",J)}}(b)),a.push(p))}return Promise.race([Promise.all(a),new Promise(function(v){return setTimeout(v,\n500)})])},types:[]});y.ready.finally(function(){for(var a=l.length-3;0<=a;a-=3){var b=l[a],e=b.style;e.viewTransitionName=l[a+1];e.viewTransitionClass=l[a+1];""===b.getAttribute("style")&&b.removeAttribute("style")}});y.finished.finally(function(){document.__reactViewTransition===y&&(document.__reactViewTransition=null)});$RB=[];return}}catch(a){}z(g)}.bind(null,$RV);';
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"))};';
export const completeSegment =

View File

@@ -13,7 +13,7 @@ const SUSPENSE_PENDING_START_DATA = '$?';
const SUSPENSE_QUEUED_START_DATA = '$~';
const SUSPENSE_FALLBACK_START_DATA = '$!';
const SUSPENSEY_FONT_TIMEOUT = 500;
const SUSPENSEY_FONT_AND_IMAGE_TIMEOUT = 500;
// TODO: Symbols that are referenced outside this module use dynamic accessor
// notation instead of dot notation to prevent Closure's advanced compilation
@@ -136,6 +136,7 @@ export function revealCompletedBoundariesWithViewTransitions(
);
}
}
const suspenseyImages = [];
// Next we'll find the nodes that we're going to animate and apply names to them..
for (let i = 0; i < batch.length; i += 2) {
const suspenseIdNode = batch[i];
@@ -248,21 +249,50 @@ export function revealCompletedBoundariesWithViewTransitions(
ancestorElement.nodeType === ELEMENT_NODE &&
ancestorElement.getAttribute('vt-update') !== 'none'
);
// Find the appearing Suspensey Images inside the new content.
const appearingImages = contentNode.querySelectorAll(
'img[src]:not([loading="lazy"])',
);
// TODO: Consider marking shouldStartViewTransition if we found any images.
// But only once we can disable the root animation for that case.
suspenseyImages.push.apply(suspenseyImages, appearingImages);
}
if (shouldStartViewTransition) {
const transition = (document['__reactViewTransition'] = document[
'startViewTransition'
]({
update: () => {
revealBoundaries(
batch,
// Force layout to trigger font loading, we pass the actual value to trick minifiers.
revealBoundaries(batch);
const blockingPromises = [
// Force layout to trigger font loading, we stash the actual value to trick minifiers.
document.documentElement.clientHeight,
);
return Promise.race([
// Block on fonts finishing loading before revealing these boundaries.
document.fonts.ready,
new Promise(resolve => setTimeout(resolve, SUSPENSEY_FONT_TIMEOUT)),
];
for (let i = 0; i < suspenseyImages.length; i++) {
const suspenseyImage = suspenseyImages[i];
if (!suspenseyImage.complete) {
const rect = suspenseyImage.getBoundingClientRect();
const inViewport =
rect.bottom > 0 &&
rect.right > 0 &&
rect.top < window.innerHeight &&
rect.left < window.innerWidth;
if (inViewport) {
const loadingImage = new Promise(resolve => {
suspenseyImage.addEventListener('load', resolve);
suspenseyImage.addEventListener('error', resolve);
});
blockingPromises.push(loadingImage);
}
}
}
return Promise.race([
Promise.all(blockingPromises),
new Promise(resolve =>
setTimeout(resolve, SUSPENSEY_FONT_AND_IMAGE_TIMEOUT),
),
]);
},
types: [], // TODO: Add a hard coded type for Suspense reveals.