Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 5832fe9

Browse files
committedJan 28, 2025·
[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 6e675b4 commit 5832fe9

11 files changed

+816
-40
lines changed
 

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

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

@@ -986,6 +989,24 @@ export function clearSuspenseBoundary(
986989
data === SUSPENSE_FALLBACK_START_DATA
987990
) {
988991
depth++;
992+
} else if (data.length === 1) {
993+
const ownerDocument = parentInstance.ownerDocument;
994+
const code = data.charCodeAt(0) - 48;
995+
if (code & PREAMBLE_CONTRIBUTION_HTML) {
996+
const documentElement: Element = (ownerDocument.documentElement: any);
997+
releaseSingletonInstance(documentElement);
998+
}
999+
if (code & PREAMBLE_CONTRIBUTION_BODY) {
1000+
const body: Element = (ownerDocument.body: any);
1001+
releaseSingletonInstance(body);
1002+
}
1003+
if (code & PREAMBLE_CONTRIBUTION_HEAD) {
1004+
const head: Element = (ownerDocument.head: any);
1005+
releaseSingletonInstance(head);
1006+
// We need to clear the head because this is the only singleton that can have children that
1007+
// were part of this boundary but are not inside this boundary.
1008+
clearHead(head);
1009+
}
9891010
}
9901011
}
9911012
// $FlowFixMe[incompatible-type] we bail out when we get a null
@@ -1499,7 +1520,7 @@ function clearContainerSparingly(container: Node) {
14991520
case 'STYLE': {
15001521
continue;
15011522
}
1502-
// Stylesheet tags are retained because tehy may likely come from 3rd party scripts and extensions
1523+
// Stylesheet tags are retained because they may likely come from 3rd party scripts and extensions
15031524
case 'LINK': {
15041525
if (((node: any): HTMLLinkElement).rel.toLowerCase() === 'stylesheet') {
15051526
continue;
@@ -1511,6 +1532,27 @@ function clearContainerSparingly(container: Node) {
15111532
return;
15121533
}
15131534

1535+
function clearHead(head: Element): void {
1536+
let node = head.firstChild;
1537+
while (node) {
1538+
const nextNode = node.nextSibling;
1539+
const nodeName = node.nodeName;
1540+
if (
1541+
isMarkedHoistable(node) ||
1542+
nodeName === 'SCRIPT' ||
1543+
nodeName === 'STYLE' ||
1544+
(nodeName === 'LINK' &&
1545+
((node: any): HTMLLinkElement).rel.toLowerCase() === 'stylesheet')
1546+
) {
1547+
// retain these nodes
1548+
} else {
1549+
head.removeChild(node);
1550+
}
1551+
node = nextNode;
1552+
}
1553+
return;
1554+
}
1555+
15141556
// Making this so we can eventually move all of the instance caching to the commit phase.
15151557
// Currently this is only used to associate fiber and props to instances for hydrating
15161558
// HostSingletons. The reason we need it here is we only want to make this binding on commit
@@ -1872,7 +1914,20 @@ export function getFirstHydratableChild(
18721914
export function getFirstHydratableChildWithinContainer(
18731915
parentContainer: Container,
18741916
): null | HydratableInstance {
1875-
return getNextHydratable(parentContainer.firstChild);
1917+
let parentElement: Element;
1918+
switch (parentContainer.nodeType) {
1919+
case DOCUMENT_NODE:
1920+
parentElement = (parentContainer: any).body;
1921+
break;
1922+
default: {
1923+
if (parentContainer.nodeName === 'HTML') {
1924+
parentElement = (parentContainer: any).ownerDocument.body;
1925+
} else {
1926+
parentElement = (parentContainer: any);
1927+
}
1928+
}
1929+
}
1930+
return getNextHydratable(parentElement.firstChild);
18761931
}
18771932

18781933
export function getFirstHydratableChildWithinSuspenseInstance(
@@ -1881,6 +1936,40 @@ export function getFirstHydratableChildWithinSuspenseInstance(
18811936
return getNextHydratable(parentInstance.nextSibling);
18821937
}
18831938

1939+
// If it were possible to have more than one scope singleton in a DOM tree
1940+
// we would need to model this as a stack but since you can only have one <head>
1941+
// and head is the only singleton that is a scope in DOM we can get away with
1942+
// tracking this as a single value.
1943+
let previousHydratableOnEnteringScopedSingleton: null | HydratableInstance =
1944+
null;
1945+
1946+
export function getFirstHydratableChildWithinSingleton(
1947+
type: string,
1948+
singletonInstance: Instance,
1949+
currentHydratableInstance: null | HydratableInstance,
1950+
): null | HydratableInstance {
1951+
if (isSingletonScope(type)) {
1952+
previousHydratableOnEnteringScopedSingleton = currentHydratableInstance;
1953+
return getNextHydratable(singletonInstance.firstChild);
1954+
} else {
1955+
return currentHydratableInstance;
1956+
}
1957+
}
1958+
1959+
export function getNextHydratableSiblingAfterSingleton(
1960+
type: string,
1961+
currentHydratableInstance: null | HydratableInstance,
1962+
): null | HydratableInstance {
1963+
if (isSingletonScope(type)) {
1964+
const previousHydratableInstance =
1965+
previousHydratableOnEnteringScopedSingleton;
1966+
previousHydratableOnEnteringScopedSingleton = null;
1967+
return previousHydratableInstance;
1968+
} else {
1969+
return currentHydratableInstance;
1970+
}
1971+
}
1972+
18841973
export function describeHydratableInstanceForDevWarnings(
18851974
instance: HydratableInstance,
18861975
): 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)
Please sign in to comment.