Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
Original file line number Diff line number Diff line change
Expand Up @@ -7041,7 +7041,17 @@ export function hoistHoistables(
}
}

export function hasSuspenseyContent(hoistableState: HoistableState): boolean {
export function hasSuspenseyContent(
hoistableState: HoistableState,
flushingInShell: boolean,
): boolean {
if (flushingInShell) {
// When flushing the shell, stylesheets with precedence are already emitted
// in the <head> which blocks paint. There's no benefit to outlining for CSS
// alone during the shell flush. However, suspensey images (for ViewTransition
// animation reveals) should still trigger outlining even during the shell.
return hoistableState.suspenseyImages;
}
return hoistableState.stylesheets.size > 0 || hoistableState.suspenseyImages;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,10 @@ export function writePreambleStart(
);
}

export function hasSuspenseyContent(hoistableState: HoistableState): boolean {
export function hasSuspenseyContent(
hoistableState: HoistableState,
flushingInShell: boolean,
): boolean {
// Never outline.
return false;
}
Expand Down
188 changes: 188 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMFloat-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9449,4 +9449,192 @@ background-color: green;
);
});
});

it('does not outline a boundary with suspensey CSS when flushing the shell', async () => {
// When flushing the shell, stylesheets with precedence are emitted in the
// <head> which blocks paint anyway. So there's no benefit to outlining the
// boundary — it would just show a higher-level fallback unnecessarily.
// Instead, the boundary should be inlined so the innermost fallback is shown.
let streamedContent = '';
writable.on('data', chunk => (streamedContent += chunk));

await act(() => {
renderToPipeableStream(
<html>
<body>
<Suspense fallback="Outer Fallback">
<Suspense fallback="Middle Fallback">
<link rel="stylesheet" href="style.css" precedence="default" />
<Suspense fallback="Inner Fallback">
<BlockedOn value="content">Async Content</BlockedOn>
</Suspense>
</Suspense>
</Suspense>
</body>
</html>,
).pipe(writable);
});

// The middle boundary should have been inlined (not outlined) so the
// middle fallback text should never appear in the streamed HTML.
expect(streamedContent).not.toContain('Middle Fallback');

// The stylesheet is in the head (blocks paint), and the innermost
// fallback is visible.
expect(getMeaningfulChildren(document)).toEqual(
<html>
<head>
<link rel="stylesheet" href="style.css" data-precedence="default" />
</head>
<body>Inner Fallback</body>
</html>,
);

// Resolve the async content — streams in without needing to load CSS
// since the stylesheet was already in the head.
await act(() => {
resolveText('content');
});

expect(getMeaningfulChildren(document)).toEqual(
<html>
<head>
<link rel="stylesheet" href="style.css" data-precedence="default" />
</head>
<body>Async Content</body>
</html>,
);
});

it('outlines a boundary with suspensey CSS when flushing a streamed completion', async () => {
// When a boundary completes via streaming (not as part of the shell),
// suspensey CSS should cause the boundary to be outlined. The parent
// content can show sooner while the CSS loads separately.
let streamedContent = '';
writable.on('data', chunk => (streamedContent += chunk));

await act(() => {
renderToPipeableStream(
<html>
<body>
<Suspense fallback="Root Fallback">
<BlockedOn value="shell">
<Suspense fallback="Outer Fallback">
<Suspense fallback="Middle Fallback">
<link
rel="stylesheet"
href="style.css"
precedence="default"
/>
<Suspense fallback="Inner Fallback">
<BlockedOn value="content">Async Content</BlockedOn>
</Suspense>
</Suspense>
</Suspense>
</BlockedOn>
</Suspense>
</body>
</html>,
).pipe(writable);
});

// Shell is showing root fallback
expect(getMeaningfulChildren(document)).toEqual(
<html>
<head />
<body>Root Fallback</body>
</html>,
);

// Unblock the shell — content streams in. The middle boundary should
// be outlined because the CSS arrived via streaming, not in the shell head.
streamedContent = '';
await act(() => {
resolveText('shell');
});

// The middle fallback should appear in the streamed HTML because the
// boundary was outlined.
expect(streamedContent).toContain('Middle Fallback');

// The CSS needs to load before the boundary reveals. Until then
// the middle fallback is visible.
expect(getMeaningfulChildren(document)).toEqual(
<html>
<head>
<link rel="stylesheet" href="style.css" data-precedence="default" />
</head>
<body>
{'Middle Fallback'}
<link rel="preload" href="style.css" as="style" />
</body>
</html>,
);

// Load the stylesheet — now the middle boundary can reveal
await act(() => {
loadStylesheets();
});
assertLog(['load stylesheet: style.css']);

expect(getMeaningfulChildren(document)).toEqual(
<html>
<head>
<link rel="stylesheet" href="style.css" data-precedence="default" />
</head>
<body>
{'Inner Fallback'}
<link rel="preload" href="style.css" as="style" />
</body>
</html>,
);

// Resolve the async content
await act(() => {
resolveText('content');
});

expect(getMeaningfulChildren(document)).toEqual(
<html>
<head>
<link rel="stylesheet" href="style.css" data-precedence="default" />
</head>
<body>
{'Async Content'}
<link rel="preload" href="style.css" as="style" />
</body>
</html>,
);
});

