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 6b2c858d6f3466..58b2e2d199665e 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 f99a6e41c55d0c..3ca474eeedb1ab 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 @@ -17,21 +17,62 @@ 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([ - '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.", + 'Warning: In HTML, %s cannot be a child of <%s>.%s\nThis will cause a hydration error.%s', + 'Warning: In HTML, %s cannot be a descendant of <%s>.\nThis will cause a hydration error.%s', + 'Warning: In HTML, text nodes cannot be a child of <%s>.\nThis will cause a hydration error.', + "Warning: 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.", + 'Warning: Expected server HTML to contain a matching <%s> in <%s>.%s', + 'Warning: Did not expect server HTML to contain a <%s> in <%s>.%s', ]) +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', +]) +const textMismatchWarning = + 'Warning: Text content did not match. Server: "%s" Client: "%s"%s' + +export const getHydrationWarningType = ( + message: NullableText +): 'tag' | 'text' | 'text-in-tag' => { + if (typeof message !== 'string') { + // TODO: Doesn't make sense to treat no message as a hydration error message. + // We should bail out somewhere earlier. + return 'text' + } + + const normalizedMessage = message.startsWith('Warning: ') + ? message + : `Warning: ${message}` + + if (isHtmlTagsWarning(normalizedMessage)) return 'tag' + if (isTextInTagsMismatchWarning(normalizedMessage)) return 'text-in-tag' -export const getHydrationWarningType = (msg: NullableText): 'tag' | 'text' => { - if (isHtmlTagsWarning(msg)) return 'tag' return 'text' } -const isHtmlTagsWarning = (msg: NullableText) => - Boolean(msg && htmlTagsWarnings.has(msg)) +const isHtmlTagsWarning = (message: string) => htmlTagsWarnings.has(message) -const isKnownHydrationWarning = (msg: NullableText) => isHtmlTagsWarning(msg) +const isTextMismatchWarning = (message: string) => + textMismatchWarning === message +const isTextInTagsMismatchWarning = (msg: string) => + textAndTagsMismatchWarnings.has(msg) + +const isKnownHydrationWarning = (message: NullableText) => { + if (typeof message !== 'string') { + return false + } + // React 18 has the `Warning: ` prefix. + // React 19 does not. + const normalizedMessage = message.startsWith('Warning: ') + ? message + : `Warning: ${message}` + + return ( + isHtmlTagsWarning(normalizedMessage) || + isTextInTagsMismatchWarning(normalizedMessage) || + isTextMismatchWarning(normalizedMessage) + ) +} export const getReactHydrationDiffSegments = (msg: NullableText) => { if (msg) { diff --git a/test/development/acceptance/hydration-error.test.ts b/test/development/acceptance/hydration-error.test.ts index b7459d66e86c87..6046e2ea8e872f 100644 --- a/test/development/acceptance/hydration-error.test.ts +++ b/test/development/acceptance/hydration-error.test.ts @@ -74,48 +74,85 @@ describe('Error overlay for hydration errors in Pages router', () => { ) await session.assertHasRedbox() - expect(await getRedboxTotalErrorCount(browser)).toBe(1) + expect(await getRedboxTotalErrorCount(browser)).toBe(isReact18 ? 2 : 1) - expect(await session.getRedboxDescription()).toMatchInlineSnapshot(` - "Hydration failed because the server rendered HTML didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used - See more info here: https://nextjs.org/docs/messages/react-hydration-error" - `) - - expect(await session.getRedboxDescriptionWarning()).toMatchInlineSnapshot(` - "- A server/client branch \`if (typeof window !== 'undefined')\`. - - Variable input such as \`Date.now()\` or \`Math.random()\` which changes each time it's called. - - Date formatting in a user's locale which doesn't match the server. - - External changing data without sending a snapshot of it along with the HTML. - - Invalid HTML tag nesting. + if (isReact18) { + expect(await session.getRedboxDescription()).toMatchInlineSnapshot( + `"Text content did not match. Server: "server" Client: "client""` + ) + } else { + expect(await session.getRedboxDescription()).toMatchInlineSnapshot(` + "Hydration failed because the server rendered HTML didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used + See more info here: https://nextjs.org/docs/messages/react-hydration-error" + `) + } - It can also happen if the client has a browser extension installed which messes with the HTML before React loaded." - `) + if (isReact18) { + expect(await session.getRedboxDescriptionWarning()).toMatchInlineSnapshot( + `undefined` + ) + } else { + expect(await session.getRedboxDescriptionWarning()) + .toMatchInlineSnapshot(` + "- A server/client branch \`if (typeof window !== 'undefined')\`. + - Variable input such as \`Date.now()\` or \`Math.random()\` which changes each time it's called. + - Date formatting in a user's locale which doesn't match the server. + - External changing data without sending a snapshot of it along with the HTML. + - Invalid HTML tag nesting. + + It can also happen if the client has a browser extension installed which messes with the HTML before React loaded." + `) + } const pseudoHtml = await session.getRedboxComponentStack() if (isTurbopack) { - expect(pseudoHtml).toMatchInlineSnapshot(` - "... - - - - - - - -
-
- + client - - server" - `) + if (isReact18) { + expect(pseudoHtml).toMatchInlineSnapshot(` + "... + + + + +
+
+ "server" + "client"" + `) + } else { + expect(pseudoHtml).toMatchInlineSnapshot(` + "... + + + + + + + +
+
+ + client + - server" + `) + } } else { - expect(pseudoHtml).toMatchInlineSnapshot(` - "... -
-
- + client - - server" - `) + if (isReact18) { + expect(pseudoHtml).toMatchInlineSnapshot(` + " +
+
+ "server" + "client"" + `) + } else { + expect(pseudoHtml).toMatchInlineSnapshot(` + "... +
+
+ + client + - server" + `) + } } await session.patch( @@ -160,36 +197,63 @@ describe('Error overlay for hydration errors in Pages router', () => { ) await session.assertHasRedbox() - expect(await getRedboxTotalErrorCount(browser)).toBe(1) + expect(await getRedboxTotalErrorCount(browser)).toBe(isReact18 ? 3 : 1) const pseudoHtml = await session.getRedboxComponentStack() if (isTurbopack) { - expect(pseudoHtml).toMatchInlineSnapshot(` - "... - - - - - - - -
- ... - +
" - `) + if (isReact18) { + expect(pseudoHtml).toMatchInlineSnapshot(` + "... + +
+ ^^^^^ +
+ ^^^^^^" + `) + } else { + expect(pseudoHtml).toMatchInlineSnapshot(` + "... + + + + + + + +
+ ... + +
" + `) + } } else { - expect(pseudoHtml).toMatchInlineSnapshot(` - "... -
- ... - +
" - `) + if (isReact18) { + expect(pseudoHtml).toMatchInlineSnapshot(` + " +
+ ^^^^^ +
+ ^^^^^^" + `) + } else { + expect(pseudoHtml).toMatchInlineSnapshot(` + "... +
+ ... + +
" + `) + } } - expect(await session.getRedboxDescription()).toMatchInlineSnapshot(` - "Hydration failed because the server rendered HTML didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used - See more info here: https://nextjs.org/docs/messages/react-hydration-error" - `) + if (isReact18) { + expect(await session.getRedboxDescription()).toMatchInlineSnapshot( + `"Expected server HTML to contain a matching
in
."` + ) + } else { + expect(await session.getRedboxDescription()).toMatchInlineSnapshot(` + "Hydration failed because the server rendered HTML didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used + See more info here: https://nextjs.org/docs/messages/react-hydration-error" + `) + } await cleanup() }) @@ -217,36 +281,65 @@ describe('Error overlay for hydration errors in Pages router', () => { ) await session.assertHasRedbox() - expect(await getRedboxTotalErrorCount(browser)).toBe(1) + expect(await getRedboxTotalErrorCount(browser)).toBe(isReact18 ? 3 : 1) const pseudoHtml = await session.getRedboxComponentStack() if (isTurbopack) { - expect(pseudoHtml).toMatchInlineSnapshot(` - "... - - - - - - - -
- + second - -