diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index 9e3574885fa6e..5aba0d371831b 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -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'; @@ -973,6 +976,34 @@ export function clearSuspenseBoundary( if (nextNode && nextNode.nodeType === COMMENT_NODE) { const data = ((nextNode: any).data: string); if (data === SUSPENSE_END_DATA) { + if (node.nodeType === COMMENT_NODE) { + const code: number = (node: any).data.charCodeAt(0) - 48; + if (code > 0 && code < 8) { + // 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. @@ -1501,7 +1532,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 +1544,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 +1926,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 +1948,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..8fac981ab29f2 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -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, headChunks: null | Array, bodyChunks: null | Array, + 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 ${'``'} 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 +3258,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 +3282,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 +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, + 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 +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(''); + +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(''); diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js index 491d322793941..ecd12d2ac5fd1 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js @@ -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; diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index ac6374600fc64..d0a1e484f2810 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -8923,7 +8923,7 @@ describe('ReactDOMFizzServer', () => { ); }); - 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; @@ -8989,9 +8989,33 @@ describe('ReactDOMFizzServer', () => { , ); + + const root = ReactDOMClient.hydrateRoot(document, ); + await waitForAll([]); + expect(getVisibleChildren(document)).toEqual( + + + + + +
before
+
hello world
+
after
+ + , + ); + assertConsoleErrorDev(['In HTML,
cannot be a child of <#document>']); + + 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; @@ -9052,9 +9076,83 @@ 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('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( + + + + + +
primary body
+ + , + ); + + root.unmount(); + expect(getVisibleChildren(document)).toEqual( + + + + , + ); }); it('Can render a fallback 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', () => { , ); + + serverRendering = false; + + const recoverableErrors = []; + const root = ReactDOMClient.hydrateRoot(document, , { + onRecoverableError(err) { + recoverableErrors.push(err); + }, + }); + await waitForAll([]); + expect(getVisibleChildren(document)).toEqual( + + + + + +
primary body
+ + , + ); + 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('Can render a outside of a containing ', async () => { @@ -9528,6 +9885,27 @@ describe('ReactDOMFizzServer', () => { , ); + + const root = ReactDOMClient.hydrateRoot(document, ); + await waitForAll([]); + expect(getVisibleChildren(document)).toEqual( + + + + + + hello world + + , + ); + + root.unmount(); + expect(getVisibleChildren(document)).toEqual( + + + + , + ); }); it('can render preamble tags in deeply nested indirect component trees', async () => { @@ -9661,6 +10039,28 @@ describe('ReactDOMFizzServer', () => { , ); + + const root = ReactDOMClient.hydrateRoot(document, ); + await waitForAll([]); + expect(getVisibleChildren(document)).toEqual( + + + + + + +
This is soooo cool!
+ + , + ); + + root.unmount(); + expect(getVisibleChildren(document)).toEqual( + + + + , + ); }); it('will flush the preamble as soon as a complete preamble is available', async () => { @@ -9740,5 +10140,177 @@ describe('ReactDOMFizzServer', () => { , ); + + const root = ReactDOMClient.hydrateRoot(document, ); + await waitForAll([]); + expect(getVisibleChildren(document)).toEqual( + + + + + + loading before... +
body
+ loading after... + + , + ); + + await act(() => { + resolveText('before'); + resolveText('after'); + }); + await waitForAll([]); + expect(getVisibleChildren(document)).toEqual( + + + + + +
before
+
body
+
after
+ + , + ); + assertConsoleErrorDev(['In HTML,
cannot be a child of <#document>']); + + root.unmount(); + 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 ServerApp() { + return ( + + + + + + {content} + + + ); + } + + function ClientApp() { + return ( + + + + + + {content} + + + ); + } + + await act(() => { + const {pipe} = renderToPipeableStream(); + pipe(writable); + }); + expect(getVisibleChildren(document)).toEqual( + + + + + server + , + ); + + content = 'client'; + + const recoverableErrors = []; + const root = ReactDOMClient.hydrateRoot(document, , { + onRecoverableError(err) { + recoverableErrors.push(err.message); + }, + }); + await waitForAll([]); + if (gate(flags => flags.favorSafetyOverHydrationPerf)) { + expect(getVisibleChildren(document)).toEqual( + + + + + client + , + ); + expect(recoverableErrors).toEqual([ + expect.stringContaining( + "Hydration failed because the server rendered HTML didn't match the client.", + ), + ]); + } else { + expect(getVisibleChildren(document)).toEqual( + + + + + server + , + ); + 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 ' + + '\n ' + + '\n ' + + '\n ' + + '\n ' + + '\n ' + + '\n+ client' + + '\n- server' + + '\n+ client' + + '\n- server' + + '\n' + + '\n in Suspense (at **)' + + '\n in ClientApp (at **)', + ]); + } + + root.unmount(); + expect(getVisibleChildren(document)).toEqual( + + + + , + ); }); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationUntrustedURL-test.js b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationUntrustedURL-test.js index 384e8beb0b214..8664904130c11 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationUntrustedURL-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationUntrustedURL-test.js @@ -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( diff --git a/packages/react-markup/src/ReactFizzConfigMarkup.js b/packages/react-markup/src/ReactFizzConfigMarkup.js index 99e9921c8190a..358a08e7c54b8 100644 --- a/packages/react-markup/src/ReactFizzConfigMarkup.js +++ b/packages/react-markup/src/ReactFizzConfigMarkup.js @@ -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; diff --git a/packages/react-reconciler/src/ReactFiberCommitHostEffects.js b/packages/react-reconciler/src/ReactFiberCommitHostEffects.js index 159c12bd4bcf2..c104c2a8464b5 100644 --- a/packages/react-reconciler/src/ReactFiberCommitHostEffects.js +++ b/packages/react-reconciler/src/ReactFiberCommitHostEffects.js @@ -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; diff --git a/packages/react-reconciler/src/ReactFiberConfigWithNoHydration.js b/packages/react-reconciler/src/ReactFiberConfigWithNoHydration.js index 3707f99f488fb..0bb85246dfe24 100644 --- a/packages/react-reconciler/src/ReactFiberConfigWithNoHydration.js +++ b/packages/react-reconciler/src/ReactFiberConfigWithNoHydration.js @@ -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; diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.js b/packages/react-reconciler/src/ReactFiberHydrationContext.js index b4d948e735276..23f13bbcadbf8 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.js @@ -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) diff --git a/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js index 91420ca88cd95..ee5f40ad829ad 100644 --- a/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js @@ -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; diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 4c02d06e600a9..041ef31e2bd3d 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -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, + ); } }