@samitouri / QOS-React / commits / 22b929156c

[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.

Sebastian Markbåge committed Jun 6, 2025 at 10:14 UTC 22b929156c325eaf52c375f0c62801831951814a
3 files changed +41 -10
packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
+3 -2
@@ -4860,8 +4860,9 @@ export function writeCompletedSegmentInstruction(
4860 const completeBoundaryScriptFunctionOnly = stringToPrecomputedChunk(
4861 completeBoundaryFunction,
4862 );
4863 -const completeBoundaryUpgradeToViewTransitionsInstruction =
4864 - stringToPrecomputedChunk(upgradeToViewTransitionsInstruction);
4863 +const completeBoundaryUpgradeToViewTransitionsInstruction = stringToChunk(
4864 + upgradeToViewTransitionsInstruction,
4865 +);
4866 const completeBoundaryScript1Partial = stringToPrecomputedChunk('$RC("');
4867
4868 const completeBoundaryWithStylesScript1FullPartial = stringToPrecomputedChunk(
packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js
+1 -1
@@ -8,7 +8,7 @@ export const clientRenderBoundary =
8 export const completeBoundary =
9 '$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())};';
10 export const completeBoundaryUpgradeToViewTransitions =
11 - '$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);';
11 + '$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);';
12 export const completeBoundaryWithStyles =
13 '$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"))};';
14 export const completeSegment =
packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetShared.js
+37 -7
@@ -13,7 +13,7 @@ const SUSPENSE_PENDING_START_DATA = '$?';
13 const SUSPENSE_QUEUED_START_DATA = '$~';
14 const SUSPENSE_FALLBACK_START_DATA = '$!';
15
16 -const SUSPENSEY_FONT_TIMEOUT = 500;
16 +const SUSPENSEY_FONT_AND_IMAGE_TIMEOUT = 500;
17
18 // TODO: Symbols that are referenced outside this module use dynamic accessor
19 // notation instead of dot notation to prevent Closure's advanced compilation
@@ -136,6 +136,7 @@ export function revealCompletedBoundariesWithViewTransitions(
136 );
137 }
138 }
139 + const suspenseyImages = [];
140 // Next we'll find the nodes that we're going to animate and apply names to them..
141 for (let i = 0; i < batch.length; i += 2) {
142 const suspenseIdNode = batch[i];
@@ -248,21 +249,50 @@ export function revealCompletedBoundariesWithViewTransitions(
249 ancestorElement.nodeType === ELEMENT_NODE &&
250 ancestorElement.getAttribute('vt-update') !== 'none'
251 );
252 +
253 + // Find the appearing Suspensey Images inside the new content.
254 + const appearingImages = contentNode.querySelectorAll(
255 + 'img[src]:not([loading="lazy"])',
256 + );
257 + // TODO: Consider marking shouldStartViewTransition if we found any images.
258 + // But only once we can disable the root animation for that case.
259 + suspenseyImages.push.apply(suspenseyImages, appearingImages);
260 }
261 if (shouldStartViewTransition) {
262 const transition = (document['__reactViewTransition'] = document[
263 'startViewTransition'
264 ]({
265 update: () => {
257 - revealBoundaries(
258 - batch,
259 - // Force layout to trigger font loading, we pass the actual value to trick minifiers.
266 + revealBoundaries(batch);
267 + const blockingPromises = [
268 + // Force layout to trigger font loading, we stash the actual value to trick minifiers.
269 document.documentElement.clientHeight,
261 - );
262 - return Promise.race([
270 // Block on fonts finishing loading before revealing these boundaries.
271 document.fonts.ready,
265 - new Promise(resolve => setTimeout(resolve, SUSPENSEY_FONT_TIMEOUT)),
272 + ];
273 + for (let i = 0; i < suspenseyImages.length; i++) {
274 + const suspenseyImage = suspenseyImages[i];
275 + if (!suspenseyImage.complete) {
276 + const rect = suspenseyImage.getBoundingClientRect();
277 + const inViewport =
278 + rect.bottom > 0 &&
279 + rect.right > 0 &&
280 + rect.top < window.innerHeight &&
281 + rect.left < window.innerWidth;
282 + if (inViewport) {
283 + const loadingImage = new Promise(resolve => {
284 + suspenseyImage.addEventListener('load', resolve);
285 + suspenseyImage.addEventListener('error', resolve);
286 + });
287 + blockingPromises.push(loadingImage);
288 + }
289 + }
290 + }
291 + return Promise.race([
292 + Promise.all(blockingPromises),
293 + new Promise(resolve =>
294 + setTimeout(resolve, SUSPENSEY_FONT_AND_IMAGE_TIMEOUT),
295 + ),
296 ]);
297 },
298 types: [], // TODO: Add a hard coded type for Suspense reveals.