Skip to content

Commit 7a9da91

Browse files
committed
[Fizz] Support Suspense boundaries anywhere
Suspense is meant to be composable but there has been a lonstanding limitation with using Suspense above the `<body>` tag of an HTML document due to peculiarities of how HTML is parsed. For instance if you used Suspense to render an entire HTML document and had a fallback that might flush an alternate Document the comment nodes which describe this boundary scope won't be where they need to be in the DOM for client React to properly hydrate them. This is somewhat a problem of our own making in that we have a concept of a Preamble and we leave the closing body and html tags behind until streaming has completed which produces a valid HTML document that also matches the DOM structure that would be parsed from it. However Preambles as a concept are too important to features like Float to imagine moving away from this model and so we can either choose to just accept that you cannot use Suspense anywhere except inside the `<body>` or we can build special support for Suspense into react-dom that has a coherent semantic with how HTML documents are written and parsed. This change implements Suspense support for react-dom/server by correctly serializing boundaries during rendering, prerendering, and resumgin on the server. It does not yet support Suspense everywhere on the client but this will arrive in a subsequent change. In practice Suspense cannot be used above the <body> tag today so this is not a breaking change since no programs in the wild could be using this feature anyway. React's streaming rendering of HTML doesn't lend itself to replacing the contents of the documentElement, head, or body of a Document. These are already special cased in fiber as HostSingletons and similarly for Fizz the values we render for these tags must never be updated by the Fizz runtime once written. To accomplish these we redefine the Preamble as the tags that represent these three singletons plus the contents of the document.head. If you use Suspense above any part of the Preamble then nothing will be written to the destination until the boundary is no longer pending. If the boundary completes then the preamble from within that boudnary will be output. If the boundary postpones or errors then the preamble from the fallback will be used instead. Additionally, by default anything that is not part of the preamble is implicitly in body scope. This leads to the somewhat counterintuitive consequence that the comment nodes we use to mark the borders of a Suspense boundary in Fizz can appear INSIDE the preamble that was rendered within it. Later when I update Fiber to support Suspense anywhere hydration will similarly start implicitly in the document body when the root is part of the preamble (the document or one of it's singletons).
1 parent 61e713c commit 7a9da91

File tree

13 files changed

+2024
-211
lines changed

13 files changed

+2024
-211
lines changed

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

