Repository: facebook/react
Pull request: [Fizz] Suspensey Images for View Transition Reveals

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.

Changed files:
filename: packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
patch: @@ -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(

filename: packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js
patch: @@ -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 =

filename: packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetShared.js
patch: @@ -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.