Skip to content

Commit 37e273b

Browse files
committed
[Fiber] support hydration when rendering Suspense anywhere
stacked on #32163 This continues the work of making Suspense workable anywhere in a react-dom tree. See the prior PRs for how we handle server rendering and client rendering. In this change we update the hydration implementation to be able to locate expected nodes. In particular this means hydration understands now that the default hydration context is the document body when the container is above the body. One case that is unique to hydration is clearing Suspense boundaries. When hydration fails or when the server instructs the client to recover an errored boundary it's possible that the html, head, and body tags in the initial document were written from a fallback or a different primary content on the server and need to be replaced by the client render. However these tags (and in the case of head, their content) won't be inside the comment nodes that identify the bounds of the Suspense boundary. And when client rendering you may not even render the same singletons that were server rendered. So when server rendering a boudnary which contributes to the preamble (the html, head, and body tag openings plus the head contents) we emit a special marker comment just before closing the boundary out. This marker encodes which parts of the preamble this boundary owned. If we need to clear the suspense boundary on the client we read this marker and use it to reset the appropriate singleton state.
1 parent c492f97 commit 37e273b

11 files changed

+826
-40
lines changed

packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js

+101-2
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,9 @@ const SUSPENSE_START_DATA = '$';
207207
const SUSPENSE_END_DATA = '/$';
208208
const SUSPENSE_PENDING_START_DATA = '$?';
209209
const SUSPENSE_FALLBACK_START_DATA = '$!';
210+
const PREAMBLE_CONTRIBUTION_HTML = 0b001;
211+
const PREAMBLE_CONTRIBUTION_BODY = 0b010;
212+
const PREAMBLE_CONTRIBUTION_HEAD = 0b100;
210213
const FORM_STATE_IS_MATCHING = 'F!';
211214
const FORM_STATE_IS_NOT_MATCHING = 'F';
212215

