Repository: facebook/react
Pull request: [Fizz] Add "Queued" Status to SSR:ed Suspense Boundaries

Stacked on #33076.

This fixes a bug where we used the "complete" status but the DOMContentLoaded event. This checks for not "loading" instead.

We also add a new status where the boundary has been marked as complete by the server but has not yet flushed either due to being throttled, suspended on CSS or animating.



Changed files:
filename: packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
patch: @@ -238,14 +238,15 @@ const ACTIVITY_END_DATA = '/&';
 const SUSPENSE_START_DATA = '$';
 const SUSPENSE_END_DATA = '/$';
 const SUSPENSE_PENDING_START_DATA = '$?';
+const SUSPENSE_QUEUED_START_DATA = '$~';
 const SUSPENSE_FALLBACK_START_DATA = '$!';
 const PREAMBLE_CONTRIBUTION_HTML = 'html';
 const PREAMBLE_CONTRIBUTION_BODY = 'body';
 const PREAMBLE_CONTRIBUTION_HEAD = 'head';
 const FORM_STATE_IS_MATCHING = 'F!';
 const FORM_STATE_IS_NOT_MATCHING = 'F';
 
-const DOCUMENT_READY_STATE_COMPLETE = 'complete';
+const DOCUMENT_READY_STATE_LOADING = 'loading';
 
 const STYLE = 'style';
 