+174-32
Original file line numberDiff line numberDiff line change
@@ -135,8 +135,7 @@ export type RenderState = {
135135
// be null or empty when resuming.
136136

137137
// preamble chunks
138-
htmlChunks: null | Array<Chunk | PrecomputedChunk>,
139-
headChunks: null | Array<Chunk | PrecomputedChunk>,
138+
preamble: PreambleState,
140139

141140
// external runtime script chunks
142141
externalRuntimeScript: null | ExternalRuntimeScript,
@@ -442,8 +441,7 @@ export function createRenderState(
442441
segmentPrefix: stringToPrecomputedChunk(idPrefix + 'S:'),
443442
boundaryPrefix: stringToPrecomputedChunk(idPrefix + 'B:'),
444443
startInlineScript: inlineScriptWithNonce,
445-
htmlChunks: null,
446-
headChunks: null,
444+
preamble: createPreambleState(),
447445

448446
externalRuntimeScript: externalRuntimeScript,
449447
bootstrapChunks: bootstrapChunks,
@@ -686,6 +684,19 @@ export function completeResumableState(resumableState: ResumableState): void {
686684
resumableState.bootstrapModules = undefined;
687685
}
688686

687+
export type PreambleState = {
688+
htmlChunks: null | Array<Chunk | PrecomputedChunk>,
689+
headChunks: null | Array<Chunk | PrecomputedChunk>,
690+
bodyChunks: null | Array<Chunk | PrecomputedChunk>,
691+
};
692+
export function createPreambleState(): PreambleState {
693+
return {
694+
htmlChunks: null,
695+
headChunks: null,
696+
bodyChunks: null,
697+
};
698+
}
699+
689700
// Constants for the insertion mode we're currently writing in. We don't encode all HTML5 insertion
690701
// modes. We only include the variants as they matter for the sake of our purposes.
691702
// We don't actually provide the namespace therefore we use constants instead of the string.
@@ -694,16 +705,17 @@ export const ROOT_HTML_MODE = 0; // Used for the root most element tag.
694705
// still makes sense
695706
const HTML_HTML_MODE = 1; // Used for the <html> if it is at the top level.
696707
const HTML_MODE = 2;
697-
const SVG_MODE = 3;
698-
const MATHML_MODE = 4;
699-
const HTML_TABLE_MODE = 5;
700-
const HTML_TABLE_BODY_MODE = 6;
701-
const HTML_TABLE_ROW_MODE = 7;
702-
const HTML_COLGROUP_MODE = 8;
708+
const HTML_HEAD_MODE = 3;
709+
const SVG_MODE = 4;
710+
const MATHML_MODE = 5;
711+
const HTML_TABLE_MODE = 6;
712+
const HTML_TABLE_BODY_MODE = 7;
713+
const HTML_TABLE_ROW_MODE = 8;
714+
const HTML_COLGROUP_MODE = 9;
703715
// We have a greater than HTML_TABLE_MODE check elsewhere. If you add more cases here, make sure it
704716
// still makes sense
705717

706-
type InsertionMode = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
718+
type InsertionMode = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
707719

708720
const NO_SCOPE = /* */ 0b00;
709721
const NOSCRIPT_SCOPE = /* */ 0b01;
@@ -728,6 +740,10 @@ function createFormatContext(
728740
};
729741
}
730742

743+
export function canHavePreamble(formatContext: FormatContext): boolean {
744+
return formatContext.insertionMode < HTML_MODE;
745+
}
746+
731747
export function createRootFormatContext(namespaceURI?: string): FormatContext {
732748
const insertionMode =
733749
namespaceURI === 'http://www.w3.org/2000/svg'
@@ -792,27 +808,42 @@ export function getChildFormatContext(
792808
null,
793809
parentContext.tagScope,
794810
);
811+
case 'head':
812+
if (parentContext.insertionMode < HTML_MODE) {
813+
// We are either at the root or inside the <html> tag and can enter
814+
// the <head> scope
815+
return createFormatContext(
816+
HTML_HEAD_MODE,
817+
null,
818+
parentContext.tagScope,
819+
);
820+
}
821+
break;
822+
case 'html':
823+
if (parentContext.insertionMode === ROOT_HTML_MODE) {
824+
return createFormatContext(
825+
HTML_HTML_MODE,
826+
null,
827+
parentContext.tagScope,
828+
);
829+
}
830+
break;
795831
}
796832
if (parentContext.insertionMode >= HTML_TABLE_MODE) {
797833
// Whatever tag this was, it wasn't a table parent or other special parent, so we must have
798834
// entered plain HTML again.
799835
return createFormatContext(HTML_MODE, null, parentContext.tagScope);
800836
}
801-
if (parentContext.insertionMode === ROOT_HTML_MODE) {
802-
if (type === 'html') {
803-
// We've emitted the root and is now in <html> mode.
804-
return createFormatContext(HTML_HTML_MODE, null, parentContext.tagScope);
805-
} else {
806-
// We've emitted the root and is now in plain HTML mode.
807-
return createFormatContext(HTML_MODE, null, parentContext.tagScope);
808-
}
809-
} else if (parentContext.insertionMode === HTML_HTML_MODE) {
810-
// We've emitted the document element and is now in plain HTML mode.
837+
if (parentContext.insertionMode < HTML_MODE) {
811838
return createFormatContext(HTML_MODE, null, parentContext.tagScope);
812839
}
813840
return parentContext;
814841
}
815842

843+
export function isPreambleContext(formatContext: FormatContext): boolean {
844+
return formatContext.insertionMode === HTML_HEAD_MODE;
845+
}
846+
816847
export function makeId(
817848
resumableState: ResumableState,
818849
treeId: string,
@@ -3185,29 +3216,66 @@ function pushStartHead(
31853216
target: Array<Chunk | PrecomputedChunk>,
31863217
props: Object,
31873218
renderState: RenderState,
3219+
preambleState: null | PreambleState,
31883220
insertionMode: InsertionMode,
31893221
): ReactNodeList {
3190-
if (insertionMode < HTML_MODE && renderState.headChunks === null) {
3222+
if (insertionMode < HTML_MODE) {
31913223
// This <head> is the Document.head and should be part of the preamble
3192-
renderState.headChunks = [];
3193-
return pushStartGenericElement(renderState.headChunks, props, 'head');
3224+
const preamble = preambleState || renderState.preamble;
3225+
3226+
if (preamble.headChunks) {
3227+
throw new Error(`The ${'`<head>`'} tag may only be rendered once.`);
3228+
}
3229+
preamble.headChunks = [];
3230+
return pushStartGenericElement(preamble.headChunks, props, 'head');
31943231
} else {
31953232
// This <head> is deep and is likely just an error. we emit it inline though.
31963233
// Validation should warn that this tag is the the wrong spot.
31973234
return pushStartGenericElement(target, props, 'head');
31983235
}
31993236
}
32003237

3238+
function pushStartBody(
3239+
target: Array<Chunk | PrecomputedChunk>,
3240+
props: Object,
3241+
renderState: RenderState,
3242+
preambleState: null | PreambleState,
3243+
insertionMode: InsertionMode,
3244+
): ReactNodeList {
3245+
if (insertionMode < HTML_MODE) {
3246+
// This <body> is the Document.body
3247+
const preamble = preambleState || renderState.preamble;
3248+
3249+
if (preamble.bodyChunks) {
3250+
throw new Error(`The ${'`<body>`'} tag may only be rendered once.`);
3251+
}
3252+
3253+
preamble.bodyChunks = [];
3254+
return pushStartGenericElement(preamble.bodyChunks, props, 'body');
3255+
} else {
3256+
// This <head> is deep and is likely just an error. we emit it inline though.
3257+
// Validation should warn that this tag is the the wrong spot.
3258+
return pushStartGenericElement(target, props, 'body');
3259+
}
3260+
}
3261+
32013262
function pushStartHtml(
32023263
target: Array<Chunk | PrecomputedChunk>,
32033264
props: Object,
32043265
renderState: RenderState,
3266+
preambleState: null | PreambleState,
32053267
insertionMode: InsertionMode,
32063268
): ReactNodeList {
3207-
if (insertionMode === ROOT_HTML_MODE && renderState.htmlChunks === null) {
3208-
// This <html> is the Document.documentElement and should be part of the preamble
3209-
renderState.htmlChunks = [DOCTYPE];
3210-
return pushStartGenericElement(renderState.htmlChunks, props, 'html');
3269+
if (insertionMode === ROOT_HTML_MODE) {
3270+
// This <html> is the Document.documentElement
3271+
const preamble = preambleState || renderState.preamble;
3272+
3273+
if (preamble.htmlChunks) {
3274+
throw new Error(`The ${'`<html>`'} tag may only be rendered once.`);
3275+
}
3276+
3277+
preamble.htmlChunks = [DOCTYPE];
3278+
return pushStartGenericElement(preamble.htmlChunks, props, 'html');
32113279
} else {
32123280
// This <html> is deep and is likely just an error. we emit it inline though.
32133281
// Validation should warn that this tag is the the wrong spot.
@@ -3562,6 +3630,7 @@ export function pushStartInstance(
35623630
props: Object,
35633631
resumableState: ResumableState,
35643632
renderState: RenderState,
3633+
preambleState: null | PreambleState,
35653634
hoistableState: null | HoistableState,
35663635
formatContext: FormatContext,
35673636
textEmbedded: boolean,
@@ -3729,13 +3798,23 @@ export function pushStartInstance(
37293798
target,
37303799
props,
37313800
renderState,
3801+
preambleState,
3802+
formatContext.insertionMode,
3803+
);
3804+
case 'body':
3805+
return pushStartBody(
3806+
target,
3807+
props,
3808+
renderState,
3809+
preambleState,
37323810
formatContext.insertionMode,
37333811
);
37343812
case 'html': {
37353813
return pushStartHtml(
37363814
target,
37373815
props,
37383816
renderState,
3817+
preambleState,
37393818
formatContext.insertionMode,
37403819
);
37413820
}
@@ -3814,10 +3893,50 @@ export function pushEndInstance(
38143893
return;
38153894
}
38163895
break;
3896+
case 'head':
3897+
if (formatContext.insertionMode <= HTML_HTML_MODE) {
3898+
return;
3899+
}
3900+
break;
38173901
}
38183902
target.push(endChunkForTag(type));
38193903
}
38203904

3905+
export function hoistPreambleState(
3906+
renderState: RenderState,
3907+
preambleState: PreambleState,
3908+
) {
3909+
const rootPreamble = renderState.preamble;
3910+
if (rootPreamble.htmlChunks === null) {
3911+
rootPreamble.htmlChunks = preambleState.htmlChunks;
3912+
}
3913+
if (rootPreamble.headChunks === null) {
3914+
rootPreamble.headChunks = preambleState.headChunks;
3915+
}
3916+
if (rootPreamble.bodyChunks === null) {
3917+
rootPreamble.bodyChunks = preambleState.bodyChunks;
3918+
}
3919+
}
3920+
3921+
export function isPreambleReady(
3922+
renderState: RenderState,
3923+
// This means there are unfinished Suspense boundaries which could contain
3924+
// a preamble. In the case of DOM we constrain valid programs to only having
3925+
// one instance of each singleton so we can determine the preamble is ready
3926+
// as long as we have chunks for each of these tags.
3927+
hasPendingPreambles: boolean,
3928+
): boolean {
3929+
const preamble = renderState.preamble;
3930+
return (
3931+
// There are no remaining boundaries which might contain a preamble so
3932+
// the preamble is as complete as it is going to get
3933+
hasPendingPreambles === false ||
3934+
// we have a head and body tag. we don't need to wait for any more
3935+
// because it would be invalid to render additional copies of these tags
3936+
!!(preamble.headChunks && preamble.bodyChunks)
3937+
);
3938+
}
3939+
38213940
function writeBootstrap(
38223941
destination: Destination,
38233942
renderState: RenderState,
@@ -4033,6 +4152,7 @@ export function writeStartSegment(
40334152
switch (formatContext.insertionMode) {
40344153
case ROOT_HTML_MODE:
40354154
case HTML_HTML_MODE:
4155+
case HTML_HEAD_MODE:
40364156
case HTML_MODE: {
40374157
writeChunk(destination, startSegmentHTML);
40384158
writeChunk(destination, renderState.segmentPrefix);
@@ -4091,6 +4211,7 @@ export function writeEndSegment(
40914211
switch (formatContext.insertionMode) {
40924212
case ROOT_HTML_MODE:
40934213
case HTML_HTML_MODE:
4214+
case HTML_HEAD_MODE:
40944215
case HTML_MODE: {
40954216
return writeChunkAndReturn(destination, endSegmentHTML);
40964217
}
@@ -4679,7 +4800,7 @@ function preloadLateStyles(this: Destination, styleQueue: StyleQueue) {
46794800
// flush the entire preamble in a single pass. This probably should be modified
46804801
// in the future to be backpressure sensitive but that requires a larger refactor
46814802
// of the flushing code in Fizz.
4682-
export function writePreamble(
4803+
export function writePreambleStart(
46834804
destination: Destination,
46844805
resumableState: ResumableState,
46854806
renderState: RenderState,
@@ -4700,8 +4821,10 @@ export function writePreamble(
47004821
internalPreinitScript(resumableState, renderState, src, chunks);
47014822
}
47024823

4703-
const htmlChunks = renderState.htmlChunks;
4704-
const headChunks = renderState.headChunks;
4824+
const preamble = renderState.preamble;
4825+
4826+
const htmlChunks = preamble.htmlChunks;
4827+
const headChunks = preamble.headChunks;
47054828

47064829
let i = 0;
47074830

@@ -4773,12 +4896,31 @@ export function writePreamble(
47734896
writeChunk(destination, hoistableChunks[i]);
47744897
}
47754898
hoistableChunks.length = 0;
4899+
}
47764900

4777-
if (htmlChunks && headChunks === null) {
4901+
// We don't bother reporting backpressure at the moment because we expect to
4902+
// flush the entire preamble in a single pass. This probably should be modified
4903+
// in the future to be backpressure sensitive but that requires a larger refactor
4904+
// of the flushing code in Fizz.
4905+
export function writePreambleEnd(
4906+
destination: Destination,
4907+
renderState: RenderState,
4908+
): void {
4909+
const preamble = renderState.preamble;
4910+
const htmlChunks = preamble.htmlChunks;
4911+
const headChunks = preamble.headChunks;
4912+
if (htmlChunks || headChunks) {
47784913
// we have an <html> but we inserted an implicit <head> tag. We need
47794914
// to close it since the main content won't have it
47804915
writeChunk(destination, endChunkForTag('head'));
47814916
}
4917+
4918+
const bodyChunks = preamble.bodyChunks;
4919+
if (bodyChunks) {
4920+
for (let i = 0; i < bodyChunks.length; i++) {
4921+
writeChunk(destination, bodyChunks[i]);
4922+
}
4923+
}
47824924
}
47834925

47844926
// We don't bother reporting backpressure at the moment because we expect to

0 commit comments

Comments
 (0)