Skip to content

Commit 38cd020

Browse files
authored
Don't outline Suspense boundaries with suspensey CSS during shell flush (#35824)
When flushing the shell, stylesheets with precedence are emitted in the `<head>` which blocks paint regardless. Outlining a boundary solely because it has suspensey CSS provides no benefit during the shell flush and causes a higher-level fallback to be shown unnecessarily (e.g. "Middle Fallback" instead of "Inner Fallback"). This change passes a flushingInShell flag to hasSuspenseyContent so the host config can skip stylesheet-only suspensey content when flushing the shell. Suspensey images (used for ViewTransition animation reveals) still trigger outlining during the shell since their motivation is different. When flushing streamed completions the behavior is unchanged — suspensey CSS still causes outlining so the parent content can display sooner while the stylesheet loads.
1 parent f247eba commit 38cd020

6 files changed

Lines changed: 216 additions & 6 deletions

File tree

‎packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7041,7 +7041,17 @@ export function hoistHoistables(
70417041
}
70427042
}
70437043

7044-
exportfunctionhasSuspenseyContent(hoistableState: HoistableState): boolean{
7044+
exportfunctionhasSuspenseyContent(
7045+
hoistableState: HoistableState,
7046+
flushingInShell: boolean,
7047+
): boolean{
7048+
if(flushingInShell){
7049+
// When flushing the shell, stylesheets with precedence are already emitted
7050+
// in the <head> which blocks paint. There's no benefit to outlining for CSS
7051+
// alone during the shell flush. However, suspensey images (for ViewTransition
7052+
// animation reveals) should still trigger outlining even during the shell.
7053+
returnhoistableState.suspenseyImages;
7054+
}
70457055
returnhoistableState.stylesheets.size>0||hoistableState.suspenseyImages;
70467056
}
70477057

‎packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,10 @@ export function writePreambleStart(
326326
);
327327
}
328328

329-
exportfunctionhasSuspenseyContent(hoistableState: HoistableState): boolean{
329+
exportfunctionhasSuspenseyContent(
330+
hoistableState: HoistableState,
331+
flushingInShell: boolean,
332+
): boolean{
330333
// Never outline.
331334
returnfalse;
332335
}

‎packages/react-dom/src/__tests__/ReactDOMFloat-test.js‎

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9449,4 +9449,192 @@ background-color: green;
94499449
);
94509450
});
94519451
});
9452+
9453+
it('does not outline a boundary with suspensey CSS when flushing the shell',async()=>{
9454+
// When flushing the shell, stylesheets with precedence are emitted in the
9455+
// <head> which blocks paint anyway. So there's no benefit to outlining the
9456+
// boundary — it would just show a higher-level fallback unnecessarily.
9457+
// Instead, the boundary should be inlined so the innermost fallback is shown.
9458+
letstreamedContent='';
9459+
writable.on('data',chunk=>(streamedContent+=chunk));
9460+
9461+
awaitact(()=>{
9462+
renderToPipeableStream(
9463+
<html>
9464+
<body>
9465+
<Suspensefallback="Outer Fallback">
9466+
<Suspensefallback="Middle Fallback">
9467+
<linkrel="stylesheet"href="style.css"precedence="default"/>
9468+
<Suspensefallback="Inner Fallback">
9469+
<BlockedOnvalue="content">Async Content</BlockedOn>
9470+
</Suspense>
9471+
</Suspense>
9472+
</Suspense>
9473+
</body>
9474+
</html>,
9475+
).pipe(writable);
9476+
});
9477+
9478+
// The middle boundary should have been inlined (not outlined) so the
9479+
// middle fallback text should never appear in the streamed HTML.
9480+
expect(streamedContent).not.toContain('Middle Fallback');
9481+
9482+
// The stylesheet is in the head (blocks paint), and the innermost
9483+
// fallback is visible.
9484+
expect(getMeaningfulChildren(document)).toEqual(
9485+
<html>
9486+
<head>
9487+
<linkrel="stylesheet"href="style.css"data-precedence="default"/>
9488+
</head>
9489+
<body>Inner Fallback</body>
9490+
</html>,
9491+
);
9492+
9493+
// Resolve the async content — streams in without needing to load CSS
9494+
// since the stylesheet was already in the head.
9495+
awaitact(()=>{
9496+
resolveText('content');
9497+
});
9498+
9499+
expect(getMeaningfulChildren(document)).toEqual(
9500+
<html>
9501+
<head>
9502+
<linkrel="stylesheet"href="style.css"data-precedence="default"/>
9503+
</head>
9504+
<body>Async Content</body>
9505+
</html>,
9506+
);
9507+
});
9508+
9509+
it('outlines a boundary with suspensey CSS when flushing a streamed completion',async()=>{
9510+
// When a boundary completes via streaming (not as part of the shell),
9511+
// suspensey CSS should cause the boundary to be outlined. The parent
9512+
// content can show sooner while the CSS loads separately.
9513+
letstreamedContent='';
9514+
writable.on('data',chunk=>(streamedContent+=chunk));
9515+
9516+
awaitact(()=>{
9517+
renderToPipeableStream(
9518+
<html>
9519+
<body>
9520+
<Suspensefallback="Root Fallback">
9521+
<BlockedOnvalue="shell">
9522+
<Suspensefallback="Outer Fallback">
9523+
<Suspensefallback="Middle Fallback">
9524+
<link
9525+
rel="stylesheet"
9526+
href="style.css"
9527+
precedence="default"
9528+
/>
9529+
<Suspensefallback="Inner Fallback">
9530+
<BlockedOnvalue="content">Async Content</BlockedOn>
9531+
</Suspense>
9532+
</Suspense>
9533+
</Suspense>
9534+
</BlockedOn>
9535+
</Suspense>
9536+
</body>
9537+
</html>,
9538+
).pipe(writable);
9539+
});
9540+
9541+
// Shell is showing root fallback
9542+
expect(getMeaningfulChildren(document)).toEqual(
9543+
<html>
9544+
<head/>
9545+
<body>Root Fallback</body>
9546+
</html>,
9547+
);
9548+
9549+
// Unblock the shell — content streams in. The middle boundary should
9550+
// be outlined because the CSS arrived via streaming, not in the shell head.
9551+
streamedContent='';
9552+
awaitact(()=>{
9553+
resolveText('shell');
9554+
});
9555+
9556+
// The middle fallback should appear in the streamed HTML because the
9557+
// boundary was outlined.
9558+
expect(streamedContent).toContain('Middle Fallback');
9559+
9560+
// The CSS needs to load before the boundary reveals. Until then
9561+
// the middle fallback is visible.
9562+
expect(getMeaningfulChildren(document)).toEqual(
9563+
<html>
9564+
<head>
9565+
<linkrel="stylesheet"href="style.css"data-precedence="default"/>
9566+
</head>
9567+
<body>
9568+
{'Middle Fallback'}
9569+
<linkrel="preload"href="style.css"as="style"/>
9570+
</body>
9571+
</html>,
9572+
);
9573+
9574+
// Load the stylesheet — now the middle boundary can reveal
9575+
awaitact(()=>{
9576+
loadStylesheets();
9577+
});
9578+
assertLog(['load stylesheet: style.css']);
9579+
9580+
expect(getMeaningfulChildren(document)).toEqual(
9581+
<html>
9582+
<head>
9583+
<linkrel="stylesheet"href="style.css"data-precedence="default"/>
9584+
</head>
9585+
<body>
9586+
{'Inner Fallback'}
9587+
<linkrel="preload"href="style.css"as="style"/>
9588+
</body>
9589+
</html>,
9590+
);
9591+
9592+
// Resolve the async content
9593+
awaitact(()=>{
9594+
resolveText('content');
9595+
});
9596+
9597+
expect(getMeaningfulChildren(document)).toEqual(
9598+
<html>
9599+
<head>
9600+
<linkrel="stylesheet"href="style.css"data-precedence="default"/>
9601+
</head>
9602+
<body>
9603+
{'Async Content'}
9604+
<linkrel="preload"href="style.css"as="style"/>
9605+
</body>
9606+
</html>,
9607+
);
9608+
});
9609+
9610+
// @gate enableViewTransition
9611+
it('still outlines a boundary with a suspensey image inside a ViewTransition when flushing the shell',async()=>{
9612+
// Unlike stylesheets (which block paint from the <head> anyway), images
9613+
// inside ViewTransitions are outlined to enable animation reveals. This
9614+
// should happen even during the shell flush.
9615+
constViewTransition=React.ViewTransition;
9616+
9617+
letstreamedContent='';
9618+
writable.on('data',chunk=>(streamedContent+=chunk));
9619+
9620+
awaitact(()=>{
9621+
renderToPipeableStream(
9622+
<html>
9623+
<body>
9624+
<ViewTransition>
9625+
<Suspensefallback="Image Fallback">
9626+
<linkrel="stylesheet"href="style.css"precedence="default"/>
9627+
<imgsrc="large-image.jpg"/>
9628+
<div>Content</div>
9629+
</Suspense>
9630+
</ViewTransition>
9631+
</body>
9632+
</html>,
9633+
).pipe(writable);
9634+
});
9635+
9636+
// The boundary should be outlined because the suspensey image motivates
9637+
// outlining for animation reveals, even during the shell flush.
9638+
expect(streamedContent).toContain('Image Fallback');
9639+
});
94529640
});

‎packages/react-markup/src/ReactFizzConfigMarkup.js‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,10 @@ export function writeCompletedRoot(
242242
returntrue;
243243
}
244244

