Commit 16d2bbb

Browse files
authored
Client render dehydrated Suspense boundaries on document load (#31620)
When streaming SSR while hydrating React will wait for Suspense boundaries to be revealed by the SSR stream before attempting to hydrate them. The rationale here is that the Server render is likely further ahead of whatever the client would produce so waiting to let the server stream in the UI is preferable to retrying on the client and possibly delaying how quickly the primary content becomes available. However If the connection closes early (user hits stop for instance) or there is a server error which prevents additional HTML from being delivered to the client this can put React into a broken state where the boundary never resolves nor errors and the hydration never retries that boundary freezing it in it's fallback state. Once the document has fully loaded we know there is not way any additional Suspense boundaries can arrive. This update changes react-dom on the client to schedule client renders for any unfinished Suspense boundaries upon document loading. The technique for client rendering a fallback is pretty straight forward. When hydrating a Suspense boundary if the Document is in 'complete' readyState we interpret pending boundaries as fallback boundaries. If the readyState is not 'complete' we register an event to retry the boundary when the DOMContentLoaded event fires. To test this I needed JSDOM to model readyState. We previously had a temporary implementation of readyState for SSR streaming but I ended up implementing this as a mock of JSDOM that implements a fake readyState that is mutable. It starts off in 'loading' readyState and you can advance it by mutating document.readyState. You can also reset it to 'loading'. It fires events when changing states. This seems like the least invasive way to get closer-to-real-browser behavior in a way that won't require remembering this subtle detail every time you create a test that asserts Suspense resolution order.
1 parent 6bcf0d2 commit 16d2bbb

10 files changed

Lines changed: 445 additions & 224 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
constJSDOMModule=jest.requireActual('jsdom');
2+
3+
constOriginalJSDOM=JSDOMModule.JSDOM;
4+
5+
module.exports=JSDOMModule;
6+
module.exports.JSDOM=functionJSDOM(){
7+
letresult;
8+
if(new.target){
9+
result=Reflect.construct(OriginalJSDOM,arguments);
10+
}else{
11+
result=JSDOM.apply(undefined,arguments);
12+
}
13+
14+
require('./ReactJSDOMUtils').setupDocumentReadyState(
15+
result.window.document,
16+
result.window.Event,
17+
);
18+
19+
returnresult;
20+
};
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
exportfunctionsetupDocumentReadyState(
2+
document: Document,
3+
Event: typeofEvent,
4+
){
5+
letreadyState: 0|1|2=0;
6+
Object.defineProperty(document,'readyState',{
7+
get(){
8+
switch(readyState){
9+
case0:
10+
return'loading';
11+
case1:
12+
return'interactive';
13+
case2:
14+
return'complete';
15+
}
16+
},
17+
set(value){
18+
if(value==='interactive'&&readyState<1){
19+
readyState=1;
20+
document.dispatchEvent(newEvent('readystatechange'));
21+
}elseif(value==='complete'&&readyState<2){
22+
readyState=2;
23+
document.dispatchEvent(newEvent('readystatechange'));
24+
document.dispatchEvent(newEvent('DOMContentLoaded'));
25+
}elseif(value==='loading'){
26+
// We allow resetting the readyState to loading mostly for pragamtism.
27+
// tests that use this environment don't reset the document between tests.
28+
readyState=0;
29+
}
30+
},
31+
configurable: true,
32+
});
33+
}

‎packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,8 @@ const SUSPENSE_FALLBACK_START_DATA = '$!';
194194
constFORM_STATE_IS_MATCHING='F!';
195195
constFORM_STATE_IS_NOT_MATCHING='F';
196196

197+
constDOCUMENT_READY_STATE_COMPLETE='complete';
198+
197199
constSTYLE='style';
198200

199201
opaquetypeHostContextNamespace=0|1|2;
@@ -1262,7 +1264,11 @@ export function isSuspenseInstancePending(instance: SuspenseInstance): boolean {
12621264
exportfunctionisSuspenseInstanceFallback(
12631265
instance: SuspenseInstance,
12641266
): boolean{
1265-
returninstance.data===SUSPENSE_FALLBACK_START_DATA;
1267+
return(
1268+
instance.data===SUSPENSE_FALLBACK_START_DATA||
1269+
(instance.data===SUSPENSE_PENDING_START_DATA&&
1270+
instance.ownerDocument.readyState===DOCUMENT_READY_STATE_COMPLETE)
1271+
);
12661272
}
12671273

12681274
exportfunctiongetSuspenseInstanceFallbackErrorDetails(
@@ -1303,6 +1309,20 @@ export function registerSuspenseInstanceRetry(
13031309
instance: SuspenseInstance,
13041310
callback: ()=>void,
13051311
){
1312+
const ownerDocument =instance.ownerDocument;
1313+
if(ownerDocument.readyState!==DOCUMENT_READY_STATE_COMPLETE){
1314+
ownerDocument.addEventListener(
1315+
'DOMContentLoaded',
1316+
()=>{
1317+
if(instance.data===SUSPENSE_PENDING_START_DATA){
1318+
callback();
1319+
}
1320+
},
1321+
{
1322+
once: true,
1323+
},
1324+
);
1325+
}
13061326
instance._reactRetry=callback;
13071327
}
13081328

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Commit 16d2bbb

Browse files
authored
Client render dehydrated Suspense boundaries on document load (#31620)
When streaming SSR while hydrating React will wait for Suspense boundaries to be revealed by the SSR stream before attempting to hydrate them. The rationale here is that the Server render is likely further ahead of whatever the client would produce so waiting to let the server stream in the UI is preferable to retrying on the client and possibly delaying how quickly the primary content becomes available. However If the connection closes early (user hits stop for instance) or there is a server error which prevents additional HTML from being delivered to the client this can put React into a broken state where the boundary never resolves nor errors and the hydration never retries that boundary freezing it in it's fallback state. Once the document has fully loaded we know there is not way any additional Suspense boundaries can arrive. This update changes react-dom on the client to schedule client renders for any unfinished Suspense boundaries upon document loading. The technique for client rendering a fallback is pretty straight forward. When hydrating a Suspense boundary if the Document is in 'complete' readyState we interpret pending boundaries as fallback boundaries. If the readyState is not 'complete' we register an event to retry the boundary when the DOMContentLoaded event fires. To test this I needed JSDOM to model readyState. We previously had a temporary implementation of readyState for SSR streaming but I ended up implementing this as a mock of JSDOM that implements a fake readyState that is mutable. It starts off in 'loading' readyState and you can advance it by mutating document.readyState. You can also reset it to 'loading'. It fires events when changing states. This seems like the least invasive way to get closer-to-real-browser behavior in a way that won't require remembering this subtle detail every time you create a test that asserts Suspense resolution order.
1 parent 6bcf0d2 commit 16d2bbb

10 files changed

Lines changed: 445 additions & 224 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
constJSDOMModule=jest.requireActual('jsdom');
2+
3+
constOriginalJSDOM=JSDOMModule.JSDOM;
4+
5+
module.exports=JSDOMModule;
6+
module.exports.JSDOM=functionJSDOM(){
7+
letresult;
8+
if(new.target){
9+
result=Reflect.construct(OriginalJSDOM,arguments);
10+
}else{
11+
result=JSDOM.apply(undefined,arguments);
12+
}
13+
14+
require('./ReactJSDOMUtils').setupDocumentReadyState(
15+
result.window.document,
16+
result.window.Event,
17+
);
18+
19+
returnresult;
20+
};
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
exportfunctionsetupDocumentReadyState(
2+
document: Document,
3+
Event: typeofEvent,
4+
){
5+
letreadyState: 0|1|2=0;
6+
Object.defineProperty(document,'readyState',{
7+
get(){
8+
switch(readyState){
9+
case0:
10+
return'loading';
11+
case1:
12+
return'interactive';
13+
case2:
14+
return'complete';
15+
}
16+
},
17+
set(value){
18+
if(value==='interactive'&&readyState<1){
19+
readyState=1;
20+
document.dispatchEvent(newEvent('readystatechange'));
21+
}elseif(value==='complete'&&readyState<2){
22+
readyState=2;
23+
document.dispatchEvent(newEvent('readystatechange'));
24+
document.dispatchEvent(newEvent('DOMContentLoaded'));
25+
}elseif(value==='loading'){
26+
// We allow resetting the readyState to loading mostly for pragamtism.
27+
// tests that use this environment don't reset the document between tests.
28+
readyState=0;
29+
}
30+
},
31+
configurable: true,
32+
});
33+
}

‎packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,8 @@ const SUSPENSE_FALLBACK_START_DATA = '$!';
194194
constFORM_STATE_IS_MATCHING='F!';
195195
constFORM_STATE_IS_NOT_MATCHING='F';
196196

197+
constDOCUMENT_READY_STATE_COMPLETE='complete';
198+
197199
constSTYLE='style';
198200

199201
opaquetypeHostContextNamespace=0|1|2;
@@ -1262,7 +1264,11 @@ export function isSuspenseInstancePending(instance: SuspenseInstance): boolean {
12621264
exportfunctionisSuspenseInstanceFallback(
12631265
instance: SuspenseInstance,
12641266
): boolean{
1265-
returninstance.data===SUSPENSE_FALLBACK_START_DATA;
1267+
return(
1268+
instance.data===SUSPENSE_FALLBACK_START_DATA||
1269+
(instance.data===SUSPENSE_PENDING_START_DATA&&
1270+
instance.ownerDocument.readyState===DOCUMENT_READY_STATE_COMPLETE)
1271+
);
12661272
}
12671273

12681274
exportfunctiongetSuspenseInstanceFallbackErrorDetails(
@@ -1303,6 +1309,20 @@ export function registerSuspenseInstanceRetry(
13031309
instance: SuspenseInstance,
13041310
callback: ()=>void,
13051311
){
1312+
const ownerDocument =instance.ownerDocument;
1313+
if(ownerDocument.readyState!==DOCUMENT_READY_STATE_COMPLETE){
1314+
ownerDocument.addEventListener(
1315+
'DOMContentLoaded',
1316+
()=>{
1317+
if(instance.data===SUSPENSE_PENDING_START_DATA){
1318+
callback();
1319+
}
1320+
},
1321+
{
1322+
once: true,
1323+
},
1324+
);
1325+
}
13061326
instance._reactRetry=callback;
13071327
}
13081328

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit 16d2bbb

Browse files
authored
Client render dehydrated Suspense boundaries on document load (#31620)
When streaming SSR while hydrating React will wait for Suspense boundaries to be revealed by the SSR stream before attempting to hydrate them. The rationale here is that the Server render is likely further ahead of whatever the client would produce so waiting to let the server stream in the UI is preferable to retrying on the client and possibly delaying how quickly the primary content becomes available. However If the connection closes early (user hits stop for instance) or there is a server error which prevents additional HTML from being delivered to the client this can put React into a broken state where the boundary never resolves nor errors and the hydration never retries that boundary freezing it in it's fallback state. Once the document has fully loaded we know there is not way any additional Suspense boundaries can arrive. This update changes react-dom on the client to schedule client renders for any unfinished Suspense boundaries upon document loading. The technique for client rendering a fallback is pretty straight forward. When hydrating a Suspense boundary if the Document is in 'complete' readyState we interpret pending boundaries as fallback boundaries. If the readyState is not 'complete' we register an event to retry the boundary when the DOMContentLoaded event fires. To test this I needed JSDOM to model readyState. We previously had a temporary implementation of readyState for SSR streaming but I ended up implementing this as a mock of JSDOM that implements a fake readyState that is mutable. It starts off in 'loading' readyState and you can advance it by mutating document.readyState. You can also reset it to 'loading'. It fires events when changing states. This seems like the least invasive way to get closer-to-real-browser behavior in a way that won't require remembering this subtle detail every time you create a test that asserts Suspense resolution order.
1 parent 6bcf0d2 commit 16d2bbb

10 files changed

Lines changed: 445 additions & 224 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
constJSDOMModule=jest.requireActual('jsdom');
2+
3+
constOriginalJSDOM=JSDOMModule.JSDOM;
4+
5+
module.exports=JSDOMModule;
6+
module.exports.JSDOM=functionJSDOM(){
7+
letresult;
8+
if(new.target){
9+
result=Reflect.construct(OriginalJSDOM,arguments);
10+
}else{
11+
result=JSDOM.apply(undefined,arguments);
12+
}
13+
14+
require('./ReactJSDOMUtils').setupDocumentReadyState(
15+
result.window.document,
16+
result.window.Event,
17+
);
18+
19+
returnresult;
20+
};
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
exportfunctionsetupDocumentReadyState(
2+
document: Document,
3+
Event: typeofEvent,
4+
){
5+
letreadyState: 0|1|2=0;
6+
Object.defineProperty(document,'readyState',{
7+
get(){
8+
switch(readyState){
9+
case0:
10+
return'loading';
11+
case1:
12+
return'interactive';
13+
case2:
14+
return'complete';
15+
}
16+
},
17+
set(value){
18+
if(value==='interactive'&&readyState<1){
19+
readyState=1;
20+
document.dispatchEvent(newEvent('readystatechange'));
21+
}elseif(value==='complete'&&readyState<2){
22+
readyState=2;
23+
document.dispatchEvent(newEvent('readystatechange'));
24+
document.dispatchEvent(newEvent('DOMContentLoaded'));
25+
}elseif(value==='loading'){
26+
// We allow resetting the readyState to loading mostly for pragamtism.
27+
// tests that use this environment don't reset the document between tests.
28+
readyState=0;
29+
}
30+
},
31+
configurable: true,
32+
});
33+
}

‎packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,8 @@ const SUSPENSE_FALLBACK_START_DATA = '$!';
194194
constFORM_STATE_IS_MATCHING='F!';
195195
constFORM_STATE_IS_NOT_MATCHING='F';
196196

197+
constDOCUMENT_READY_STATE_COMPLETE='complete';
198+
197199
constSTYLE='style';
198200

199201
opaquetypeHostContextNamespace=0|1|2;
@@ -1262,7 +1264,11 @@ export function isSuspenseInstancePending(instance: SuspenseInstance): boolean {
12621264
exportfunctionisSuspenseInstanceFallback(
12631265
instance: SuspenseInstance,
12641266
): boolean{
1265-
returninstance.data===SUSPENSE_FALLBACK_START_DATA;
1267+
return(
1268+
instance.data===SUSPENSE_FALLBACK_START_DATA||
1269+
(instance.data===SUSPENSE_PENDING_START_DATA&&
1270+
instance.ownerDocument.readyState===DOCUMENT_READY_STATE_COMPLETE)
1271+
);
12661272
}
12671273

12681274
exportfunctiongetSuspenseInstanceFallbackErrorDetails(
@@ -1303,6 +1309,20 @@ export function registerSuspenseInstanceRetry(
13031309
instance: SuspenseInstance,
13041310
callback: ()=>void,
13051311
){
1312+
const ownerDocument =instance.ownerDocument;
1313+
if(ownerDocument.readyState!==DOCUMENT_READY_STATE_COMPLETE){
1314+
ownerDocument.addEventListener(
1315+
'DOMContentLoaded',
1316+
()=>{
1317+
if(instance.data===SUSPENSE_PENDING_START_DATA){
1318+
callback();
1319+
}
1320+
},
1321+
{
1322+
once: true,
1323+
},
1324+
);
1325+
}
13061326
instance._reactRetry=callback;
13071327
}
13081328

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit 16d2bbb

Browse files
authored
Client render dehydrated Suspense boundaries on document load (#31620)
When streaming SSR while hydrating React will wait for Suspense boundaries to be revealed by the SSR stream before attempting to hydrate them. The rationale here is that the Server render is likely further ahead of whatever the client would produce so waiting to let the server stream in the UI is preferable to retrying on the client and possibly delaying how quickly the primary content becomes available. However If the connection closes early (user hits stop for instance) or there is a server error which prevents additional HTML from being delivered to the client this can put React into a broken state where the boundary never resolves nor errors and the hydration never retries that boundary freezing it in it's fallback state. Once the document has fully loaded we know there is not way any additional Suspense boundaries can arrive. This update changes react-dom on the client to schedule client renders for any unfinished Suspense boundaries upon document loading. The technique for client rendering a fallback is pretty straight forward. When hydrating a Suspense boundary if the Document is in 'complete' readyState we interpret pending boundaries as fallback boundaries. If the readyState is not 'complete' we register an event to retry the boundary when the DOMContentLoaded event fires. To test this I needed JSDOM to model readyState. We previously had a temporary implementation of readyState for SSR streaming but I ended up implementing this as a mock of JSDOM that implements a fake readyState that is mutable. It starts off in 'loading' readyState and you can advance it by mutating document.readyState. You can also reset it to 'loading'. It fires events when changing states. This seems like the least invasive way to get closer-to-real-browser behavior in a way that won't require remembering this subtle detail every time you create a test that asserts Suspense resolution order.
1 parent 6bcf0d2 commit 16d2bbb

10 files changed

Lines changed: 445 additions & 224 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
constJSDOMModule=jest.requireActual('jsdom');
2+
3+
constOriginalJSDOM=JSDOMModule.JSDOM;
4+
5+
module.exports=JSDOMModule;
6+
module.exports.JSDOM=functionJSDOM(){
7+
letresult;
8+
if(new.target){
9+
result=Reflect.construct(OriginalJSDOM,arguments);
10+
}else{
11+
result=JSDOM.apply(undefined,arguments);
12+
}
13+
14+
require('./ReactJSDOMUtils').setupDocumentReadyState(
15+
result.window.document,
16+
result.window.Event,
17+
);
18+
19+
returnresult;
20+
};
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
exportfunctionsetupDocumentReadyState(
2+
document: Document,
3+
Event: typeofEvent,
4+
){
5+
letreadyState: 0|1|2=0;
6+
Object.defineProperty(document,'readyState',{
7+
get(){
8+
switch(readyState){
9+
case0:
10+
return'loading';
11+
case1:
12+
return'interactive';
13+
case2:
14+
return'complete';
15+
}
16+
},
17+
set(value){
18+
if(value==='interactive'&&readyState<1){
19+
readyState=1;
20+
document.dispatchEvent(newEvent('readystatechange'));
21+
}elseif(value==='complete'&&readyState<2){
22+
readyState=2;
23+
document.dispatchEvent(newEvent('readystatechange'));
24+
document.dispatchEvent(newEvent('DOMContentLoaded'));
25+
}elseif(value==='loading'){
26+
// We allow resetting the readyState to loading mostly for pragamtism.
27+
// tests that use this environment don't reset the document between tests.
28+
readyState=0;
29+
}
30+
},
31+
configurable: true,
32+
});
33+
}

‎packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,8 @@ const SUSPENSE_FALLBACK_START_DATA = '$!';
194194
constFORM_STATE_IS_MATCHING='F!';
195195
constFORM_STATE_IS_NOT_MATCHING='F';
196196

197+
constDOCUMENT_READY_STATE_COMPLETE='complete';
198+
197199
constSTYLE='style';
198200

199201
opaquetypeHostContextNamespace=0|1|2;
@@ -1262,7 +1264,11 @@ export function isSuspenseInstancePending(instance: SuspenseInstance): boolean {
12621264
exportfunctionisSuspenseInstanceFallback(
12631265
instance: SuspenseInstance,
12641266
): boolean{
1265-
returninstance.data===SUSPENSE_FALLBACK_START_DATA;
1267+
return(
1268+
instance.data===SUSPENSE_FALLBACK_START_DATA||
1269+
(instance.data===SUSPENSE_PENDING_START_DATA&&
1270+
instance.ownerDocument.readyState===DOCUMENT_READY_STATE_COMPLETE)
1271+
);
12661272
}
12671273

12681274
exportfunctiongetSuspenseInstanceFallbackErrorDetails(
@@ -1303,6 +1309,20 @@ export function registerSuspenseInstanceRetry(
13031309
instance: SuspenseInstance,
13041310
callback: ()=>void,
13051311
){
1312+
const ownerDocument =instance.ownerDocument;
1313+
if(ownerDocument.readyState!==DOCUMENT_READY_STATE_COMPLETE){
1314+
ownerDocument.addEventListener(
1315+
'DOMContentLoaded',
1316+
()=>{
1317+
if(instance.data===SUSPENSE_PENDING_START_DATA){
1318+
callback();
1319+
}
1320+
},
1321+
{
1322+
once: true,
1323+
},
1324+
);
1325+
}
13061326
instance._reactRetry=callback;
13071327
}
13081328

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Commit 16d2bbb

Browse files
authored
Client render dehydrated Suspense boundaries on document load (#31620)
When streaming SSR while hydrating React will wait for Suspense boundaries to be revealed by the SSR stream before attempting to hydrate them. The rationale here is that the Server render is likely further ahead of whatever the client would produce so waiting to let the server stream in the UI is preferable to retrying on the client and possibly delaying how quickly the primary content becomes available. However If the connection closes early (user hits stop for instance) or there is a server error which prevents additional HTML from being delivered to the client this can put React into a broken state where the boundary never resolves nor errors and the hydration never retries that boundary freezing it in it's fallback state. Once the document has fully loaded we know there is not way any additional Suspense boundaries can arrive. This update changes react-dom on the client to schedule client renders for any unfinished Suspense boundaries upon document loading. The technique for client rendering a fallback is pretty straight forward. When hydrating a Suspense boundary if the Document is in 'complete' readyState we interpret pending boundaries as fallback boundaries. If the readyState is not 'complete' we register an event to retry the boundary when the DOMContentLoaded event fires. To test this I needed JSDOM to model readyState. We previously had a temporary implementation of readyState for SSR streaming but I ended up implementing this as a mock of JSDOM that implements a fake readyState that is mutable. It starts off in 'loading' readyState and you can advance it by mutating document.readyState. You can also reset it to 'loading'. It fires events when changing states. This seems like the least invasive way to get closer-to-real-browser behavior in a way that won't require remembering this subtle detail every time you create a test that asserts Suspense resolution order.
1 parent 6bcf0d2 commit 16d2bbb

10 files changed

Lines changed: 445 additions & 224 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
constJSDOMModule=jest.requireActual('jsdom');
2+
3+
constOriginalJSDOM=JSDOMModule.JSDOM;
4+
5+
module.exports=JSDOMModule;
6+
module.exports.JSDOM=functionJSDOM(){
7+
letresult;
8+
if(new.target){
9+
result=Reflect.construct(OriginalJSDOM,arguments);
10+
}else{
11+
result=JSDOM.apply(undefined,arguments);
12+
}
13+
14+
require('./ReactJSDOMUtils').setupDocumentReadyState(
15+
result.window.document,
16+
result.window.Event,
17+
);
18+
19+
returnresult;
20+
};
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
exportfunctionsetupDocumentReadyState(
2+
document: Document,
3+
Event: typeofEvent,
4+
){
5+
letreadyState: 0|1|2=0;
6+
Object.defineProperty(document,'readyState',{
7+
get(){
8+
switch(readyState){
9+
case0:
10+
return'loading';
11+
case1:
12+
return'interactive';
13+
case2:
14+
return'complete';
15+
}
16+
},
17+
set(value){
18+
if(value==='interactive'&&readyState<1){
19+
readyState=1;
20+
document.dispatchEvent(newEvent('readystatechange'));
21+
}elseif(value==='complete'&&readyState<2){
22+
readyState=2;
23+
document.dispatchEvent(newEvent('readystatechange'));
24+
document.dispatchEvent(newEvent('DOMContentLoaded'));
25+
}elseif(value==='loading'){
26+
// We allow resetting the readyState to loading mostly for pragamtism.
27+
// tests that use this environment don't reset the document between tests.
28+
readyState=0;
29+
}
30+
},
31+
configurable: true,
32+
});
33+
}

‎packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,8 @@ const SUSPENSE_FALLBACK_START_DATA = '$!';
194194
constFORM_STATE_IS_MATCHING='F!';
195195
constFORM_STATE_IS_NOT_MATCHING='F';
196196

197+
constDOCUMENT_READY_STATE_COMPLETE='complete';
198+
197199
constSTYLE='style';
198200

199201
opaquetypeHostContextNamespace=0|1|2;
@@ -1262,7 +1264,11 @@ export function isSuspenseInstancePending(instance: SuspenseInstance): boolean {
12621264
exportfunctionisSuspenseInstanceFallback(
12631265
instance: SuspenseInstance,
12641266
): boolean{
1265-
returninstance.data===SUSPENSE_FALLBACK_START_DATA;
1267+
return(
1268+
instance.data===SUSPENSE_FALLBACK_START_DATA||
1269+
(instance.data===SUSPENSE_PENDING_START_DATA&&
1270+
instance.ownerDocument.readyState===DOCUMENT_READY_STATE_COMPLETE)
1271+
);
12661272
}
12671273

12681274
exportfunctiongetSuspenseInstanceFallbackErrorDetails(
@@ -1303,6 +1309,20 @@ export function registerSuspenseInstanceRetry(
13031309
instance: SuspenseInstance,
13041310
callback: ()=>void,
13051311
){
1312+
const ownerDocument =instance.ownerDocument;
1313+
if(ownerDocument.readyState!==DOCUMENT_READY_STATE_COMPLETE){
1314+
ownerDocument.addEventListener(
1315+
'DOMContentLoaded',
1316+
()=>{
1317+
if(instance.data===SUSPENSE_PENDING_START_DATA){
1318+
callback();
1319+
}
1320+
},
1321+
{
1322+
once: true,
1323+
},
1324+
);
1325+
}
13061326
instance._reactRetry=callback;
13071327
}
13081328

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit 16d2bbb

Browse files
authored
Client render dehydrated Suspense boundaries on document load (#31620)
When streaming SSR while hydrating React will wait for Suspense boundaries to be revealed by the SSR stream before attempting to hydrate them. The rationale here is that the Server render is likely further ahead of whatever the client would produce so waiting to let the server stream in the UI is preferable to retrying on the client and possibly delaying how quickly the primary content becomes available. However If the connection closes early (user hits stop for instance) or there is a server error which prevents additional HTML from being delivered to the client this can put React into a broken state where the boundary never resolves nor errors and the hydration never retries that boundary freezing it in it's fallback state. Once the document has fully loaded we know there is not way any additional Suspense boundaries can arrive. This update changes react-dom on the client to schedule client renders for any unfinished Suspense boundaries upon document loading. The technique for client rendering a fallback is pretty straight forward. When hydrating a Suspense boundary if the Document is in 'complete' readyState we interpret pending boundaries as fallback boundaries. If the readyState is not 'complete' we register an event to retry the boundary when the DOMContentLoaded event fires. To test this I needed JSDOM to model readyState. We previously had a temporary implementation of readyState for SSR streaming but I ended up implementing this as a mock of JSDOM that implements a fake readyState that is mutable. It starts off in 'loading' readyState and you can advance it by mutating document.readyState. You can also reset it to 'loading'. It fires events when changing states. This seems like the least invasive way to get closer-to-real-browser behavior in a way that won't require remembering this subtle detail every time you create a test that asserts Suspense resolution order.
1 parent 6bcf0d2 commit 16d2bbb

10 files changed

Lines changed: 445 additions & 224 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
constJSDOMModule=jest.requireActual('jsdom');
2+
3+
constOriginalJSDOM=JSDOMModule.JSDOM;
4+
5+
module.exports=JSDOMModule;
6+
module.exports.JSDOM=functionJSDOM(){
7+
letresult;
8+
if(new.target){
9+
result=Reflect.construct(OriginalJSDOM,arguments);
10+
}else{
11+
result=JSDOM.apply(undefined,arguments);
12+
}
13+
14+
require('./ReactJSDOMUtils').setupDocumentReadyState(
15+
result.window.document,
16+
result.window.Event,
17+
);
18+
19+
returnresult;
20+
};
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
exportfunctionsetupDocumentReadyState(
2+
document: Document,
3+
Event: typeofEvent,
4+
){
5+
letreadyState: 0|1|2=0;
6+
Object.defineProperty(document,'readyState',{
7+
get(){
8+
switch(readyState){
9+
case0:
10+
return'loading';
11+
case1:
12+
return'interactive';
13+
case2:
14+
return'complete';
15+
}
16+
},
17+
set(value){
18+
if(value==='interactive'&&readyState<1){
19+
readyState=1;
20+
document.dispatchEvent(newEvent('readystatechange'));
21+
}elseif(value==='complete'&&readyState<2){
22+
readyState=2;
23+
document.dispatchEvent(newEvent('readystatechange'));
24+
document.dispatchEvent(newEvent('DOMContentLoaded'));
25+
}elseif(value==='loading'){
26+
// We allow resetting the readyState to loading mostly for pragamtism.
27+
// tests that use this environment don't reset the document between tests.
28+
readyState=0;
29+
}
30+
},
31+
configurable: true,
32+
});
33+
}

‎packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,8 @@ const SUSPENSE_FALLBACK_START_DATA = '$!';
194194
constFORM_STATE_IS_MATCHING='F!';
195195
constFORM_STATE_IS_NOT_MATCHING='F';
196196

197+
constDOCUMENT_READY_STATE_COMPLETE='complete';
198+
197199
constSTYLE='style';
198200

199201
opaquetypeHostContextNamespace=0|1|2;
@@ -1262,7 +1264,11 @@ export function isSuspenseInstancePending(instance: SuspenseInstance): boolean {
12621264
exportfunctionisSuspenseInstanceFallback(
12631265
instance: SuspenseInstance,
12641266
): boolean{
1265-
returninstance.data===SUSPENSE_FALLBACK_START_DATA;
1267+
return(
1268+
instance.data===SUSPENSE_FALLBACK_START_DATA||
1269+
(instance.data===SUSPENSE_PENDING_START_DATA&&
1270+
instance.ownerDocument.readyState===DOCUMENT_READY_STATE_COMPLETE)
1271+
);
12661272
}
12671273

12681274
exportfunctiongetSuspenseInstanceFallbackErrorDetails(
@@ -1303,6 +1309,20 @@ export function registerSuspenseInstanceRetry(
13031309
instance: SuspenseInstance,
13041310
callback: ()=>void,
13051311
){
1312+
const ownerDocument =instance.ownerDocument;
1313+
if(ownerDocument.readyState!==DOCUMENT_READY_STATE_COMPLETE){
1314+
ownerDocument.addEventListener(
1315+
'DOMContentLoaded',
1316+
()=>{
1317+
if(instance.data===SUSPENSE_PENDING_START_DATA){
1318+
callback();
1319+
}
1320+
},
1321+
{
1322+
once: true,
1323+
},
1324+
);
1325+
}
13061326
instance._reactRetry=callback;
13071327
}
13081328

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit 16d2bbb

Browse files
authored
Client render dehydrated Suspense boundaries on document load (#31620)
When streaming SSR while hydrating React will wait for Suspense boundaries to be revealed by the SSR stream before attempting to hydrate them. The rationale here is that the Server render is likely further ahead of whatever the client would produce so waiting to let the server stream in the UI is preferable to retrying on the client and possibly delaying how quickly the primary content becomes available. However If the connection closes early (user hits stop for instance) or there is a server error which prevents additional HTML from being delivered to the client this can put React into a broken state where the boundary never resolves nor errors and the hydration never retries that boundary freezing it in it's fallback state. Once the document has fully loaded we know there is not way any additional Suspense boundaries can arrive. This update changes react-dom on the client to schedule client renders for any unfinished Suspense boundaries upon document loading. The technique for client rendering a fallback is pretty straight forward. When hydrating a Suspense boundary if the Document is in 'complete' readyState we interpret pending boundaries as fallback boundaries. If the readyState is not 'complete' we register an event to retry the boundary when the DOMContentLoaded event fires. To test this I needed JSDOM to model readyState. We previously had a temporary implementation of readyState for SSR streaming but I ended up implementing this as a mock of JSDOM that implements a fake readyState that is mutable. It starts off in 'loading' readyState and you can advance it by mutating document.readyState. You can also reset it to 'loading'. It fires events when changing states. This seems like the least invasive way to get closer-to-real-browser behavior in a way that won't require remembering this subtle detail every time you create a test that asserts Suspense resolution order.
1 parent 6bcf0d2 commit 16d2bbb

10 files changed

Lines changed: 445 additions & 224 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
constJSDOMModule=jest.requireActual('jsdom');
2+
3+
constOriginalJSDOM=JSDOMModule.JSDOM;
4+
5+
module.exports=JSDOMModule;
6+
module.exports.JSDOM=functionJSDOM(){
7+
letresult;
8+
if(new.target){
9+
result=Reflect.construct(OriginalJSDOM,arguments);
10+
}else{
11+
result=JSDOM.apply(undefined,arguments);
12+
}
13+
14+
require('./ReactJSDOMUtils').setupDocumentReadyState(
15+
result.window.document,
16+
result.window.Event,
17+
);
18+
19+
returnresult;
20+
};
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
exportfunctionsetupDocumentReadyState(
2+
document: Document,
3+
Event: typeofEvent,
4+
){
5+
letreadyState: 0|1|2=0;
6+
Object.defineProperty(document,'readyState',{
7+
get(){
8+
switch(readyState){
9+
case0:
10+
return'loading';
11+
case1:
12+
return'interactive';
13+
case2:
14+
return'complete';
15+
}
16+
},
17+
set(value){
18+
if(value==='interactive'&&readyState<1){
19+
readyState=1;
20+
document.dispatchEvent(newEvent('readystatechange'));
21+
}elseif(value==='complete'&&readyState<2){
22+
readyState=2;
23+
document.dispatchEvent(newEvent('readystatechange'));
24+
document.dispatchEvent(newEvent('DOMContentLoaded'));
25+
}elseif(value==='loading'){
26+
// We allow resetting the readyState to loading mostly for pragamtism.
27+
// tests that use this environment don't reset the document between tests.
28+
readyState=0;
29+
}
30+
},
31+
configurable: true,
32+
});
33+
}

‎packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,8 @@ const SUSPENSE_FALLBACK_START_DATA = '$!';
194194
constFORM_STATE_IS_MATCHING='F!';
195195
constFORM_STATE_IS_NOT_MATCHING='F';
196196

197+
constDOCUMENT_READY_STATE_COMPLETE='complete';
198+
197199
constSTYLE='style';
198200

199201
opaquetypeHostContextNamespace=0|1|2;
@@ -1262,7 +1264,11 @@ export function isSuspenseInstancePending(instance: SuspenseInstance): boolean {
12621264
exportfunctionisSuspenseInstanceFallback(
12631265
instance: SuspenseInstance,
12641266
): boolean{
1265-
returninstance.data===SUSPENSE_FALLBACK_START_DATA;
1267+
return(
1268+
instance.data===SUSPENSE_FALLBACK_START_DATA||
1269+
(instance.data===SUSPENSE_PENDING_START_DATA&&
1270+
instance.ownerDocument.readyState===DOCUMENT_READY_STATE_COMPLETE)
1271+
);
12661272
}
12671273

12681274
exportfunctiongetSuspenseInstanceFallbackErrorDetails(
@@ -1303,6 +1309,20 @@ export function registerSuspenseInstanceRetry(
13031309
instance: SuspenseInstance,
13041310
callback: ()=>void,
13051311
){
1312+
const ownerDocument =instance.ownerDocument;
1313+
if(ownerDocument.readyState!==DOCUMENT_READY_STATE_COMPLETE){
1314+
ownerDocument.addEventListener(
1315+
'DOMContentLoaded',
1316+
()=>{
1317+
if(instance.data===SUSPENSE_PENDING_START_DATA){
1318+
callback();
1319+
}
1320+
},
1321+
{
1322+
once: true,
1323+
},
1324+
);
1325+
}
13061326
instance._reactRetry=callback;
13071327
}
13081328

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Commit 16d2bbb

Browse files
authored
Client render dehydrated Suspense boundaries on document load (#31620)
When streaming SSR while hydrating React will wait for Suspense boundaries to be revealed by the SSR stream before attempting to hydrate them. The rationale here is that the Server render is likely further ahead of whatever the client would produce so waiting to let the server stream in the UI is preferable to retrying on the client and possibly delaying how quickly the primary content becomes available. However If the connection closes early (user hits stop for instance) or there is a server error which prevents additional HTML from being delivered to the client this can put React into a broken state where the boundary never resolves nor errors and the hydration never retries that boundary freezing it in it's fallback state. Once the document has fully loaded we know there is not way any additional Suspense boundaries can arrive. This update changes react-dom on the client to schedule client renders for any unfinished Suspense boundaries upon document loading. The technique for client rendering a fallback is pretty straight forward. When hydrating a Suspense boundary if the Document is in 'complete' readyState we interpret pending boundaries as fallback boundaries. If the readyState is not 'complete' we register an event to retry the boundary when the DOMContentLoaded event fires. To test this I needed JSDOM to model readyState. We previously had a temporary implementation of readyState for SSR streaming but I ended up implementing this as a mock of JSDOM that implements a fake readyState that is mutable. It starts off in 'loading' readyState and you can advance it by mutating document.readyState. You can also reset it to 'loading'. It fires events when changing states. This seems like the least invasive way to get closer-to-real-browser behavior in a way that won't require remembering this subtle detail every time you create a test that asserts Suspense resolution order.
1 parent 6bcf0d2 commit 16d2bbb

10 files changed

Lines changed: 445 additions & 224 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
constJSDOMModule=jest.requireActual('jsdom');
2+
3+
constOriginalJSDOM=JSDOMModule.JSDOM;
4+
5+
module.exports=JSDOMModule;
6+
module.exports.JSDOM=functionJSDOM(){
7+
letresult;
8+
if(new.target){
9+
result=Reflect.construct(OriginalJSDOM,arguments);
10+
}else{
11+
result=JSDOM.apply(undefined,arguments);
12+
}
13+
14+
require('./ReactJSDOMUtils').setupDocumentReadyState(
15+
result.window.document,
16+
result.window.Event,
17+
);
18+
19+
returnresult;
20+
};
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
exportfunctionsetupDocumentReadyState(
2+
document: Document,
3+
Event: typeofEvent,
4+
){
5+
letreadyState: 0|1|2=0;
6+
Object.defineProperty(document,'readyState',{
7+
get(){
8+
switch(readyState){
9+
case0:
10+
return'loading';
11+
case1:
12+
return'interactive';
13+
case2:
14+
return'complete';
15+
}
16+
},
17+
set(value){
18+
if(value==='interactive'&&readyState<1){
19+
readyState=1;
20+
document.dispatchEvent(newEvent('readystatechange'));
21+
}elseif(value==='complete'&&readyState<2){
22+
readyState=2;
23+
document.dispatchEvent(newEvent('readystatechange'));
24+
document.dispatchEvent(newEvent('DOMContentLoaded'));
25+
}elseif(value==='loading'){
26+
// We allow resetting the readyState to loading mostly for pragamtism.
27+
// tests that use this environment don't reset the document between tests.
28+
readyState=0;
29+
}
30+
},
31+
configurable: true,
32+
});
33+
}

‎packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,8 @@ const SUSPENSE_FALLBACK_START_DATA = '$!';
194194
constFORM_STATE_IS_MATCHING='F!';
195195
constFORM_STATE_IS_NOT_MATCHING='F';
196196

197+
constDOCUMENT_READY_STATE_COMPLETE='complete';
198+
197199
constSTYLE='style';
198200

199201
opaquetypeHostContextNamespace=0|1|2;
@@ -1262,7 +1264,11 @@ export function isSuspenseInstancePending(instance: SuspenseInstance): boolean {
12621264
exportfunctionisSuspenseInstanceFallback(
12631265
instance: SuspenseInstance,
12641266
): boolean{
1265-
returninstance.data===SUSPENSE_FALLBACK_START_DATA;
1267+
return(
1268+
instance.data===SUSPENSE_FALLBACK_START_DATA||
1269+
(instance.data===SUSPENSE_PENDING_START_DATA&&
1270+
instance.ownerDocument.readyState===DOCUMENT_READY_STATE_COMPLETE)
1271+
);
12661272
}
12671273

12681274
exportfunctiongetSuspenseInstanceFallbackErrorDetails(
@@ -1303,6 +1309,20 @@ export function registerSuspenseInstanceRetry(
13031309
instance: SuspenseInstance,
13041310
callback: ()=>void,
13051311
){
1312+
const ownerDocument =instance.ownerDocument;
1313+
if(ownerDocument.readyState!==DOCUMENT_READY_STATE_COMPLETE){
1314+
ownerDocument.addEventListener(
1315+
'DOMContentLoaded',
1316+
()=>{
1317+
if(instance.data===SUSPENSE_PENDING_START_DATA){
1318+
callback();
1319+
}
1320+
},
1321+
{
1322+
once: true,
1323+
},
1324+
);
1325+
}
13061326
instance._reactRetry=callback;
13071327
}
13081328

0 commit comments

Comments
 (0)