@@ -973,6 +976,34 @@ export function clearSuspenseBoundary(
973976
if (nextNode && nextNode.nodeType === COMMENT_NODE) {
974977
const data = ((nextNode: any).data: string);
975978
if (data === SUSPENSE_END_DATA) {
979+
if (node.nodeType === COMMENT_NODE) {
980+
const code: number = (node: any).data.charCodeAt(0) - 48;
981+
if (code > 0 && code < 8) {
982+
// It's not normally possible to insert a comment immediately preceding Suspense boundary
983+
// closing comment marker so we can infer that if the comment preceding starts with "1" through "7"
984+
// then it is in fact a preamble contribution marker comment. We do this value test to avoid the case
985+
// where the Suspense boundary is empty and the preceding comment marker is the Suspense boundary
986+
// opening marker or the closing marker of an inner boundary. In those cases the first character won't
987+
// have the requisite value to be interpreted as a Preamble contribution
988+
}
989+
const ownerDocument = parentInstance.ownerDocument;
990+
if (code & PREAMBLE_CONTRIBUTION_HTML) {
991+
const documentElement: Element =
992+
(ownerDocument.documentElement: any);
993+
releaseSingletonInstance(documentElement);
994+
}
995+
if (code & PREAMBLE_CONTRIBUTION_BODY) {
996+
const body: Element = (ownerDocument.body: any);
997+
releaseSingletonInstance(body);
998+
}
999+
if (code & PREAMBLE_CONTRIBUTION_HEAD) {
1000+
const head: Element = (ownerDocument.head: any);
1001+
releaseSingletonInstance(head);
1002+
// We need to clear the head because this is the only singleton that can have children that
1003+
// were part of this boundary but are not inside this boundary.
1004+
clearHead(head);
1005+
}
1006+
}
9761007
if (depth === 0) {
9771008
parentInstance.removeChild(nextNode);
9781009
// Retry if any event replaying was blocked on this.
@@ -1501,7 +1532,7 @@ function clearContainerSparingly(container: Node) {
15011532
case 'STYLE': {
15021533
continue;
15031534
}
1504-
// Stylesheet tags are retained because tehy may likely come from 3rd party scripts and extensions
1535+
// Stylesheet tags are retained because they may likely come from 3rd party scripts and extensions
15051536
case 'LINK': {
15061537
if (((node: any): HTMLLinkElement).rel.toLowerCase() === 'stylesheet') {
15071538
continue;
@@ -1513,6 +1544,27 @@ function clearContainerSparingly(container: Node) {
15131544
return;
15141545
}
15151546

1547+
function clearHead(head: Element): void {
1548+
let node = head.firstChild;
1549+
while (node) {
1550+
const nextNode = node.nextSibling;
1551+
const nodeName = node.nodeName;
1552+
if (
1553+
isMarkedHoistable(node) ||
1554+
nodeName === 'SCRIPT' ||
1555+
nodeName === 'STYLE' ||
1556+
(nodeName === 'LINK' &&
1557+
((node: any): HTMLLinkElement).rel.toLowerCase() === 'stylesheet')
1558+
) {
1559+
// retain these nodes
1560+
} else {
1561+
head.removeChild(node);
1562+
}
1563+
node = nextNode;
1564+
}
1565+
return;
1566+
}
1567+
15161568
// Making this so we can eventually move all of the instance caching to the commit phase.
15171569
// Currently this is only used to associate fiber and props to instances for hydrating
15181570
// HostSingletons. The reason we need it here is we only want to make this binding on commit
@@ -1874,7 +1926,20 @@ export function getFirstHydratableChild(
18741926
export function getFirstHydratableChildWithinContainer(
18751927
parentContainer: Container,
18761928
): null | HydratableInstance {
1877-
return getNextHydratable(parentContainer.firstChild);
1929+
let parentElement: Element;
1930+
switch (parentContainer.nodeType) {
1931+
case DOCUMENT_NODE:
1932+
parentElement = (parentContainer: any).body;
1933+
break;
1934+
default: {
1935+
if (parentContainer.nodeName === 'HTML') {
1936+
parentElement = (parentContainer: any).ownerDocument.body;
1937+
} else {
1938+
parentElement = (parentContainer: any);
1939+
}
1940+
}
1941+
}
1942+
return getNextHydratable(parentElement.firstChild);
18781943
}
18791944

18801945
export function getFirstHydratableChildWithinSuspenseInstance(
@@ -1883,6 +1948,40 @@ export function getFirstHydratableChildWithinSuspenseInstance(
18831948
return getNextHydratable(parentInstance.nextSibling);
18841949
}
18851950

1951+
// If it were possible to have more than one scope singleton in a DOM tree
1952+
// we would need to model this as a stack but since you can only have one <head>
1953+
// and head is the only singleton that is a scope in DOM we can get away with
1954+
// tracking this as a single value.
1955+
let previousHydratableOnEnteringScopedSingleton: null | HydratableInstance =
1956+
null;
1957+
1958+
export function getFirstHydratableChildWithinSingleton(
1959+
type: string,
1960+
singletonInstance: Instance,
1961+
currentHydratableInstance: null | HydratableInstance,
1962+
): null | HydratableInstance {
1963+
if (isSingletonScope(type)) {
1964+
previousHydratableOnEnteringScopedSingleton = currentHydratableInstance;
1965+
return getNextHydratable(singletonInstance.firstChild);
1966+
} else {
1967+
return currentHydratableInstance;
1968+
}
1969+
}
1970+
1971+
export function getNextHydratableSiblingAfterSingleton(
1972+
type: string,
1973+
currentHydratableInstance: null | HydratableInstance,
1974+
): null | HydratableInstance {
1975+
if (isSingletonScope(type)) {
1976+
const previousHydratableInstance =
1977+
previousHydratableOnEnteringScopedSingleton;
1978+
previousHydratableOnEnteringScopedSingleton = null;
1979+
return previousHydratableInstance;
1980+
} else {
1981+
return currentHydratableInstance;
1982+
}
1983+
}
1984+
18861985
export function describeHydratableInstanceForDevWarnings(
18871986
instance: HydratableInstance,
18881987
): string | {type: string, props: $ReadOnly<Props>} {

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

+78-6
Original file line numberDiff line numberDiff line change
@@ -684,16 +684,23 @@ export function completeResumableState(resumableState: ResumableState): void {
684684
resumableState.bootstrapModules = undefined;
685685
}
686686

687+
const NoContribution /* */ = 0b000;
688+
const HTMLContribution /* */ = 0b001;
689+
const BodyContribution /* */ = 0b010;
690+
const HeadContribution /* */ = 0b100;
691+
687692
export type PreambleState = {
688693
htmlChunks: null | Array<Chunk | PrecomputedChunk>,
689694
headChunks: null | Array<Chunk | PrecomputedChunk>,
690695
bodyChunks: null | Array<Chunk | PrecomputedChunk>,
696+
contribution: number,
691697
};
692698
export function createPreambleState(): PreambleState {
693699
return {
694700
htmlChunks: null,
695701
headChunks: null,
696702
bodyChunks: null,
703+
contribution: NoContribution,
697704
};
698705
}
699706

@@ -3227,7 +3234,7 @@ function pushStartHead(
32273234
throw new Error(`The ${'`<head>`'} tag may only be rendered once.`);
32283235
}
32293236
preamble.headChunks = [];
3230-
return pushStartGenericElement(preamble.headChunks, props, 'head');
3237+
return pushStartSingletonElement(preamble.headChunks, props, 'head');
32313238
} else {
32323239
// This <head> is deep and is likely just an error. we emit it inline though.
32333240
// Validation should warn that this tag is the the wrong spot.
@@ -3251,7 +3258,7 @@ function pushStartBody(
32513258
}
32523259

32533260
preamble.bodyChunks = [];
3254-
return pushStartGenericElement(preamble.bodyChunks, props, 'body');
3261+
return pushStartSingletonElement(preamble.bodyChunks, props, 'body');
32553262
} else {
32563263
// This <head> is deep and is likely just an error. we emit it inline though.
32573264
// Validation should warn that this tag is the the wrong spot.
@@ -3275,7 +3282,7 @@ function pushStartHtml(
32753282
}
32763283

32773284
preamble.htmlChunks = [DOCTYPE];
3278-
return pushStartGenericElement(preamble.htmlChunks, props, 'html');
3285+
return pushStartSingletonElement(preamble.htmlChunks, props, 'html');
32793286
} else {
32803287
// This <html> is deep and is likely just an error. we emit it inline though.
32813288
// Validation should warn that this tag is the the wrong spot.
@@ -3416,6 +3423,43 @@ function pushScriptImpl(
34163423
return null;
34173424
}
34183425

3426+
// This is a fork of pushStartGenericElement because we don't ever want to do
3427+
// the children as strign optimization on that path when rendering singletons.
3428+
// When we eliminate that special path we can delete this fork and unify it again
3429+
function pushStartSingletonElement(
3430+
target: Array<Chunk | PrecomputedChunk>,
3431+
props: Object,
3432+
tag: string,
3433+
): ReactNodeList {
3434+
target.push(startChunkForTag(tag));
3435+
3436+
let children = null;
3437+
let innerHTML = null;
3438+
for (const propKey in props) {
3439+
if (hasOwnProperty.call(props, propKey)) {
3440+
const propValue = props[propKey];
3441+
if (propValue == null) {
3442+
continue;
3443+
}
3444+
switch (propKey) {
3445+
case 'children':
3446+
children = propValue;
3447+
break;
3448+
case 'dangerouslySetInnerHTML':
3449+
innerHTML = propValue;
3450+
break;
3451+
default:
3452+
pushAttribute(target, propKey, propValue);
3453+
break;
3454+
}
3455+
}
3456+
}
3457+
3458+
target.push(endOfStartTag);
3459+
pushInnerHTML(target, innerHTML, children);
3460+
return children;
3461+
}
3462+
34193463
function pushStartGenericElement(
34203464
target: Array<Chunk | PrecomputedChunk>,
34213465
props: Object,
@@ -3907,14 +3951,17 @@ export function hoistPreambleState(
39073951
preambleState: PreambleState,
39083952
) {
39093953
const rootPreamble = renderState.preamble;
3910-
if (rootPreamble.htmlChunks === null) {
3954+
if (rootPreamble.htmlChunks === null && preambleState.htmlChunks) {
39113955
rootPreamble.htmlChunks = preambleState.htmlChunks;
3956+
preambleState.contribution |= HTMLContribution;
39123957
}
3913-
if (rootPreamble.headChunks === null) {
3958+
if (rootPreamble.headChunks === null && preambleState.headChunks) {
39143959
rootPreamble.headChunks = preambleState.headChunks;
3960+
preambleState.contribution |= HeadContribution;
39153961
}
3916-
if (rootPreamble.bodyChunks === null) {
3962+
if (rootPreamble.bodyChunks === null && preambleState.bodyChunks) {
39173963
rootPreamble.bodyChunks = preambleState.bodyChunks;
3964+
preambleState.contribution |= BodyContribution;
39183965
}
39193966
}
39203967

@@ -4091,7 +4138,11 @@ export function writeStartClientRenderedSuspenseBoundary(
40914138
export function writeEndCompletedSuspenseBoundary(
40924139
destination: Destination,
40934140
renderState: RenderState,
4141+
preambleState: null | PreambleState,
40944142
): boolean {
4143+
if (preambleState) {
4144+
writePreambleContribution(destination, preambleState);
4145+
}
40954146
return writeChunkAndReturn(destination, endSuspenseBoundary);
40964147
}
40974148
export function writeEndPendingSuspenseBoundary(
@@ -4103,10 +4154,31 @@ export function writeEndPendingSuspenseBoundary(
41034154
export function writeEndClientRenderedSuspenseBoundary(
41044155
destination: Destination,
41054156
renderState: RenderState,
4157+
preambleState: null | PreambleState,
41064158
): boolean {
4159+
if (preambleState) {
4160+
writePreambleContribution(destination, preambleState);
4161+
}
41074162
return writeChunkAndReturn(destination, endSuspenseBoundary);
41084163
}
41094164

4165+
const boundaryPreambleContributionChunkStart = stringToPrecomputedChunk('<!--');
4166+
const boundaryPreambleContributionChunkEnd = stringToPrecomputedChunk('-->');
4167+
4168+
function writePreambleContribution(
4169+
destination: Destination,
4170+
preambleState: PreambleState,
4171+
) {
4172+
const contribution = preambleState.contribution;
4173+
if (contribution !== NoContribution) {
4174+
writeChunk(destination, boundaryPreambleContributionChunkStart);
4175+
// This is a number type so we can do the fast path without coercion checking
4176+
// eslint-disable-next-line react-internal/safe-string-coercion
4177+
writeChunk(destination, stringToChunk('' + contribution));
4178+
writeChunk(destination, boundaryPreambleContributionChunkEnd);
4179+
}
4180+
}
4181+
41104182
const startSegmentHTML = stringToPrecomputedChunk('<div hidden id="');
41114183
const startSegmentHTML2 = stringToPrecomputedChunk('">');
41124184
const endSegmentHTML = stringToPrecomputedChunk('</div>');

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

+12-2
Original file line numberDiff line numberDiff line change
@@ -244,20 +244,30 @@ export function writeStartClientRenderedSuspenseBoundary(
244244
export function writeEndCompletedSuspenseBoundary(
245245
destination: Destination,
246246
renderState: RenderState,
247+
preambleState: null | PreambleState,
247248
): boolean {
248249
if (renderState.generateStaticMarkup) {
249250
return true;
250251
}
251-
return writeEndCompletedSuspenseBoundaryImpl(destination, renderState);
252+
return writeEndCompletedSuspenseBoundaryImpl(
253+
destination,
254+
renderState,
255+
preambleState,
256+
);
252257
}
253258
export function writeEndClientRenderedSuspenseBoundary(
254259
destination: Destination,
255260
renderState: RenderState,
261+
preambleState: null | PreambleState,
256262
): boolean {
257263
if (renderState.generateStaticMarkup) {
258264
return true;
259265
}
260-
return writeEndClientRenderedSuspenseBoundaryImpl(destination, renderState);
266+
return writeEndClientRenderedSuspenseBoundaryImpl(
267+
destination,
268+
renderState,
269+
preambleState,
270+
);
261271
}
262272

263273
export type TransitionStatus = FormStatus;

0 commit comments

Comments
 (0)