Skip to content

Commit d548315

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 98418e8 commit d548315

12 files changed

+1448
-182
lines changed

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

+144-25
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
*/
99

1010
import type {ReactNodeList, ReactCustomFormAction} from 'shared/ReactTypes';
11+
import type {FizzPreamble} from 'react-server/src/ReactFizzPreamble';
1112
import type {
1213
CrossOriginEnum,
1314
PreloadImplOptions,
@@ -49,6 +50,7 @@ import {
4950
getRenderState,
5051
flushResources,
5152
} from 'react-server/src/ReactFizzServer';
53+
import {createFizzPreamble} from 'react-server/src/ReactFizzPreamble';
5254

5355
import isAttributeNameSafe from '../shared/isAttributeNameSafe';
5456
import isUnitlessNumber from '../shared/isUnitlessNumber';
@@ -135,8 +137,7 @@ export type RenderState = {
135137
// be null or empty when resuming.
136138

137139
// preamble chunks
138-
htmlChunks: null | Array<Chunk | PrecomputedChunk>,
139-
headChunks: null | Array<Chunk | PrecomputedChunk>,
140+
preamble: PreambleState,
140141

141142
// external runtime script chunks
142143
externalRuntimeScript: null | ExternalRuntimeScript,
@@ -442,8 +443,7 @@ export function createRenderState(
442443
segmentPrefix: stringToPrecomputedChunk(idPrefix + 'S:'),
443444
boundaryPrefix: stringToPrecomputedChunk(idPrefix + 'B:'),
444445
startInlineScript: inlineScriptWithNonce,
445-
htmlChunks: null,
446-
headChunks: null,
446+
preamble: createPreambleState(),
447447

448448
externalRuntimeScript: externalRuntimeScript,
449449
bootstrapChunks: bootstrapChunks,
@@ -686,6 +686,19 @@ export function completeResumableState(resumableState: ResumableState): void {
686686
resumableState.bootstrapModules = undefined;
687687
}
688688

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

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

708722
const NO_SCOPE = /* */ 0b00;
709723
const NOSCRIPT_SCOPE = /* */ 0b01;
@@ -728,6 +742,10 @@ function createFormatContext(
728742
};
729743
}
730744

745+
export function canHavePreamble(formatContext: FormatContext): boolean {
746+
return formatContext.insertionMode < HTML_MODE;
747+
}
748+
731749
export function createRootFormatContext(namespaceURI?: string): FormatContext {
732750
const insertionMode =
733751
namespaceURI === 'http://www.w3.org/2000/svg'
@@ -807,6 +825,10 @@ export function getChildFormatContext(
807825
return createFormatContext(HTML_MODE, null, parentContext.tagScope);
808826
}
809827
} else if (parentContext.insertionMode === HTML_HTML_MODE) {
828+
if (type === 'head') {
829+
// We've emitted the document element and is now in <head> mode.
830+
return createFormatContext(HTML_HEAD_MODE, null, parentContext.tagScope);
831+
}
810832
// We've emitted the document element and is now in plain HTML mode.
811833
return createFormatContext(HTML_MODE, null, parentContext.tagScope);
812834
}
@@ -3185,29 +3207,71 @@ function pushStartHead(
31853207
target: Array<Chunk | PrecomputedChunk>,
31863208
props: Object,
31873209
renderState: RenderState,
3210+
preambleState: null | PreambleState,
31883211
insertionMode: InsertionMode,
3189-
): ReactNodeList {
3190-
if (insertionMode < HTML_MODE && renderState.headChunks === null) {
3212+
): ReactNodeList | FizzPreamble {
3213+
if (insertionMode < HTML_MODE) {
31913214
// This <head> is the Document.head and should be part of the preamble
3192-
renderState.headChunks = [];
3193-
return pushStartGenericElement(renderState.headChunks, props, 'head');
3215+
const preamble = preambleState || renderState.preamble;
3216+
3217+
if (preamble.headChunks) {
3218+
throw new Error(`${'<head>'} may only be rendered once per application`);
3219+
}
3220+
preamble.headChunks = [];
3221+
const children = pushStartGenericElement(
3222+
preamble.headChunks,
3223+
props,
3224+
'head',
3225+
);
3226+
return createFizzPreamble(children);
31943227
} else {
31953228
// This <head> is deep and is likely just an error. we emit it inline though.
31963229
// Validation should warn that this tag is the the wrong spot.
31973230
return pushStartGenericElement(target, props, 'head');
31983231
}
31993232
}
32003233

3201-
function pushStartHtml(
3234+
function pushStartBody(
32023235
target: Array<Chunk | PrecomputedChunk>,
32033236
props: Object,
32043237
renderState: RenderState,
3238+
preambleState: null | PreambleState,
32053239
insertionMode: InsertionMode,
32063240
): 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');
3241+
if (insertionMode < HTML_MODE) {
3242+
// This <body> is the Document.body
3243+
const preamble = preambleState || renderState.preamble;
3244+
3245+
if (preamble.bodyChunks) {
3246+
throw new Error(`${'<body>'} may only be rendered once per application`);
3247+
}
3248+
3249+
preamble.bodyChunks = [];
3250+
return pushStartGenericElement(preamble.bodyChunks, props, 'body');
3251+
} else {
3252+
// This <head> is deep and is likely just an error. we emit it inline though.
3253+
// Validation should warn that this tag is the the wrong spot.
3254+
return pushStartGenericElement(target, props, 'body');
3255+
}
3256+
}
3257+
3258+
function pushStartHtml(
3259+
target: Array<Chunk | PrecomputedChunk>,
3260+
props: Object,
3261+
renderState: RenderState,
3262+
preambleState: null | PreambleState,
3263+
insertionMode: InsertionMode,
3264+
): ReactNodeList | FizzPreamble {
3265+
if (insertionMode === ROOT_HTML_MODE) {
3266+
// This <html> is the Document.documentElement
3267+
const preamble = preambleState || renderState.preamble;
3268+
3269+
if (preamble.htmlChunks) {
3270+
throw new Error(`${'<html>'} may only be rendered once per application`);
3271+
}
3272+
3273+
preamble.htmlChunks = [DOCTYPE];
3274+
return pushStartGenericElement(preamble.htmlChunks, props, 'html');
32113275
} else {
32123276
// This <html> is deep and is likely just an error. we emit it inline though.
32133277
// Validation should warn that this tag is the the wrong spot.
@@ -3562,11 +3626,12 @@ export function pushStartInstance(
35623626
props: Object,
35633627
resumableState: ResumableState,
35643628
renderState: RenderState,
3629+
preambleState: null | PreambleState,
35653630
hoistableState: null | HoistableState,
35663631
formatContext: FormatContext,
35673632
textEmbedded: boolean,
35683633
isFallback: boolean,
3569-
): ReactNodeList {
3634+
): ReactNodeList | FizzPreamble {
35703635
if (__DEV__) {
35713636
validateARIAProperties(type, props);
35723637
validateInputProperties(type, props);
@@ -3729,13 +3794,23 @@ export function pushStartInstance(
37293794
target,
37303795
props,
37313796
renderState,
3797+
preambleState,
3798+
formatContext.insertionMode,
3799+
);
3800+
case 'body':
3801+
return pushStartBody(
3802+
target,
3803+
props,
3804+
renderState,
3805+
preambleState,
37323806
formatContext.insertionMode,
37333807
);
37343808
case 'html': {
37353809
return pushStartHtml(
37363810
target,
37373811
props,
37383812
renderState,
3813+
preambleState,
37393814
formatContext.insertionMode,
37403815
);
37413816
}
@@ -3814,10 +3889,31 @@ export function pushEndInstance(
38143889
return;
38153890
}
38163891
break;
3892+
case 'head':
3893+
if (formatContext.insertionMode <= HTML_HTML_MODE) {
3894+
return;
3895+
}
3896+
break;
38173897
}
38183898
target.push(endChunkForTag(type));
38193899
}
38203900

3901+
export function preparePreamble(
3902+
renderState: RenderState,
3903+
preambleState: PreambleState,
3904+
) {
3905+
const rootPreamble = renderState.preamble;
3906+
if (rootPreamble.htmlChunks === null) {
3907+
rootPreamble.htmlChunks = preambleState.htmlChunks;
3908+
}
3909+
if (rootPreamble.headChunks === null) {
3910+
rootPreamble.headChunks = preambleState.headChunks;
3911+
}
3912+
if (rootPreamble.bodyChunks === null) {
3913+
rootPreamble.bodyChunks = preambleState.bodyChunks;
3914+
}
3915+
}
3916+
38213917
function writeBootstrap(
38223918
destination: Destination,
38233919
renderState: RenderState,
@@ -4033,6 +4129,7 @@ export function writeStartSegment(
40334129
switch (formatContext.insertionMode) {
40344130
case ROOT_HTML_MODE:
40354131
case HTML_HTML_MODE:
4132+
case HTML_HEAD_MODE:
40364133
case HTML_MODE: {
40374134
writeChunk(destination, startSegmentHTML);
40384135
writeChunk(destination, renderState.segmentPrefix);
@@ -4091,6 +4188,7 @@ export function writeEndSegment(
40914188
switch (formatContext.insertionMode) {
40924189
case ROOT_HTML_MODE:
40934190
case HTML_HTML_MODE:
4191+
case HTML_HEAD_MODE:
40944192
case HTML_MODE: {
40954193
return writeChunkAndReturn(destination, endSegmentHTML);
40964194
}
@@ -4679,7 +4777,7 @@ function preloadLateStyles(this: Destination, styleQueue: StyleQueue) {
46794777
// flush the entire preamble in a single pass. This probably should be modified
46804778
// in the future to be backpressure sensitive but that requires a larger refactor
46814779
// of the flushing code in Fizz.
4682-
export function writePreamble(
4780+
export function writePreambleStart(
46834781
destination: Destination,
46844782
resumableState: ResumableState,
46854783
renderState: RenderState,
@@ -4700,8 +4798,10 @@ export function writePreamble(
47004798
internalPreinitScript(resumableState, renderState, src, chunks);
47014799
}
47024800

4703-
const htmlChunks = renderState.htmlChunks;
4704-
const headChunks = renderState.headChunks;
4801+
const preamble = renderState.preamble;
4802+
4803+
const htmlChunks = preamble.htmlChunks;
4804+
const headChunks = preamble.headChunks;
47054805

47064806
let i = 0;
47074807

@@ -4773,12 +4873,31 @@ export function writePreamble(
47734873
writeChunk(destination, hoistableChunks[i]);
47744874
}
47754875
hoistableChunks.length = 0;
4876+
}
47764877

4777-
if (htmlChunks && headChunks === null) {
4878+
// We don't bother reporting backpressure at the moment because we expect to
4879+
// flush the entire preamble in a single pass. This probably should be modified
4880+
// in the future to be backpressure sensitive but that requires a larger refactor
4881+
// of the flushing code in Fizz.
4882+
export function writePreambleEnd(
4883+
destination: Destination,
4884+
renderState: RenderState,
4885+
): void {
4886+
const preamble = renderState.preamble;
4887+
const htmlChunks = preamble.htmlChunks;
4888+
const headChunks = preamble.headChunks;
4889+
if (htmlChunks || headChunks) {
47784890
// we have an <html> but we inserted an implicit <head> tag. We need
47794891
// to close it since the main content won't have it
47804892
writeChunk(destination, endChunkForTag('head'));
47814893
}
4894+
4895+
const bodyChunks = preamble.bodyChunks;
4896+
if (bodyChunks) {
4897+
for (let i = 0; i < bodyChunks.length; i++) {
4898+
writeChunk(destination, bodyChunks[i]);
4899+
}
4900+
}
47824901
}
47834902

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

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

+8-5
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type {
1313
StyleQueue,
1414
Resource,
1515
HeadersDescriptor,
16+
PreambleState,
1617
} from './ReactFizzConfigDOM';
1718

1819
import {
@@ -43,8 +44,7 @@ export type RenderState = {
4344
segmentPrefix: PrecomputedChunk,
4445
boundaryPrefix: PrecomputedChunk,
4546
startInlineScript: PrecomputedChunk,
46-
htmlChunks: null | Array<Chunk | PrecomputedChunk>,
47-
headChunks: null | Array<Chunk | PrecomputedChunk>,
47+
preamble: PreambleState,
4848
externalRuntimeScript: null | any,
4949
bootstrapChunks: Array<Chunk | PrecomputedChunk>,
5050
importMapChunks: Array<Chunk | PrecomputedChunk>,
@@ -96,8 +96,7 @@ export function createRenderState(
9696
segmentPrefix: renderState.segmentPrefix,
9797
boundaryPrefix: renderState.boundaryPrefix,
9898
startInlineScript: renderState.startInlineScript,
99-
htmlChunks: renderState.htmlChunks,
100-
headChunks: renderState.headChunks,
99+
preamble: renderState.preamble,
101100
externalRuntimeScript: renderState.externalRuntimeScript,
102101
bootstrapChunks: renderState.bootstrapChunks,
103102
importMapChunks: renderState.importMapChunks,
@@ -156,15 +155,19 @@ export {
156155
writeCompletedRoot,
157156
createRootFormatContext,
158157
createResumableState,
158+
createPreambleState,
159159
createHoistableState,
160-
writePreamble,
160+
writePreambleStart,
161+
writePreambleEnd,
161162
writeHoistables,
162163
writePostamble,
163164
hoistHoistables,
164165
resetResumableState,
165166
completeResumableState,
166167
emitEarlyPreloads,
167168
supportsClientAPIs,
169+
canHavePreamble,
170+
preparePreamble,
168171
} from './ReactFizzConfigDOM';
169172

170173
import escapeTextForBrowser from './escapeTextForBrowser';

packages/react-dom/src/__tests__/ReactDOMFizzDeferredValue-test.js

+5-3
Original file line numberDiff line numberDiff line change
@@ -114,9 +114,11 @@ describe('ReactDOMFizzForm', () => {
114114

115115
function App() {
116116
return (
117-
<Suspense fallback={<Text text="Loading..." />}>
118-
<Content />
119-
</Suspense>
117+
<div>
118+
<Suspense fallback={<Text text="Loading..." />}>
119+
<Content />
120+
</Suspense>
121+
</div>
120122
);
121123
}
122124

0 commit comments

Comments
 (0)