diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 483c588c37b9f..2fddae2d64df2 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -1740,8 +1740,13 @@ describe('ReactDOMFizzServer', () => { 'The server HTML was replaced with client content', ]); }).toErrorDev( - 'Warning: An error occurred during hydration. The server HTML was replaced with client content', - {withoutStack: true}, + [ + 'Warning: An error occurred during hydration. The server HTML was replaced with client content in
.', + 'Warning: Expected server HTML to contain a matching
in
.\n' + + ' in div (at **)\n' + + ' in App (at **)', + ], + {withoutStack: 1}, ); expect(getVisibleChildren(container)).toEqual(
client
); } else { @@ -1833,8 +1838,13 @@ describe('ReactDOMFizzServer', () => { 'The server HTML was replaced with client content', ]); }).toErrorDev( - 'Warning: An error occurred during hydration. The server HTML was replaced with client content', - {withoutStack: true}, + [ + 'Warning: An error occurred during hydration. The server HTML was replaced with client content', + 'Warning: Expected server HTML to contain a matching
in
.\n' + + ' in div (at **)\n' + + ' in App (at **)', + ], + {withoutStack: 1}, ); expect(getVisibleChildren(container)).toEqual(
client
); } else { diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js index e071a36737104..586901baa14c3 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js @@ -19,6 +19,15 @@ let SuspenseList; let act; let IdleEventPriority; +function normalizeCodeLocInfo(strOrErr) { + if (strOrErr && strOrErr.replace) { + return strOrErr.replace(/\n +(?:at|in) ([\S]+)[^\n]*/g, function(m, name) { + return '\n in ' + name + ' (at **)'; + }); + } + return strOrErr; +} + function dispatchMouseEvent(to, from) { if (!to) { to = null; @@ -240,6 +249,12 @@ describe('ReactDOMServerPartialHydration', () => { // @gate enableClientRenderFallbackOnHydrationMismatch it('falls back to client rendering boundary on mismatch', async () => { + // We can't use the toErrorDev helper here because this is async. + const originalConsoleError = console.error; + const mockError = jest.fn(); + console.error = (...args) => { + mockError(...args.map(normalizeCodeLocInfo)); + }; let client = false; let suspend = false; let resolve; @@ -276,70 +291,86 @@ describe('ReactDOMServerPartialHydration', () => { ); } - const finalHTML = ReactDOMServer.renderToString(); - const container = document.createElement('div'); - container.innerHTML = finalHTML; - expect(Scheduler).toHaveYielded([ - 'Hello', - 'Component', - 'Component', - 'Component', - 'Component', - ]); + try { + const finalHTML = ReactDOMServer.renderToString(); + const container = document.createElement('div'); + container.innerHTML = finalHTML; + expect(Scheduler).toHaveYielded([ + 'Hello', + 'Component', + 'Component', + 'Component', + 'Component', + ]); - expect(container.innerHTML).toBe( - 'Hello
Component
Component
Component
Component
', - ); + expect(container.innerHTML).toBe( + 'Hello
Component
Component
Component
Component
', + ); - suspend = true; - client = true; + suspend = true; + client = true; - ReactDOM.hydrateRoot(container, , { - onRecoverableError(error) { - Scheduler.unstable_yieldValue(error.message); - }, - }); - expect(Scheduler).toFlushAndYield([ - 'Suspend', - 'Component', - 'Component', - 'Component', - 'Component', - ]); - jest.runAllTimers(); + ReactDOM.hydrateRoot(container, , { + onRecoverableError(error) { + Scheduler.unstable_yieldValue(error.message); + }, + }); + expect(Scheduler).toFlushAndYield([ + 'Suspend', + 'Component', + 'Component', + 'Component', + 'Component', + ]); + jest.runAllTimers(); - // Unchanged - expect(container.innerHTML).toBe( - 'Hello
Component
Component
Component
Component
', - ); + // Unchanged + expect(container.innerHTML).toBe( + 'Hello
Component
Component
Component
Component
', + ); - suspend = false; - resolve(); - await promise; + suspend = false; + resolve(); + await promise; + expect(Scheduler).toFlushAndYield([ + // first pass, mismatches at end + 'Hello', + 'Component', + 'Component', + 'Component', + 'Component', + + // second pass as client render + 'Hello', + 'Component', + 'Component', + 'Component', + 'Component', + + // Hydration mismatch is logged + 'An error occurred during hydration. The server HTML was replaced with client content', + ]); - expect(Scheduler).toFlushAndYield([ - // first pass, mismatches at end - 'Hello', - 'Component', - 'Component', - 'Component', - 'Component', - - // second pass as client render - 'Hello', - 'Component', - 'Component', - 'Component', - 'Component', - - // Hydration mismatch is logged - 'An error occurred during hydration. The server HTML was replaced with client content', - ]); + // Client rendered - suspense comment nodes removed + expect(container.innerHTML).toBe( + 'Hello
Component
Component
Component
Mismatch
', + ); - // Client rendered - suspense comment nodes removed - expect(container.innerHTML).toBe( - 'Hello
Component
Component
Component
Mismatch
', - ); + if (__DEV__) { + expect(mockError.mock.calls[0]).toEqual([ + 'Warning: Expected server HTML to contain a matching <%s> in <%s>.%s', + 'div', + 'div', + '\n' + + ' in div (at **)\n' + + ' in Component (at **)\n' + + ' in Suspense (at **)\n' + + ' in App (at **)', + ]); + } + } finally { + console.error = originalConsoleError; + } }); it('calls the hydration callbacks after hydration or deletion', async () => { @@ -493,21 +524,14 @@ describe('ReactDOMServerPartialHydration', () => { }); it('recovers with client render when server rendered additional nodes at suspense root after unsuspending', async () => { - spyOnDev(console, 'error'); - const ref = React.createRef(); - function App({hasB}) { - return ( -
- - - A - {hasB ? B : null} - -
Sibling
-
- ); - } + // We can't use the toErrorDev helper here because this is async. + const originalConsoleError = console.error; + const mockError = jest.fn(); + console.error = (...args) => { + mockError(...args.map(normalizeCodeLocInfo)); + }; + const ref = React.createRef(); let shouldSuspend = false; let resolve; const promise = new Promise(res => { @@ -522,37 +546,61 @@ describe('ReactDOMServerPartialHydration', () => { } return <>; } + function App({hasB}) { + return ( +
+ + + A + {hasB ? B : null} + +
Sibling
+
+ ); + } + try { + const finalHTML = ReactDOMServer.renderToString(); - const finalHTML = ReactDOMServer.renderToString(); - - const container = document.createElement('div'); - container.innerHTML = finalHTML; + const container = document.createElement('div'); + container.innerHTML = finalHTML; - const span = container.getElementsByTagName('span')[0]; + const span = container.getElementsByTagName('span')[0]; - expect(container.innerHTML).toContain('A'); - expect(container.innerHTML).toContain('B'); - expect(ref.current).toBe(null); + expect(container.innerHTML).toContain('A'); + expect(container.innerHTML).toContain('B'); + expect(ref.current).toBe(null); - shouldSuspend = true; - act(() => { - ReactDOM.hydrateRoot(container, ); - }); + shouldSuspend = true; + act(() => { + ReactDOM.hydrateRoot(container, ); + }); - // await expect(async () => { - resolve(); - await promise; - Scheduler.unstable_flushAll(); - await null; - jest.runAllTimers(); - // }).toErrorDev('Did not expect server HTML to contain a in
'); + resolve(); + await promise; + Scheduler.unstable_flushAll(); + await null; + jest.runAllTimers(); - expect(container.innerHTML).toContain('A'); - expect(container.innerHTML).not.toContain('B'); - if (gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)) { - expect(ref.current).not.toBe(span); - } else { - expect(ref.current).toBe(span); + expect(container.innerHTML).toContain('A'); + expect(container.innerHTML).not.toContain('B'); + if (gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)) { + expect(ref.current).not.toBe(span); + } else { + expect(ref.current).toBe(span); + } + if (__DEV__) { + expect(mockError).toHaveBeenCalledWith( + 'Warning: Did not expect server HTML to contain a <%s> in <%s>.%s', + 'span', + 'div', + '\n' + + ' in Suspense (at **)\n' + + ' in div (at **)\n' + + ' in App (at **)', + ); + } + } finally { + console.error = originalConsoleError; } }); @@ -3179,9 +3227,14 @@ describe('ReactDOMServerPartialHydration', () => { }); }); }).toErrorDev( - 'Warning: An error occurred during hydration. ' + - 'The server HTML was replaced with client content in
.', - {withoutStack: true}, + [ + 'Warning: An error occurred during hydration. ' + + 'The server HTML was replaced with client content in
.', + 'Warning: Expected server HTML to contain a matching in
.\n' + + ' in span (at **)\n' + + ' in App (at **)', + ], + {withoutStack: 1}, ); expect(Scheduler).toHaveYielded([ 'Log recoverable error: An error occurred during hydration. The server ' + diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.new.js b/packages/react-reconciler/src/ReactFiberHydrationContext.new.js index 4a460d584a53d..ab2d05cea0aac 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.new.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.new.js @@ -183,8 +183,7 @@ function deleteHydratableInstance( } } -function insertNonHydratedInstance(returnFiber: Fiber, fiber: Fiber) { - fiber.flags = (fiber.flags & ~Hydrating) | Placement; +function warnNonhydratedInstance(returnFiber: Fiber, fiber: Fiber) { if (__DEV__) { switch (returnFiber.tag) { case HostRoot: { @@ -283,6 +282,10 @@ function insertNonHydratedInstance(returnFiber: Fiber, fiber: Fiber) { } } } +function insertNonHydratedInstance(returnFiber: Fiber, fiber: Fiber) { + fiber.flags = (fiber.flags & ~Hydrating) | Placement; + warnNonhydratedInstance(returnFiber, fiber); +} function tryHydrate(fiber, nextInstance) { switch (fiber.tag) { @@ -353,12 +356,10 @@ function shouldClientRenderOnMismatch(fiber: Fiber) { ); } -function throwOnHydrationMismatchIfConcurrentMode(fiber: Fiber) { - if (shouldClientRenderOnMismatch(fiber)) { - throw new Error( - 'An error occurred during hydration. The server HTML was replaced with client content', - ); - } +function throwOnHydrationMismatch(fiber: Fiber) { + throw new Error( + 'An error occurred during hydration. The server HTML was replaced with client content', + ); } function tryToClaimNextHydratableInstance(fiber: Fiber): void { @@ -367,7 +368,10 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void { } let nextInstance = nextHydratableInstance; if (!nextInstance) { - throwOnHydrationMismatchIfConcurrentMode(fiber); + if (shouldClientRenderOnMismatch(fiber)) { + warnNonhydratedInstance((hydrationParentFiber: any), fiber); + throwOnHydrationMismatch(fiber); + } // Nothing to hydrate. Make it an insertion. insertNonHydratedInstance((hydrationParentFiber: any), fiber); isHydrating = false; @@ -376,7 +380,10 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void { } const firstAttemptedInstance = nextInstance; if (!tryHydrate(fiber, nextInstance)) { - throwOnHydrationMismatchIfConcurrentMode(fiber); + if (shouldClientRenderOnMismatch(fiber)) { + warnNonhydratedInstance((hydrationParentFiber: any), fiber); + throwOnHydrationMismatch(fiber); + } // If we can't hydrate this instance let's try the next one. // We use this as a heuristic. It's based on intuition and not data so it // might be flawed or unnecessary. @@ -565,7 +572,7 @@ function popHydrationState(fiber: Fiber): boolean { if (nextInstance) { if (shouldClientRenderOnMismatch(fiber)) { warnIfUnhydratedTailNodes(fiber); - throwOnHydrationMismatchIfConcurrentMode(fiber); + throwOnHydrationMismatch(fiber); } else { while (nextInstance) { deleteHydratableInstance(fiber, nextInstance); diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.old.js b/packages/react-reconciler/src/ReactFiberHydrationContext.old.js index 3ee040237829a..9ad186495d8e2 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.old.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.old.js @@ -183,8 +183,7 @@ function deleteHydratableInstance( } } -function insertNonHydratedInstance(returnFiber: Fiber, fiber: Fiber) { - fiber.flags = (fiber.flags & ~Hydrating) | Placement; +function warnNonhydratedInstance(returnFiber: Fiber, fiber: Fiber) { if (__DEV__) { switch (returnFiber.tag) { case HostRoot: { @@ -283,6 +282,10 @@ function insertNonHydratedInstance(returnFiber: Fiber, fiber: Fiber) { } } } +function insertNonHydratedInstance(returnFiber: Fiber, fiber: Fiber) { + fiber.flags = (fiber.flags & ~Hydrating) | Placement; + warnNonhydratedInstance(returnFiber, fiber); +} function tryHydrate(fiber, nextInstance) { switch (fiber.tag) { @@ -353,12 +356,10 @@ function shouldClientRenderOnMismatch(fiber: Fiber) { ); } -function throwOnHydrationMismatchIfConcurrentMode(fiber: Fiber) { - if (shouldClientRenderOnMismatch(fiber)) { - throw new Error( - 'An error occurred during hydration. The server HTML was replaced with client content', - ); - } +function throwOnHydrationMismatch(fiber: Fiber) { + throw new Error( + 'An error occurred during hydration. The server HTML was replaced with client content', + ); } function tryToClaimNextHydratableInstance(fiber: Fiber): void { @@ -367,7 +368,10 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void { } let nextInstance = nextHydratableInstance; if (!nextInstance) { - throwOnHydrationMismatchIfConcurrentMode(fiber); + if (shouldClientRenderOnMismatch(fiber)) { + warnNonhydratedInstance((hydrationParentFiber: any), fiber); + throwOnHydrationMismatch(fiber); + } // Nothing to hydrate. Make it an insertion. insertNonHydratedInstance((hydrationParentFiber: any), fiber); isHydrating = false; @@ -376,7 +380,10 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void { } const firstAttemptedInstance = nextInstance; if (!tryHydrate(fiber, nextInstance)) { - throwOnHydrationMismatchIfConcurrentMode(fiber); + if (shouldClientRenderOnMismatch(fiber)) { + warnNonhydratedInstance((hydrationParentFiber: any), fiber); + throwOnHydrationMismatch(fiber); + } // If we can't hydrate this instance let's try the next one. // We use this as a heuristic. It's based on intuition and not data so it // might be flawed or unnecessary. @@ -565,7 +572,7 @@ function popHydrationState(fiber: Fiber): boolean { if (nextInstance) { if (shouldClientRenderOnMismatch(fiber)) { warnIfUnhydratedTailNodes(fiber); - throwOnHydrationMismatchIfConcurrentMode(fiber); + throwOnHydrationMismatch(fiber); } else { while (nextInstance) { deleteHydratableInstance(fiber, nextInstance);