[Fizz] Add "Queued" Status to SSR:ed Suspense Boundaries (#33087)
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.
Sebastian Markbåge committed
May 1, 2025 at 16:11 UTC
0ed6ceb9f6c19f28c504cf46193cef40166a61f6
3 files changed
+42
-15
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+26
-13
@@ -238,6 +238,7 @@ const ACTIVITY_END_DATA = '/&';
238
const SUSPENSE_START_DATA = '$';
239
const SUSPENSE_END_DATA = '/$';
240
const SUSPENSE_PENDING_START_DATA = '$?';
241
+const SUSPENSE_QUEUED_START_DATA = '$~';
242
const SUSPENSE_FALLBACK_START_DATA = '$!';
243
const PREAMBLE_CONTRIBUTION_HTML = 'html';
244
const PREAMBLE_CONTRIBUTION_BODY = 'body';
@@ -245,7 +246,7 @@ const PREAMBLE_CONTRIBUTION_HEAD = 'head';
246
const FORM_STATE_IS_MATCHING = 'F!';
247
const FORM_STATE_IS_NOT_MATCHING = 'F';
248
248
-const DOCUMENT_READY_STATE_COMPLETE = 'complete';
249
+const DOCUMENT_READY_STATE_LOADING = 'loading';
250
251
const STYLE = 'style';
252
@@ -1084,6 +1085,7 @@ function clearHydrationBoundary(
1085
} else if (
1086
data === SUSPENSE_START_DATA ||
1087
data === SUSPENSE_PENDING_START_DATA ||
1088
+ data === SUSPENSE_QUEUED_START_DATA ||
1089
data === SUSPENSE_FALLBACK_START_DATA ||
1090
data === ACTIVITY_START_DATA
1091
) {
@@ -1205,6 +1207,7 @@ function hideOrUnhideDehydratedBoundary(
1207
} else if (
1208
data === SUSPENSE_START_DATA ||
1209
data === SUSPENSE_PENDING_START_DATA ||
1210
+ data === SUSPENSE_QUEUED_START_DATA ||
1211
data === SUSPENSE_FALLBACK_START_DATA
1212
) {
1213
depth++;
@@ -3140,7 +3143,10 @@ export function canHydrateSuspenseInstance(
3143
}
3144
3145
export function isSuspenseInstancePending(instance: SuspenseInstance): boolean {
3143
- return instance.data === SUSPENSE_PENDING_START_DATA;
3146
+ return (
3147
+ instance.data === SUSPENSE_PENDING_START_DATA ||
3148
+ instance.data === SUSPENSE_QUEUED_START_DATA
3149
+ );
3150
}
3151
3152
export function isSuspenseInstanceFallback(
@@ -3149,7 +3155,7 @@ export function isSuspenseInstanceFallback(
3155
return (
3156
instance.data === SUSPENSE_FALLBACK_START_DATA ||
3157
(instance.data === SUSPENSE_PENDING_START_DATA &&
3152
- instance.ownerDocument.readyState === DOCUMENT_READY_STATE_COMPLETE)
3158
+ instance.ownerDocument.readyState !== DOCUMENT_READY_STATE_LOADING)
3159
);
3160
}
3161
@@ -3192,7 +3198,11 @@ export function registerSuspenseInstanceRetry(
3198
callback: () => void,
3199
) {
3200
const ownerDocument = instance.ownerDocument;
3195
- if (
3201
+ if (instance.data === SUSPENSE_QUEUED_START_DATA) {
3202
+ // The Fizz runtime has already queued this boundary for reveal. We wait for it
3203
+ // to be revealed and then retries.
3204
+ instance._reactRetry = callback;
3205
+ } else if (
3206
// The Fizz runtime must have put this boundary into client render or complete
3207
// state after the render finished but before it committed. We need to call the
3208
// callback now rather than wait
@@ -3200,7 +3210,7 @@ export function registerSuspenseInstanceRetry(
3210
// The boundary is still in pending status but the document has finished loading
3211
// before we could register the event handler that would have scheduled the retry
3212
// on load so we call teh callback now.
3203
- ownerDocument.readyState === DOCUMENT_READY_STATE_COMPLETE
3213
+ ownerDocument.readyState !== DOCUMENT_READY_STATE_LOADING
3214
) {
3215
callback();
3216
} else {
@@ -3255,18 +3265,19 @@ function getNextHydratable(node: ?Node) {
3265
break;
3266
}
3267
if (nodeType === COMMENT_NODE) {
3258
- const nodeData = (node: any).data;
3268
+ const data = (node: any).data;
3269
if (
3260
- nodeData === SUSPENSE_START_DATA ||
3261
- nodeData === SUSPENSE_FALLBACK_START_DATA ||
3262
- nodeData === SUSPENSE_PENDING_START_DATA ||
3263
- nodeData === ACTIVITY_START_DATA ||
3264
- nodeData === FORM_STATE_IS_MATCHING ||
3265
- nodeData === FORM_STATE_IS_NOT_MATCHING
3270
+ data === SUSPENSE_START_DATA ||
3271
+ data === SUSPENSE_FALLBACK_START_DATA ||
3272
+ data === SUSPENSE_PENDING_START_DATA ||
3273
+ data === SUSPENSE_QUEUED_START_DATA ||
3274
+ data === ACTIVITY_START_DATA ||
3275
+ data === FORM_STATE_IS_MATCHING ||
3276
+ data === FORM_STATE_IS_NOT_MATCHING
3277
) {
3278
break;
3279
}
3269
- if (nodeData === SUSPENSE_END_DATA || nodeData === ACTIVITY_END_DATA) {
3280
+ if (data === SUSPENSE_END_DATA || data === ACTIVITY_END_DATA) {
3281
return null;
3282
}
3283
}
@@ -3494,6 +3505,7 @@ function getNextHydratableInstanceAfterHydrationBoundary(
3505
data === SUSPENSE_START_DATA ||
3506
data === SUSPENSE_FALLBACK_START_DATA ||
3507
data === SUSPENSE_PENDING_START_DATA ||
3508
+ data === SUSPENSE_QUEUED_START_DATA ||
3509
data === ACTIVITY_START_DATA
3510
) {
3511
depth++;
@@ -3535,6 +3547,7 @@ export function getParentHydrationBoundary(
3547
data === SUSPENSE_START_DATA ||
3548
data === SUSPENSE_FALLBACK_START_DATA ||
3549
data === SUSPENSE_PENDING_START_DATA ||
3550
+ data === SUSPENSE_QUEUED_START_DATA ||
3551
data === ACTIVITY_START_DATA
3552
) {
3553
if (depth === 0) {
packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js
+2
-2
@@ -6,9 +6,9 @@ export const markShellTime =
6
export const clientRenderBoundary =
7
'$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())};';
8
export const completeBoundary =
9
- '$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())};';
9
+ '$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())};';
10
export const completeBoundaryWithStyles =
11
- '$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"))};';
11
+ '$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"))};';
12
export const completeSegment =
13
'$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)};';
14
export const formReplaying =
packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetShared.js
+14
@@ -9,6 +9,7 @@ const ACTIVITY_END_DATA = '/&';
9
const SUSPENSE_START_DATA = '$';
10
const SUSPENSE_END_DATA = '/$';
11
const SUSPENSE_PENDING_START_DATA = '$?';
12
+const SUSPENSE_QUEUED_START_DATA = '$~';
13
const SUSPENSE_FALLBACK_START_DATA = '$!';
14
15
// TODO: Symbols that are referenced outside this module use dynamic accessor
@@ -106,6 +107,7 @@ export function completeBoundary(suspenseBoundaryID, contentID) {
107
} else if (
108
data === SUSPENSE_START_DATA ||
109
data === SUSPENSE_PENDING_START_DATA ||
110
+ data === SUSPENSE_QUEUED_START_DATA ||
111
data === SUSPENSE_FALLBACK_START_DATA ||
112
data === ACTIVITY_START_DATA
113
) {
@@ -132,6 +134,10 @@ export function completeBoundary(suspenseBoundaryID, contentID) {
134
}
135
}
136
137
+ // Mark this Suspense boundary as queued so we know not to client render it
138
+ // at the end of document load.
139
+ const suspenseNodeOuter = suspenseIdNodeOuter.previousSibling;
140
+ suspenseNodeOuter.data = SUSPENSE_QUEUED_START_DATA;
141
// Queue this boundary for the next batch
142
window['$RB'].push(suspenseIdNodeOuter, contentNodeOuter);
143
@@ -261,6 +267,14 @@ export function completeBoundaryWithStyles(
267
}
268
}
269
270
+ const suspenseIdNodeOuter = document.getElementById(suspenseBoundaryID);
271
+ if (suspenseIdNodeOuter) {
272
+ // Mark this Suspense boundary as queued so we know not to client render it
273
+ // at the end of document load.
274
+ const suspenseNodeOuter = suspenseIdNodeOuter.previousSibling;
275
+ suspenseNodeOuter.data = SUSPENSE_QUEUED_START_DATA;
276
+ }
277
+
278
Promise.all(dependencies).then(
279
window['$RC'].bind(null, suspenseBoundaryID, contentID),
280
window['$RX'].bind(null, suspenseBoundaryID, 'CSS failed to load'),