@@ -1084,6 +1085,7 @@ function clearHydrationBoundary(
       } 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
       ) {
@@ -1205,6 +1207,7 @@ function hideOrUnhideDehydratedBoundary(
       } else if (
         data === SUSPENSE_START_DATA ||
         data === SUSPENSE_PENDING_START_DATA ||
+        data === SUSPENSE_QUEUED_START_DATA ||
         data === SUSPENSE_FALLBACK_START_DATA
       ) {
         depth++;
@@ -3140,7 +3143,10 @@ export function canHydrateSuspenseInstance(
 }
 
 export function isSuspenseInstancePending(instance: SuspenseInstance): boolean {
-  return instance.data === SUSPENSE_PENDING_START_DATA;
+  return (
+    instance.data === SUSPENSE_PENDING_START_DATA ||
+    instance.data === SUSPENSE_QUEUED_START_DATA
+  );
 }
 
 export function isSuspenseInstanceFallback(
@@ -3149,7 +3155,7 @@ export function isSuspenseInstanceFallback(
   return (
     instance.data === SUSPENSE_FALLBACK_START_DATA ||
     (instance.data === SUSPENSE_PENDING_START_DATA &&
-      instance.ownerDocument.readyState === DOCUMENT_READY_STATE_COMPLETE)
+      instance.ownerDocument.readyState !== DOCUMENT_READY_STATE_LOADING)
   );
 }
 
@@ -3192,15 +3198,19 @@ export function registerSuspenseInstanceRetry(
   callback: () => void,
 ) {
   const ownerDocument = instance.ownerDocument;
-  if (
+  if (instance.data === SUSPENSE_QUEUED_START_DATA) {
+    // The Fizz runtime has already queued this boundary for reveal. We wait for it
+    // to be revealed and then retries.
+    instance._reactRetry = callback;
+  } else if (
     // The Fizz runtime must have put this boundary into client render or complete
     // state after the render finished but before it committed. We need to call the
     // callback now rather than wait
     instance.data !== SUSPENSE_PENDING_START_DATA ||
     // The boundary is still in pending status but the document has finished loading
     // before we could register the event handler that would have scheduled the retry
     // on load so we call teh callback now.
-    ownerDocument.readyState === DOCUMENT_READY_STATE_COMPLETE
+    ownerDocument.readyState !== DOCUMENT_READY_STATE_LOADING
   ) {
     callback();
   } else {
@@ -3255,18 +3265,19 @@ function getNextHydratable(node: ?Node) {
       break;
     }
     if (nodeType === COMMENT_NODE) {
-      const nodeData = (node: any).data;
+      const data = (node: any).data;
       if (
-        nodeData === SUSPENSE_START_DATA ||
-        nodeData === SUSPENSE_FALLBACK_START_DATA ||
-        nodeData === SUSPENSE_PENDING_START_DATA ||
-        nodeData === ACTIVITY_START_DATA ||
-        nodeData === FORM_STATE_IS_MATCHING ||
-        nodeData === FORM_STATE_IS_NOT_MATCHING
+        data === SUSPENSE_START_DATA ||
+        data === SUSPENSE_FALLBACK_START_DATA ||
+        data === SUSPENSE_PENDING_START_DATA ||
+        data === SUSPENSE_QUEUED_START_DATA ||
+        data === ACTIVITY_START_DATA ||
+        data === FORM_STATE_IS_MATCHING ||
+        data === FORM_STATE_IS_NOT_MATCHING
       ) {
         break;
       }
-      if (nodeData === SUSPENSE_END_DATA || nodeData === ACTIVITY_END_DATA) {
+      if (data === SUSPENSE_END_DATA || data === ACTIVITY_END_DATA) {
         return null;
       }
     }
@@ -3494,6 +3505,7 @@ function getNextHydratableInstanceAfterHydrationBoundary(
         data === SUSPENSE_START_DATA ||
         data === SUSPENSE_FALLBACK_START_DATA ||
         data === SUSPENSE_PENDING_START_DATA ||
+        data === SUSPENSE_QUEUED_START_DATA ||
         data === ACTIVITY_START_DATA
       ) {
         depth++;
@@ -3535,6 +3547,7 @@ export function getParentHydrationBoundary(
         data === SUSPENSE_START_DATA ||
         data === SUSPENSE_FALLBACK_START_DATA ||
         data === SUSPENSE_PENDING_START_DATA ||
+        data === SUSPENSE_QUEUED_START_DATA ||
         data === ACTIVITY_START_DATA
       ) {
         if (depth === 0) {

filename: packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js
patch: @@ -6,9 +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(e,c){function m(){$RT=performance.now();var f=$RB;$RB=[];for(var d=0;d<f.length;d+=2){var a=f[d],l=f[d+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||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),e=\ndocument.getElementById(e))$RB.push(e,c),2===$RB.length&&setTimeout(m,("number"!==typeof $RT?0:$RT)+300-performance.now())};';
+  '$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())};';
 export const completeBoundaryWithStyles =
-  '$RM=new Map;$RR=function(r,v,w){function t(n){this._p=null;n()}for(var p=new Map,q=document,g,b,h=q.querySelectorAll("link[data-precedence],style[data-precedence]"),u=[],k=0;b=h[k++];)"not all"===b.getAttribute("media")?u.push(b):("LINK"===b.tagName&&$RM.set(b.getAttribute("href"),b),p.set(b.dataset.precedence,g=b));b=0;h=[];var l,a;for(k=!0;;){if(k){var e=w[b++];if(!e){k=!1;b=0;continue}var c=!1,m=0;var d=e[m++];if(a=$RM.get(d)){var f=a._p;c=!0}else{a=q.createElement("link");a.href=d;a.rel=\n"stylesheet";for(a.dataset.precedence=l=e[m++];f=e[m++];)a.setAttribute(f,e[m++]);f=a._p=new Promise(function(n,x){a.onload=t.bind(a,n);a.onerror=t.bind(a,x)});$RM.set(d,a)}d=a.getAttribute("media");!f||d&&!matchMedia(d).matches||h.push(f);if(c)continue}else{a=u[b++];if(!a)break;l=a.getAttribute("data-precedence");a.removeAttribute("media")}c=p.get(l)||g;c===g&&(g=a);p.set(l,a);c?c.parentNode.insertBefore(a,c.nextSibling):(c=q.head,c.insertBefore(a,c.firstChild))}Promise.all(h).then($RC.bind(null,\nr,v),$RX.bind(null,r,"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 =
   '$RS=function(a,b){a=document.getElementById(a);b=document.getElementById(b);for(a.parentNode.removeChild(a);a.firstChild;)b.parentNode.insertBefore(a.firstChild,b);b.parentNode.removeChild(b)};';
 export const formReplaying =

filename: packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetShared.js
patch: @@ -9,6 +9,7 @@ const ACTIVITY_END_DATA = '/&';
 const SUSPENSE_START_DATA = '$';
 const SUSPENSE_END_DATA = '/$';
 const SUSPENSE_PENDING_START_DATA = '$?';
+const SUSPENSE_QUEUED_START_DATA = '$~';
 const SUSPENSE_FALLBACK_START_DATA = '$!';
 
 // TODO: Symbols that are referenced outside this module use dynamic accessor
@@ -106,6 +107,7 @@ export function completeBoundary(suspenseBoundaryID, contentID) {
           } 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
           ) {
@@ -132,6 +134,10 @@ export function completeBoundary(suspenseBoundaryID, contentID) {
     }
   }
 
+  // Mark this Suspense boundary as queued so we know not to client render it
+  // at the end of document load.
+  const suspenseNodeOuter = suspenseIdNodeOuter.previousSibling;
+  suspenseNodeOuter.data = SUSPENSE_QUEUED_START_DATA;
   // Queue this boundary for the next batch
   window['$RB'].push(suspenseIdNodeOuter, contentNodeOuter);
 
@@ -261,6 +267,14 @@ export function completeBoundaryWithStyles(
     }
   }
 
+  const suspenseIdNodeOuter = document.getElementById(suspenseBoundaryID);
+  if (suspenseIdNodeOuter) {
+    // Mark this Suspense boundary as queued so we know not to client render it
+    // at the end of document load.
+    const suspenseNodeOuter = suspenseIdNodeOuter.previousSibling;
+    suspenseNodeOuter.data = SUSPENSE_QUEUED_START_DATA;
+  }
+
   Promise.all(dependencies).then(
     window['$RC'].bind(null, suspenseBoundaryID, contentID),
     window['$RX'].bind(null, suspenseBoundaryID, 'CSS failed to load'),