From f20e3c5814cce3e3a2d09ab9f41ccc578456a3aa Mon Sep 17 00:00:00 2001 From: Zack Tanner <1939140+ztanner@users.noreply.github.com> Date: Thu, 5 Sep 2024 13:59:20 -0700 Subject: [PATCH] re-add hydration support to React 18 errors --- .../internal/container/Errors.tsx | 6 ++- .../internal/helpers/hydration-error-info.ts | 47 +++++++++++++++---- .../react-dev-overlay/pages/client.ts | 8 ++-- .../acceptance/hydration-error.test.ts | 5 +- test/development/basic/hmr.test.ts | 7 ++- 5 files changed, 54 insertions(+), 19 deletions(-) diff --git a/packages/next/src/client/components/react-dev-overlay/internal/container/Errors.tsx b/packages/next/src/client/components/react-dev-overlay/internal/container/Errors.tsx index 38587213a6f595..74df140a255c3f 100644 --- a/packages/next/src/client/components/react-dev-overlay/internal/container/Errors.tsx +++ b/packages/next/src/client/components/react-dev-overlay/internal/container/Errors.tsx @@ -238,7 +238,6 @@ export function Errors({ ) const errorDetails: HydrationErrorState = (error as any).details || {} - const notes = errorDetails.notes || '' const [warningTemplate, serverContent, clientContent] = errorDetails.warning || [null, '', ''] @@ -252,6 +251,7 @@ export function Errors({ .replace(/^Warning: /, '') .replace(/^Error: /, '') : null + const notes = isAppDir ? errorDetails.notes || '' : hydrationWarning return ( @@ -307,7 +307,9 @@ export function Errors({ {/* If there's hydration warning, skip displaying the error name */} {hydrationWarning ? '' : error.name + ': '}

diff --git a/packages/next/src/client/components/react-dev-overlay/internal/helpers/hydration-error-info.ts b/packages/next/src/client/components/react-dev-overlay/internal/helpers/hydration-error-info.ts index f99a6e41c55d0c..b3776ad77daf9f 100644 --- a/packages/next/src/client/components/react-dev-overlay/internal/helpers/hydration-error-info.ts +++ b/packages/next/src/client/components/react-dev-overlay/internal/helpers/hydration-error-info.ts @@ -16,22 +16,45 @@ type NullableText = string | null | undefined export const hydrationErrorState: HydrationErrorState = {} // https://github.com/facebook/react/blob/main/packages/react-dom/src/__tests__/ReactDOMHydrationDiff-test.js used as a reference -const htmlTagsWarnings = new Set([ +const htmlTagsWarnings19 = new Set([ 'In HTML, %s cannot be a child of <%s>.%s\nThis will cause a hydration error.%s', 'In HTML, %s cannot be a descendant of <%s>.\nThis will cause a hydration error.%s', 'In HTML, text nodes cannot be a child of <%s>.\nThis will cause a hydration error.', "In HTML, whitespace text nodes cannot be a child of <%s>. Make sure you don't have any extra whitespace between tags on each line of your source code.\nThis will cause a hydration error.", ]) -export const getHydrationWarningType = (msg: NullableText): 'tag' | 'text' => { +// In React 18, the warning message is prefixed with "Warning: " +const normalizeWarningMessage = (msg: string): string => + msg.replace(/^Warning: /, '') + +// Note: React 18 only +const textAndTagsMismatchWarnings = new Set([ + 'Warning: Expected server HTML to contain a matching text node for "%s" in <%s>.%s', + 'Warning: Did not expect server HTML to contain the text node "%s" in <%s>.%s', +]) + +// Note: React 18 only +const textMismatchWarning = + 'Warning: Text content did not match. Server: "%s" Client: "%s"%s' + +const isTextMismatchWarning = (msg: NullableText) => textMismatchWarning === msg +const isTextInTagsMismatchWarning = (msg: NullableText) => + Boolean(msg && textAndTagsMismatchWarnings.has(msg)) + +export const getHydrationWarningType = ( + msg: NullableText +): 'tag' | 'text' | 'text-in-tag' => { if (isHtmlTagsWarning(msg)) return 'tag' return 'text' } const isHtmlTagsWarning = (msg: NullableText) => - Boolean(msg && htmlTagsWarnings.has(msg)) + Boolean(msg && htmlTagsWarnings19.has(normalizeWarningMessage(msg))) -const isKnownHydrationWarning = (msg: NullableText) => isHtmlTagsWarning(msg) +const isKnownHydrationWarning = (msg: NullableText) => + isHtmlTagsWarning(msg) || + isTextInTagsMismatchWarning(msg) || + isTextMismatchWarning(msg) export const getReactHydrationDiffSegments = (msg: NullableText) => { if (msg) { @@ -51,14 +74,18 @@ export const getReactHydrationDiffSegments = (msg: NullableText) => { export function storeHydrationErrorStateFromConsoleArgs(...args: any[]) { const [msg, serverContent, clientContent, componentStack] = args if (isKnownHydrationWarning(msg)) { - hydrationErrorState.warning = [ - // remove the last %s from the message - msg, - serverContent, - clientContent, - ] + hydrationErrorState.warning = [msg, serverContent, clientContent] hydrationErrorState.componentStack = componentStack hydrationErrorState.serverContent = serverContent hydrationErrorState.clientContent = clientContent + + return [ + ...args, + // We tack on the hydration error message to the console.error message so that + // it matches the error we display in the redbox overlay + `\nSee more info here: https://nextjs.org/docs/messages/react-hydration-error`, + ] } + + return args } diff --git a/packages/next/src/client/components/react-dev-overlay/pages/client.ts b/packages/next/src/client/components/react-dev-overlay/pages/client.ts index e10ddbd1b15940..e60871157f4b89 100644 --- a/packages/next/src/client/components/react-dev-overlay/pages/client.ts +++ b/packages/next/src/client/components/react-dev-overlay/pages/client.ts @@ -52,11 +52,13 @@ function handleError(error: unknown) { let origConsoleError = console.error function nextJsHandleConsoleError(...args: any[]) { + // To support React 19, this will need to be updated as follows: + // const error = process.env.NODE_ENV !== 'production' ? args[1] : args[0] // See https://github.com/facebook/react/blob/d50323eb845c5fde0d720cae888bf35dedd05506/packages/react-reconciler/src/ReactFiberErrorLogger.js#L78 - const error = process.env.NODE_ENV !== 'production' ? args[1] : args[0] - storeHydrationErrorStateFromConsoleArgs(...args) + const error = args[0] + const errorArgs = storeHydrationErrorStateFromConsoleArgs(...args) handleError(error) - origConsoleError.apply(window.console, args) + origConsoleError.apply(window.console, errorArgs) } function onUnhandledError(event: ErrorEvent) { diff --git a/test/development/acceptance/hydration-error.test.ts b/test/development/acceptance/hydration-error.test.ts index af299cf5b52283..dfcb2c7dbc196a 100644 --- a/test/development/acceptance/hydration-error.test.ts +++ b/test/development/acceptance/hydration-error.test.ts @@ -4,8 +4,7 @@ import { FileRef, nextTestSetup } from 'e2e-utils' import { outdent } from 'outdent' import path from 'path' -// TODO: Enable this test once react 18 is supported for pages router -describe.skip('Error overlay for hydration errors (React 18)', () => { +describe('Error overlay for hydration errors (React 18)', () => { const { next } = nextTestSetup({ files: new FileRef(path.join(__dirname, 'fixtures', 'default-template')), dependencies: { @@ -38,7 +37,7 @@ describe.skip('Error overlay for hydration errors (React 18)', () => { await session.assertHasRedbox() expect(await session.getRedboxDescription()).toMatchInlineSnapshot(` - "Error: Text content does not match server-rendered HTML. + "Text content does not match server-rendered HTML. See more info here: https://nextjs.org/docs/messages/react-hydration-error" `) diff --git a/test/development/basic/hmr.test.ts b/test/development/basic/hmr.test.ts index 3dad5302c2cb82..b065a1af3b0412 100644 --- a/test/development/basic/hmr.test.ts +++ b/test/development/basic/hmr.test.ts @@ -18,6 +18,8 @@ import { NextInstance } from 'e2e-utils' import { outdent } from 'outdent' import type { NextConfig } from 'next' +const isReact18 = true + describe.each([ { basePath: '', assetPrefix: '' }, { basePath: '', assetPrefix: '/asset-prefix' }, @@ -41,11 +43,14 @@ describe.each([ }) await retry(async () => { const logs = await browser.log() + expect(logs).toEqual( expect.arrayContaining([ { message: expect.stringContaining( - 'https://react.dev/link/hydration-mismatch' + isReact18 + ? 'https://nextjs.org/docs/messages/react-hydration-error' + : 'https://react.dev/link/hydration-mismatch' ), source: 'error', },