diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index d90ea76892545..d22c1d87cb428 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -19,6 +19,7 @@ let Suspense; let textCache; let document; let writable; +let container; let buffer = ''; let hasErrored = false; let fatalError = undefined; @@ -38,10 +39,14 @@ describe('ReactDOMFizzServer', () => { textCache = new Map(); // Test Environment - const jsdom = new JSDOM('', { - runScripts: 'dangerously', - }); + const jsdom = new JSDOM( + '
', + { + runScripts: 'dangerously', + }, + ); document = jsdom.window.document; + container = document.getElementById('container'); buffer = ''; hasErrored = false; @@ -80,9 +85,9 @@ describe('ReactDOMFizzServer', () => { const script = document.createElement('script'); script.textContent = node.textContent; fakeBody.removeChild(node); - document.body.appendChild(script); + container.appendChild(script); } else { - document.body.appendChild(node); + container.appendChild(node); } } } @@ -200,11 +205,11 @@ describe('ReactDOMFizzServer', () => { writable, ); }); - expect(getVisibleChildren(document.body)).toEqual(
Loading...
); + expect(getVisibleChildren(container)).toEqual(
Loading...
); await act(async () => { resolveText('Hello World'); }); - expect(getVisibleChildren(document.body)).toEqual(
Hello World
); + expect(getVisibleChildren(container)).toEqual(
Hello World
); }); // @gate experimental @@ -224,20 +229,12 @@ describe('ReactDOMFizzServer', () => { } await act(async () => { - ReactDOMFizzServer.pipeToNodeWritable( - // We currently have to wrap the server node in a container because - // otherwise the Fizz nodes get deleted during hydration. -
- -
, - writable, - ); + ReactDOMFizzServer.pipeToNodeWritable(, writable); }); // We're still showing a fallback. // Attempt to hydrate the content. - const container = document.body.firstChild; const root = ReactDOM.unstable_createRoot(container, {hydrate: true}); root.render(); Scheduler.unstable_flushAll(); diff --git a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationFragment-test.js b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationFragment-test.js index cd0ea2c45d441..b729176f413e0 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationFragment-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationFragment-test.js @@ -103,7 +103,15 @@ describe('ReactDOMServerIntegration', () => { }); itRenders('an empty fragment', async render => { - expect(await render()).toBe(null); + expect( + ( + await render( +
+ +
, + ) + ).firstChild, + ).toBe(null); }); }); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.js b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.js index 397b739546dcc..3014ecd810d20 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.js @@ -1654,9 +1654,13 @@ describe('ReactDOMServerHooks', () => { // This is the wrong HTML string container.innerHTML = ''; ReactDOM.unstable_createRoot(container, {hydrate: true}).render(); - expect(() => Scheduler.unstable_flushAll()).toErrorDev([ - 'Warning: Expected server HTML to contain a matching
in
.', - ]); + expect(() => Scheduler.unstable_flushAll()).toErrorDev( + [ + 'Warning: An error occurred during hydration. The server HTML was replaced with client content in
.', + 'Warning: Expected server HTML to contain a matching
in
.', + ], + {withoutStack: 1}, + ); }); // @gate experimental @@ -1740,9 +1744,13 @@ describe('ReactDOMServerHooks', () => { // This is the wrong HTML string container.innerHTML = ''; ReactDOM.unstable_createRoot(container, {hydrate: true}).render(); - expect(() => Scheduler.unstable_flushAll()).toErrorDev([ - 'Warning: Expected server HTML to contain a matching
in
.', - ]); + expect(() => Scheduler.unstable_flushAll()).toErrorDev( + [ + 'Warning: An error occurred during hydration. The server HTML was replaced with client content in
.', + 'Warning: Expected server HTML to contain a matching
in
.', + ], + {withoutStack: 1}, + ); }); // @gate experimental @@ -1764,7 +1772,7 @@ describe('ReactDOMServerHooks', () => { expect(() => Scheduler.unstable_flushAll()).toErrorDev( [ 'Warning: The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. Do not read the value directly.', - 'Warning: Did not expect server HTML to contain a in
.', + 'Warning: An error occurred during hydration. The server HTML was replaced with client content in
.', ], {withoutStack: 1}, ); @@ -1789,7 +1797,7 @@ describe('ReactDOMServerHooks', () => { expect(() => Scheduler.unstable_flushAll()).toErrorDev( [ 'Warning: The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. Do not read the value directly.', - 'Warning: Did not expect server HTML to contain a in
.', + 'Warning: An error occurred during hydration. The server HTML was replaced with client content in
.', ], {withoutStack: 1}, ); @@ -1813,7 +1821,7 @@ describe('ReactDOMServerHooks', () => { expect(() => Scheduler.unstable_flushAll()).toErrorDev( [ 'Warning: The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. Do not read the value directly.', - 'Warning: Did not expect server HTML to contain a
in
.', + 'Warning: An error occurred during hydration. The server HTML was replaced with client content in
.', ], {withoutStack: 1}, ); @@ -1834,7 +1842,7 @@ describe('ReactDOMServerHooks', () => { expect(() => Scheduler.unstable_flushAll()).toErrorDev( [ 'Warning: The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. Do not read the value directly.', - 'Warning: Did not expect server HTML to contain a
in
.', + 'Warning: An error occurred during hydration. The server HTML was replaced with client content in
.', ], {withoutStack: 1}, ); diff --git a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationModes-test.js b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationModes-test.js index 8086ff4414600..1a387fc7cb031 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationModes-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationModes-test.js @@ -153,7 +153,15 @@ describe('ReactDOMServerIntegration', () => { }); itRenders('an empty strict mode', async render => { - expect(await render()).toBe(null); + expect( + ( + await render( +
+ +
, + ) + ).firstChild, + ).toBe(null); }); }); }); diff --git a/packages/react-dom/src/__tests__/ReactMount-test.js b/packages/react-dom/src/__tests__/ReactMount-test.js index 8d1b03cb4b4fd..a214c32f58d06 100644 --- a/packages/react-dom/src/__tests__/ReactMount-test.js +++ b/packages/react-dom/src/__tests__/ReactMount-test.js @@ -123,16 +123,12 @@ describe('ReactMount', () => { expect(instance1 === instance2).toBe(true); }); - it('should warn if mounting into left padded rendered markup', () => { + it('does not warn if mounting into left padded rendered markup', () => { const container = document.createElement('container'); container.innerHTML = ReactDOMServer.renderToString(
) + ' '; - expect(() => - ReactDOM.hydrate(
, container), - ).toErrorDev( - 'Did not expect server HTML to contain the text node " " in .', - {withoutStack: true}, - ); + // This should probably ideally warn but we ignore extra markup at the root. + ReactDOM.hydrate(
, container); }); it('should warn if mounting into right padded rendered markup', () => { diff --git a/packages/react-dom/src/__tests__/ReactRenderDocument-test.js b/packages/react-dom/src/__tests__/ReactRenderDocument-test.js index 2e7a60c9975fa..def1c35bc64e9 100644 --- a/packages/react-dom/src/__tests__/ReactRenderDocument-test.js +++ b/packages/react-dom/src/__tests__/ReactRenderDocument-test.js @@ -368,12 +368,31 @@ describe('rendering React components at document', () => { expect(testDocument.body.innerHTML).toBe('Hello world'); }); - it('renders over an existing text child without throwing', () => { + it('cannot render over an existing text child at the root', () => { const container = document.createElement('div'); container.textContent = 'potato'; expect(() => ReactDOM.hydrate(
parsnip
, container)).toErrorDev( 'Expected server HTML to contain a matching
in
.', ); + // This creates an unfortunate double text case. + expect(container.textContent).toBe('potatoparsnip'); + }); + + it('renders over an existing nested text child without throwing', () => { + const container = document.createElement('div'); + const wrapper = document.createElement('div'); + wrapper.textContent = 'potato'; + container.appendChild(wrapper); + expect(() => + ReactDOM.hydrate( +
+
parsnip
+
, + container, + ), + ).toErrorDev( + 'Expected server HTML to contain a matching
in
.', + ); expect(container.textContent).toBe('parsnip'); }); diff --git a/packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js b/packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js index a361e55a7f8fc..0ea7972f5d65d 100644 --- a/packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js +++ b/packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js @@ -510,26 +510,47 @@ describe('ReactDOMServerHydration', () => { it('Suspense + hydration in legacy mode', () => { const element = document.createElement('div'); - element.innerHTML = '
Hello World
'; - const div = element.firstChild; + element.innerHTML = '
Hello World
'; + const div = element.firstChild.firstChild; const ref = React.createRef(); expect(() => ReactDOM.hydrate( - -
Hello World
-
, +
+ +
Hello World
+
+
, element, ), ).toErrorDev( 'Warning: Did not expect server HTML to contain a
in
.', - {withoutStack: true}, ); // The content should've been client rendered and replaced the // existing div. expect(ref.current).not.toBe(div); // The HTML should be the same though. - expect(element.innerHTML).toBe('
Hello World
'); + expect(element.innerHTML).toBe('
Hello World
'); + }); + + it('Suspense + hydration in legacy mode (at root)', () => { + const element = document.createElement('div'); + element.innerHTML = '
Hello World
'; + const div = element.firstChild; + const ref = React.createRef(); + ReactDOM.hydrate( + +
Hello World
+
, + element, + ); + + // The content should've been client rendered. + expect(ref.current).not.toBe(div); + // Unfortunately, since we don't delete the tail at the root, a duplicate will remain. + expect(element.innerHTML).toBe( + '
Hello World
Hello World
', + ); }); it('Suspense + hydration in legacy mode with no fallback', () => { diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js index dfd29b375a41b..156951ac748bc 100644 --- a/packages/react-dom/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom/src/client/ReactDOMHostConfig.js @@ -894,6 +894,12 @@ export function commitHydratedSuspenseInstance( retryIfBlockedOn(suspenseInstance); } +export function shouldDeleteUnhydratedTailInstances( + parentType: string, +): boolean { + return parentType !== 'head' || parentType !== 'body'; +} + export function didNotMatchHydratedContainerTextInstance( parentContainer: Container, textInstance: TextInstance, @@ -1008,6 +1014,15 @@ export function didNotFindHydratableSuspenseInstance( } } +export function errorHydratingContainer(parentContainer: Container): void { + if (__DEV__) { + console.error( + 'An error occurred during hydration. The server HTML was replaced with client content in <%s>.', + parentContainer.nodeName.toLowerCase(), + ); + } +} + export function getInstanceFromNode(node: HTMLElement): null | Object { return getClosestInstanceFromNode(node) || null; } diff --git a/packages/react-reconciler/src/ReactFiberHostConfigWithNoHydration.js b/packages/react-reconciler/src/ReactFiberHostConfigWithNoHydration.js index b432a1d400736..c57b313fd90fa 100644 --- a/packages/react-reconciler/src/ReactFiberHostConfigWithNoHydration.js +++ b/packages/react-reconciler/src/ReactFiberHostConfigWithNoHydration.js @@ -40,6 +40,7 @@ export const commitHydratedContainer = shim; export const commitHydratedSuspenseInstance = shim; export const clearSuspenseBoundary = shim; export const clearSuspenseBoundaryFromContainer = shim; +export const shouldDeleteUnhydratedTailInstances = shim; export const didNotMatchHydratedContainerTextInstance = shim; export const didNotMatchHydratedTextInstance = shim; export const didNotHydrateContainerInstance = shim; @@ -50,3 +51,4 @@ export const didNotFindHydratableContainerSuspenseInstance = shim; export const didNotFindHydratableInstance = shim; export const didNotFindHydratableTextInstance = shim; export const didNotFindHydratableSuspenseInstance = shim; +export const errorHydratingContainer = shim; diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.new.js b/packages/react-reconciler/src/ReactFiberHydrationContext.new.js index d91901f519764..2ebb1386a5684 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.new.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.new.js @@ -43,6 +43,7 @@ import { hydrateTextInstance, hydrateSuspenseInstance, getNextHydratableInstanceAfterSuspenseInstance, + shouldDeleteUnhydratedTailInstances, didNotMatchHydratedContainerTextInstance, didNotMatchHydratedTextInstance, didNotHydrateContainerInstance, @@ -438,18 +439,15 @@ function popHydrationState(fiber: Fiber): boolean { return false; } - const type = fiber.type; - // If we have any remaining hydratable nodes, we need to delete them now. // We only do this deeper than head and body since they tend to have random // other nodes in them. We also ignore components with pure text content in - // side of them. - // TODO: Better heuristic. + // side of them. We also don't delete anything inside the root container. if ( - fiber.tag !== HostComponent || - (type !== 'head' && - type !== 'body' && - !shouldSetTextContent(type, fiber.memoizedProps)) + fiber.tag !== HostRoot && + (fiber.tag !== HostComponent || + (shouldDeleteUnhydratedTailInstances(fiber.type) && + !shouldSetTextContent(fiber.type, fiber.memoizedProps))) ) { let nextInstance = nextHydratableInstance; while (nextInstance) { diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.old.js b/packages/react-reconciler/src/ReactFiberHydrationContext.old.js index 6f7c51e0a569f..c8e17e0b6374f 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.old.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.old.js @@ -43,6 +43,7 @@ import { hydrateTextInstance, hydrateSuspenseInstance, getNextHydratableInstanceAfterSuspenseInstance, + shouldDeleteUnhydratedTailInstances, didNotMatchHydratedContainerTextInstance, didNotMatchHydratedTextInstance, didNotHydrateContainerInstance, @@ -438,18 +439,15 @@ function popHydrationState(fiber: Fiber): boolean { return false; } - const type = fiber.type; - // If we have any remaining hydratable nodes, we need to delete them now. // We only do this deeper than head and body since they tend to have random // other nodes in them. We also ignore components with pure text content in - // side of them. - // TODO: Better heuristic. + // side of them. We also don't delete anything inside the root container. if ( - fiber.tag !== HostComponent || - (type !== 'head' && - type !== 'body' && - !shouldSetTextContent(type, fiber.memoizedProps)) + fiber.tag !== HostRoot && + (fiber.tag !== HostComponent || + (shouldDeleteUnhydratedTailInstances(fiber.type) && + !shouldSetTextContent(fiber.type, fiber.memoizedProps))) ) { let nextInstance = nextHydratableInstance; while (nextInstance) { diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js index 19d53553f43d8..ea425725250ed 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js @@ -88,6 +88,7 @@ import { clearContainer, getCurrentEventPriority, supportsMicrotasks, + errorHydratingContainer, } from './ReactFiberHostConfig'; import { @@ -782,6 +783,9 @@ function performConcurrentWorkOnRoot(root, didTimeout) { // discard server response and fall back to client side render. if (root.hydrate) { root.hydrate = false; + if (__DEV__) { + errorHydratingContainer(root.containerInfo); + } clearContainer(root.containerInfo); } @@ -992,6 +996,9 @@ function performSyncWorkOnRoot(root) { // discard server response and fall back to client side render. if (root.hydrate) { root.hydrate = false; + if (__DEV__) { + errorHydratingContainer(root.containerInfo); + } clearContainer(root.containerInfo); } diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js index da22638e75758..c29c9353b1732 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js @@ -88,6 +88,7 @@ import { clearContainer, getCurrentEventPriority, supportsMicrotasks, + errorHydratingContainer, } from './ReactFiberHostConfig'; import { @@ -782,6 +783,9 @@ function performConcurrentWorkOnRoot(root, didTimeout) { // discard server response and fall back to client side render. if (root.hydrate) { root.hydrate = false; + if (__DEV__) { + errorHydratingContainer(root.containerInfo); + } clearContainer(root.containerInfo); } @@ -992,6 +996,9 @@ function performSyncWorkOnRoot(root) { // discard server response and fall back to client side render. if (root.hydrate) { root.hydrate = false; + if (__DEV__) { + errorHydratingContainer(root.containerInfo); + } clearContainer(root.containerInfo); } diff --git a/packages/react-reconciler/src/__tests__/useMutableSourceHydration-test.js b/packages/react-reconciler/src/__tests__/useMutableSourceHydration-test.js index c322a0a48ed4c..f737994ad7e70 100644 --- a/packages/react-reconciler/src/__tests__/useMutableSourceHydration-test.js +++ b/packages/react-reconciler/src/__tests__/useMutableSourceHydration-test.js @@ -214,7 +214,8 @@ describe('useMutableSourceHydration', () => { source.value = 'two'; }); }).toErrorDev( - 'Warning: Did not expect server HTML to contain a
in
.', + 'Warning: An error occurred during hydration. ' + + 'The server HTML was replaced with client content in
.', {withoutStack: true}, ); expect(Scheduler).toHaveYielded(['only:two']); @@ -266,7 +267,8 @@ describe('useMutableSourceHydration', () => { source.value = 'two'; }); }).toErrorDev( - 'Warning: Did not expect server HTML to contain a
in
.', + 'Warning: An error occurred during hydration. ' + + 'The server HTML was replaced with client content in
.', {withoutStack: true}, ); expect(Scheduler).toHaveYielded(['a:two', 'b:two']); @@ -334,7 +336,8 @@ describe('useMutableSourceHydration', () => { source.valueB = 'b:two'; }); }).toErrorDev( - 'Warning: Did not expect server HTML to contain a
in
.', + 'Warning: An error occurred during hydration. ' + + 'The server HTML was replaced with client content in
.', {withoutStack: true}, ); expect(Scheduler).toHaveYielded(['0:a:one', '1:b:two']); @@ -401,7 +404,13 @@ describe('useMutableSourceHydration', () => { source.value = 'two'; }); }).toErrorDev( - 'Warning: Text content did not match. Server: "1" Client: "2"', + [ + 'Warning: An error occurred during hydration. ' + + 'The server HTML was replaced with client content in
.', + + 'Warning: Text content did not match. Server: "1" Client: "2"', + ], + {withoutStack: 1}, ); expect(Scheduler).toHaveYielded([2, 'a:two']); expect(source.listenerCount).toBe(1); diff --git a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js index a2f065102d05f..5bfdf3a305f32 100644 --- a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js @@ -156,6 +156,8 @@ export const commitHydratedSuspenseInstance = export const clearSuspenseBoundary = $$$hostConfig.clearSuspenseBoundary; export const clearSuspenseBoundaryFromContainer = $$$hostConfig.clearSuspenseBoundaryFromContainer; +export const shouldDeleteUnhydratedTailInstances = + $$$hostConfig.shouldDeleteUnhydratedTailInstances; export const didNotMatchHydratedContainerTextInstance = $$$hostConfig.didNotMatchHydratedContainerTextInstance; export const didNotMatchHydratedTextInstance = @@ -175,3 +177,4 @@ export const didNotFindHydratableTextInstance = $$$hostConfig.didNotFindHydratableTextInstance; export const didNotFindHydratableSuspenseInstance = $$$hostConfig.didNotFindHydratableSuspenseInstance; +export const errorHydratingContainer = $$$hostConfig.errorHydratingContainer;