diff --git a/packages/next/src/client/app-index.tsx b/packages/next/src/client/app-index.tsx
index 75867b17e50745..f59ee8507b4120 100644
--- a/packages/next/src/client/app-index.tsx
+++ b/packages/next/src/client/app-index.tsx
@@ -7,7 +7,7 @@ import React, { use } from 'react'
import { createFromReadableStream } from 'react-server-dom-webpack/client'
import { HeadManagerContext } from '../shared/lib/head-manager-context.shared-runtime'
-import onRecoverableError from './on-recoverable-error'
+import { onRecoverableError } from './on-recoverable-error'
import { callServer } from './app-call-server'
import { isNextRouterError } from './components/is-next-router-error'
import {
@@ -165,7 +165,9 @@ export function hydrate() {
const rootLayoutMissingTags = window.__next_root_layout_missing_tags
const hasMissingTags = !!rootLayoutMissingTags?.length
- const options = { onRecoverableError } satisfies ReactDOMClient.RootOptions
+ const options = {
+ onRecoverableError,
+ } satisfies ReactDOMClient.RootOptions
const isError =
document.documentElement.id === '__next_error__' || hasMissingTags
diff --git a/packages/next/src/client/components/is-hydration-error.ts b/packages/next/src/client/components/is-hydration-error.ts
index eaa9b0df90548b..f66eda30984c86 100644
--- a/packages/next/src/client/components/is-hydration-error.ts
+++ b/packages/next/src/client/components/is-hydration-error.ts
@@ -3,6 +3,62 @@ import isError from '../../lib/is-error'
const hydrationErrorRegex =
/hydration failed|while hydrating|content does not match|did not match/i
+const reactUnifiedMismatchWarning = `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`
+
+const reactHydrationErrorDocLink = 'https://react.dev/link/hydration-mismatch'
+
+export const getDefaultHydrationErrorMessage = () => {
+ return (
+ reactUnifiedMismatchWarning +
+ '\nSee more info here: https://nextjs.org/docs/messages/react-hydration-error'
+ )
+}
+
export function isHydrationError(error: unknown): boolean {
return isError(error) && hydrationErrorRegex.test(error.message)
}
+
+export function isReactHydrationErrorStack(stack: string): boolean {
+ return stack.startsWith(reactUnifiedMismatchWarning)
+}
+
+export function getHydrationErrorStackInfo(rawMessage: string): {
+ message: string | null
+ link?: string
+ stack?: string
+ diff?: string
+} {
+ rawMessage = rawMessage.replace(/^Error: /, '')
+ if (!isReactHydrationErrorStack(rawMessage)) {
+ return { message: null }
+ }
+ rawMessage = rawMessage.slice(reactUnifiedMismatchWarning.length + 1).trim()
+ const [message, trailing] = rawMessage.split(`${reactHydrationErrorDocLink}`)
+ const trimmedMessage = message.trim()
+ // React built-in hydration diff starts with a newline, checking if length is > 1
+ if (trailing && trailing.length > 1) {
+ const stacks: string[] = []
+ const diffs: string[] = []
+ trailing.split('\n').forEach((line) => {
+ if (line.trim() === '') return
+ if (line.trim().startsWith('at ')) {
+ stacks.push(line)
+ } else {
+ diffs.push(line)
+ }
+ })
+
+ return {
+ message: trimmedMessage,
+ link: reactHydrationErrorDocLink,
+ diff: diffs.join('\n'),
+ stack: stacks.join('\n'),
+ }
+ } else {
+ return {
+ message: trimmedMessage,
+ link: reactHydrationErrorDocLink,
+ stack: trailing, // without hydration diff
+ }
+ }
+}
diff --git a/packages/next/src/client/components/react-dev-overlay/app/hot-reloader-client.tsx b/packages/next/src/client/components/react-dev-overlay/app/hot-reloader-client.tsx
index 895f4c2054b131..29090e8cbb1f23 100644
--- a/packages/next/src/client/components/react-dev-overlay/app/hot-reloader-client.tsx
+++ b/packages/next/src/client/components/react-dev-overlay/app/hot-reloader-client.tsx
@@ -483,15 +483,17 @@ export default function HotReload({
| HydrationErrorState
| undefined
// Component stack is added to the error in use-error-handler in case there was a hydration errror
- const componentStack = errorDetails?.componentStack
+ const componentStackTrace =
+ (error as any)._componentStack || errorDetails?.componentStack
const warning = errorDetails?.warning
dispatch({
type: ACTION_UNHANDLED_ERROR,
reason: error,
frames: parseStack(error.stack!),
- componentStackFrames: componentStack
- ? parseComponentStack(componentStack)
- : undefined,
+ componentStackFrames:
+ typeof componentStackTrace === 'string'
+ ? parseComponentStack(componentStackTrace)
+ : undefined,
warning,
})
},
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 674828bead9119..9a9a9858300bed 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
@@ -227,6 +227,7 @@ export function Errors({
)
const errorDetails: HydrationErrorState = (error as any).details || {}
+ const notes = errorDetails.notes || ''
const [warningTemplate, serverContent, clientContent] =
errorDetails.warning || [null, '', '']
@@ -238,6 +239,7 @@ export function Errors({
.replace('%s', '') // remove the %s for stack
.replace(/%s$/, '') // If there's still a %s at the end, remove it
.replace(/^Warning: /, '')
+ .replace(/^Error: /, '')
: null
return (
@@ -272,28 +274,36 @@ export function Errors({
id="nextjs__container_errors_desc"
className="nextjs__container_errors_desc"
>
- {error.name}:{' '}
-
+ {/* If there's hydration warning, skip displaying the error name */}
+ {hydrationWarning ? '' : error.name + ': '}
+
- {hydrationWarning && (
+ {notes ? (
<>
- {hydrationWarning}
+ {notes}
- {activeError.componentStackFrames?.length ? (
-
- ) : null}
>
- )}
+ ) : null}
+
+ {hydrationWarning &&
+ (activeError.componentStackFrames?.length ||
+ !!errorDetails.reactOutputComponentDiff) ? (
+
+ ) : null}
{isServerError ? (
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 58f3156b222562..f502accaf9b025 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
@@ -59,25 +59,77 @@ export function PseudoHtmlDiff({
firstContent,
secondContent,
hydrationMismatchType,
+ reactOutputComponentDiff,
...props
}: {
componentStackFrames: ComponentStackFrame[]
firstContent: string
secondContent: string
+ reactOutputComponentDiff: string | undefined
hydrationMismatchType: 'tag' | 'text' | 'text-in-tag'
} & React.HTMLAttributes) {
const isHtmlTagsWarning = hydrationMismatchType === 'tag'
+ const isReactHydrationDiff = !!reactOutputComponentDiff
+
// For text mismatch, mismatched text will take 2 rows, so we display 4 rows of component stack
const MAX_NON_COLLAPSED_FRAMES = isHtmlTagsWarning ? 6 : 4
const shouldCollapse = componentStackFrames.length > MAX_NON_COLLAPSED_FRAMES
const [isHtmlCollapsed, toggleCollapseHtml] = useState(shouldCollapse)
const htmlComponents = useMemo(() => {
+ const componentStacks: React.ReactNode[] = []
+ // React 19 unified mismatch
+ if (isReactHydrationDiff) {
+ let currentComponentIndex = componentStackFrames.length - 1
+ const reactComponentDiffLines = reactOutputComponentDiff.split('\n')
+ const diffHtmlStack: React.ReactNode[] = []
+ reactComponentDiffLines.forEach((line, index) => {
+ let trimmedLine = line.trim()
+ const isDiffLine = trimmedLine[0] === '+' || trimmedLine[0] === '-'
+ const spaces = ' '.repeat(componentStacks.length * 2)
+
+ if (isDiffLine) {
+ const sign = trimmedLine[0]
+ trimmedLine = trimmedLine.slice(1).trim() // trim spaces after sign
+ diffHtmlStack.push(
+
+ {sign}
+ {spaces}
+ {trimmedLine}
+ {'\n'}
+
+ )
+ } else if (currentComponentIndex >= 0) {
+ const isUserLandComponent = trimmedLine.startsWith(
+ '<' + componentStackFrames[currentComponentIndex].component
+ )
+ // If it's matched userland component or it's ... we will keep the component stack in diff
+ if (isUserLandComponent || trimmedLine === '...') {
+ currentComponentIndex--
+ componentStacks.push(
+
+ {spaces}
+ {trimmedLine}
+ {'\n'}
+
+ )
+ }
+ }
+ })
+ return componentStacks.concat(diffHtmlStack)
+ }
+
+ const nestedHtmlStack: React.ReactNode[] = []
const tagNames = isHtmlTagsWarning
? // tags could have < or > in the name, so we always remove them to match
[firstContent.replace(/<|>/g, ''), secondContent.replace(/<|>/g, '')]
: []
- const nestedHtmlStack: React.ReactNode[] = []
+
let lastText = ''
const componentStack = componentStackFrames
@@ -105,10 +157,8 @@ export function PseudoHtmlDiff({
componentStack.forEach((component, index, componentList) => {
const spaces = ' '.repeat(nestedHtmlStack.length * 2)
- // const prevComponent = componentList[index - 1]
- // const nextComponent = componentList[index + 1]
- // When component is the server or client tag name, highlight it
+ // When component is the server or client tag name, highlight it
const isHighlightedTag = isHtmlTagsWarning
? index === matchedIndex[0] || index === matchedIndex[1]
: tagNames.includes(component)
@@ -181,7 +231,6 @@ export function PseudoHtmlDiff({
}
}
})
-
// Hydration mismatch: text or text-tag
if (!isHtmlTagsWarning) {
const spaces = ' '.repeat(nestedHtmlStack.length * 2)
@@ -190,22 +239,22 @@ export function PseudoHtmlDiff({
// hydration type is "text", represent [server content, client content]
wrappedCodeLine = (
-
+
{spaces + `"${firstContent}"\n`}
-
+
{spaces + `"${secondContent}"\n`}
)
- } else {
+ } else if (hydrationMismatchType === 'text-in-tag') {
// hydration type is "text-in-tag", represent [parent tag, mismatch content]
wrappedCodeLine = (
{spaces + `<${secondContent}>\n`}
-
+
{spaces + ` "${firstContent}"\n`}
@@ -223,6 +272,8 @@ export function PseudoHtmlDiff({
isHtmlTagsWarning,
hydrationMismatchType,
MAX_NON_COLLAPSED_FRAMES,
+ isReactHydrationDiff,
+ reactOutputComponentDiff,
])
return (
diff --git a/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/index.tsx b/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/index.tsx
index 8e9ec077916334..f68ac1582b0678 100644
--- a/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/index.tsx
+++ b/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/index.tsx
@@ -200,10 +200,10 @@ export const styles = css`
border: none;
padding: 0;
}
- [data-nextjs-container-errors-pseudo-html--diff-add] {
+ [data-nextjs-container-errors-pseudo-html--diff='add'] {
color: var(--color-ansi-green);
}
- [data-nextjs-container-errors-pseudo-html--diff-remove] {
+ [data-nextjs-container-errors-pseudo-html--diff='remove'] {
color: var(--color-ansi-red);
}
[data-nextjs-container-errors-pseudo-html--tag-error] {
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 731bba840c3630..318514a1814665 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
@@ -1,13 +1,37 @@
+import { getHydrationErrorStackInfo } from '../../../is-hydration-error'
+
export type HydrationErrorState = {
- // [message, serverContent, clientContent]
+ // Hydration warning template format:
warning?: [string, string, string]
componentStack?: string
serverContent?: string
clientContent?: string
+ // React 19 hydration diff format:
+ notes?: string
+ reactOutputComponentDiff?: string
}
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([
+ 'Warning: Cannot render a sync or defer