Skip to content

Commit

Permalink
[Fiber] support hydration when rendering Suspense anywhere
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
gnoff committed Jan 25, 2025
1 parent 6cd02da commit 29269cc
Show file tree
Hide file tree
Showing 10 changed files with 769 additions and 40 deletions.
102 changes: 100 additions & 2 deletions packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,8 @@ const SUSPENSE_START_DATA = '$';
const SUSPENSE_END_DATA = '/$';
const SUSPENSE_PENDING_START_DATA = '$?';
const SUSPENSE_FALLBACK_START_DATA = '$!';
const PREAMBLE_CONTRIBUTION_MARKER = 'P';
const PREAMBLE_CONTRIBUTION_INDICATOR = '1';
const FORM_STATE_IS_MATCHING = 'F!';
const FORM_STATE_IS_NOT_MATCHING = 'F';

Expand Down Expand Up @@ -986,6 +988,34 @@ export function clearSuspenseBoundary(
data === SUSPENSE_FALLBACK_START_DATA
) {
depth++;
} else if (data[0] === PREAMBLE_CONTRIBUTION_MARKER) {
const ownerDocument = parentInstance.ownerDocument;
const documentElement: Element = (ownerDocument.documentElement: any);
const head: Element = (ownerDocument.head: any);
const body: Element = (ownerDocument.body: any);

if (data.length === 1) {
// this boundary contributed the entire preamble.
releaseSingletonInstance(documentElement);
releaseSingletonInstance(head);
releaseSingletonInstance(body);
// We need to clear the head because this is the only singleton that can have children that
// were part of this boundary but are not inside this boundary.
clearHead(head);
} else {
if (data[1] === PREAMBLE_CONTRIBUTION_INDICATOR) {
releaseSingletonInstance(documentElement);
}
if (data[2] === PREAMBLE_CONTRIBUTION_INDICATOR) {
releaseSingletonInstance(head);
// We need to clear the head because this is the only singleton that can have children that
// were part of this boundary but are not inside this boundary.
clearHead(head);
}
if (data[3] === PREAMBLE_CONTRIBUTION_INDICATOR) {
releaseSingletonInstance(body);
}
}
}
}
// $FlowFixMe[incompatible-type] we bail out when we get a null
Expand Down Expand Up @@ -1499,7 +1529,7 @@ function clearContainerSparingly(container: Node) {
case 'STYLE': {
continue;
}
// Stylesheet tags are retained because tehy may likely come from 3rd party scripts and extensions
// Stylesheet tags are retained because they may likely come from 3rd party scripts and extensions
case 'LINK': {
if (((node: any): HTMLLinkElement).rel.toLowerCase() === 'stylesheet') {
continue;
Expand All @@ -1511,6 +1541,27 @@ function clearContainerSparingly(container: Node) {
return;
}

function clearHead(head: Element): void {
let node = head.firstChild;
while (node) {
const nextNode = node.nextSibling;
const nodeName = node.nodeName;
if (
isMarkedHoistable(node) ||
nodeName === 'SCRIPT' ||
nodeName === 'STYLE' ||
(nodeName === 'LINK' &&
((node: any): HTMLLinkElement).rel.toLowerCase() === 'stylesheet')
) {
// retain these nodes
} else {
head.removeChild(node);
}
node = nextNode;
}
return;
}

// Making this so we can eventually move all of the instance caching to the commit phase.
// Currently this is only used to associate fiber and props to instances for hydrating
// HostSingletons. The reason we need it here is we only want to make this binding on commit
Expand Down Expand Up @@ -1872,7 +1923,20 @@ export function getFirstHydratableChild(
export function getFirstHydratableChildWithinContainer(
parentContainer: Container,
): null | HydratableInstance {
return getNextHydratable(parentContainer.firstChild);
let parentElement: Element;
switch (parentContainer.nodeType) {
case DOCUMENT_NODE:
parentElement = (parentContainer: any).body;
break;
default: {
if (parentContainer.nodeName === 'HTML') {
parentElement = (parentContainer: any).ownerDocument.body;
} else {
parentElement = (parentContainer: any);
}
}
}
return getNextHydratable(parentElement.firstChild);
}

export function getFirstHydratableChildWithinSuspenseInstance(
Expand All @@ -1881,6 +1945,40 @@ export function getFirstHydratableChildWithinSuspenseInstance(
return getNextHydratable(parentInstance.nextSibling);
}

// If it were possible to have more than one scope singleton in a DOM tree
// we would need to model this as a stack but since you can only have one <head>
// and head is the only singleton that is a scope in DOM we can get away with
// tracking this as a single value.
let previousHydratableOnEnteringScopedSingleton: null | HydratableInstance =
null;

export function getFirstHydratableChildWithinSingleton(
type: string,
singletonInstance: Instance,
currentHydratableInstance: null | HydratableInstance,
): null | HydratableInstance {
if (isSingletonScope(type)) {
previousHydratableOnEnteringScopedSingleton = currentHydratableInstance;
return getNextHydratable(singletonInstance.firstChild);
} else {
return currentHydratableInstance;
}
}

export function getNextHydratableSiblingAfterSingleton(
type: string,
currentHydratableInstance: null | HydratableInstance,
): null | HydratableInstance {
if (isSingletonScope(type)) {
const previousHydratableInstance =
previousHydratableOnEnteringScopedSingleton;
previousHydratableOnEnteringScopedSingleton = null;
return previousHydratableInstance;
} else {
return currentHydratableInstance;
}
}

export function describeHydratableInstanceForDevWarnings(
instance: HydratableInstance,
): string | {type: string, props: $ReadOnly<Props>} {
Expand Down
111 changes: 105 additions & 6 deletions packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
Original file line number Diff line number Diff line change
Expand Up @@ -684,16 +684,25 @@ export function completeResumableState(resumableState: ResumableState): void {
resumableState.bootstrapModules = undefined;
}

const NoContribution /* */ = 0b000;
const HTMLContribution /* */ = 0b001;
const HeadContribution /* */ = 0b010;
const BodyContribution /* */ = 0b100;
const TotalContribution =
HTMLContribution | HeadContribution | BodyContribution;

export type PreambleState = {
htmlChunks: null | Array<Chunk | PrecomputedChunk>,
headChunks: null | Array<Chunk | PrecomputedChunk>,
bodyChunks: null | Array<Chunk | PrecomputedChunk>,
contribution: number,
};
export function createPreambleState(): PreambleState {
return {
htmlChunks: null,
headChunks: null,
bodyChunks: null,
contribution: NoContribution,
};
}

Expand Down Expand Up @@ -3227,7 +3236,7 @@ function pushStartHead(
throw new Error(`The ${'`<head>`'} tag may only be rendered once.`);
}
preamble.headChunks = [];
return pushStartGenericElement(preamble.headChunks, props, 'head');
return pushStartSingletonElement(preamble.headChunks, props, 'head');
} else {
// This <head> is deep and is likely just an error. we emit it inline though.
// Validation should warn that this tag is the the wrong spot.
Expand All @@ -3251,7 +3260,7 @@ function pushStartBody(
}

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

preamble.htmlChunks = [DOCTYPE];
return pushStartGenericElement(preamble.htmlChunks, props, 'html');
return pushStartSingletonElement(preamble.htmlChunks, props, 'html');
} else {
// This <html> is deep and is likely just an error. we emit it inline though.
// Validation should warn that this tag is the the wrong spot.
Expand Down Expand Up @@ -3416,6 +3425,43 @@ function pushScriptImpl(
return null;
}

// This is a fork of pushStartGenericElement because we don't ever want to do
// the children as strign optimization on that path when rendering singletons.
// When we eliminate that special path we can delete this fork and unify it again
function pushStartSingletonElement(
target: Array<Chunk | PrecomputedChunk>,
props: Object,
tag: string,
): ReactNodeList {
target.push(startChunkForTag(tag));

let children = null;
let innerHTML = null;
for (const propKey in props) {
if (hasOwnProperty.call(props, propKey)) {
const propValue = props[propKey];
if (propValue == null) {
continue;
}
switch (propKey) {
case 'children':
children = propValue;
break;
case 'dangerouslySetInnerHTML':
innerHTML = propValue;
break;
default:
pushAttribute(target, propKey, propValue);
break;
}
}
}

target.push(endOfStartTag);
pushInnerHTML(target, innerHTML, children);
return children;
}

function pushStartGenericElement(
target: Array<Chunk | PrecomputedChunk>,
props: Object,
Expand Down Expand Up @@ -3907,14 +3953,17 @@ export function hoistPreambleState(
preambleState: PreambleState,
) {
const rootPreamble = renderState.preamble;
if (rootPreamble.htmlChunks === null) {
if (rootPreamble.htmlChunks === null && preambleState.htmlChunks) {
rootPreamble.htmlChunks = preambleState.htmlChunks;
preambleState.contribution |= HTMLContribution;
}
if (rootPreamble.headChunks === null) {
if (rootPreamble.headChunks === null && preambleState.headChunks) {
rootPreamble.headChunks = preambleState.headChunks;
preambleState.contribution |= HeadContribution;
}
if (rootPreamble.bodyChunks === null) {
if (rootPreamble.bodyChunks === null && preambleState.bodyChunks) {
rootPreamble.bodyChunks = preambleState.bodyChunks;
preambleState.contribution |= BodyContribution;
}
}

Expand Down Expand Up @@ -4005,6 +4054,14 @@ const clientRenderedSuspenseBoundaryError1D =
const clientRenderedSuspenseBoundaryError2 =
stringToPrecomputedChunk('></template>');

const boundaryPreambleContributionChunkTotal =
stringToPrecomputedChunk('<!--P-->');
const boundaryPreambleContributionChunkStart =
stringToPrecomputedChunk('<!--P');
const ContributionChunk = stringToPrecomputedChunk('1');
const NonContributionChunk = stringToPrecomputedChunk(' ');
const boundaryPreambleContributionChunkEnd = stringToPrecomputedChunk('-->');

export function writeStartCompletedSuspenseBoundary(
destination: Destination,
renderState: RenderState,
Expand Down Expand Up @@ -4091,7 +4148,11 @@ export function writeStartClientRenderedSuspenseBoundary(
export function writeEndCompletedSuspenseBoundary(
destination: Destination,
renderState: RenderState,
preambleState: null | PreambleState,
): boolean {
if (preambleState) {
writePreambleContribution(destination, preambleState);
}
return writeChunkAndReturn(destination, endSuspenseBoundary);
}
export function writeEndPendingSuspenseBoundary(
Expand All @@ -4103,9 +4164,47 @@ export function writeEndPendingSuspenseBoundary(
export function writeEndClientRenderedSuspenseBoundary(
destination: Destination,
renderState: RenderState,
preambleState: null | PreambleState,
): boolean {
if (preambleState) {
writePreambleContribution(destination, preambleState);
}
return writeChunkAndReturn(destination, endSuspenseBoundary);
}
function writePreambleContribution(
destination: Destination,
preambleState: PreambleState,
) {
const contribution = preambleState.contribution;
if (contribution !== NoContribution) {
if (contribution === TotalContribution) {
// We shortcut the preamble marker for boundaries that contribute the entire preamble
// since we expect this to be the most common case
writeChunk(destination, boundaryPreambleContributionChunkTotal);
} else {
writeChunk(destination, boundaryPreambleContributionChunkStart);
writeChunk(
destination,
contribution & HTMLContribution
? ContributionChunk
: NonContributionChunk,
);
writeChunk(
destination,
contribution & HeadContribution
? ContributionChunk
: NonContributionChunk,
);
writeChunk(
destination,
contribution & BodyContribution
? ContributionChunk
: NonContributionChunk,
);
writeChunk(destination, boundaryPreambleContributionChunkEnd);
}
}
}

const startSegmentHTML = stringToPrecomputedChunk('<div hidden id="');
const startSegmentHTML2 = stringToPrecomputedChunk('">');
Expand Down
14 changes: 12 additions & 2 deletions packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js
Original file line number Diff line number Diff line change
Expand Up @@ -244,20 +244,30 @@ export function writeStartClientRenderedSuspenseBoundary(
export function writeEndCompletedSuspenseBoundary(
destination: Destination,
renderState: RenderState,
preambleState: PreambleState,
): boolean {
if (renderState.generateStaticMarkup) {
return true;
}
return writeEndCompletedSuspenseBoundaryImpl(destination, renderState);
return writeEndCompletedSuspenseBoundaryImpl(
destination,
renderState,
preambleState,
);
}
export function writeEndClientRenderedSuspenseBoundary(
destination: Destination,
renderState: RenderState,
preambleState: PreambleState,
): boolean {
if (renderState.generateStaticMarkup) {
return true;
}
return writeEndClientRenderedSuspenseBoundaryImpl(destination, renderState);
return writeEndClientRenderedSuspenseBoundaryImpl(
destination,
renderState,
preambleState,
);
}

export type TransitionStatus = FormStatus;
Expand Down
Loading

0 comments on commit 29269cc

Please sign in to comment.