// @gate enableViewTransition
it('still outlines a boundary with a suspensey image inside a ViewTransition when flushing the shell', async () => {
// Unlike stylesheets (which block paint from the <head> anyway), images
// inside ViewTransitions are outlined to enable animation reveals. This
// should happen even during the shell flush.
const ViewTransition = React.ViewTransition;

let streamedContent = '';
writable.on('data', chunk => (streamedContent += chunk));

await act(() => {
renderToPipeableStream(
<html>
<body>
<ViewTransition>
<Suspense fallback="Image Fallback">
<link rel="stylesheet" href="style.css" precedence="default" />
<img src="large-image.jpg" />
<div>Content</div>
</Suspense>
</ViewTransition>
</body>
</html>,
).pipe(writable);
});

// The boundary should be outlined because the suspensey image motivates
// outlining for animation reveals, even during the shell flush.
expect(streamedContent).toContain('Image Fallback');
});
});
5 changes: 4 additions & 1 deletion packages/react-markup/src/ReactFizzConfigMarkup.js
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,10 @@ export function writeCompletedRoot(
return true;
}

export function hasSuspenseyContent(hoistableState: HoistableState): boolean {
export function hasSuspenseyContent(
hoistableState: HoistableState,
flushingInShell: boolean,
): boolean {
// Never outline.
return false;
}
Expand Down
5 changes: 4 additions & 1 deletion packages/react-noop-renderer/src/ReactNoopServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,10 @@ const ReactNoopServer = ReactFizzServer({
writeHoistablesForBoundary() {},
writePostamble() {},
hoistHoistables(parent: HoistableState, child: HoistableState) {},
hasSuspenseyContent(hoistableState: HoistableState): boolean {
hasSuspenseyContent(
hoistableState: HoistableState,
flushingInShell: boolean,
): boolean {
return false;
},
createHoistableState(): HoistableState {
Expand Down
7 changes: 5 additions & 2 deletions packages/react-server/src/ReactFizzServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -479,7 +479,7 @@ function isEligibleForOutlining(
// outlining.
return (
(boundary.byteSize > 500 ||
hasSuspenseyContent(boundary.contentState) ||
hasSuspenseyContent(boundary.contentState, /* flushingInShell */ false) ||
boundary.defer) &&
// For boundaries that can possibly contribute to the preamble we don't want to outline
// them regardless of their size since the fallbacks should only be emitted if we've
Expand Down Expand Up @@ -5593,7 +5593,7 @@ function flushSegment(
!flushingPartialBoundaries &&
isEligibleForOutlining(request, boundary) &&
(flushedByteSize + boundary.byteSize > request.progressiveChunkSize ||
hasSuspenseyContent(boundary.contentState) ||
hasSuspenseyContent(boundary.contentState, flushingShell) ||
boundary.defer)
) {
// Inlining this boundary would make the current sequence being written too large
Expand Down Expand Up @@ -5826,6 +5826,7 @@ function flushPartiallyCompletedSegment(
}

let flushingPartialBoundaries = false;
let flushingShell = false;

function flushCompletedQueues(
request: Request,
Expand Down Expand Up @@ -5885,7 +5886,9 @@ function flushCompletedQueues(
completedPreambleSegments,
skipBlockingShell,
);
flushingShell = true;
flushSegment(request, destination, completedRootSegment, null);
flushingShell = false;
request.completedRootSegment = null;
const isComplete =
request.allPendingTasks === 0 &&
Expand Down
Loading