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 0e39257

Browse files
committedFeb 1, 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 c492f97 commit 0e39257

11 files changed

+833
-40
lines changed
 

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

+108-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

@@ -963,6 +966,7 @@ export function clearSuspenseBoundary(
963966
suspenseInstance: SuspenseInstance,
964967
): void {
965968
let node: Node = suspenseInstance;
969+
let possiblePreambleContribution: number = 0;
966970
// Delete all nodes within this suspense boundary.
967971
// There might be nested nodes so we need to keep track of how
968972
// deep we are and only break out when we're back on top.
@@ -973,6 +977,36 @@ export function clearSuspenseBoundary(
973977
if (nextNode && nextNode.nodeType === COMMENT_NODE) {
974978
const data = ((nextNode: any).data: string);
975979
if (data === SUSPENSE_END_DATA) {
980+
if (
981+
// represents 3 bits where at least one bit is set (1-7)
982+
possiblePreambleContribution > 0 &&
983+
possiblePreambleContribution < 8
984+
) {
985+
const code = possiblePreambleContribution;
986+
// It's not normally possible to insert a comment immediately preceding Suspense boundary
987+
// closing comment marker so we can infer that if the comment preceding starts with "1" through "7"
988+
// then it is in fact a preamble contribution marker comment. We do this value test to avoid the case
989+
// where the Suspense boundary is empty and the preceding comment marker is the Suspense boundary
990+
// opening marker or the closing marker of an inner boundary. In those cases the first character won't
991+
// have the requisite value to be interpreted as a Preamble contribution
992+
const ownerDocument = parentInstance.ownerDocument;
993+
if (code & PREAMBLE_CONTRIBUTION_HTML) {
994+
const documentElement: Element =
995+
(ownerDocument.documentElement: any);
996+
releaseSingletonInstance(documentElement);
997+
}
998+
if (code & PREAMBLE_CONTRIBUTION_BODY) {
999+
const body: Element = (ownerDocument.body: any);
1000+
releaseSingletonInstance(body);
1001+
}
1002+
if (code & PREAMBLE_CONTRIBUTION_HEAD) {
1003+
const head: Element = (ownerDocument.head: any);
1004+
releaseSingletonInstance(head);
1005+
// We need to clear the head because this is the only singleton that can have children that
1006+
// were part of this boundary but are not inside this boundary.
1007+
clearHead(head);
1008+
}
1009+
}
9761010
if (depth === 0) {
9771011
parentInstance.removeChild(nextNode);
9781012
// Retry if any event replaying was blocked on this.
@@ -987,7 +1021,11 @@ export function clearSuspenseBoundary(
9871021
data === SUSPENSE_FALLBACK_START_DATA
9881022
) {
9891023
depth++;
1024+
} else {
1025+
possiblePreambleContribution = data.charCodeAt(0) - 48;
9901026
}
1027+
} else {
1028+
possiblePreambleContribution = 0;
9911029
}
9921030
// $FlowFixMe[incompatible-type] we bail out when we get a null
9931031
node = nextNode;
@@ -1501,7 +1539,7 @@ function clearContainerSparingly(container: Node) {
15011539
case 'STYLE': {
15021540
continue;
15031541
}
1504-
// Stylesheet tags are retained because tehy may likely come from 3rd party scripts and extensions
1542+
// Stylesheet tags are retained because they may likely come from 3rd party scripts and extensions
15051543
case 'LINK': {
15061544
if (((node: any): HTMLLinkElement).rel.toLowerCase() === 'stylesheet') {
15071545
continue;
@@ -1513,6 +1551,27 @@ function clearContainerSparingly(container: Node) {
15131551
return;
15141552
}
15151553

1554+
function clearHead(head: Element): void {
1555+
let node = head.firstChild;
1556+
while (node) {
1557+
const nextNode = node.nextSibling;
1558+
const nodeName = node.nodeName;
1559+
if (
1560+
isMarkedHoistable(node) ||
1561+
nodeName === 'SCRIPT' ||
1562+
nodeName === 'STYLE' ||
1563+
(nodeName === 'LINK' &&
1564+
((node: any): HTMLLinkElement).rel.toLowerCase() === 'stylesheet')
1565+
) {
1566+
// retain these nodes
1567+
} else {
1568+
head.removeChild(node);
1569+
}
1570+
node = nextNode;
1571+
}
1572+
return;
1573+
}
1574+
15161575
// Making this so we can eventually move all of the instance caching to the commit phase.
15171576
// Currently this is only used to associate fiber and props to instances for hydrating
15181577
// HostSingletons. The reason we need it here is we only want to make this binding on commit
@@ -1874,7 +1933,20 @@ export function getFirstHydratableChild(
18741933
export function getFirstHydratableChildWithinContainer(
18751934
parentContainer: Container,
18761935
): null | HydratableInstance {
1877-
return getNextHydratable(parentContainer.firstChild);
1936+
let parentElement: Element;
1937+
switch (parentContainer.nodeType) {
1938+
case DOCUMENT_NODE:
1939+
parentElement = (parentContainer: any).body;
1940+
break;
1941+
default: {
1942+
if (parentContainer.nodeName === 'HTML') {
1943+
parentElement = (parentContainer: any).ownerDocument.body;
1944+
} else {
1945+
parentElement = (parentContainer: any);
1946+
}
1947+
}
1948+
}
1949+
return getNextHydratable(parentElement.firstChild);
18781950
}
18791951

18801952
export function getFirstHydratableChildWithinSuspenseInstance(
@@ -1883,6 +1955,40 @@ export function getFirstHydratableChildWithinSuspenseInstance(
18831955
return getNextHydratable(parentInstance.nextSibling);
18841956
}
18851957

1958+
// If it were possible to have more than one scope singleton in a DOM tree
1959+
// we would need to model this as a stack but since you can only have one <head>
1960+
// and head is the only singleton that is a scope in DOM we can get away with
1961+
// tracking this as a single value.
1962+
let previousHydratableOnEnteringScopedSingleton: null | HydratableInstance =
1963+
null;
1964+
1965+
export function getFirstHydratableChildWithinSingleton(
1966+
type: string,
1967+
singletonInstance: Instance,
1968+
currentHydratableInstance: null | HydratableInstance,
1969+
): null | HydratableInstance {
1970+
if (isSingletonScope(type)) {
1971+
previousHydratableOnEnteringScopedSingleton = currentHydratableInstance;
1972+
return getNextHydratable(singletonInstance.firstChild);
1973+
} else {
1974+
return currentHydratableInstance;
1975+
}
1976+
}
1977+
1978+
export function getNextHydratableSiblingAfterSingleton(
1979+
type: string,
1980+
currentHydratableInstance: null | HydratableInstance,
1981+
): null | HydratableInstance {
1982+
if (isSingletonScope(type)) {
1983+
const previousHydratableInstance =
1984+
previousHydratableOnEnteringScopedSingleton;
1985+
previousHydratableOnEnteringScopedSingleton = null;
1986+
return previousHydratableInstance;
1987+
} else {
1988+
return currentHydratableInstance;
1989+
}
1990+
}
1991+
18861992
export function describeHydratableInstanceForDevWarnings(
18871993
instance: HydratableInstance,
18881994
): 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.