Skip to content

Commit 6eb5d67

Browse files
authored
[Fizz] Outline a Suspense Boundary if it has Suspensey CSS or Images (#34552)
We should favor outlining a boundary if it contains Suspensey CSS or Suspensey Images since then we can load that content separately and not block the main content. This also allows us to animate the reveal. For example this should be able to animate the reveal even though the actual HTML content isn't large in this case it's worth outlining so that the JS runtime can choose to animate this reveal. ```js <ViewTransition> <Suspense> <img src="..." /> </Suspense> </ViewTransition> ``` For Suspensey Images, in Fizz, we currently only implement the suspensey semantics when a View Transition is running. Therefore the outlining only applies if it appears inside a Suspense boundary which might animate. Otherwise there's no point in outlining. It is also only if the Suspense boundary itself might animate its appear and not just any ViewTransition. So the effect is very conservative. For CSS it applies even without ViewTransition though, since it can help unblock the main content faster.
1 parent ac2c1a5 commit 6eb5d67

File tree

7 files changed

+87
-13
lines changed

7 files changed

+87
-13
lines changed

fixtures/view-transition/src/components/Page.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,8 +238,8 @@ export default function Page({url, navigate}) {
238238
<Suspend />
239239
</div>
240240
</ViewTransition>
241+
{show ? <Component /> : null}
241242
</Suspense>
242-
{show ? <Component /> : null}
243243
</div>
244244
</ViewTransition>
245245
</SwipeRecognizer>

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

Lines changed: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -782,13 +782,14 @@ const HTML_COLGROUP_MODE = 9;
782782

783783
type InsertionMode = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
784784

785-
const NO_SCOPE = /* */ 0b000000;
786-
const NOSCRIPT_SCOPE = /* */ 0b000001;
787-
const PICTURE_SCOPE = /* */ 0b000010;
788-
const FALLBACK_SCOPE = /* */ 0b000100;
789-
const EXIT_SCOPE = /* */ 0b001000; // A direct Instance below a Suspense fallback is the only thing that can "exit"
790-
const ENTER_SCOPE = /* */ 0b010000; // A direct Instance below Suspense content is the only thing that can "enter"
791-
const UPDATE_SCOPE = /* */ 0b100000; // Inside a scope that applies "update" ViewTransitions if anything mutates here.
785+
const NO_SCOPE = /* */ 0b0000000;
786+
const NOSCRIPT_SCOPE = /* */ 0b0000001;
787+
const PICTURE_SCOPE = /* */ 0b0000010;
788+
const FALLBACK_SCOPE = /* */ 0b0000100;
789+
const EXIT_SCOPE = /* */ 0b0001000; // A direct Instance below a Suspense fallback is the only thing that can "exit"
790+
const ENTER_SCOPE = /* */ 0b0010000; // A direct Instance below Suspense content is the only thing that can "enter"
791+
const UPDATE_SCOPE = /* */ 0b0100000; // Inside a scope that applies "update" ViewTransitions if anything mutates here.
792+
const APPEARING_SCOPE = /* */ 0b1000000; // Below Suspense content subtree which might appear in an "enter" animation or "shared" animation.
792793

793794
// Everything not listed here are tracked for the whole subtree as opposed to just
794795
// until the next Instance.
@@ -987,11 +988,20 @@ export function getSuspenseContentFormatContext(
987988
resumableState: ResumableState,
988989
parentContext: FormatContext,
989990
): FormatContext {
991+
const viewTransition = getSuspenseViewTransition(
992+
parentContext.viewTransition,
993+
);
994+
let subtreeScope = parentContext.tagScope | ENTER_SCOPE;
995+
if (viewTransition !== null && viewTransition.share !== 'none') {
996+
// If we have a ViewTransition wrapping Suspense then the appearing animation
997+
// will be applied just like an "enter" below. Mark it as animating.
998+
subtreeScope |= APPEARING_SCOPE;
999+
}
9901000
return createFormatContext(
9911001
parentContext.insertionMode,
9921002
parentContext.selectedValue,
993-
parentContext.tagScope | ENTER_SCOPE,
994-
getSuspenseViewTransition(parentContext.viewTransition),
1003+
subtreeScope,
1004+
viewTransition,
9951005
);
9961006
}
9971007

@@ -1063,6 +1073,9 @@ export function getViewTransitionFormatContext(
10631073
} else {
10641074
subtreeScope &= ~UPDATE_SCOPE;
10651075
}
1076+
if (enter !== 'none') {
1077+
subtreeScope |= APPEARING_SCOPE;
1078+
}
10661079
return createFormatContext(
10671080
parentContext.insertionMode,
10681081
parentContext.selectedValue,
@@ -3289,6 +3302,7 @@ function pushImg(
32893302
props: Object,
32903303
resumableState: ResumableState,
32913304
renderState: RenderState,
3305+
hoistableState: null | HoistableState,
32923306
formatContext: FormatContext,
32933307
): null {
32943308
const pictureOrNoScriptTagInScope =
@@ -3321,6 +3335,19 @@ function pushImg(
33213335
) {
33223336
// We have a suspensey image and ought to preload it to optimize the loading of display blocking
33233337
// resumableState.
3338+
3339+
if (hoistableState !== null) {
3340+
// Mark this boundary's state as having suspensey images.
3341+
// Only do that if we have a ViewTransition that might trigger a parent Suspense boundary
3342+
// to animate its appearing. Since that's the only case we'd actually apply suspensey images
3343+
// for SSR reveals.
3344+
const isInSuspenseWithEnterViewTransition =
3345+
formatContext.tagScope & APPEARING_SCOPE;
3346+
if (isInSuspenseWithEnterViewTransition) {
3347+
hoistableState.suspenseyImages = true;
3348+
}
3349+
}
3350+
33243351
const sizes = typeof props.sizes === 'string' ? props.sizes : undefined;
33253352
const key = getImageResourceKey(src, srcSet, sizes);
33263353

@@ -4255,7 +4282,14 @@ export function pushStartInstance(
42554282
return pushStartPreformattedElement(target, props, type, formatContext);
42564283
}
42574284
case 'img': {
4258-
return pushImg(target, props, resumableState, renderState, formatContext);
4285+
return pushImg(
4286+
target,
4287+
props,
4288+
resumableState,
4289+
renderState,
4290+
hoistableState,
4291+
formatContext,
4292+
);
42594293
}
42604294
// Omitted close tags
42614295
case 'base':
@@ -6125,6 +6159,7 @@ type StylesheetResource = {
61256159
export type HoistableState = {
61266160
styles: Set<StyleQueue>,
61276161
stylesheets: Set<StylesheetResource>,
6162+
suspenseyImages: boolean,
61286163
};
61296164

61306165
export type StyleQueue = {
@@ -6138,6 +6173,7 @@ export function createHoistableState(): HoistableState {
61386173
return {
61396174
styles: new Set(),
61406175
stylesheets: new Set(),
6176+
suspenseyImages: false,
61416177
};
61426178
}
61436179

@@ -6995,6 +7031,18 @@ export function hoistHoistables(
69957031
): void {
69967032
childState.styles.forEach(hoistStyleQueueDependency, parentState);
69977033
childState.stylesheets.forEach(hoistStylesheetDependency, parentState);
7034+
if (childState.suspenseyImages) {
7035+
// If the child has suspensey images, the parent now does too if it's inlined.
7036+
// Similarly, if a SuspenseList row has a suspensey image then effectively
7037+
// the next row should be blocked on it as well since the next row can't show
7038+
// earlier. In practice, since the child will be outlined this transferring
7039+
// may never matter but is conceptually correct.
7040+
parentState.suspenseyImages = true;
7041+
}
7042+
}
7043+
7044+
export function hasSuspenseyContent(hoistableState: HoistableState): boolean {
7045+
return hoistableState.stylesheets.size > 0 || hoistableState.suspenseyImages;
69987046
}
69997047

70007048
// This function is called at various times depending on whether we are rendering

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import type {
1111
RenderState as BaseRenderState,
1212
ResumableState,
13+
HoistableState,
1314
StyleQueue,
1415
Resource,
1516
HeadersDescriptor,
@@ -325,5 +326,10 @@ export function writePreambleStart(
325326
);
326327
}
327328

329+
export function hasSuspenseyContent(hoistableState: HoistableState): boolean {
330+
// Never outline.
331+
return false;
332+
}
333+
328334
export type TransitionStatus = FormStatus;
329335
export const NotPendingTransition: TransitionStatus = NotPending;

packages/react-markup/src/ReactFizzConfigMarkup.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,5 +242,10 @@ export function writeCompletedRoot(
242242
return true;
243243
}
244244

245+
export function hasSuspenseyContent(hoistableState: HoistableState): boolean {
246+
// Never outline.
247+
return false;
248+
}
249+
245250
export type TransitionStatus = FormStatus;
246251
export const NotPendingTransition: TransitionStatus = NotPending;

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,9 @@ const ReactNoopServer = ReactFizzServer({
324324
writeHoistablesForBoundary() {},
325325
writePostamble() {},
326326
hoistHoistables(parent: HoistableState, child: HoistableState) {},
327+
hasSuspenseyContent(hoistableState: HoistableState): boolean {
328+
return false;
329+
},
327330
createHoistableState(): HoistableState {
328331
return null;
329332
},

packages/react-server/src/ReactFizzServer.js

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ import {
9999
hoistPreambleState,
100100
isPreambleReady,
101101
isPreambleContext,
102+
hasSuspenseyContent,
102103
} from './ReactFizzConfig';
103104
import {
104105
constructClassInstance,
@@ -461,7 +462,7 @@ function isEligibleForOutlining(
461462
// The larger this limit is, the more we can save on preparing fallbacks in case we end up
462463
// outlining.
463464
return (
464-
boundary.byteSize > 500 &&
465+
(boundary.byteSize > 500 || hasSuspenseyContent(boundary.contentState)) &&
465466
// For boundaries that can possibly contribute to the preamble we don't want to outline
466467
// them regardless of their size since the fallbacks should only be emitted if we've
467468
// errored the boundary.
@@ -5748,8 +5749,13 @@ function flushSegment(
57485749

57495750
return writeEndPendingSuspenseBoundary(destination, request.renderState);
57505751
} else if (
5752+
// We don't outline when we're emitting partially completed boundaries optimistically
5753+
// because it doesn't make sense to outline something if its parent is going to be
5754+
// blocked on something later in the stream anyway.
5755+
!flushingPartialBoundaries &&
57515756
isEligibleForOutlining(request, boundary) &&
5752-
flushedByteSize + boundary.byteSize > request.progressiveChunkSize
5757+
(flushedByteSize + boundary.byteSize > request.progressiveChunkSize ||
5758+
hasSuspenseyContent(boundary.contentState))
57535759
) {
57545760
// Inlining this boundary would make the current sequence being written too large
57555761
// and block the parent for too long. Instead, it will be emitted separately so that we
@@ -5980,6 +5986,8 @@ function flushPartiallyCompletedSegment(
59805986
}
59815987
}
59825988

5989+
let flushingPartialBoundaries = false;
5990+
59835991
function flushCompletedQueues(
59845992
request: Request,
59855993
destination: Destination,
@@ -6095,6 +6103,7 @@ function flushCompletedQueues(
60956103

60966104
// Next we emit any segments of any boundaries that are partially complete
60976105
// but not deeply complete.
6106+
flushingPartialBoundaries = true;
60986107
const partialBoundaries = request.partialBoundaries;
60996108
for (i = 0; i < partialBoundaries.length; i++) {
61006109
const boundary = partialBoundaries[i];
@@ -6106,6 +6115,7 @@ function flushCompletedQueues(
61066115
}
61076116
}
61086117
partialBoundaries.splice(0, i);
6118+
flushingPartialBoundaries = false;
61096119

61106120
// Next we check the completed boundaries again. This may have had
61116121
// boundaries added to it in case they were too larged to be inlined.
@@ -6123,6 +6133,7 @@ function flushCompletedQueues(
61236133
}
61246134
largeBoundaries.splice(0, i);
61256135
} finally {
6136+
flushingPartialBoundaries = false;
61266137
if (
61276138
request.allPendingTasks === 0 &&
61286139
request.clientRenderedBoundaries.length === 0 &&

packages/react-server/src/forks/ReactFizzConfig.custom.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,4 +104,5 @@ export const writeHoistablesForBoundary = $$$config.writeHoistablesForBoundary;
104104
export const writePostamble = $$$config.writePostamble;
105105
export const hoistHoistables = $$$config.hoistHoistables;
106106
export const createHoistableState = $$$config.createHoistableState;
107+
export const hasSuspenseyContent = $$$config.hasSuspenseyContent;
107108
export const emitEarlyPreloads = $$$config.emitEarlyPreloads;

0 commit comments

Comments
 (0)