diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
index 042d6897c6aae..7e9a43056c649 100644
--- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
@@ -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';
@@ -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
@@ -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;
@@ -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
@@ -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(
@@ -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
+// 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} {
diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
index 4b8841a06e67b..2a06560bcb626 100644
--- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
+++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
@@ -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,
headChunks: null | Array,
bodyChunks: null | Array,
+ contribution: number,
};
export function createPreambleState(): PreambleState {
return {
htmlChunks: null,
headChunks: null,
bodyChunks: null,
+ contribution: NoContribution,
};
}
@@ -3227,7 +3236,7 @@ function pushStartHead(
throw new Error(`The ${'``'} tag may only be rendered once.`);
}
preamble.headChunks = [];
- return pushStartGenericElement(preamble.headChunks, props, 'head');
+ return pushStartSingletonElement(preamble.headChunks, props, 'head');
} else {
// This 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 +3260,7 @@ function pushStartBody(
}
preamble.bodyChunks = [];
- return pushStartGenericElement(preamble.bodyChunks, props, 'body');
+ return pushStartSingletonElement(preamble.bodyChunks, props, 'body');
} else {
// This 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 +3284,7 @@ function pushStartHtml(
}
preamble.htmlChunks = [DOCTYPE];
- return pushStartGenericElement(preamble.htmlChunks, props, 'html');
+ return pushStartSingletonElement(preamble.htmlChunks, props, 'html');
} else {
// This 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 +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,
+ 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,
props: Object,
@@ -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;
}
}
@@ -4005,6 +4054,14 @@ const clientRenderedSuspenseBoundaryError1D =
const clientRenderedSuspenseBoundaryError2 =
stringToPrecomputedChunk('>');
+const boundaryPreambleContributionChunkTotal =
+ stringToPrecomputedChunk('');
+const boundaryPreambleContributionChunkStart =
+ stringToPrecomputedChunk('');
+
export function writeStartCompletedSuspenseBoundary(
destination: Destination,
renderState: RenderState,
@@ -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(
@@ -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('
+
+
+ ,
+ );
+ if (gate(flags => flags.enableOwnerStacks)) {
+ assertConsoleErrorDev([
+ [
+ 'Cannot render a 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 remove the `itemProp` prop. Otherwise, try moving this tag into the or of the Document.',
+ {withoutStack: true},
+ ],
+ 'In HTML, cannot be a child of .\nThis will cause a hydration error.' +
+ '\n' +
+ '\n ' +
+ '\n> ' +
+ '\n ' +
+ '\n ' +
+ '\n> ' +
+ '\n ...' +
+ '\n' +
+ '\n in meta (at **)' +
+ '\n in App (at **)',
+ ' cannot contain a nested .\nSee this log for the ancestor stack trace.' +
+ '\n in html (at **)' +
+ '\n in App (at **)',
+ [
+ 'Cannot render a 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 remove the `itemProp` prop. Otherwise, try moving this tag into the or of the Document.',
+ {withoutStack: true},
+ ],
+ ]);
+ } else {
+ assertConsoleErrorDev([
+ 'Cannot render a 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 remove the `itemProp` prop. Otherwise, try moving this tag into the or of the Document.' +
+ '\n in Suspense (at **)' +
+ '\n in html (at **)' +
+ '\n in App (at **)',
+ 'In HTML, cannot be a child of .\nThis will cause a hydration error.' +
+ '\n' +
+ '\n ' +
+ '\n> ' +
+ '\n ' +
+ '\n ' +
+ '\n> ' +
+ '\n ...' +
+ '\n' +
+ '\n in meta (at **)' +
+ '\n in Suspense (at **)' +
+ '\n in html (at **)' +
+ '\n in App (at **)',
+ 'Cannot render a 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 remove the `itemProp` prop. Otherwise, try moving this tag into the or of the Document.' +
+ '\n in Suspense (at **)' +
+ '\n in html (at **)' +
+ '\n in App (at **)',
+ ]);
+ }
+
+ await root.unmount();
+ expect(getVisibleChildren(document)).toEqual(
+
+
+
+ ,
+ );
});
- it('can server render Suspense before, after, and around ', async () => {
+ it('can render Suspense before, after, and around ', async () => {
function BlockedOn({value, children}) {
readText(value);
return children;
@@ -9119,11 +9217,90 @@ describe('ReactDOMFizzServer', () => {
,
);
+
+ const root = ReactDOMClient.hydrateRoot(document, );
+ await waitForAll([]);
+ expect(getVisibleChildren(document)).toEqual(
+
+
+
+
+
+
+
+
+
+
hello world
+
+ ,
+ );
+ if (gate(flags => flags.enableOwnerStacks)) {
+ assertConsoleErrorDev([
+ [
+ 'Cannot render a 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 remove the `itemProp` prop. Otherwise, try moving this tag into the or of the Document.',
+ {withoutStack: true},
+ ],
+ 'In HTML, cannot be a child of .\nThis will cause a hydration error.' +
+ '\n' +
+ '\n ' +
+ '\n> ' +
+ '\n ' +
+ '\n ' +
+ '\n> ' +
+ '\n ...' +
+ '\n' +
+ '\n in meta (at **)' +
+ '\n in App (at **)',
+ ' cannot contain a nested .\nSee this log for the ancestor stack trace.' +
+ '\n in html (at **)' +
+ '\n in App (at **)',
+ [
+ 'Cannot render a 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 remove the `itemProp` prop. Otherwise, try moving this tag into the or of the Document.',
+ {withoutStack: true},
+ ],
+ ]);
+ } else {
+ assertConsoleErrorDev([
+ 'Cannot render a 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 remove the `itemProp` prop. Otherwise, try moving this tag into the or of the Document.' +
+ '\n in Suspense (at **)' +
+ '\n in html (at **)' +
+ '\n in App (at **)',
+ 'In HTML, cannot be a child of .\nThis will cause a hydration error.' +
+ '\n' +
+ '\n ' +
+ '\n> ' +
+ '\n ' +
+ '\n ' +
+ '\n> ' +
+ '\n ...' +
+ '\n' +
+ '\n in meta (at **)' +
+ '\n in Suspense (at **)' +
+ '\n in html (at **)' +
+ '\n in App (at **)',
+ 'Cannot render a 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 remove the `itemProp` prop. Otherwise, try moving this tag into the or of the Document.' +
+ '\n in Suspense (at **)' +
+ '\n in html (at **)' +
+ '\n in App (at **)',
+ ]);
+ }
+
+ await root.unmount();
+ expect(getVisibleChildren(document)).toEqual(
+
+
+
+ ,
+ );
});
- 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', () => {
,
);
+
+ serverRendering = false;
+
+ const recoverableErrors = [];
+ const root = ReactDOMClient.hydrateRoot(document, , {
+ onRecoverableError(err) {
+ recoverableErrors.push(err);
+ },
+ });
+ await waitForAll([]);
+ expect(getVisibleChildren(document)).toEqual(
+
+
+
+ hello world
+
+ ,
+ );
+ 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(
+
+
+
+ ,
+ );
});
it('will hoist resources and hositables from a primary tree into the 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', () => {
,
);
+
+ serverRendering = false;
+
+ const recoverableErrors = [];
+ const root = ReactDOMClient.hydrateRoot(document, , {
+ 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(
+
+
+
+
+
+
+
+
+ hello world
+
+ ,
+ );
+
+ root.unmount();
+ expect(getVisibleChildren(document)).toEqual(
+
+
+
+
+
+
+ ,
+ );
});
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 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', () => {
,
);
+
+ serverRendering = false;
+
+ const recoverableErrors = [];
+ const root = ReactDOMClient.hydrateRoot(document, , {
+ 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(
+
+
+
+
+
+
cannot be a child of <#document>']);
+
+ root.unmount();
+ expect(getVisibleChildren(document)).toEqual(
+
+
+
+
+ ,
+ );
});
it('will flush the preamble as soon as a complete preamble is available', async () => {
@@ -9740,5 +10140,108 @@ describe('ReactDOMFizzServer', () => {
,
);
+
+ const root = ReactDOMClient.hydrateRoot(document, );
+ await waitForAll([]);
+ expect(getVisibleChildren(document)).toEqual(
+
+
+
+
+
+ ,
+ );
+ });
+
+ it('will clean up the head when a hydration mismatch causes a boundary to recover on the client', async () => {
+ let content = 'server';
+
+ function App() {
+ return (
+
+
+
+
+
+