245-
exportfunctionhasSuspenseyContent(hoistableState: HoistableState): boolean{
245+
exportfunctionhasSuspenseyContent(
246+
hoistableState: HoistableState,
247+
flushingInShell: boolean,
248+
): boolean{
246249
// Never outline.
247250
returnfalse;
248251
}

‎packages/react-noop-renderer/src/ReactNoopServer.js‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,10 @@ const ReactNoopServer = ReactFizzServer({
324324
writeHoistablesForBoundary(){},
325325
writePostamble(){},
326326
hoistHoistables(parent: HoistableState,child: HoistableState){},
327-
hasSuspenseyContent(hoistableState: HoistableState): boolean{
327+
hasSuspenseyContent(
328+
hoistableState: HoistableState,
329+
flushingInShell: boolean,
330+
): boolean{
328331
returnfalse;
329332
},
330333
createHoistableState(): HoistableState{

‎packages/react-server/src/ReactFizzServer.js‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -479,7 +479,7 @@ function isEligibleForOutlining(
479479
// outlining.
480480
return(
481481
(boundary.byteSize>500||
482-
hasSuspenseyContent(boundary.contentState)||
482+
hasSuspenseyContent(boundary.contentState,/* flushingInShell */false)||
483483
boundary.defer)&&
484484
// For boundaries that can possibly contribute to the preamble we don't want to outline
485485
// them regardless of their size since the fallbacks should only be emitted if we've
@@ -5593,7 +5593,7 @@ function flushSegment(
55935593
!flushingPartialBoundaries&&
55945594
isEligibleForOutlining(request,boundary)&&
55955595
(flushedByteSize+boundary.byteSize>request.progressiveChunkSize||
5596-
hasSuspenseyContent(boundary.contentState)||
5596+
hasSuspenseyContent(boundary.contentState,flushingShell)||
55975597
boundary.defer)
55985598
){
55995599
// Inlining this boundary would make the current sequence being written too large
@@ -5826,6 +5826,7 @@ function flushPartiallyCompletedSegment(
58265826
}
58275827

58285828
letflushingPartialBoundaries=false;
5829+
letflushingShell=false;
58295830

58305831
functionflushCompletedQueues(
58315832
request: Request,
@@ -5885,7 +5886,9 @@ function flushCompletedQueues(
58855886
completedPreambleSegments,
58865887
skipBlockingShell,
58875888
);
5889+
flushingShell=true;
58885890
flushSegment(request,destination,completedRootSegment,null);
5891+
flushingShell=false;
58895892
request.completedRootSegment=null;
58905893
constisComplete=
58915894
request.allPendingTasks===0&&

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
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;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Don't outline Suspense boundaries with suspensey CSS during shell flu… · react/react@38cd020 · GitHub
Skip to content

Commit 38cd020

Browse files
authored
Don't outline Suspense boundaries with suspensey CSS during shell flush (#35824)
When flushing the shell, stylesheets with precedence are emitted in the `<head>` which blocks paint regardless. Outlining a boundary solely because it has suspensey CSS provides no benefit during the shell flush and causes a higher-level fallback to be shown unnecessarily (e.g. "Middle Fallback" instead of "Inner Fallback"). This change passes a flushingInShell flag to hasSuspenseyContent so the host config can skip stylesheet-only suspensey content when flushing the shell. Suspensey images (used for ViewTransition animation reveals) still trigger outlining during the shell since their motivation is different. When flushing streamed completions the behavior is unchanged — suspensey CSS still causes outlining so the parent content can display sooner while the stylesheet loads.
1 parent f247eba commit 38cd020

6 files changed

Lines changed: 216 additions & 6 deletions

File tree

‎packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7041,7 +7041,17 @@ export function hoistHoistables(
70417041
}
70427042
}
70437043

7044-
exportfunctionhasSuspenseyContent(hoistableState: HoistableState): boolean{
7044+
exportfunctionhasSuspenseyContent(
7045+
hoistableState: HoistableState,
7046+
flushingInShell: boolean,
7047+
): boolean{
7048+
if(flushingInShell){
7049+
// When flushing the shell, stylesheets with precedence are already emitted
7050+
// in the <head> which blocks paint. There's no benefit to outlining for CSS
7051+
// alone during the shell flush. However, suspensey images (for ViewTransition
7052+
// animation reveals) should still trigger outlining even during the shell.
7053+
returnhoistableState.suspenseyImages;
7054+
}
70457055
returnhoistableState.stylesheets.size>0||hoistableState.suspenseyImages;
70467056
}
70477057

‎packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,10 @@ export function writePreambleStart(
326326
);
327327
}
328328

329-
exportfunctionhasSuspenseyContent(hoistableState: HoistableState): boolean{
329+
exportfunctionhasSuspenseyContent(
330+
hoistableState: HoistableState,
331+
flushingInShell: boolean,
332+
): boolean{
330333
// Never outline.
331334
returnfalse;
332335
}

‎packages/react-dom/src/__tests__/ReactDOMFloat-test.js‎

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9449,4 +9449,192 @@ background-color: green;
94499449
);
94509450
});
94519451
});
9452+
9453+
it('does not outline a boundary with suspensey CSS when flushing the shell',async()=>{
9454+
// When flushing the shell, stylesheets with precedence are emitted in the
9455+
// <head> which blocks paint anyway. So there's no benefit to outlining the
9456+
// boundary — it would just show a higher-level fallback unnecessarily.
9457+
// Instead, the boundary should be inlined so the innermost fallback is shown.
9458+
letstreamedContent='';
9459+
writable.on('data',chunk=>(streamedContent+=chunk));
9460+
9461+
awaitact(()=>{
9462+
renderToPipeableStream(
9463+
<html>
9464+
<body>
9465+
<Suspensefallback="Outer Fallback">
9466+
<Suspensefallback="Middle Fallback">
9467+
<linkrel="stylesheet"href="style.css"precedence="default"/>
9468+
<Suspensefallback="Inner Fallback">
9469+
<BlockedOnvalue="content">Async Content</BlockedOn>
9470+
</Suspense>
9471+
</Suspense>
9472+
</Suspense>
9473+
</body>
9474+
</html>,
9475+
).pipe(writable);
9476+
});
9477+
9478+
// The middle boundary should have been inlined (not outlined) so the
9479+
// middle fallback text should never appear in the streamed HTML.
9480+
expect(streamedContent).not.toContain('Middle Fallback');
9481+
9482+
// The stylesheet is in the head (blocks paint), and the innermost
9483+
// fallback is visible.
9484+
expect(getMeaningfulChildren(document)).toEqual(
9485+
<html>
9486+
<head>
9487+
<linkrel="stylesheet"href="style.css"data-precedence="default"/>
9488+
</head>
9489+
<body>Inner Fallback</body>
9490+
</html>,
9491+
);
9492+
9493+
// Resolve the async content — streams in without needing to load CSS
9494+
// since the stylesheet was already in the head.
9495+
awaitact(()=>{
9496+
resolveText('content');
9497+
});
9498+
9499+
expect(getMeaningfulChildren(document)).toEqual(
9500+
<html>
9501+
<head>
9502+
<linkrel="stylesheet"href="style.css"data-precedence="default"/>
9503+
</head>
9504+
<body>Async Content</body>
9505+
</html>,
9506+
);
9507+
});
9508+
9509+
it('outlines a boundary with suspensey CSS when flushing a streamed completion',async()=>{
9510+
// When a boundary completes via streaming (not as part of the shell),
9511+
// suspensey CSS should cause the boundary to be outlined. The parent
9512+
// content can show sooner while the CSS loads separately.
9513+
letstreamedContent='';
9514+
writable.on('data',chunk=>(streamedContent+=chunk));
9515+
9516+
awaitact(()=>{
9517+
renderToPipeableStream(
9518+
<html>
9519+
<body>
9520+
<Suspensefallback="Root Fallback">
9521+
<BlockedOnvalue="shell">
9522+
<Suspensefallback="Outer Fallback">
9523+
<Suspensefallback="Middle Fallback">
9524+
<link
9525+
rel="stylesheet"
9526+
href="style.css"
9527+
precedence="default"
9528+
/>
9529+
<Suspensefallback="Inner Fallback">
9530+
<BlockedOnvalue="content">Async Content</BlockedOn>
9531+
</Suspense>
9532+
</Suspense>
9533+
</Suspense>
9534+
</BlockedOn>
9535+
</Suspense>
9536+
</body>
9537+
</html>,
9538+
).pipe(writable);
9539+
});
9540+
9541+
// Shell is showing root fallback
9542+
expect(getMeaningfulChildren(document)).toEqual(
9543+
<html>
9544+
<head/>
9545+
<body>Root Fallback</body>
9546+
</html>,
9547+
);
9548+
9549+
// Unblock the shell — content streams in. The middle boundary should
9550+
// be outlined because the CSS arrived via streaming, not in the shell head.
9551+
streamedContent='';
9552+
awaitact(()=>{
9553+
resolveText('shell');
9554+
});
9555+
9556+
// The middle fallback should appear in the streamed HTML because the
9557+
// boundary was outlined.
9558+
expect(streamedContent).toContain('Middle Fallback');
9559+
9560+
// The CSS needs to load before the boundary reveals. Until then
9561+
// the middle fallback is visible.
9562+
expect(getMeaningfulChildren(document)).toEqual(
9563+
<html>
9564+
<head>
9565+
<linkrel="stylesheet"href="style.css"data-precedence="default"/>
9566+
</head>
9567+
<body>
9568+
{'Middle Fallback'}
9569+
<linkrel="preload"href="style.css"as="style"/>
9570+
</body>
9571+
</html>,
9572+
);
9573+
9574+
// Load the stylesheet — now the middle boundary can reveal
9575+
awaitact(()=>{
9576+
loadStylesheets();
9577+
});
9578+
assertLog(['load stylesheet: style.css']);
9579+
9580+
expect(getMeaningfulChildren(document)).toEqual(
9581+
<html>
9582+
<head>
9583+
<linkrel="stylesheet"href="style.css"data-precedence="default"/>
9584+
</head>
9585+
<body>
9586+
{'Inner Fallback'}
9587+
<linkrel="preload"href="style.css"as="style"/>
9588+
</body>
9589+
</html>,
9590+
);
9591+
9592+
// Resolve the async content
9593+
awaitact(()=>{
9594+
resolveText('content');
9595+
});
9596+
9597+
expect(getMeaningfulChildren(document)).toEqual(
9598+
<html>
9599+
<head>
9600+
<linkrel="stylesheet"href="style.css"data-precedence="default"/>
9601+
</head>
9602+
<body>
9603+
{'Async Content'}
9604+
<linkrel="preload"href="style.css"as="style"/>
9605+
</body>
9606+
</html>,
9607+
);
9608+
});
9609+
9610+
// @gate enableViewTransition
9611+
it('still outlines a boundary with a suspensey image inside a ViewTransition when flushing the shell',async()=>{
9612+
// Unlike stylesheets (which block paint from the <head> anyway), images
9613+
// inside ViewTransitions are outlined to enable animation reveals. This
9614+
// should happen even during the shell flush.
9615+
constViewTransition=React.ViewTransition;
9616+
9617+
letstreamedContent='';
9618+
writable.on('data',chunk=>(streamedContent+=chunk));
9619+
9620+
awaitact(()=>{
9621+
renderToPipeableStream(
9622+
<html>
9623+
<body>
9624+
<ViewTransition>
9625+
<Suspensefallback="Image Fallback">
9626+
<linkrel="stylesheet"href="style.css"precedence="default"/>
9627+
<imgsrc="large-image.jpg"/>
9628+
<div>Content</div>
9629+
</Suspense>
9630+
</ViewTransition>
9631+
</body>
9632+
</html>,
9633+
).pipe(writable);
9634+
});
9635+
9636+
// The boundary should be outlined because the suspensey image motivates
9637+
// outlining for animation reveals, even during the shell flush.
9638+
expect(streamedContent).toContain('Image Fallback');
9639+
});
94529640
});

‎packages/react-markup/src/ReactFizzConfigMarkup.js‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,10 @@ export function writeCompletedRoot(
242242
returntrue;
243243
}
244244

245-
exportfunctionhasSuspenseyContent(hoistableState: HoistableState): boolean{
245+
exportfunctionhasSuspenseyContent(
246+
hoistableState: HoistableState,
247+
flushingInShell: boolean,
248+
): boolean{
246249
// Never outline.
247250
returnfalse;
248251
}

‎packages/react-noop-renderer/src/ReactNoopServer.js‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,10 @@ const ReactNoopServer = ReactFizzServer({
324324
writeHoistablesForBoundary(){},
325325
writePostamble(){},
326326
hoistHoistables(parent: HoistableState,child: HoistableState){},
327-
hasSuspenseyContent(hoistableState: HoistableState): boolean{
327+
hasSuspenseyContent(
328+
hoistableState: HoistableState,
329+
flushingInShell: boolean,
330+
): boolean{
328331
returnfalse;
329332
},
330333
createHoistableState(): HoistableState{

‎packages/react-server/src/ReactFizzServer.js‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -479,7 +479,7 @@ function isEligibleForOutlining(
479479
// outlining.
480480
return(
481481
(boundary.byteSize>500||
482-
hasSuspenseyContent(boundary.contentState)||
482+
hasSuspenseyContent(boundary.contentState,/* flushingInShell */false)||
483483
boundary.defer)&&
484484
// For boundaries that can possibly contribute to the preamble we don't want to outline
485485
// them regardless of their size since the fallbacks should only be emitted if we've
@@ -5593,7 +5593,7 @@ function flushSegment(
55935593
!flushingPartialBoundaries&&
55945594
isEligibleForOutlining(request,boundary)&&
55955595
(flushedByteSize+boundary.byteSize>request.progressiveChunkSize||
5596-
hasSuspenseyContent(boundary.contentState)||
5596+
hasSuspenseyContent(boundary.contentState,flushingShell)||
55975597
boundary.defer)
55985598
){
55995599
// Inlining this boundary would make the current sequence being written too large
@@ -5826,6 +5826,7 @@ function flushPartiallyCompletedSegment(
58265826
}
58275827

58285828
letflushingPartialBoundaries=false;
5829+
letflushingShell=false;
58295830

58305831
functionflushCompletedQueues(
58315832
request: Request,
@@ -5885,7 +5886,9 @@ function flushCompletedQueues(
58855886
completedPreambleSegments,
58865887
skipBlockingShell,
58875888
);
5889+
flushingShell=true;
58885890
flushSegment(request,destination,completedRootSegment,null);
5891+
flushingShell=false;
58895892
request.completedRootSegment=null;
58905893
constisComplete=
58915894
request.allPendingTasks===0&&

0 commit comments

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

Commit 38cd020

Browse files
authored
Don't outline Suspense boundaries with suspensey CSS during shell flush (#35824)
When flushing the shell, stylesheets with precedence are emitted in the `<head>` which blocks paint regardless. Outlining a boundary solely because it has suspensey CSS provides no benefit during the shell flush and causes a higher-level fallback to be shown unnecessarily (e.g. "Middle Fallback" instead of "Inner Fallback"). This change passes a flushingInShell flag to hasSuspenseyContent so the host config can skip stylesheet-only suspensey content when flushing the shell. Suspensey images (used for ViewTransition animation reveals) still trigger outlining during the shell since their motivation is different. When flushing streamed completions the behavior is unchanged — suspensey CSS still causes outlining so the parent content can display sooner while the stylesheet loads.
1 parent f247eba commit 38cd020

6 files changed

Lines changed: 216 additions & 6 deletions

File tree

‎packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7041,7 +7041,17 @@ export function hoistHoistables(
70417041
}
70427042
}
70437043

7044-
exportfunctionhasSuspenseyContent(hoistableState: HoistableState): boolean{
7044+
exportfunctionhasSuspenseyContent(
7045+
hoistableState: HoistableState,
7046+
flushingInShell: boolean,
7047+
): boolean{
7048+
if(flushingInShell){
7049+
// When flushing the shell, stylesheets with precedence are already emitted
7050+
// in the <head> which blocks paint. There's no benefit to outlining for CSS
7051+
// alone during the shell flush. However, suspensey images (for ViewTransition
7052+
// animation reveals) should still trigger outlining even during the shell.
7053+
returnhoistableState.suspenseyImages;
7054+
}
70457055
returnhoistableState.stylesheets.size>0||hoistableState.suspenseyImages;
70467056
}
70477057

‎packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,10 @@ export function writePreambleStart(
326326
);
327327
}
328328

329-
exportfunctionhasSuspenseyContent(hoistableState: HoistableState): boolean{
329+
exportfunctionhasSuspenseyContent(
330+
hoistableState: HoistableState,
331+
flushingInShell: boolean,
332+
): boolean{
330333
// Never outline.
331334
returnfalse;
332335
}

‎packages/react-dom/src/__tests__/ReactDOMFloat-test.js‎

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9449,4 +9449,192 @@ background-color: green;
94499449
);
94509450
});
94519451
});
9452+
9453+
it('does not outline a boundary with suspensey CSS when flushing the shell',async()=>{
9454+
// When flushing the shell, stylesheets with precedence are emitted in the
9455+
// <head> which blocks paint anyway. So there's no benefit to outlining the
9456+
// boundary — it would just show a higher-level fallback unnecessarily.
9457+
// Instead, the boundary should be inlined so the innermost fallback is shown.
9458+
letstreamedContent='';
9459+
writable.on('data',chunk=>(streamedContent+=chunk));
9460+
9461+
awaitact(()=>{
9462+
renderToPipeableStream(
9463+
<html>
9464+
<body>
9465+
<Suspensefallback="Outer Fallback">
9466+
<Suspensefallback="Middle Fallback">
9467+
<linkrel="stylesheet"href="style.css"precedence="default"/>
9468+
<Suspensefallback="Inner Fallback">
9469+
<BlockedOnvalue="content">Async Content</BlockedOn>
9470+
</Suspense>
9471+
</Suspense>
9472+
</Suspense>
9473+
</body>
9474+
</html>,
9475+
).pipe(writable);
9476+
});
9477+
9478+
// The middle boundary should have been inlined (not outlined) so the
9479+
// middle fallback text should never appear in the streamed HTML.
9480+
expect(streamedContent).not.toContain('Middle Fallback');
9481+
9482+
// The stylesheet is in the head (blocks paint), and the innermost
9483+
// fallback is visible.
9484+
expect(getMeaningfulChildren(document)).toEqual(
9485+
<html>
9486+
<head>
9487+
<linkrel="stylesheet"href="style.css"data-precedence="default"/>
9488+
</head>
9489+
<body>Inner Fallback</body>
9490+
</html>,
9491+
);
9492+
9493+
// Resolve the async content — streams in without needing to load CSS
9494+
// since the stylesheet was already in the head.
9495+
awaitact(()=>{
9496+
resolveText('content');
9497+
});
9498+
9499+
expect(getMeaningfulChildren(document)).toEqual(
9500+
<html>
9501+
<head>
9502+
<linkrel="stylesheet"href="style.css"data-precedence="default"/>
9503+
</head>
9504+
<body>Async Content</body>
9505+
</html>,
9506+
);
9507+
});
9508+
9509+
it('outlines a boundary with suspensey CSS when flushing a streamed completion',async()=>{
9510+
// When a boundary completes via streaming (not as part of the shell),
9511+
// suspensey CSS should cause the boundary to be outlined. The parent
9512+
// content can show sooner while the CSS loads separately.
9513+
letstreamedContent='';
9514+
writable.on('data',chunk=>(streamedContent+=chunk));
9515+
9516+
awaitact(()=>{
9517+
renderToPipeableStream(
9518+
<html>
9519+
<body>
9520+
<Suspensefallback="Root Fallback">
9521+
<BlockedOnvalue="shell">
9522+
<Suspensefallback="Outer Fallback">
9523+
<Suspensefallback="Middle Fallback">
9524+
<link
9525+
rel="stylesheet"
9526+
href="style.css"
9527+
precedence="default"
9528+
/>
9529+
<Suspensefallback="Inner Fallback">
9530+
<BlockedOnvalue="content">Async Content</BlockedOn>
9531+
</Suspense>
9532+
</Suspense>
9533+
</Suspense>
9534+
</BlockedOn>
9535+
</Suspense>
9536+
</body>
9537+
</html>,
9538+
).pipe(writable);
9539+
});
9540+
9541+
// Shell is showing root fallback
9542+
expect(getMeaningfulChildren(document)).toEqual(
9543+
<html>
9544+
<head/>
9545+
<body>Root Fallback</body>
9546+
</html>,
9547+
);
9548+
9549+
// Unblock the shell — content streams in. The middle boundary should
9550+
// be outlined because the CSS arrived via streaming, not in the shell head.
9551+
streamedContent='';
9552+
awaitact(()=>{
9553+
resolveText('shell');
9554+
});
9555+
9556+
// The middle fallback should appear in the streamed HTML because the
9557+
// boundary was outlined.
9558+
expect(streamedContent).toContain('Middle Fallback');
9559+
9560+
// The CSS needs to load before the boundary reveals. Until then
9561+
// the middle fallback is visible.
9562+
expect(getMeaningfulChildren(document)).toEqual(
9563+
<html>
9564+
<head>
9565+
<linkrel="stylesheet"href="style.css"data-precedence="default"/>
9566+
</head>
9567+
<body>
9568+
{'Middle Fallback'}
9569+
<linkrel="preload"href="style.css"as="style"/>
9570+
</body>
9571+
</html>,
9572+
);
9573+
9574+
// Load the stylesheet — now the middle boundary can reveal
9575+
awaitact(()=>{
9576+
loadStylesheets();
9577+
});
9578+
assertLog(['load stylesheet: style.css']);
9579+
9580+
expect(getMeaningfulChildren(document)).toEqual(
9581+
<html>
9582+
<head>
9583+
<linkrel="stylesheet"href="style.css"data-precedence="default"/>
9584+
</head>
9585+
<body>
9586+
{'Inner Fallback'}
9587+
<linkrel="preload"href="style.css"as="style"/>
9588+
</body>
9589+
</html>,
9590+
);
9591+
9592+
// Resolve the async content
9593+
awaitact(()=>{
9594+
resolveText('content');
9595+
});
9596+
9597+
expect(getMeaningfulChildren(document)).toEqual(
9598+
<html>
9599+
<head>
9600+
<linkrel="stylesheet"href="style.css"data-precedence="default"/>
9601+
</head>
9602+
<body>
9603+
{'Async Content'}
9604+
<linkrel="preload"href="style.css"as="style"/>
9605+
</body>
9606+
</html>,
9607+
);
9608+
});
9609+
9610+
// @gate enableViewTransition
9611+
it('still outlines a boundary with a suspensey image inside a ViewTransition when flushing the shell',async()=>{
9612+
// Unlike stylesheets (which block paint from the <head> anyway), images
9613+
// inside ViewTransitions are outlined to enable animation reveals. This
9614+
// should happen even during the shell flush.
9615+
constViewTransition=React.ViewTransition;
9616+
9617+
letstreamedContent='';
9618+
writable.on('data',chunk=>(streamedContent+=chunk));
9619+
9620+
awaitact(()=>{
9621+
renderToPipeableStream(
9622+
<html>
9623+
<body>
9624+
<ViewTransition>
9625+
<Suspensefallback="Image Fallback">
9626+
<linkrel="stylesheet"href="style.css"precedence="default"/>
9627+
<imgsrc="large-image.jpg"/>
9628+
<div>Content</div>
9629+
</Suspense>
9630+
</ViewTransition>
9631+
</body>
9632+
</html>,
9633+
).pipe(writable);
9634+
});
9635+
9636+
// The boundary should be outlined because the suspensey image motivates
9637+
// outlining for animation reveals, even during the shell flush.
9638+
expect(streamedContent).toContain('Image Fallback');
9639+
});
94529640
});

‎packages/react-markup/src/ReactFizzConfigMarkup.js‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,10 @@ export function writeCompletedRoot(
242242
returntrue;
243243
}
244244

245-
exportfunctionhasSuspenseyContent(hoistableState: HoistableState): boolean{
245+
exportfunctionhasSuspenseyContent(
246+
hoistableState: HoistableState,
247+
flushingInShell: boolean,
248+
): boolean{
246249
// Never outline.
247250
returnfalse;
248251
}

‎packages/react-noop-renderer/src/ReactNoopServer.js‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,10 @@ const ReactNoopServer = ReactFizzServer({
324324
writeHoistablesForBoundary(){},
325325
writePostamble(){},
326326
hoistHoistables(parent: HoistableState,child: HoistableState){},
327-
hasSuspenseyContent(hoistableState: HoistableState): boolean{
327+
hasSuspenseyContent(
328+
hoistableState: HoistableState,
329+
flushingInShell: boolean,
330+
): boolean{
328331
returnfalse;
329332
},
330333
createHoistableState(): HoistableState{

‎packages/react-server/src/ReactFizzServer.js‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -479,7 +479,7 @@ function isEligibleForOutlining(
479479
// outlining.
480480
return(
481481
(boundary.byteSize>500||
482-
hasSuspenseyContent(boundary.contentState)||
482+
hasSuspenseyContent(boundary.contentState,/* flushingInShell */false)||
483483
boundary.defer)&&
484484
// For boundaries that can possibly contribute to the preamble we don't want to outline
485485
// them regardless of their size since the fallbacks should only be emitted if we've
@@ -5593,7 +5593,7 @@ function flushSegment(
55935593
!flushingPartialBoundaries&&
55945594
isEligibleForOutlining(request,boundary)&&
55955595
(flushedByteSize+boundary.byteSize>request.progressiveChunkSize||
5596-
hasSuspenseyContent(boundary.contentState)||
5596+
hasSuspenseyContent(boundary.contentState,flushingShell)||
55975597
boundary.defer)
55985598
){
55995599
// Inlining this boundary would make the current sequence being written too large
@@ -5826,6 +5826,7 @@ function flushPartiallyCompletedSegment(
58265826
}
58275827

58285828
letflushingPartialBoundaries=false;
5829+
letflushingShell=false;
58295830

58305831
functionflushCompletedQueues(
58315832
request: Request,
@@ -5885,7 +5886,9 @@ function flushCompletedQueues(
58855886
completedPreambleSegments,
58865887
skipBlockingShell,
58875888
);
5889+
flushingShell=true;
58885890
flushSegment(request,destination,completedRootSegment,null);
5891+
flushingShell=false;
58895892
request.completedRootSegment=null;
58905893
constisComplete=
58915894
request.allPendingTasks===0&&

0 commit comments

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

Commit 38cd020

Browse files
authored
Don't outline Suspense boundaries with suspensey CSS during shell flush (#35824)
When flushing the shell, stylesheets with precedence are emitted in the `<head>` which blocks paint regardless. Outlining a boundary solely because it has suspensey CSS provides no benefit during the shell flush and causes a higher-level fallback to be shown unnecessarily (e.g. "Middle Fallback" instead of "Inner Fallback"). This change passes a flushingInShell flag to hasSuspenseyContent so the host config can skip stylesheet-only suspensey content when flushing the shell. Suspensey images (used for ViewTransition animation reveals) still trigger outlining during the shell since their motivation is different. When flushing streamed completions the behavior is unchanged — suspensey CSS still causes outlining so the parent content can display sooner while the stylesheet loads.
1 parent f247eba commit 38cd020

6 files changed

Lines changed: 216 additions & 6 deletions

File tree

‎packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7041,7 +7041,17 @@ export function hoistHoistables(
70417041
}
70427042
}
70437043

7044-
exportfunctionhasSuspenseyContent(hoistableState: HoistableState): boolean{
7044+
exportfunctionhasSuspenseyContent(
7045+
hoistableState: HoistableState,
7046+
flushingInShell: boolean,
7047+
): boolean{
7048+
if(flushingInShell){
7049+
// When flushing the shell, stylesheets with precedence are already emitted
7050+
// in the <head> which blocks paint. There's no benefit to outlining for CSS
7051+
// alone during the shell flush. However, suspensey images (for ViewTransition
7052+
// animation reveals) should still trigger outlining even during the shell.
7053+
returnhoistableState.suspenseyImages;
7054+
}
70457055
returnhoistableState.stylesheets.size>0||hoistableState.suspenseyImages;
70467056
}
70477057

‎packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,10 @@ export function writePreambleStart(
326326
);
327327
}
328328

329-
exportfunctionhasSuspenseyContent(hoistableState: HoistableState): boolean{
329+
exportfunctionhasSuspenseyContent(
330+
hoistableState: HoistableState,
331+
flushingInShell: boolean,
332+
): boolean{
330333
// Never outline.
331334
returnfalse;
332335
}

‎packages/react-dom/src/__tests__/ReactDOMFloat-test.js‎

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9449,4 +9449,192 @@ background-color: green;
94499449
);
94509450
});
94519451
});
9452+
9453+
it('does not outline a boundary with suspensey CSS when flushing the shell',async()=>{
9454+
// When flushing the shell, stylesheets with precedence are emitted in the
9455+
// <head> which blocks paint anyway. So there's no benefit to outlining the
9456+
// boundary — it would just show a higher-level fallback unnecessarily.
9457+
// Instead, the boundary should be inlined so the innermost fallback is shown.
9458+
letstreamedContent='';
9459+
writable.on('data',chunk=>(streamedContent+=chunk));
9460+
9461+
awaitact(()=>{
9462+
renderToPipeableStream(
9463+
<html>
9464+
<body>
9465+
<Suspensefallback="Outer Fallback">
9466+
<Suspensefallback="Middle Fallback">
9467+
<linkrel="stylesheet"href="style.css"precedence="default"/>
9468+
<Suspensefallback="Inner Fallback">
9469+
<BlockedOnvalue="content">Async Content</BlockedOn>
9470+
</Suspense>
9471+
</Suspense>
9472+
</Suspense>
9473+
</body>
9474+
</html>,
9475+
).pipe(writable);
9476+
});
9477+
9478+
// The middle boundary should have been inlined (not outlined) so the
9479+
// middle fallback text should never appear in the streamed HTML.
9480+
expect(streamedContent).not.toContain('Middle Fallback');
9481+
9482+
// The stylesheet is in the head (blocks paint), and the innermost
9483+
// fallback is visible.
9484+
expect(getMeaningfulChildren(document)).toEqual(
9485+
<html>
9486+
<head>
9487+
<linkrel="stylesheet"href="style.css"data-precedence="default"/>
9488+
</head>
9489+
<body>Inner Fallback</body>
9490+
</html>,
9491+
);
9492+
9493+
// Resolve the async content — streams in without needing to load CSS
9494+
// since the stylesheet was already in the head.
9495+
awaitact(()=>{
9496+
resolveText('content');
9497+
});
9498+
9499+
expect(getMeaningfulChildren(document)).toEqual(
9500+
<html>
9501+
<head>
9502+
<linkrel="stylesheet"href="style.css"data-precedence="default"/>
9503+
</head>
9504+
<body>Async Content</body>
9505+
</html>,
9506+
);
9507+
});
9508+
9509+
it('outlines a boundary with suspensey CSS when flushing a streamed completion',async()=>{
9510+
// When a boundary completes via streaming (not as part of the shell),
9511+
// suspensey CSS should cause the boundary to be outlined. The parent
9512+
// content can show sooner while the CSS loads separately.
9513+
letstreamedContent='';
9514+
writable.on('data',chunk=>(streamedContent+=chunk));
9515+
9516+
awaitact(()=>{
9517+
renderToPipeableStream(
9518+
<html>
9519+
<body>
9520+
<Suspensefallback="Root Fallback">
9521+
<BlockedOnvalue="shell">
9522+
<Suspensefallback="Outer Fallback">
9523+
<Suspensefallback="Middle Fallback">
9524+
<link
9525+
rel="stylesheet"
9526+
href="style.css"
9527+
precedence="default"
9528+
/>
9529+
<Suspensefallback="Inner Fallback">
9530+
<BlockedOnvalue="content">Async Content</BlockedOn>
9531+
</Suspense>
9532+
</Suspense>
9533+
</Suspense>
9534+
</BlockedOn>
9535+
</Suspense>
9536+
</body>
9537+
</html>,
9538+
).pipe(writable);
9539+
});
9540+
9541+
// Shell is showing root fallback
9542+
expect(getMeaningfulChildren(document)).toEqual(
9543+
<html>
9544+
<head/>
9545+
<body>Root Fallback</body>
9546+
</html>,
9547+
);
9548+
9549+
// Unblock the shell — content streams in. The middle boundary should
9550+
// be outlined because the CSS arrived via streaming, not in the shell head.
9551+
streamedContent='';
9552+
awaitact(()=>{
9553+
resolveText('shell');
9554+
});
9555+
9556+
// The middle fallback should appear in the streamed HTML because the
9557+
// boundary was outlined.
9558+
expect(streamedContent).toContain('Middle Fallback');
9559+
9560+
// The CSS needs to load before the boundary reveals. Until then
9561+
// the middle fallback is visible.
9562+
expect(getMeaningfulChildren(document)).toEqual(
9563+
<html>
9564+
<head>
9565+
<linkrel="stylesheet"href="style.css"data-precedence="default"/>
9566+
</head>
9567+
<body>
9568+
{'Middle Fallback'}
9569+
<linkrel="preload"href="style.css"as="style"/>
9570+
</body>
9571+
</html>,
9572+
);
9573+
9574+
// Load the stylesheet — now the middle boundary can reveal
9575+
awaitact(()=>{
9576+
loadStylesheets();
9577+
});
9578+
assertLog(['load stylesheet: style.css']);
9579+
9580+
expect(getMeaningfulChildren(document)).toEqual(
9581+
<html>
9582+
<head>
9583+
<linkrel="stylesheet"href="style.css"data-precedence="default"/>
9584+
</head>
9585+
<body>
9586+
{'Inner Fallback'}
9587+
<linkrel="preload"href="style.css"as="style"/>
9588+
</body>
9589+
</html>,
9590+
);
9591+
9592+
// Resolve the async content
9593+
awaitact(()=>{
9594+
resolveText('content');
9595+
});
9596+
9597+
expect(getMeaningfulChildren(document)).toEqual(
9598+
<html>
9599+
<head>
9600+
<linkrel="stylesheet"href="style.css"data-precedence="default"/>
9601+
</head>
9602+
<body>
9603+
{'Async Content'}
9604+
<linkrel="preload"href="style.css"as="style"/>
9605+
</body>
9606+
</html>,
9607+
);
9608+
});
9609+
9610+
// @gate enableViewTransition
9611+
it('still outlines a boundary with a suspensey image inside a ViewTransition when flushing the shell',async()=>{
9612+
// Unlike stylesheets (which block paint from the <head> anyway), images
9613+
// inside ViewTransitions are outlined to enable animation reveals. This
9614+
// should happen even during the shell flush.
9615+
constViewTransition=React.ViewTransition;
9616+
9617+
letstreamedContent='';
9618+
writable.on('data',chunk=>(streamedContent+=chunk));
9619+
9620+
awaitact(()=>{
9621+
renderToPipeableStream(
9622+
<html>
9623+
<body>
9624+
<ViewTransition>
9625+
<Suspensefallback="Image Fallback">
9626+
<linkrel="stylesheet"href="style.css"precedence="default"/>
9627+
<imgsrc="large-image.jpg"/>
9628+
<div>Content</div>
9629+
</Suspense>
9630+
</ViewTransition>
9631+
</body>
9632+
</html>,
9633+
).pipe(writable);
9634+
});
9635+
9636+
// The boundary should be outlined because the suspensey image motivates
9637+
// outlining for animation reveals, even during the shell flush.
9638+
expect(streamedContent).toContain('Image Fallback');
9639+
});
94529640
});

‎packages/react-markup/src/ReactFizzConfigMarkup.js‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,10 @@ export function writeCompletedRoot(
242242
returntrue;
243243
}
244244

245-
exportfunctionhasSuspenseyContent(hoistableState: HoistableState): boolean{
245+
exportfunctionhasSuspenseyContent(
246+
hoistableState: HoistableState,
247+
flushingInShell: boolean,
248+
): boolean{
246249
// Never outline.
247250
returnfalse;
248251
}

‎packages/react-noop-renderer/src/ReactNoopServer.js‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,10 @@ const ReactNoopServer = ReactFizzServer({
324324
writeHoistablesForBoundary(){},
325325
writePostamble(){},
326326
hoistHoistables(parent: HoistableState,child: HoistableState){},
327-
hasSuspenseyContent(hoistableState: HoistableState): boolean{
327+
hasSuspenseyContent(
328+
hoistableState: HoistableState,
329+
flushingInShell: boolean,
330+
): boolean{
328331
returnfalse;
329332
},
330333
createHoistableState(): HoistableState{

‎packages/react-server/src/ReactFizzServer.js‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -479,7 +479,7 @@ function isEligibleForOutlining(
479479
// outlining.
480480
return(
481481
(boundary.byteSize>500||
482-
hasSuspenseyContent(boundary.contentState)||
482+
hasSuspenseyContent(boundary.contentState,/* flushingInShell */false)||
483483
boundary.defer)&&
484484
// For boundaries that can possibly contribute to the preamble we don't want to outline
485485
// them regardless of their size since the fallbacks should only be emitted if we've
@@ -5593,7 +5593,7 @@ function flushSegment(
55935593
!flushingPartialBoundaries&&
55945594
isEligibleForOutlining(request,boundary)&&
55955595
(flushedByteSize+boundary.byteSize>request.progressiveChunkSize||
5596-
hasSuspenseyContent(boundary.contentState)||
5596+
hasSuspenseyContent(boundary.contentState,flushingShell)||
55975597
boundary.defer)
55985598
){
55995599
// Inlining this boundary would make the current sequence being written too large
@@ -5826,6 +5826,7 @@ function flushPartiallyCompletedSegment(
58265826
}
58275827

58285828
letflushingPartialBoundaries=false;
5829+
letflushingShell=false;
58295830

58305831
functionflushCompletedQueues(
58315832
request: Request,
@@ -5885,7 +5886,9 @@ function flushCompletedQueues(
58855886
completedPreambleSegments,
58865887
skipBlockingShell,
58875888
);
5889+
flushingShell=true;
58885890
flushSegment(request,destination,completedRootSegment,null);
5891+
flushingShell=false;
58895892
request.completedRootSegment=null;
58905893
constisComplete=
58915894
request.allPendingTasks===0&&

0 commit comments

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

Commit 38cd020

Browse files
authored
Don't outline Suspense boundaries with suspensey CSS during shell flush (#35824)
When flushing the shell, stylesheets with precedence are emitted in the `<head>` which blocks paint regardless. Outlining a boundary solely because it has suspensey CSS provides no benefit during the shell flush and causes a higher-level fallback to be shown unnecessarily (e.g. "Middle Fallback" instead of "Inner Fallback"). This change passes a flushingInShell flag to hasSuspenseyContent so the host config can skip stylesheet-only suspensey content when flushing the shell. Suspensey images (used for ViewTransition animation reveals) still trigger outlining during the shell since their motivation is different. When flushing streamed completions the behavior is unchanged — suspensey CSS still causes outlining so the parent content can display sooner while the stylesheet loads.
1 parent f247eba commit 38cd020

6 files changed

Lines changed: 216 additions & 6 deletions

File tree

‎packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7041,7 +7041,17 @@ export function hoistHoistables(
70417041
}
70427042
}
70437043

7044-
exportfunctionhasSuspenseyContent(hoistableState: HoistableState): boolean{
7044+
exportfunctionhasSuspenseyContent(
7045+
hoistableState: HoistableState,
7046+
flushingInShell: boolean,
7047+
): boolean{
7048+
if(flushingInShell){
7049+
// When flushing the shell, stylesheets with precedence are already emitted
7050+
// in the <head> which blocks paint. There's no benefit to outlining for CSS
7051+
// alone during the shell flush. However, suspensey images (for ViewTransition
7052+
// animation reveals) should still trigger outlining even during the shell.
7053+
returnhoistableState.suspenseyImages;
7054+
}
70457055
returnhoistableState.stylesheets.size>0||hoistableState.suspenseyImages;
70467056
}
70477057

‎packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,10 @@ export function writePreambleStart(
326326
);
327327
}
328328

329-
exportfunctionhasSuspenseyContent(hoistableState: HoistableState): boolean{
329+
exportfunctionhasSuspenseyContent(
330+
hoistableState: HoistableState,
331+
flushingInShell: boolean,
332+
): boolean{
330333
// Never outline.
331334
returnfalse;
332335
}

‎packages/react-dom/src/__tests__/ReactDOMFloat-test.js‎

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9449,4 +9449,192 @@ background-color: green;
94499449
);
94509450
});
94519451
});
9452+
9453+
it('does not outline a boundary with suspensey CSS when flushing the shell',async()=>{
9454+
// When flushing the shell, stylesheets with precedence are emitted in the
9455+
// <head> which blocks paint anyway. So there's no benefit to outlining the
9456+
// boundary — it would just show a higher-level fallback unnecessarily.
9457+
// Instead, the boundary should be inlined so the innermost fallback is shown.
9458+
letstreamedContent='';
9459+
writable.on('data',chunk=>(streamedContent+=chunk));
9460+
9461+
awaitact(()=>{
9462+
renderToPipeableStream(
9463+
<html>
9464+
<body>
9465+
<Suspensefallback="Outer Fallback">
9466+
<Suspensefallback="Middle Fallback">
9467+
<linkrel="stylesheet"href="style.css"precedence="default"/>
9468+
<Suspensefallback="Inner Fallback">
9469+
<BlockedOnvalue="content">Async Content</BlockedOn>
9470+
</Suspense>
9471+
</Suspense>
9472+
</Suspense>
9473+
</body>
9474+
</html>,
9475+
).pipe(writable);
9476+
});
9477+
9478+
// The middle boundary should have been inlined (not outlined) so the
9479+
// middle fallback text should never appear in the streamed HTML.
9480+
expect(streamedContent).not.toContain('Middle Fallback');
9481+
9482+
// The stylesheet is in the head (blocks paint), and the innermost
9483+
// fallback is visible.
9484+
expect(getMeaningfulChildren(document)).toEqual(
9485+
<html>
9486+
<head>
9487+
<linkrel="stylesheet"href="style.css"data-precedence="default"/>
9488+
</head>
9489+
<body>Inner Fallback</body>
9490+
</html>,
9491+
);
9492+
9493+
// Resolve the async content — streams in without needing to load CSS
9494+
// since the stylesheet was already in the head.
9495+
awaitact(()=>{
9496+
resolveText('content');
9497+
});
9498+
9499+
expect(getMeaningfulChildren(document)).toEqual(
9500+
<html>
9501+
<head>
9502+
<linkrel="stylesheet"href="style.css"data-precedence="default"/>
9503+
</head>
9504+
<body>Async Content</body>
9505+
</html>,
9506+
);
9507+
});
9508+
9509+
it('outlines a boundary with suspensey CSS when flushing a streamed completion',async()=>{
9510+
// When a boundary completes via streaming (not as part of the shell),
9511+
// suspensey CSS should cause the boundary to be outlined. The parent
9512+
// content can show sooner while the CSS loads separately.
9513+
letstreamedContent='';
9514+
writable.on('data',chunk=>(streamedContent+=chunk));
9515+
9516+
awaitact(()=>{
9517+
renderToPipeableStream(
9518+
<html>
9519+
<body>
9520+
<Suspensefallback="Root Fallback">
9521+
<BlockedOnvalue="shell">
9522+
<Suspensefallback="Outer Fallback">
9523+
<Suspensefallback="Middle Fallback">
9524+
<link
9525+
rel="stylesheet"
9526+
href="style.css"
9527+
precedence="default"
9528+
/>
9529+
<Suspensefallback="Inner Fallback">
9530+
<BlockedOnvalue="content">Async Content</BlockedOn>
9531+
</Suspense>
9532+
</Suspense>
9533+
</Suspense>
9534+
</BlockedOn>
9535+
</Suspense>
9536+
</body>
9537+
</html>,
9538+
).pipe(writable);
9539+
});
9540+
9541+
// Shell is showing root fallback
9542+
expect(getMeaningfulChildren(document)).toEqual(
9543+
<html>
9544+
<head/>
9545+
<body>Root Fallback</body>
9546+
</html>,
9547+
);
9548+
9549+
// Unblock the shell — content streams in. The middle boundary should
9550+
// be outlined because the CSS arrived via streaming, not in the shell head.
9551+
streamedContent='';
9552+
awaitact(()=>{
9553+
resolveText('shell');
9554+
});
9555+
9556+
// The middle fallback should appear in the streamed HTML because the
9557+
// boundary was outlined.
9558+
expect(streamedContent).toContain('Middle Fallback');
9559+
9560+
// The CSS needs to load before the boundary reveals. Until then
9561+
// the middle fallback is visible.
9562+
expect(getMeaningfulChildren(document)).toEqual(
9563+
<html>
9564+
<head>
9565+
<linkrel="stylesheet"href="style.css"data-precedence="default"/>
9566+
</head>
9567+
<body>
9568+
{'Middle Fallback'}
9569+
<linkrel="preload"href="style.css"as="style"/>
9570+
</body>
9571+
</html>,
9572+
);
9573+
9574+
// Load the stylesheet — now the middle boundary can reveal
9575+
awaitact(()=>{
9576+
loadStylesheets();
9577+
});
9578+
assertLog(['load stylesheet: style.css']);
9579+
9580+
expect(getMeaningfulChildren(document)).toEqual(
9581+
<html>
9582+
<head>
9583+
<linkrel="stylesheet"href="style.css"data-precedence="default"/>
9584+
</head>
9585+
<body>
9586+
{'Inner Fallback'}
9587+
<linkrel="preload"href="style.css"as="style"/>
9588+
</body>
9589+
</html>,
9590+
);
9591+
9592+
// Resolve the async content
9593+
awaitact(()=>{
9594+
resolveText('content');
9595+
});
9596+
9597+
expect(getMeaningfulChildren(document)).toEqual(
9598+
<html>
9599+
<head>
9600+
<linkrel="stylesheet"href="style.css"data-precedence="default"/>
9601+
</head>
9602+
<body>
9603+
{'Async Content'}
9604+
<linkrel="preload"href="style.css"as="style"/>
9605+
</body>
9606+
</html>,
9607+
);
9608+
});
9609+
9610+
// @gate enableViewTransition
9611+
it('still outlines a boundary with a suspensey image inside a ViewTransition when flushing the shell',async()=>{
9612+
// Unlike stylesheets (which block paint from the <head> anyway), images
9613+
// inside ViewTransitions are outlined to enable animation reveals. This
9614+
// should happen even during the shell flush.
9615+
constViewTransition=React.ViewTransition;
9616+
9617+
letstreamedContent='';
9618+
writable.on('data',chunk=>(streamedContent+=chunk));
9619+
9620+
awaitact(()=>{
9621+
renderToPipeableStream(
9622+
<html>
9623+
<body>
9624+
<ViewTransition>
9625+
<Suspensefallback="Image Fallback">
9626+
<linkrel="stylesheet"href="style.css"precedence="default"/>
9627+
<imgsrc="large-image.jpg"/>
9628+
<div>Content</div>
9629+
</Suspense>
9630+
</ViewTransition>
9631+
</body>
9632+
</html>,
9633+
).pipe(writable);
9634+
});
9635+
9636+
// The boundary should be outlined because the suspensey image motivates
9637+
// outlining for animation reveals, even during the shell flush.
9638+
expect(streamedContent).toContain('Image Fallback');
9639+
});
94529640
});

‎packages/react-markup/src/ReactFizzConfigMarkup.js‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,10 @@ export function writeCompletedRoot(
242242
returntrue;
243243
}
244244

245-
exportfunctionhasSuspenseyContent(hoistableState: HoistableState): boolean{
245+
exportfunctionhasSuspenseyContent(
246+
hoistableState: HoistableState,
247+
flushingInShell: boolean,
248+
): boolean{
246249
// Never outline.
247250
returnfalse;
248251
}

‎packages/react-noop-renderer/src/ReactNoopServer.js‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,10 @@ const ReactNoopServer = ReactFizzServer({
324324
writeHoistablesForBoundary(){},
325325
writePostamble(){},
326326
hoistHoistables(parent: HoistableState,child: HoistableState){},
327-
hasSuspenseyContent(hoistableState: HoistableState): boolean{
327+
hasSuspenseyContent(
328+
hoistableState: HoistableState,
329+
flushingInShell: boolean,
330+
): boolean{
328331
returnfalse;
329332
},
330333
createHoistableState(): HoistableState{

‎packages/react-server/src/ReactFizzServer.js‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -479,7 +479,7 @@ function isEligibleForOutlining(
479479
// outlining.
480480
return(
481481
(boundary.byteSize>500||
482-
hasSuspenseyContent(boundary.contentState)||
482+
hasSuspenseyContent(boundary.contentState,/* flushingInShell */false)||
483483
boundary.defer)&&
484484
// For boundaries that can possibly contribute to the preamble we don't want to outline
485485
// them regardless of their size since the fallbacks should only be emitted if we've
@@ -5593,7 +5593,7 @@ function flushSegment(
55935593
!flushingPartialBoundaries&&
55945594
isEligibleForOutlining(request,boundary)&&
55955595
(flushedByteSize+boundary.byteSize>request.progressiveChunkSize||
5596-
hasSuspenseyContent(boundary.contentState)||
5596+
hasSuspenseyContent(boundary.contentState,flushingShell)||
55975597
boundary.defer)
55985598
){
55995599
// Inlining this boundary would make the current sequence being written too large
@@ -5826,6 +5826,7 @@ function flushPartiallyCompletedSegment(
58265826
}
58275827

58285828
letflushingPartialBoundaries=false;
5829+
letflushingShell=false;
58295830

58305831
functionflushCompletedQueues(
58315832
request: Request,
@@ -5885,7 +5886,9 @@ function flushCompletedQueues(
58855886
completedPreambleSegments,
58865887
skipBlockingShell,
58875888
);
5889+
flushingShell=true;
58885890
flushSegment(request,destination,completedRootSegment,null);
5891+
flushingShell=false;
58895892
request.completedRootSegment=null;
58905893
constisComplete=
58915894
request.allPendingTasks===0&&

0 commit comments

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

Commit 38cd020

Browse files
authored
Don't outline Suspense boundaries with suspensey CSS during shell flush (#35824)
When flushing the shell, stylesheets with precedence are emitted in the `<head>` which blocks paint regardless. Outlining a boundary solely because it has suspensey CSS provides no benefit during the shell flush and causes a higher-level fallback to be shown unnecessarily (e.g. "Middle Fallback" instead of "Inner Fallback"). This change passes a flushingInShell flag to hasSuspenseyContent so the host config can skip stylesheet-only suspensey content when flushing the shell. Suspensey images (used for ViewTransition animation reveals) still trigger outlining during the shell since their motivation is different. When flushing streamed completions the behavior is unchanged — suspensey CSS still causes outlining so the parent content can display sooner while the stylesheet loads.
1 parent f247eba commit 38cd020

6 files changed

Lines changed: 216 additions & 6 deletions

File tree

‎packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7041,7 +7041,17 @@ export function hoistHoistables(
70417041
}
70427042
}
70437043

7044-
exportfunctionhasSuspenseyContent(hoistableState: HoistableState): boolean{
7044+
exportfunctionhasSuspenseyContent(
7045+
hoistableState: HoistableState,
7046+
flushingInShell: boolean,
7047+
): boolean{
7048+
if(flushingInShell){
7049+
// When flushing the shell, stylesheets with precedence are already emitted
7050+
// in the <head> which blocks paint. There's no benefit to outlining for CSS
7051+
// alone during the shell flush. However, suspensey images (for ViewTransition
7052+
// animation reveals) should still trigger outlining even during the shell.
7053+
returnhoistableState.suspenseyImages;
7054+
}
70457055
returnhoistableState.stylesheets.size>0||hoistableState.suspenseyImages;
70467056
}
70477057

‎packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,10 @@ export function writePreambleStart(
326326
);
327327
}
328328

329-
exportfunctionhasSuspenseyContent(hoistableState: HoistableState): boolean{
329+
exportfunctionhasSuspenseyContent(
330+
hoistableState: HoistableState,
331+
flushingInShell: boolean,
332+
): boolean{
330333
// Never outline.
331334
returnfalse;
332335
}

‎packages/react-dom/src/__tests__/ReactDOMFloat-test.js‎

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9449,4 +9449,192 @@ background-color: green;
94499449
);
94509450
});
94519451
});
9452+
9453+
it('does not outline a boundary with suspensey CSS when flushing the shell',async()=>{
9454+
// When flushing the shell, stylesheets with precedence are emitted in the
9455+
// <head> which blocks paint anyway. So there's no benefit to outlining the
9456+
// boundary — it would just show a higher-level fallback unnecessarily.
9457+
// Instead, the boundary should be inlined so the innermost fallback is shown.
9458+
letstreamedContent='';
9459+
writable.on('data',chunk=>(streamedContent+=chunk));
9460+
9461+
awaitact(()=>{
9462+
renderToPipeableStream(
9463+
<html>
9464+
<body>
9465+
<Suspensefallback="Outer Fallback">
9466+
<Suspensefallback="Middle Fallback">
9467+
<linkrel="stylesheet"href="style.css"precedence="default"/>
9468+
<Suspensefallback="Inner Fallback">
9469+
<BlockedOnvalue="content">Async Content</BlockedOn>
9470+
</Suspense>
9471+
</Suspense>
9472+
</Suspense>
9473+
</body>
9474+
</html>,
9475+
).pipe(writable);
9476+
});
9477+
9478+
// The middle boundary should have been inlined (not outlined) so the
9479+
// middle fallback text should never appear in the streamed HTML.
9480+
expect(streamedContent).not.toContain('Middle Fallback');
9481+
9482+
// The stylesheet is in the head (blocks paint), and the innermost
9483+
// fallback is visible.
9484+
expect(getMeaningfulChildren(document)).toEqual(
9485+
<html>
9486+
<head>
9487+
<linkrel="stylesheet"href="style.css"data-precedence="default"/>
9488+
</head>
9489+
<body>Inner Fallback</body>
9490+
</html>,
9491+
);
9492+
9493+
// Resolve the async content — streams in without needing to load CSS
9494+
// since the stylesheet was already in the head.
9495+
awaitact(()=>{
9496+
resolveText('content');
9497+
});
9498+
9499+
expect(getMeaningfulChildren(document)).toEqual(
9500+
<html>
9501+
<head>
9502+
<linkrel="stylesheet"href="style.css"data-precedence="default"/>
9503+
</head>
9504+
<body>Async Content</body>
9505+
</html>,
9506+
);
9507+
});
9508+
9509+
it('outlines a boundary with suspensey CSS when flushing a streamed completion',async()=>{
9510+
// When a boundary completes via streaming (not as part of the shell),
9511+
// suspensey CSS should cause the boundary to be outlined. The parent
9512+
// content can show sooner while the CSS loads separately.
9513+
letstreamedContent='';
9514+
writable.on('data',chunk=>(streamedContent+=chunk));
9515+
9516+
awaitact(()=>{
9517+
renderToPipeableStream(
9518+
<html>
9519+
<body>
9520+
<Suspensefallback="Root Fallback">
9521+
<BlockedOnvalue="shell">
9522+
<Suspensefallback="Outer Fallback">
9523+
<Suspensefallback="Middle Fallback">
9524+
<link
9525+
rel="stylesheet"
9526+
href="style.css"
9527+
precedence="default"
9528+
/>
9529+
<Suspensefallback="Inner Fallback">
9530+
<BlockedOnvalue="content">Async Content</BlockedOn>
9531+
</Suspense>
9532+
</Suspense>
9533+
</Suspense>
9534+
</BlockedOn>
9535+
</Suspense>
9536+
</body>
9537+
</html>,
9538+
).pipe(writable);
9539+
});
9540+
9541+
// Shell is showing root fallback
9542+
expect(getMeaningfulChildren(document)).toEqual(
9543+
<html>
9544+
<head/>
9545+
<body>Root Fallback</body>
9546+
</html>,
9547+
);
9548+
9549+
// Unblock the shell — content streams in. The middle boundary should
9550+
// be outlined because the CSS arrived via streaming, not in the shell head.
9551+
streamedContent='';
9552+
awaitact(()=>{
9553+
resolveText('shell');
9554+
});
9555+
9556+
// The middle fallback should appear in the streamed HTML because the
9557+
// boundary was outlined.
9558+
expect(streamedContent).toContain('Middle Fallback');
9559+
9560+
// The CSS needs to load before the boundary reveals. Until then
9561+
// the middle fallback is visible.
9562+
expect(getMeaningfulChildren(document)).toEqual(
9563+
<html>
9564+
<head>
9565+
<linkrel="stylesheet"href="style.css"data-precedence="default"/>
9566+
</head>
9567+
<body>
9568+
{'Middle Fallback'}
9569+
<linkrel="preload"href="style.css"as="style"/>
9570+
</body>
9571+
</html>,
9572+
);
9573+
9574+
// Load the stylesheet — now the middle boundary can reveal
9575+
awaitact(()=>{
9576+
loadStylesheets();
9577+
});
9578+
assertLog(['load stylesheet: style.css']);
9579+
9580+
expect(getMeaningfulChildren(document)).toEqual(
9581+
<html>
9582+
<head>
9583+
<linkrel="stylesheet"href="style.css"data-precedence="default"/>
9584+
</head>
9585+
<body>
9586+
{'Inner Fallback'}
9587+
<linkrel="preload"href="style.css"as="style"/>
9588+
</body>
9589+
</html>,
9590+
);
9591+
9592+
// Resolve the async content
9593+
awaitact(()=>{
9594+
resolveText('content');
9595+
});
9596+
9597+
expect(getMeaningfulChildren(document)).toEqual(
9598+
<html>
9599+
<head>
9600+
<linkrel="stylesheet"href="style.css"data-precedence="default"/>
9601+
</head>
9602+
<body>
9603+
{'Async Content'}
9604+
<linkrel="preload"href="style.css"as="style"/>
9605+
</body>
9606+
</html>,
9607+
);
9608+
});
9609+
9610+
// @gate enableViewTransition
9611+
it('still outlines a boundary with a suspensey image inside a ViewTransition when flushing the shell',async()=>{
9612+
// Unlike stylesheets (which block paint from the <head> anyway), images
9613+
// inside ViewTransitions are outlined to enable animation reveals. This
9614+
// should happen even during the shell flush.
9615+
constViewTransition=React.ViewTransition;
9616+
9617+
letstreamedContent='';
9618+
writable.on('data',chunk=>(streamedContent+=chunk));
9619+
9620+
awaitact(()=>{
9621+
renderToPipeableStream(
9622+
<html>
9623+
<body>
9624+
<ViewTransition>
9625+
<Suspensefallback="Image Fallback">
9626+
<linkrel="stylesheet"href="style.css"precedence="default"/>
9627+
<imgsrc="large-image.jpg"/>
9628+
<div>Content</div>
9629+
</Suspense>
9630+
</ViewTransition>
9631+
</body>
9632+
</html>,
9633+
).pipe(writable);
9634+
});
9635+
9636+
// The boundary should be outlined because the suspensey image motivates
9637+
// outlining for animation reveals, even during the shell flush.
9638+
expect(streamedContent).toContain('Image Fallback');
9639+
});
94529640
});

‎packages/react-markup/src/ReactFizzConfigMarkup.js‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,10 @@ export function writeCompletedRoot(
242242
returntrue;
243243
}
244244

245-
exportfunctionhasSuspenseyContent(hoistableState: HoistableState): boolean{
245+
exportfunctionhasSuspenseyContent(
246+
hoistableState: HoistableState,
247+
flushingInShell: boolean,
248+
): boolean{
246249
// Never outline.
247250
returnfalse;
248251
}

‎packages/react-noop-renderer/src/ReactNoopServer.js‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,10 @@ const ReactNoopServer = ReactFizzServer({
324324
writeHoistablesForBoundary(){},
325325
writePostamble(){},
326326
hoistHoistables(parent: HoistableState,child: HoistableState){},
327-
hasSuspenseyContent(hoistableState: HoistableState): boolean{
327+
hasSuspenseyContent(
328+
hoistableState: HoistableState,
329+
flushingInShell: boolean,
330+
): boolean{
328331
returnfalse;
329332
},
330333
createHoistableState(): HoistableState{

‎packages/react-server/src/ReactFizzServer.js‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -479,7 +479,7 @@ function isEligibleForOutlining(
479479
// outlining.
480480
return(
481481
(boundary.byteSize>500||
482-
hasSuspenseyContent(boundary.contentState)||
482+
hasSuspenseyContent(boundary.contentState,/* flushingInShell */false)||
483483
boundary.defer)&&
484484
// For boundaries that can possibly contribute to the preamble we don't want to outline
485485
// them regardless of their size since the fallbacks should only be emitted if we've
@@ -5593,7 +5593,7 @@ function flushSegment(
55935593
!flushingPartialBoundaries&&
55945594
isEligibleForOutlining(request,boundary)&&
55955595
(flushedByteSize+boundary.byteSize>request.progressiveChunkSize||
5596-
hasSuspenseyContent(boundary.contentState)||
5596+
hasSuspenseyContent(boundary.contentState,flushingShell)||
55975597
boundary.defer)
55985598
){
55995599
// Inlining this boundary would make the current sequence being written too large
@@ -5826,6 +5826,7 @@ function flushPartiallyCompletedSegment(
58265826
}
58275827

58285828
letflushingPartialBoundaries=false;
5829+
letflushingShell=false;
58295830

58305831
functionflushCompletedQueues(
58315832
request: Request,
@@ -5885,7 +5886,9 @@ function flushCompletedQueues(
58855886
completedPreambleSegments,
58865887
skipBlockingShell,
58875888
);
5889+
flushingShell=true;
58885890
flushSegment(request,destination,completedRootSegment,null);
5891+
flushingShell=false;
58895892
request.completedRootSegment=null;
58905893
constisComplete=
58915894
request.allPendingTasks===0&&

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Don't outline Suspense boundaries with suspensey CSS during shell flu… · react/react@38cd020 · GitHub
Skip to content

Commit 38cd020

Browse files
authored
Don't outline Suspense boundaries with suspensey CSS during shell flush (#35824)
When flushing the shell, stylesheets with precedence are emitted in the `<head>` which blocks paint regardless. Outlining a boundary solely because it has suspensey CSS provides no benefit during the shell flush and causes a higher-level fallback to be shown unnecessarily (e.g. "Middle Fallback" instead of "Inner Fallback"). This change passes a flushingInShell flag to hasSuspenseyContent so the host config can skip stylesheet-only suspensey content when flushing the shell. Suspensey images (used for ViewTransition animation reveals) still trigger outlining during the shell since their motivation is different. When flushing streamed completions the behavior is unchanged — suspensey CSS still causes outlining so the parent content can display sooner while the stylesheet loads.
1 parent f247eba commit 38cd020

6 files changed

Lines changed: 216 additions & 6 deletions

File tree

‎packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7041,7 +7041,17 @@ export function hoistHoistables(
70417041
}
70427042
}
70437043

7044-
exportfunctionhasSuspenseyContent(hoistableState: HoistableState): boolean{
7044+
exportfunctionhasSuspenseyContent(
7045+
hoistableState: HoistableState,
7046+
flushingInShell: boolean,
7047+
): boolean{
7048+
if(flushingInShell){
7049+
// When flushing the shell, stylesheets with precedence are already emitted
7050+
// in the <head> which blocks paint. There's no benefit to outlining for CSS
7051+
// alone during the shell flush. However, suspensey images (for ViewTransition
7052+
// animation reveals) should still trigger outlining even during the shell.
7053+
returnhoistableState.suspenseyImages;
7054+
}
70457055
returnhoistableState.stylesheets.size>0||hoistableState.suspenseyImages;
70467056
}
70477057

‎packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,10 @@ export function writePreambleStart(
326326
);
327327
}
328328

329-
exportfunctionhasSuspenseyContent(hoistableState: HoistableState): boolean{
329+
exportfunctionhasSuspenseyContent(
330+
hoistableState: HoistableState,
331+
flushingInShell: boolean,
332+
): boolean{
330333
// Never outline.
331334
returnfalse;
332335
}

‎packages/react-dom/src/__tests__/ReactDOMFloat-test.js‎

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9449,4 +9449,192 @@ background-color: green;
94499449
);
94509450
});
94519451
});
9452+
9453+
it('does not outline a boundary with suspensey CSS when flushing the shell',async()=>{
9454+
// When flushing the shell, stylesheets with precedence are emitted in the
9455+
// <head> which blocks paint anyway. So there's no benefit to outlining the
9456+
// boundary — it would just show a higher-level fallback unnecessarily.
9457+
// Instead, the boundary should be inlined so the innermost fallback is shown.
9458+
letstreamedContent='';
9459+
writable.on('data',chunk=>(streamedContent+=chunk));
9460+
9461+
awaitact(()=>{
9462+
renderToPipeableStream(
9463+
<html>
9464+
<body>
9465+
<Suspensefallback="Outer Fallback">
9466+
<Suspensefallback="Middle Fallback">
9467+
<linkrel="stylesheet"href="style.css"precedence="default"/>
9468+
<Suspensefallback="Inner Fallback">
9469+
<BlockedOnvalue="content">Async Content</BlockedOn>
9470+
</Suspense>
9471+
</Suspense>
9472+
</Suspense>
9473+
</body>
9474+
</html>,
9475+
).pipe(writable);
9476+
});
9477+
9478+
// The middle boundary should have been inlined (not outlined) so the
9479+
// middle fallback text should never appear in the streamed HTML.
9480+
expect(streamedContent).not.toContain('Middle Fallback');
9481+
9482+
// The stylesheet is in the head (blocks paint), and the innermost
9483+
// fallback is visible.
9484+
expect(getMeaningfulChildren(document)).toEqual(
9485+
<html>
9486+
<head>
9487+
<linkrel="stylesheet"href="style.css"data-precedence="default"/>
9488+
</head>
9489+
<body>Inner Fallback</body>
9490+
</html>,
9491+
);
9492+
9493+
// Resolve the async content — streams in without needing to load CSS
9494+
// since the stylesheet was already in the head.
9495+
awaitact(()=>{
9496+
resolveText('content');
9497+
});
9498+
9499+
expect(getMeaningfulChildren(document)).toEqual(
9500+
<html>
9501+
<head>
9502+
<linkrel="stylesheet"href="style.css"data-precedence="default"/>
9503+
</head>
9504+
<body>Async Content</body>
9505+
</html>,
9506+
);
9507+
});
9508+
9509+
it('outlines a boundary with suspensey CSS when flushing a streamed completion',async()=>{
9510+
// When a boundary completes via streaming (not as part of the shell),
9511+
// suspensey CSS should cause the boundary to be outlined. The parent
9512+
// content can show sooner while the CSS loads separately.
9513+
letstreamedContent='';
9514+
writable.on('data',chunk=>(streamedContent+=chunk));
9515+
9516+
awaitact(()=>{
9517+
renderToPipeableStream(
9518+
<html>
9519+
<body>
9520+
<Suspensefallback="Root Fallback">
9521+
<BlockedOnvalue="shell">
9522+
<Suspensefallback="Outer Fallback">
9523+
<Suspensefallback="Middle Fallback">
9524+
<link
9525+
rel="stylesheet"
9526+
href="style.css"
9527+
precedence="default"
9528+
/>
9529+
<Suspensefallback="Inner Fallback">
9530+
<BlockedOnvalue="content">Async Content</BlockedOn>
9531+
</Suspense>
9532+
</Suspense>
9533+
</Suspense>
9534+
</BlockedOn>
9535+
</Suspense>
9536+
</body>
9537+
</html>,
9538+
).pipe(writable);
9539+
});
9540+
9541+
// Shell is showing root fallback
9542+
expect(getMeaningfulChildren(document)).toEqual(
9543+
<html>
9544+
<head/>
9545+
<body>Root Fallback</body>
9546+
</html>,
9547+
);
9548+
9549+
// Unblock the shell — content streams in. The middle boundary should
9550+
// be outlined because the CSS arrived via streaming, not in the shell head.
9551+
streamedContent='';
9552+
awaitact(()=>{
9553+
resolveText('shell');
9554+
});
9555+
9556+
// The middle fallback should appear in the streamed HTML because the
9557+
// boundary was outlined.
9558+
expect(streamedContent).toContain('Middle Fallback');
9559+
9560+
// The CSS needs to load before the boundary reveals. Until then
9561+
// the middle fallback is visible.
9562+
expect(getMeaningfulChildren(document)).toEqual(
9563+
<html>
9564+
<head>
9565+
<linkrel="stylesheet"href="style.css"data-precedence="default"/>
9566+
</head>
9567+
<body>
9568+
{'Middle Fallback'}
9569+
<linkrel="preload"href="style.css"as="style"/>
9570+
</body>
9571+
</html>,
9572+
);
9573+
9574+
// Load the stylesheet — now the middle boundary can reveal
9575+
awaitact(()=>{
9576+
loadStylesheets();
9577+
});
9578+
assertLog(['load stylesheet: style.css']);
9579+
9580+
expect(getMeaningfulChildren(document)).toEqual(
9581+
<html>
9582+
<head>
9583+
<linkrel="stylesheet"href="style.css"data-precedence="default"/>
9584+
</head>
9585+
<body>
9586+
{'Inner Fallback'}
9587+
<linkrel="preload"href="style.css"as="style"/>
9588+
</body>
9589+
</html>,
9590+
);
9591+
9592+
// Resolve the async content
9593+
awaitact(()=>{
9594+
resolveText('content');
9595+
});
9596+
9597+
expect(getMeaningfulChildren(document)).toEqual(
9598+
<html>
9599+
<head>
9600+
<linkrel="stylesheet"href="style.css"data-precedence="default"/>
9601+
</head>
9602+
<body>
9603+
{'Async Content'}
9604+
<linkrel="preload"href="style.css"as="style"/>
9605+
</body>
9606+
</html>,
9607+
);
9608+
});
9609+
9610+
// @gate enableViewTransition
9611+
it('still outlines a boundary with a suspensey image inside a ViewTransition when flushing the shell',async()=>{
9612+
// Unlike stylesheets (which block paint from the <head> anyway), images
9613+
// inside ViewTransitions are outlined to enable animation reveals. This
9614+
// should happen even during the shell flush.
9615+
constViewTransition=React.ViewTransition;
9616+
9617+
letstreamedContent='';
9618+
writable.on('data',chunk=>(streamedContent+=chunk));
9619+
9620+
awaitact(()=>{
9621+
renderToPipeableStream(
9622+
<html>
9623+
<body>
9624+
<ViewTransition>
9625+
<Suspensefallback="Image Fallback">
9626+
<linkrel="stylesheet"href="style.css"precedence="default"/>
9627+
<imgsrc="large-image.jpg"/>
9628+
<div>Content</div>
9629+
</Suspense>
9630+
</ViewTransition>
9631+
</body>
9632+
</html>,
9633+
).pipe(writable);
9634+
});
9635+
9636+
// The boundary should be outlined because the suspensey image motivates
9637+
// outlining for animation reveals, even during the shell flush.
9638+
expect(streamedContent).toContain('Image Fallback');
9639+
});
94529640
});

‎packages/react-markup/src/ReactFizzConfigMarkup.js‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,10 @@ export function writeCompletedRoot(
242242
returntrue;
243243
}
244244

245-
exportfunctionhasSuspenseyContent(hoistableState: HoistableState): boolean{
245+
exportfunctionhasSuspenseyContent(
246+
hoistableState: HoistableState,
247+
flushingInShell: boolean,
248+
): boolean{
246249
// Never outline.
247250
returnfalse;
248251
}

‎packages/react-noop-renderer/src/ReactNoopServer.js‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,10 @@ const ReactNoopServer = ReactFizzServer({
324324
writeHoistablesForBoundary(){},
325325
writePostamble(){},
326326
hoistHoistables(parent: HoistableState,child: HoistableState){},
327-
hasSuspenseyContent(hoistableState: HoistableState): boolean{
327+
hasSuspenseyContent(
328+
hoistableState: HoistableState,
329+
flushingInShell: boolean,
330+
): boolean{
328331
returnfalse;
329332
},
330333
createHoistableState(): HoistableState{

‎packages/react-server/src/ReactFizzServer.js‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -479,7 +479,7 @@ function isEligibleForOutlining(
479479
// outlining.
480480
return(
481481
(boundary.byteSize>500||
482-
hasSuspenseyContent(boundary.contentState)||
482+
hasSuspenseyContent(boundary.contentState,/* flushingInShell */false)||
483483
boundary.defer)&&
484484
// For boundaries that can possibly contribute to the preamble we don't want to outline
485485
// them regardless of their size since the fallbacks should only be emitted if we've
@@ -5593,7 +5593,7 @@ function flushSegment(
55935593
!flushingPartialBoundaries&&
55945594
isEligibleForOutlining(request,boundary)&&
55955595
(flushedByteSize+boundary.byteSize>request.progressiveChunkSize||
5596-
hasSuspenseyContent(boundary.contentState)||
5596+
hasSuspenseyContent(boundary.contentState,flushingShell)||
55975597
boundary.defer)
55985598
){
55995599
// Inlining this boundary would make the current sequence being written too large
@@ -5826,6 +5826,7 @@ function flushPartiallyCompletedSegment(
58265826
}
58275827

58285828
letflushingPartialBoundaries=false;
5829+
letflushingShell=false;
58295830

58305831
functionflushCompletedQueues(
58315832
request: Request,
@@ -5885,7 +5886,9 @@ function flushCompletedQueues(
58855886
completedPreambleSegments,
58865887
skipBlockingShell,
58875888
);
5889+
flushingShell=true;
58885890
flushSegment(request,destination,completedRootSegment,null);
5891+
flushingShell=false;
58895892
request.completedRootSegment=null;
58905893
constisComplete=
58915894
request.allPendingTasks===0&&

0 commit comments

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

Commit 38cd020

Browse files
authored
Don't outline Suspense boundaries with suspensey CSS during shell flush (#35824)
When flushing the shell, stylesheets with precedence are emitted in the `<head>` which blocks paint regardless. Outlining a boundary solely because it has suspensey CSS provides no benefit during the shell flush and causes a higher-level fallback to be shown unnecessarily (e.g. "Middle Fallback" instead of "Inner Fallback"). This change passes a flushingInShell flag to hasSuspenseyContent so the host config can skip stylesheet-only suspensey content when flushing the shell. Suspensey images (used for ViewTransition animation reveals) still trigger outlining during the shell since their motivation is different. When flushing streamed completions the behavior is unchanged — suspensey CSS still causes outlining so the parent content can display sooner while the stylesheet loads.
1 parent f247eba commit 38cd020

6 files changed

Lines changed: 216 additions & 6 deletions

File tree

‎packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7041,7 +7041,17 @@ export function hoistHoistables(
70417041
}
70427042
}
70437043

7044-
exportfunctionhasSuspenseyContent(hoistableState: HoistableState): boolean{
7044+
exportfunctionhasSuspenseyContent(
7045+
hoistableState: HoistableState,
7046+
flushingInShell: boolean,
7047+
): boolean{
7048+
if(flushingInShell){
7049+
// When flushing the shell, stylesheets with precedence are already emitted
7050+
// in the <head> which blocks paint. There's no benefit to outlining for CSS
7051+
// alone during the shell flush. However, suspensey images (for ViewTransition
7052+
// animation reveals) should still trigger outlining even during the shell.
7053+
returnhoistableState.suspenseyImages;
7054+
}
70457055
returnhoistableState.stylesheets.size>0||hoistableState.suspenseyImages;
70467056
}
70477057

‎packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,10 @@ export function writePreambleStart(
326326
);
327327
}
328328

329-
exportfunctionhasSuspenseyContent(hoistableState: HoistableState): boolean{
329+
exportfunctionhasSuspenseyContent(
330+
hoistableState: HoistableState,
331+
flushingInShell: boolean,
332+
): boolean{
330333
// Never outline.
331334
returnfalse;
332335
}

‎packages/react-dom/src/__tests__/ReactDOMFloat-test.js‎

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9449,4 +9449,192 @@ background-color: green;
94499449
);
94509450
});
94519451
});
9452+
9453+
it('does not outline a boundary with suspensey CSS when flushing the shell',async()=>{
9454+
// When flushing the shell, stylesheets with precedence are emitted in the
9455+
// <head> which blocks paint anyway. So there's no benefit to outlining the
9456+
// boundary — it would just show a higher-level fallback unnecessarily.
9457+
// Instead, the boundary should be inlined so the innermost fallback is shown.
9458+
letstreamedContent='';
9459+
writable.on('data',chunk=>(streamedContent+=chunk));
9460+
9461+
awaitact(()=>{
9462+
renderToPipeableStream(
9463+
<html>
9464+
<body>
9465+
<Suspensefallback="Outer Fallback">
9466+
<Suspensefallback="Middle Fallback">
9467+
<linkrel="stylesheet"href="style.css"precedence="default"/>
9468+
<Suspensefallback="Inner Fallback">
9469+
<BlockedOnvalue="content">Async Content</BlockedOn>
9470+
</Suspense>
9471+
</Suspense>
9472+
</Suspense>
9473+
</body>
9474+
</html>,
9475+
).pipe(writable);
9476+
});
9477+
9478+
// The middle boundary should have been inlined (not outlined) so the
9479+
// middle fallback text should never appear in the streamed HTML.
9480+
expect(streamedContent).not.toContain('Middle Fallback');
9481+
9482+
// The stylesheet is in the head (blocks paint), and the innermost
9483+
// fallback is visible.
9484+
expect(getMeaningfulChildren(document)).toEqual(
9485+
<html>
9486+
<head>
9487+
<linkrel="stylesheet"href="style.css"data-precedence="default"/>
9488+
</head>
9489+
<body>Inner Fallback</body>
9490+
</html>,
9491+
);
9492+
9493+
// Resolve the async content — streams in without needing to load CSS
9494+
// since the stylesheet was already in the head.
9495+
awaitact(()=>{
9496+
resolveText('content');
9497+
});
9498+
9499+
expect(getMeaningfulChildren(document)).toEqual(
9500+
<html>
9501+
<head>
9502+
<linkrel="stylesheet"href="style.css"data-precedence="default"/>
9503+
</head>
9504+
<body>Async Content</body>
9505+
</html>,
9506+
);
9507+
});
9508+
9509+
it('outlines a boundary with suspensey CSS when flushing a streamed completion',async()=>{
9510+
// When a boundary completes via streaming (not as part of the shell),
9511+
// suspensey CSS should cause the boundary to be outlined. The parent
9512+
// content can show sooner while the CSS loads separately.
9513+
letstreamedContent='';
9514+
writable.on('data',chunk=>(streamedContent+=chunk));
9515+
9516+
awaitact(()=>{
9517+
renderToPipeableStream(
9518+
<html>
9519+
<body>
9520+
<Suspensefallback="Root Fallback">
9521+
<BlockedOnvalue="shell">
9522+
<Suspensefallback="Outer Fallback">
9523+
<Suspensefallback="Middle Fallback">
9524+
<link
9525+
rel="stylesheet"
9526+
href="style.css"
9527+
precedence="default"
9528+
/>
9529+
<Suspensefallback="Inner Fallback">
9530+
<BlockedOnvalue="content">Async Content</BlockedOn>
9531+
</Suspense>
9532+
</Suspense>
9533+
</Suspense>
9534+
</BlockedOn>
9535+
</Suspense>
9536+
</body>
9537+
</html>,
9538+
).pipe(writable);
9539+
});
9540+
9541+
// Shell is showing root fallback
9542+
expect(getMeaningfulChildren(document)).toEqual(
9543+
<html>
9544+
<head/>
9545+
<body>Root Fallback</body>
9546+
</html>,
9547+
);
9548+
9549+
// Unblock the shell — content streams in. The middle boundary should
9550+
// be outlined because the CSS arrived via streaming, not in the shell head.
9551+
streamedContent='';
9552+
awaitact(()=>{
9553+
resolveText('shell');
9554+
});
9555+
9556+
// The middle fallback should appear in the streamed HTML because the
9557+
// boundary was outlined.
9558+
expect(streamedContent).toContain('Middle Fallback');
9559+
9560+
// The CSS needs to load before the boundary reveals. Until then
9561+
// the middle fallback is visible.
9562+
expect(getMeaningfulChildren(document)).toEqual(
9563+
<html>
9564+
<head>
9565+
<linkrel="stylesheet"href="style.css"data-precedence="default"/>
9566+
</head>
9567+
<body>
9568+
{'Middle Fallback'}
9569+
<linkrel="preload"href="style.css"as="style"/>
9570+
</body>
9571+
</html>,
9572+
);
9573+
9574+
// Load the stylesheet — now the middle boundary can reveal
9575+
awaitact(()=>{
9576+
loadStylesheets();
9577+
});
9578+
assertLog(['load stylesheet: style.css']);
9579+
9580+
expect(getMeaningfulChildren(document)).toEqual(
9581+
<html>
9582+
<head>
9583+
<linkrel="stylesheet"href="style.css"data-precedence="default"/>
9584+
</head>
9585+
<body>
9586+
{'Inner Fallback'}
9587+
<linkrel="preload"href="style.css"as="style"/>
9588+
</body>
9589+
</html>,
9590+
);
9591+
9592+
// Resolve the async content
9593+
awaitact(()=>{
9594+
resolveText('content');
9595+
});
9596+
9597+
expect(getMeaningfulChildren(document)).toEqual(
9598+
<html>
9599+
<head>
9600+
<linkrel="stylesheet"href="style.css"data-precedence="default"/>
9601+
</head>
9602+
<body>
9603+
{'Async Content'}
9604+
<linkrel="preload"href="style.css"as="style"/>
9605+
</body>
9606+
</html>,
9607+
);
9608+
});
9609+
9610+
// @gate enableViewTransition
9611+
it('still outlines a boundary with a suspensey image inside a ViewTransition when flushing the shell',async()=>{
9612+
// Unlike stylesheets (which block paint from the <head> anyway), images
9613+
// inside ViewTransitions are outlined to enable animation reveals. This
9614+
// should happen even during the shell flush.
9615+
constViewTransition=React.ViewTransition;
9616+
9617+
letstreamedContent='';
9618+
writable.on('data',chunk=>(streamedContent+=chunk));
9619+
9620+
awaitact(()=>{
9621+
renderToPipeableStream(
9622+
<html>
9623+
<body>
9624+
<ViewTransition>
9625+
<Suspensefallback="Image Fallback">
9626+
<linkrel="stylesheet"href="style.css"precedence="default"/>
9627+
<imgsrc="large-image.jpg"/>
9628+
<div>Content</div>
9629+
</Suspense>
9630+
</ViewTransition>
9631+
</body>
9632+
</html>,
9633+
).pipe(writable);
9634+
});
9635+
9636+
// The boundary should be outlined because the suspensey image motivates
9637+
// outlining for animation reveals, even during the shell flush.
9638+
expect(streamedContent).toContain('Image Fallback');
9639+
});
94529640
});

‎packages/react-markup/src/ReactFizzConfigMarkup.js‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,10 @@ export function writeCompletedRoot(
242242
returntrue;
243243
}
244244

245-
exportfunctionhasSuspenseyContent(hoistableState: HoistableState): boolean{
245+
exportfunctionhasSuspenseyContent(
246+
hoistableState: HoistableState,
247+
flushingInShell: boolean,
248+
): boolean{
246249
// Never outline.
247250
returnfalse;
248251
}

‎packages/react-noop-renderer/src/ReactNoopServer.js‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,10 @@ const ReactNoopServer = ReactFizzServer({
324324
writeHoistablesForBoundary(){},
325325
writePostamble(){},
326326
hoistHoistables(parent: HoistableState,child: HoistableState){},
327-
hasSuspenseyContent(hoistableState: HoistableState): boolean{
327+
hasSuspenseyContent(
328+
hoistableState: HoistableState,
329+
flushingInShell: boolean,
330+
): boolean{
328331
returnfalse;
329332
},
330333
createHoistableState(): HoistableState{

‎packages/react-server/src/ReactFizzServer.js‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -479,7 +479,7 @@ function isEligibleForOutlining(
479479
// outlining.
480480
return(
481481
(boundary.byteSize>500||
482-
hasSuspenseyContent(boundary.contentState)||
482+
hasSuspenseyContent(boundary.contentState,/* flushingInShell */false)||
483483
boundary.defer)&&
484484
// For boundaries that can possibly contribute to the preamble we don't want to outline
485485
// them regardless of their size since the fallbacks should only be emitted if we've
@@ -5593,7 +5593,7 @@ function flushSegment(
55935593
!flushingPartialBoundaries&&
55945594
isEligibleForOutlining(request,boundary)&&
55955595
(flushedByteSize+boundary.byteSize>request.progressiveChunkSize||
5596-
hasSuspenseyContent(boundary.contentState)||
5596+
hasSuspenseyContent(boundary.contentState,flushingShell)||
55975597
boundary.defer)
55985598
){
55995599
// Inlining this boundary would make the current sequence being written too large
@@ -5826,6 +5826,7 @@ function flushPartiallyCompletedSegment(
58265826
}
58275827

58285828
letflushingPartialBoundaries=false;
5829+
letflushingShell=false;
58295830

58305831
functionflushCompletedQueues(
58315832
request: Request,
@@ -5885,7 +5886,9 @@ function flushCompletedQueues(
58855886
completedPreambleSegments,
58865887
skipBlockingShell,
58875888
);
5889+
flushingShell=true;
58885890
flushSegment(request,destination,completedRootSegment,null);
5891+
flushingShell=false;
58895892
request.completedRootSegment=null;
58905893
constisComplete=
58915894
request.allPendingTasks===0&&

0 commit comments

Comments
 (0)