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