[Fiber] support hydration when rendering Suspense anywhere (#32224)

follow up to https://github.com/facebook/react/pull/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.
This commit is contained in:
Josh Story
2025-02-04 12:30:30 -08:00
committed by GitHub
parent bbce0b625a
commit 007bf8a64a
11 changed files with 833 additions and 40 deletions
+108 -2
View File
@@ -207,6 +207,9 @@ const SUSPENSE_START_DATA = '$';
const SUSPENSE_END_DATA = '/$';
const SUSPENSE_PENDING_START_DATA = '$?';
const SUSPENSE_FALLBACK_START_DATA = '$!';
const PREAMBLE_CONTRIBUTION_HTML = 0b001;
const PREAMBLE_CONTRIBUTION_BODY = 0b010;
const PREAMBLE_CONTRIBUTION_HEAD = 0b100;
const FORM_STATE_IS_MATCHING = 'F!';
const FORM_STATE_IS_NOT_MATCHING = 'F';
@@ -963,6 +966,7 @@ export function clearSuspenseBoundary(
suspenseInstance: SuspenseInstance,
): void {
let node: Node = suspenseInstance;
let possiblePreambleContribution: number = 0;
// Delete all nodes within this suspense boundary.
// There might be nested nodes so we need to keep track of how
// deep we are and only break out when we're back on top.
@@ -973,6 +977,36 @@ export function clearSuspenseBoundary(
if (nextNode && nextNode.nodeType === COMMENT_NODE) {
const data = ((nextNode: any).data: string);
if (data === SUSPENSE_END_DATA) {
if (
// represents 3 bits where at least one bit is set (1-7)
possiblePreambleContribution > 0 &&
possiblePreambleContribution < 8
) {
const code = possiblePreambleContribution;
// It's not normally possible to insert a comment immediately preceding Suspense boundary
// closing comment marker so we can infer that if the comment preceding starts with "1" through "7"
// then it is in fact a preamble contribution marker comment. We do this value test to avoid the case
// where the Suspense boundary is empty and the preceding comment marker is the Suspense boundary
// opening marker or the closing marker of an inner boundary. In those cases the first character won't
// have the requisite value to be interpreted as a Preamble contribution
const ownerDocument = parentInstance.ownerDocument;
if (code & PREAMBLE_CONTRIBUTION_HTML) {
const documentElement: Element =
(ownerDocument.documentElement: any);
releaseSingletonInstance(documentElement);
}
if (code & PREAMBLE_CONTRIBUTION_BODY) {
const body: Element = (ownerDocument.body: any);
releaseSingletonInstance(body);
}
if (code & PREAMBLE_CONTRIBUTION_HEAD) {
const head: Element = (ownerDocument.head: any);
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 (depth === 0) {
parentInstance.removeChild(nextNode);
// Retry if any event replaying was blocked on this.
@@ -987,7 +1021,11 @@ export function clearSuspenseBoundary(
data === SUSPENSE_FALLBACK_START_DATA
) {
depth++;
} else {
possiblePreambleContribution = data.charCodeAt(0) - 48;
}
} else {
possiblePreambleContribution = 0;
}
// $FlowFixMe[incompatible-type] we bail out when we get a null
node = nextNode;
@@ -1501,7 +1539,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;
@@ -1513,6 +1551,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
@@ -1874,7 +1933,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(
@@ -1883,6 +1955,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>} {
@@ -684,16 +684,23 @@ export function completeResumableState(resumableState: ResumableState): void {
resumableState.bootstrapModules = undefined;
}
const NoContribution /* */ = 0b000;
const HTMLContribution /* */ = 0b001;
const BodyContribution /* */ = 0b010;
const HeadContribution /* */ = 0b100;
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,
};
}
@@ -3227,7 +3234,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.
@@ -3251,7 +3258,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.
@@ -3275,7 +3282,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.
@@ -3416,6 +3423,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,
@@ -3907,14 +3951,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;
}
}
@@ -4091,7 +4138,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(
@@ -4103,10 +4154,31 @@ export function writeEndPendingSuspenseBoundary(
export function writeEndClientRenderedSuspenseBoundary(
destination: Destination,
renderState: RenderState,
preambleState: null | PreambleState,
): boolean {
if (preambleState) {
writePreambleContribution(destination, preambleState);
}
return writeChunkAndReturn(destination, endSuspenseBoundary);
}
const boundaryPreambleContributionChunkStart = stringToPrecomputedChunk('<!--');
const boundaryPreambleContributionChunkEnd = stringToPrecomputedChunk('-->');
function writePreambleContribution(
destination: Destination,
preambleState: PreambleState,
) {
const contribution = preambleState.contribution;
if (contribution !== NoContribution) {
writeChunk(destination, boundaryPreambleContributionChunkStart);
// This is a number type so we can do the fast path without coercion checking
// eslint-disable-next-line react-internal/safe-string-coercion
writeChunk(destination, stringToChunk('' + contribution));
writeChunk(destination, boundaryPreambleContributionChunkEnd);
}
}
const startSegmentHTML = stringToPrecomputedChunk('<div hidden id="');
const startSegmentHTML2 = stringToPrecomputedChunk('">');
const endSegmentHTML = stringToPrecomputedChunk('</div>');
@@ -244,20 +244,30 @@ export function writeStartClientRenderedSuspenseBoundary(
export function writeEndCompletedSuspenseBoundary(
destination: Destination,
renderState: RenderState,
preambleState: null | PreambleState,
): boolean {
if (renderState.generateStaticMarkup) {
return true;
}
return writeEndCompletedSuspenseBoundaryImpl(destination, renderState);
return writeEndCompletedSuspenseBoundaryImpl(
destination,
renderState,
preambleState,
);
}
export function writeEndClientRenderedSuspenseBoundary(
destination: Destination,
renderState: RenderState,
preambleState: null | PreambleState,
): boolean {
if (renderState.generateStaticMarkup) {
return true;
}
return writeEndClientRenderedSuspenseBoundaryImpl(destination, renderState);
return writeEndClientRenderedSuspenseBoundaryImpl(
destination,
renderState,
preambleState,
);
}
export type TransitionStatus = FormStatus;
+580 -8
View File
@@ -8923,7 +8923,7 @@ describe('ReactDOMFizzServer', () => {
);
});
it('can server render Suspense before, after, and around <html>', async () => {
it('can render Suspense before, after, and around <html>', async () => {
function BlockedOn({value, children}) {
readText(value);
return children;
@@ -8989,9 +8989,33 @@ describe('ReactDOMFizzServer', () => {
</body>
</html>,
);
const root = ReactDOMClient.hydrateRoot(document, <App />);
await waitForAll([]);
expect(getVisibleChildren(document)).toEqual(
<html lang="en">
<head>
<meta itemprop="" content="non-floaty meta" />
</head>
<body>
<div>before</div>
<div>hello world</div>
<div>after</div>
</body>
</html>,
);
assertConsoleErrorDev(['In HTML, <div> cannot be a child of <#document>']);
root.unmount();
expect(getVisibleChildren(document)).toEqual(
<html>
<head />
<body />
</html>,
);
});
it('can server render Suspense before, after, and around <body>', async () => {
it('can render Suspense before, after, and around <body>', async () => {
function BlockedOn({value, children}) {
readText(value);
return children;
@@ -9052,9 +9076,83 @@ describe('ReactDOMFizzServer', () => {
</body>
</html>,
);
const root = ReactDOMClient.hydrateRoot(document, <App />);
await waitForAll([]);
expect(getVisibleChildren(document)).toEqual(
<html>
<head>
<meta content="before" />
<meta content="after" />
</head>
<body lang="en">
<meta itemprop="" content="before" />
<div>hello world</div>
<meta itemprop="" content="after" />
</body>
</html>,
);
if (gate(flags => flags.enableOwnerStacks)) {
assertConsoleErrorDev([
[
'Cannot render a <meta> outside the main document if it has an `itemProp` prop. `itemProp` suggests the tag belongs to an `itemScope` which can appear anywhere in the DOM. If you were intending for React to hoist this <meta> remove the `itemProp` prop. Otherwise, try moving this tag into the <head> or <body> of the Document.',
{withoutStack: true},
],
'In HTML, <meta> cannot be a child of <html>.\nThis will cause a hydration error.' +
'\n' +
'\n <App>' +
'\n> <html>' +
'\n <Suspense fallback="this fallb...">' +
'\n <meta>' +
'\n> <meta itemProp="" content="before">' +
'\n ...' +
'\n' +
'\n in meta (at **)' +
'\n in App (at **)',
'<html> cannot contain a nested <meta>.\nSee this log for the ancestor stack trace.' +
'\n in html (at **)' +
'\n in App (at **)',
[
'Cannot render a <meta> outside the main document if it has an `itemProp` prop. `itemProp` suggests the tag belongs to an `itemScope` which can appear anywhere in the DOM. If you were intending for React to hoist this <meta> remove the `itemProp` prop. Otherwise, try moving this tag into the <head> or <body> of the Document.',
{withoutStack: true},
],
]);
} else {
assertConsoleErrorDev([
'Cannot render a <meta> outside the main document if it has an `itemProp` prop. `itemProp` suggests the tag belongs to an `itemScope` which can appear anywhere in the DOM. If you were intending for React to hoist this <meta> remove the `itemProp` prop. Otherwise, try moving this tag into the <head> or <body> of the Document.' +
'\n in Suspense (at **)' +
'\n in html (at **)' +
'\n in App (at **)',
'In HTML, <meta> cannot be a child of <html>.\nThis will cause a hydration error.' +
'\n' +
'\n <App>' +
'\n> <html>' +
'\n <Suspense fallback="this fallb...">' +
'\n <meta>' +
'\n> <meta itemProp="" content="before">' +
'\n ...' +
'\n' +
'\n in meta (at **)' +
'\n in Suspense (at **)' +
'\n in html (at **)' +
'\n in App (at **)',
'Cannot render a <meta> outside the main document if it has an `itemProp` prop. `itemProp` suggests the tag belongs to an `itemScope` which can appear anywhere in the DOM. If you were intending for React to hoist this <meta> remove the `itemProp` prop. Otherwise, try moving this tag into the <head> or <body> of the Document.' +
'\n in Suspense (at **)' +
'\n in html (at **)' +
'\n in App (at **)',
]);
}
await root.unmount();
expect(getVisibleChildren(document)).toEqual(
<html>
<head />
<body />
</html>,
);
});
it('can server render Suspense before, after, and around <head>', async () => {
it('can render Suspense before, after, and around <head>', async () => {
function BlockedOn({value, children}) {
readText(value);
return children;
@@ -9119,11 +9217,90 @@ describe('ReactDOMFizzServer', () => {
</body>
</html>,
);
const root = ReactDOMClient.hydrateRoot(document, <App />);
await waitForAll([]);
expect(getVisibleChildren(document)).toEqual(
<html>
<head lang="en">
<meta content="before" />
<meta content="after" />
<meta itemprop="" />
</head>
<body>
<meta itemprop="" content="before" />
<meta itemprop="" content="after" />
<div>hello world</div>
</body>
</html>,
);
if (gate(flags => flags.enableOwnerStacks)) {
assertConsoleErrorDev([
[
'Cannot render a <meta> outside the main document if it has an `itemProp` prop. `itemProp` suggests the tag belongs to an `itemScope` which can appear anywhere in the DOM. If you were intending for React to hoist this <meta> remove the `itemProp` prop. Otherwise, try moving this tag into the <head> or <body> of the Document.',
{withoutStack: true},
],
'In HTML, <meta> cannot be a child of <html>.\nThis will cause a hydration error.' +
'\n' +
'\n <App>' +
'\n> <html>' +
'\n <Suspense fallback="this fallb...">' +
'\n <meta>' +
'\n> <meta itemProp="" content="before">' +
'\n ...' +
'\n' +
'\n in meta (at **)' +
'\n in App (at **)',
'<html> cannot contain a nested <meta>.\nSee this log for the ancestor stack trace.' +
'\n in html (at **)' +
'\n in App (at **)',
[
'Cannot render a <meta> outside the main document if it has an `itemProp` prop. `itemProp` suggests the tag belongs to an `itemScope` which can appear anywhere in the DOM. If you were intending for React to hoist this <meta> remove the `itemProp` prop. Otherwise, try moving this tag into the <head> or <body> of the Document.',
{withoutStack: true},
],
]);
} else {
assertConsoleErrorDev([
'Cannot render a <meta> outside the main document if it has an `itemProp` prop. `itemProp` suggests the tag belongs to an `itemScope` which can appear anywhere in the DOM. If you were intending for React to hoist this <meta> remove the `itemProp` prop. Otherwise, try moving this tag into the <head> or <body> of the Document.' +
'\n in Suspense (at **)' +
'\n in html (at **)' +
'\n in App (at **)',
'In HTML, <meta> cannot be a child of <html>.\nThis will cause a hydration error.' +
'\n' +
'\n <App>' +
'\n> <html>' +
'\n <Suspense fallback="this fallb...">' +
'\n <meta>' +
'\n> <meta itemProp="" content="before">' +
'\n ...' +
'\n' +
'\n in meta (at **)' +
'\n in Suspense (at **)' +
'\n in html (at **)' +
'\n in App (at **)',
'Cannot render a <meta> outside the main document if it has an `itemProp` prop. `itemProp` suggests the tag belongs to an `itemScope` which can appear anywhere in the DOM. If you were intending for React to hoist this <meta> remove the `itemProp` prop. Otherwise, try moving this tag into the <head> or <body> of the Document.' +
'\n in Suspense (at **)' +
'\n in html (at **)' +
'\n in App (at **)',
]);
}
await root.unmount();
expect(getVisibleChildren(document)).toEqual(
<html>
<head />
<body />
</html>,
);
});
it('will render fallback Document when erroring a boundary above the body', async () => {
it('will render fallback Document when erroring a boundary above the body and recover on the client', async () => {
let serverRendering = true;
function Boom() {
throw new Error('Boom!');
if (serverRendering) {
throw new Error('Boom!');
}
return null;
}
function App() {
@@ -9174,11 +9351,50 @@ describe('ReactDOMFizzServer', () => {
</body>
</html>,
);
serverRendering = false;
const recoverableErrors = [];
const root = ReactDOMClient.hydrateRoot(document, <App />, {
onRecoverableError(err) {
recoverableErrors.push(err);
},
});
await waitForAll([]);
expect(getVisibleChildren(document)).toEqual(
<html data-content-html="">
<head />
<body data-content-body="">
<span>hello world</span>
</body>
</html>,
);
expect(recoverableErrors).toEqual([
__DEV__
? new Error(
'Switched to client rendering because the server rendering errored:\n\nBoom!',
)
: new Error(
'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
),
]);
root.unmount();
expect(getVisibleChildren(document)).toEqual(
<html>
<head />
<body />
</html>,
);
});
it('will hoist resources and hositables from a primary tree into the <head> of a client rendered fallback', async () => {
let serverRendering = true;
function Boom() {
throw new Error('Boom!');
if (serverRendering) {
throw new Error('Boom!');
}
return null;
}
function App() {
@@ -9255,6 +9471,65 @@ describe('ReactDOMFizzServer', () => {
</body>
</html>,
);
serverRendering = false;
const recoverableErrors = [];
const root = ReactDOMClient.hydrateRoot(document, <App />, {
onRecoverableError(err) {
recoverableErrors.push(err);
},
});
await waitForAll([]);
expect(recoverableErrors).toEqual([
__DEV__
? new Error(
'Switched to client rendering because the server rendering errored:\n\nBoom!',
)
: new Error(
'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
),
]);
expect(getVisibleChildren(document)).toEqual(
<html data-content-html="">
<head>
<link
rel="stylesheet"
href="hoistable before"
data-precedence="default"
/>
<link
rel="stylesheet"
href="hoistable after"
data-precedence="default"
/>
<meta content="hoistable before" />
<meta content="hoistable after" />
</head>
<body data-content-body="">
<span>hello world</span>
</body>
</html>,
);
root.unmount();
expect(getVisibleChildren(document)).toEqual(
<html>
<head>
<link
rel="stylesheet"
href="hoistable before"
data-precedence="default"
/>
<link
rel="stylesheet"
href="hoistable after"
data-precedence="default"
/>
</head>
<body />
</html>,
);
});
it('Will wait to flush Document chunks until all boundaries which might contain a preamble are errored or resolved', async () => {
@@ -9353,8 +9628,12 @@ describe('ReactDOMFizzServer', () => {
});
it('Can render a fallback <head> alongside a non-fallback body', async () => {
let serverRendering = true;
function Boom() {
throw new Error('Boom!');
if (serverRendering) {
throw new Error('Boom!');
}
return null;
}
function App() {
@@ -9416,11 +9695,52 @@ describe('ReactDOMFizzServer', () => {
</body>
</html>,
);
serverRendering = false;
const recoverableErrors = [];
const root = ReactDOMClient.hydrateRoot(document, <App />, {
onRecoverableError(err) {
recoverableErrors.push(err);
},
});
await waitForAll([]);
expect(recoverableErrors).toEqual([
__DEV__
? new Error(
'Switched to client rendering because the server rendering errored:\n\nBoom!',
)
: new Error(
'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
),
]);
expect(getVisibleChildren(document)).toEqual(
<html>
<head data-primary="">
<meta itemprop="" content="primary" />
</head>
<body data-primary="">
<div>primary body</div>
</body>
</html>,
);
root.unmount();
expect(getVisibleChildren(document)).toEqual(
<html>
<head />
<body />
</html>,
);
});
it('Can render a fallback <body> alongside a non-fallback head', async () => {
let serverRendering = true;
function Boom() {
throw new Error('Boom!');
if (serverRendering) {
throw new Error('Boom!');
}
return null;
}
function App() {
@@ -9482,6 +9802,43 @@ describe('ReactDOMFizzServer', () => {
</body>
</html>,
);
serverRendering = false;
const recoverableErrors = [];
const root = ReactDOMClient.hydrateRoot(document, <App />, {
onRecoverableError(err) {
recoverableErrors.push(err);
},
});
await waitForAll([]);
expect(getVisibleChildren(document)).toEqual(
<html>
<head data-primary="">
<meta itemprop="" content="primary" />
</head>
<body data-primary="">
<div>primary body</div>
</body>
</html>,
);
expect(recoverableErrors).toEqual([
__DEV__
? new Error(
'Switched to client rendering because the server rendering errored:\n\nBoom!',
)
: new Error(
'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
),
]);
root.unmount();
expect(getVisibleChildren(document)).toEqual(
<html>
<head />
<body />
</html>,
);
});
it('Can render a <head> outside of a containing <html>', async () => {
@@ -9528,6 +9885,27 @@ describe('ReactDOMFizzServer', () => {
</body>
</html>,
);
const root = ReactDOMClient.hydrateRoot(document, <App />);
await waitForAll([]);
expect(getVisibleChildren(document)).toEqual(
<html data-x="">
<head data-y="">
<meta itemprop="" />
</head>
<body data-x="">
<span>hello world</span>
</body>
</html>,
);
root.unmount();
expect(getVisibleChildren(document)).toEqual(
<html>
<head />
<body />
</html>,
);
});
it('can render preamble tags in deeply nested indirect component trees', async () => {
@@ -9661,6 +10039,28 @@ describe('ReactDOMFizzServer', () => {
</body>
</html>,
);
const root = ReactDOMClient.hydrateRoot(document, <App />);
await waitForAll([]);
expect(getVisibleChildren(document)).toEqual(
<html lang="es">
<head data-main="">
<meta content="author" />
<meta content="published date" />
</head>
<body data-main="">
<div>This is soooo cool!</div>
</body>
</html>,
);
root.unmount();
expect(getVisibleChildren(document)).toEqual(
<html>
<head />
<body />
</html>,
);
});
it('will flush the preamble as soon as a complete preamble is available', async () => {
@@ -9740,5 +10140,177 @@ describe('ReactDOMFizzServer', () => {
</body>
</html>,
);
const root = ReactDOMClient.hydrateRoot(document, <App />);
await waitForAll([]);
expect(getVisibleChildren(document)).toEqual(
<html>
<head>
<meta content="head" />
</head>
<body>
loading before...
<div>body</div>
loading after...
</body>
</html>,
);
await act(() => {
resolveText('before');
resolveText('after');
});
await waitForAll([]);
expect(getVisibleChildren(document)).toEqual(
<html>
<head>
<meta content="head" />
</head>
<body>
<div>before</div>
<div>body</div>
<div>after</div>
</body>
</html>,
);
assertConsoleErrorDev(['In HTML, <div> cannot be a child of <#document>']);
root.unmount();
expect(getVisibleChildren(document)).toEqual(
<html>
<head />
<body />
</html>,
);
});
it('will clean up the head when a hydration mismatch causes a boundary to recover on the client', async () => {
let content = 'server';
function ServerApp() {
return (
<Suspense>
<html data-x={content}>
<head data-x={content}>
<meta itemProp="" content={content} />
</head>
<body data-x={content}>{content}</body>
</html>
</Suspense>
);
}
function ClientApp() {
return (
<Suspense>
<html data-y={content}>
<head data-y={content}>
<meta itemProp="" name={content} />
</head>
<body data-y={content}>{content}</body>
</html>
</Suspense>
);
}
await act(() => {
const {pipe} = renderToPipeableStream(<ServerApp />);
pipe(writable);
});
expect(getVisibleChildren(document)).toEqual(
<html data-x="server">
<head data-x="server">
<meta itemprop="" content="server" />
</head>
<body data-x="server">server</body>
</html>,
);
content = 'client';
const recoverableErrors = [];
const root = ReactDOMClient.hydrateRoot(document, <ClientApp />, {
onRecoverableError(err) {
recoverableErrors.push(err.message);
},
});
await waitForAll([]);
if (gate(flags => flags.favorSafetyOverHydrationPerf)) {
expect(getVisibleChildren(document)).toEqual(
<html data-y="client">
<head data-y="client">
<meta itemprop="" name="client" />
</head>
<body data-y="client">client</body>
</html>,
);
expect(recoverableErrors).toEqual([
expect.stringContaining(
"Hydration failed because the server rendered HTML didn't match the client.",
),
]);
} else {
expect(getVisibleChildren(document)).toEqual(
<html data-x="server">
<head data-x="server">
<meta itemprop="" content="server" />
</head>
<body data-x="server">server</body>
</html>,
);
expect(recoverableErrors).toEqual([]);
assertConsoleErrorDev([
"A tree hydrated but some attributes of the server rendered HTML didn't match the client properties. This won't be patched up. This can happen if a SSR-ed Client Component used:" +
'\n' +
"\n- A server/client branch `if (typeof window !== 'undefined')`." +
"\n- Variable input such as `Date.now()` or `Math.random()` which changes each time it's called." +
"\n- Date formatting in a user's locale which doesn't match the server." +
'\n- External changing data without sending a snapshot of it along with the HTML.' +
'\n- Invalid HTML tag nesting.' +
'\n' +
'\nIt can also happen if the client has a browser extension installed which messes with the HTML before React loaded.' +
'\n' +
'\nhttps://react.dev/link/hydration-mismatch' +
'\n' +
'\n <ClientApp>' +
'\n <Suspense>' +
'\n <html' +
'\n+ data-y="client"' +
'\n- data-y={null}' +
'\n- data-x="server"' +
'\n >' +
'\n <head' +
'\n+ data-y="client"' +
'\n- data-y={null}' +
'\n- data-x="server"' +
'\n >' +
'\n <meta' +
'\n itemProp=""' +
'\n+ name="client"' +
'\n- name={null}' +
'\n- content="server"' +
'\n >' +
'\n <body' +
'\n+ data-y="client"' +
'\n- data-y={null}' +
'\n- data-x="server"' +
'\n >' +
'\n+ client' +
'\n- server' +
'\n+ client' +
'\n- server' +
'\n' +
'\n in Suspense (at **)' +
'\n in ClientApp (at **)',
]);
}
root.unmount();
expect(getVisibleChildren(document)).toEqual(
<html>
<head />
<body />
</html>,
);
});
});
@@ -41,6 +41,7 @@ describe('ReactDOMServerIntegration - Untrusted URLs', () => {
const {
resetModules,
itRenders,
clientCleanRender,
clientRenderOnBadMarkup,
clientRenderOnServerString,
} = ReactDOMServerIntegrationUtils(initModules);
@@ -141,6 +142,11 @@ describe('ReactDOMServerIntegration - Untrusted URLs', () => {
});
itRenders('a javascript protocol frame src', async render => {
if (render === clientCleanRender || render === clientRenderOnServerString) {
// React does not hydrate framesets properly because the default hydration scope
// is the body
return;
}
const e = await render(
<html>
<head />
+2
View File
@@ -174,6 +174,7 @@ export function writeStartClientRenderedSuspenseBoundary(
export function writeEndCompletedSuspenseBoundary(
destination: Destination,
renderState: RenderState,
preambleState: null | PreambleState,
): boolean {
// Markup doesn't have any instructions.
return true;
@@ -181,6 +182,7 @@ export function writeEndCompletedSuspenseBoundary(
export function writeEndClientRenderedSuspenseBoundary(
destination: Destination,
renderState: RenderState,
preambleState: null | PreambleState,
): boolean {
// Markup doesn't have any instructions.
return true;
@@ -314,6 +314,7 @@ function insertOrAppendPlacementNodeIntoContainer(
// This singleton is the parent of deeper nodes and needs to become
// the parent for child insertions and appends
parent = node.stateNode;
before = null;
}
const child = node.child;
@@ -28,9 +28,11 @@ export const registerSuspenseInstanceRetry = shim;
export const canHydrateFormStateMarker = shim;
export const isFormStateMarkerMatching = shim;
export const getNextHydratableSibling = shim;
export const getNextHydratableSiblingAfterSingleton = shim;
export const getFirstHydratableChild = shim;
export const getFirstHydratableChildWithinContainer = shim;
export const getFirstHydratableChildWithinSuspenseInstance = shim;
export const getFirstHydratableChildWithinSingleton = shim;
export const canHydrateInstance = shim;
export const canHydrateTextInstance = shim;
export const canHydrateSuspenseInstance = shim;
+34 -21
View File
@@ -37,9 +37,11 @@ import {
supportsHydration,
supportsSingletons,
getNextHydratableSibling,
getNextHydratableSiblingAfterSingleton,
getFirstHydratableChild,
getFirstHydratableChildWithinContainer,
getFirstHydratableChildWithinSuspenseInstance,
getFirstHydratableChildWithinSingleton,
hydrateInstance,
diffHydratedPropsForDevWarnings,
describeHydratableInstanceForDevWarnings,
@@ -366,7 +368,11 @@ function claimHydratableSingleton(fiber: Fiber): void {
hydrationParentFiber = fiber;
rootOrSingletonContext = true;
nextHydratableInstance = getFirstHydratableChild(instance);
nextHydratableInstance = getFirstHydratableChildWithinSingleton(
fiber.type,
instance,
nextHydratableInstance,
);
}
}
@@ -593,14 +599,14 @@ function popToNextHostParent(fiber: Fiber): void {
hydrationParentFiber = fiber.return;
while (hydrationParentFiber) {
switch (hydrationParentFiber.tag) {
case HostRoot:
case HostSingleton:
rootOrSingletonContext = true;
return;
case HostComponent:
case SuspenseComponent:
rootOrSingletonContext = false;
return;
case HostSingleton:
case HostRoot:
rootOrSingletonContext = true;
return;
default:
hydrationParentFiber = hydrationParentFiber.return;
}
@@ -625,20 +631,25 @@ function popHydrationState(fiber: Fiber): boolean {
return false;
}
let shouldClear = false;
const tag = fiber.tag;
if (supportsSingletons) {
// With float we never clear the Root, or Singleton instances. We also do not clear Instances
// that have singleton text content
if (
fiber.tag !== HostRoot &&
fiber.tag !== HostSingleton &&
tag !== HostRoot &&
tag !== HostSingleton &&
!(
fiber.tag === HostComponent &&
tag === HostComponent &&
(!shouldDeleteUnhydratedTailInstances(fiber.type) ||
shouldSetTextContent(fiber.type, fiber.memoizedProps))
)
) {
shouldClear = true;
const nextInstance = nextHydratableInstance;
if (nextInstance) {
warnIfUnhydratedTailNodes(fiber);
throwOnHydrationMismatch(fiber);
}
}
} else {
// If we have any remaining hydratable nodes, we need to delete them now.
@@ -646,24 +657,26 @@ function popHydrationState(fiber: Fiber): boolean {
// other nodes in them. We also ignore components with pure text content in
// side of them. We also don't delete anything inside the root container.
if (
fiber.tag !== HostRoot &&
(fiber.tag !== HostComponent ||
tag !== HostRoot &&
(tag !== HostComponent ||
(shouldDeleteUnhydratedTailInstances(fiber.type) &&
!shouldSetTextContent(fiber.type, fiber.memoizedProps)))
) {
shouldClear = true;
}
}
if (shouldClear) {
const nextInstance = nextHydratableInstance;
if (nextInstance) {
warnIfUnhydratedTailNodes(fiber);
throwOnHydrationMismatch(fiber);
const nextInstance = nextHydratableInstance;
if (nextInstance) {
warnIfUnhydratedTailNodes(fiber);
throwOnHydrationMismatch(fiber);
}
}
}
popToNextHostParent(fiber);
if (fiber.tag === SuspenseComponent) {
if (tag === SuspenseComponent) {
nextHydratableInstance = skipPastDehydratedSuspenseInstance(fiber);
} else if (supportsSingletons && tag === HostSingleton) {
nextHydratableInstance = getNextHydratableSiblingAfterSingleton(
fiber.type,
nextHydratableInstance,
);
} else {
nextHydratableInstance = hydrationParentFiber
? getNextHydratableSibling(fiber.stateNode)
@@ -174,11 +174,15 @@ export const registerSuspenseInstanceRetry =
export const canHydrateFormStateMarker = $$$config.canHydrateFormStateMarker;
export const isFormStateMarkerMatching = $$$config.isFormStateMarkerMatching;
export const getNextHydratableSibling = $$$config.getNextHydratableSibling;
export const getNextHydratableSiblingAfterSingleton =
$$$config.getNextHydratableSiblingAfterSingleton;
export const getFirstHydratableChild = $$$config.getFirstHydratableChild;
export const getFirstHydratableChildWithinContainer =
$$$config.getFirstHydratableChildWithinContainer;
export const getFirstHydratableChildWithinSuspenseInstance =
$$$config.getFirstHydratableChildWithinSuspenseInstance;
export const getFirstHydratableChildWithinSingleton =
$$$config.getFirstHydratableChildWithinSingleton;
export const canHydrateInstance = $$$config.canHydrateInstance;
export const canHydrateTextInstance = $$$config.canHydrateTextInstance;
export const canHydrateSuspenseInstance = $$$config.canHydrateSuspenseInstance;
+6 -1
View File
@@ -4865,6 +4865,7 @@ function flushSegment(
return writeEndClientRenderedSuspenseBoundary(
destination,
request.renderState,
boundary.fallbackPreamble,
);
} else if (boundary.status !== COMPLETED) {
if (boundary.status === PENDING) {
@@ -4935,7 +4936,11 @@ function flushSegment(
const contentSegment = completedSegments[0];
flushSegment(request, destination, contentSegment, hoistableState);
return writeEndCompletedSuspenseBoundary(destination, request.renderState);
return writeEndCompletedSuspenseBoundary(
destination,
request.renderState,
boundary.contentPreamble,
);
}
}