From f7a2265121549f77699197c90d953ca6cd0ef551 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 1/2] re-add hydration support to React 18 errors --- .../internal/container/Errors.tsx | 6 ++- .../component-stack-pseudo-html.tsx | 2 +- .../internal/helpers/hydration-error-info.ts | 51 +++++++++++++++---- .../react-dev-overlay/pages/client.ts | 8 +-- .../hydration-error-react-19.test.ts | 3 +- .../acceptance/hydration-error.test.ts | 5 +- test/development/basic/hmr.test.ts | 7 ++- .../auto-export/test/index.test.js | 16 +++++- 8 files changed, 76 insertions(+), 22 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 38587213a6f59..74df140a255c3 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/container/RuntimeError/component-stack-pseudo-html.tsx b/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/component-stack-pseudo-html.tsx index 6b2c858d6f346..58b2e2d199665 100644 --- a/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/component-stack-pseudo-html.tsx +++ b/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/component-stack-pseudo-html.tsx @@ -66,7 +66,7 @@ export function PseudoHtmlDiff({ firstContent: string secondContent: string reactOutputComponentDiff: string | undefined - hydrationMismatchType: 'tag' | 'text' + hydrationMismatchType: 'tag' | 'text' | 'text-in-tag' } & React.HTMLAttributes) { const isHtmlTagsWarning = hydrationMismatchType === 'tag' const isReactHydrationDiff = !!reactOutputComponentDiff 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 f99a6e41c55d0..413b0746008ea 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 @@ -23,15 +23,42 @@ const htmlTagsWarnings = new Set([ "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) => 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)) +const isHtmlTagsWarning = (msg: NullableText) => { + if (msg && typeof msg === 'string') { + return htmlTagsWarnings.has(normalizeWarningMessage(msg)) + } + + return false +} -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 +78,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 e10ddbd1b1594..e60871157f4b8 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-react-19.test.ts b/test/development/acceptance/hydration-error-react-19.test.ts index 21ea0b97563a0..f1e5ae181acd5 100644 --- a/test/development/acceptance/hydration-error-react-19.test.ts +++ b/test/development/acceptance/hydration-error-react-19.test.ts @@ -4,7 +4,8 @@ import { FileRef, nextTestSetup } from 'e2e-utils' import { outdent } from 'outdent' import path from 'path' -describe('Error overlay for hydration errors (React 19)', () => { +// TODO: Enable once React 19 support is added to pages. +describe.skip('Error overlay for hydration errors (React 19)', () => { const { next } = nextTestSetup({ files: new FileRef(path.join(__dirname, 'fixtures', 'default-template')), skipStart: true, diff --git a/test/development/acceptance/hydration-error.test.ts b/test/development/acceptance/hydration-error.test.ts index af299cf5b5228..dfcb2c7dbc196 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 3dad5302c2cb8..b065a1af3b041 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', }, diff --git a/test/integration/auto-export/test/index.test.js b/test/integration/auto-export/test/index.test.js index e90f76052c129..6c3b87c089c25 100644 --- a/test/integration/auto-export/test/index.test.js +++ b/test/integration/auto-export/test/index.test.js @@ -15,6 +15,8 @@ const appDir = path.join(__dirname, '..') let appPort let app +const isReact18 = true + const runTests = () => { it('Supports commonjs 1', async () => { const browser = await webdriver(appPort, '/commonjs1') @@ -99,7 +101,19 @@ describe('Auto Export', () => { expect.arrayContaining([ { message: expect.stringContaining( - 'https://react.dev/link/hydration-mismatch' + 'See more info here: https://nextjs.org/docs/messages/react-hydration-error' + ), + source: 'error', + }, + ]) + ) + expect(logs).toEqual( + expect.arrayContaining([ + { + message: expect.stringContaining( + isReact18 + ? 'https://nextjs.org/docs/messages/react-hydration-error' + : 'https://react.dev/link/hydration-mismatch' ), source: 'error', }, From d2eaa44728dfb734e1fca07e0ce1ab7ac2f70bb6 Mon Sep 17 00:00:00 2001 From: Zack Tanner <1939140+ztanner@users.noreply.github.com> Date: Thu, 5 Sep 2024 15:38:22 -0700 Subject: [PATCH 2/2] update pages-dir test for React 18 assertions (#69759) Matches the same guard we added in [`f06d21e` (#69484)](https://github.com/vercel/next.js/pull/69484/commits/f06d21e5de2785d24b815255b050040bebce8066) --- .../pages-dir/production/test/index.test.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/test/production/pages-dir/production/test/index.test.ts b/test/production/pages-dir/production/test/index.test.ts index 82fd919e85e7c..8da6362cb3d8d 100644 --- a/test/production/pages-dir/production/test/index.test.ts +++ b/test/production/pages-dir/production/test/index.test.ts @@ -29,6 +29,8 @@ if (process.env.TEST_WASM) { jest.setTimeout(120 * 1000) } +const isReact18 = true + describe('Production Usage', () => { const { next } = nextTestSetup({ files: path.join(__dirname, '../fixture'), @@ -181,7 +183,9 @@ describe('Production Usage', () => { /webpack-runtime\.js/, /node_modules\/react\/index\.js/, /node_modules\/react\/package\.json/, - /node_modules\/react\/cjs\/react\.production\.js/, + isReact18 + ? /node_modules\/react\/cjs\/react\.production\.min\.js/ + : /node_modules\/react\/cjs\/react\.production\.js/, ], notTests: [/\0/, /\?/, /!/], }, @@ -192,7 +196,9 @@ describe('Production Usage', () => { /chunks\/.*?\.js/, /node_modules\/react\/index\.js/, /node_modules\/react\/package\.json/, - /node_modules\/react\/cjs\/react\.production\.js/, + isReact18 + ? /node_modules\/react\/cjs\/react\.production\.min\.js/ + : /node_modules\/react\/cjs\/react\.production\.js/, /node_modules\/next/, ], notTests: [/\0/, /\?/, /!/], @@ -204,7 +210,9 @@ describe('Production Usage', () => { /chunks\/.*?\.js/, /node_modules\/react\/index\.js/, /node_modules\/react\/package\.json/, - /node_modules\/react\/cjs\/react\.production\.js/, + isReact18 + ? /node_modules\/react\/cjs\/react\.production\.min\.js/ + : /node_modules\/react\/cjs\/react\.production\.js/, /node_modules\/next/, /node_modules\/nanoid\/index\.js/, /node_modules\/nanoid\/url-alphabet\/index\.js/, @@ -219,7 +227,9 @@ describe('Production Usage', () => { /chunks\/.*?\.js/, /node_modules\/react\/index\.js/, /node_modules\/react\/package\.json/, - /node_modules\/react\/cjs\/react\.production\.js/, + isReact18 + ? /node_modules\/react\/cjs\/react\.production\.min\.js/ + : /node_modules\/react\/cjs\/react\.production\.js/, /node_modules\/next/, ], notTests: [