From 9d1672becc3ab7e82e3eae93c85d55c84aa448a4 Mon Sep 17 00:00:00 2001 From: Jiachi Liu Date: Thu, 2 May 2024 15:03:25 +0200 Subject: [PATCH 01/16] parse stack and diff --- .../client/components/is-hydration-error.ts | 49 +++++ .../internal/container/Errors.tsx | 37 ++-- .../component-stack-pseudo-html.tsx | 185 +++++++++++------- .../internal/container/RuntimeError/index.tsx | 4 +- .../internal/helpers/hydration-error-info.ts | 54 +++-- .../internal/helpers/parseStack.ts | 12 ++ .../internal/helpers/use-error-handler.ts | 39 ++-- .../acceptance-app/component-stack.test.ts | 25 ++- 8 files changed, 275 insertions(+), 130 deletions(-) diff --git a/packages/next/src/client/components/is-hydration-error.ts b/packages/next/src/client/components/is-hydration-error.ts index eaa9b0df90548..26b34720ed820 100644 --- a/packages/next/src/client/components/is-hydration-error.ts +++ b/packages/next/src/client/components/is-hydration-error.ts @@ -3,6 +3,55 @@ 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 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}`) + + // 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, + link: reactHydrationErrorDocLink, + diff: diffs.join('\n'), + stack: stacks.join('\n'), + } + } else { + return { + message, + link: reactHydrationErrorDocLink, + stack: trailing, // without hydration diff + } + } +} 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 674828bead911..912c31231be75 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 @@ -22,7 +22,6 @@ import { RuntimeError } from './RuntimeError' import { VersionStalenessInfo } from '../components/VersionStalenessInfo' import type { VersionInfo } from '../../../../../server/dev/parse-version-info' import { getErrorSource } from '../../../../../shared/lib/error-source' -import { HotlinkedText } from '../components/hot-linked-text' import { PseudoHtmlDiff } from './RuntimeError/component-stack-pseudo-html' import { type HydrationErrorState, @@ -227,17 +226,19 @@ export function Errors({ ) const errorDetails: HydrationErrorState = (error as any).details || {} + const notes = errorDetails.notes || '' const [warningTemplate, serverContent, clientContent] = errorDetails.warning || [null, '', ''] const hydrationErrorType = getHydrationWarningType(warningTemplate) - const hydrationWarning = warningTemplate + let hydrationWarning = warningTemplate ? warningTemplate .replace('%s', serverContent) .replace('%s', clientContent) .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 ( @@ -268,32 +269,36 @@ export function Errors({

{isServerError ? 'Server Error' : 'Unhandled Runtime Error'}

+

- {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 58f3156b22256..b26ea0caf9208 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,14 +59,18 @@ 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 @@ -103,109 +107,140 @@ 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 - - const isHighlightedTag = isHtmlTagsWarning - ? index === matchedIndex[0] || index === matchedIndex[1] - : tagNames.includes(component) - const isAdjacentTag = - isHighlightedTag || - Math.abs(index - matchedIndex[0]) <= 1 || - Math.abs(index - matchedIndex[1]) <= 1 - - const isLastFewFrames = - !isHtmlTagsWarning && index >= componentList.length - 6 - - const adjProps = getAdjacentProps(isAdjacentTag) - - if ((isHtmlTagsWarning && isAdjacentTag) || isLastFewFrames) { - const codeLine = ( - - {spaces} + // React 19 unified mismatch + if (isReactHydrationDiff) { + const reactComponentDiffLines = reactOutputComponentDiff.split('\n') + reactComponentDiffLines.forEach((line, index) => { + const trimmedLine = line.trim() + if (/\+|\-/.test(trimmedLine[0])) { + nestedHtmlStack.push( - {`<${component}>\n`} - - - ) - lastText = component - - const wrappedCodeLine = ( - - {codeLine} - {/* Add ^^^^ to the target tags used for snapshots but not displayed for users */} - {isHighlightedTag && ( - - {spaces + '^'.repeat(component.length + 2) + '\n'} - - )} - - ) - nestedHtmlStack.push(wrappedCodeLine) - } else { - if ( - nestedHtmlStack.length >= MAX_NON_COLLAPSED_FRAMES && - isHtmlCollapsed - ) { - return - } - - if (!isHtmlCollapsed || isLastFewFrames) { - nestedHtmlStack.push( - - {spaces} - {'<' + component + '>\n'} + {line} + {'\n'} ) - } else if (isHtmlCollapsed && lastText !== '...') { - lastText = '...' + } else { nestedHtmlStack.push( - - {spaces} - {'...\n'} + + {line} + {'\n'} ) } - } - }) + }) + return nestedHtmlStack + } // Hydration mismatch: text or text-tag - if (!isHtmlTagsWarning) { + if ( + hydrationMismatchType === 'text' || + hydrationMismatchType === 'text-in-tag' + ) { + 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 + + const isHighlightedTag = isHtmlTagsWarning + ? index === matchedIndex[0] || index === matchedIndex[1] + : tagNames.includes(component) + const isAdjacentTag = + isHighlightedTag || + Math.abs(index - matchedIndex[0]) <= 1 || + Math.abs(index - matchedIndex[1]) <= 1 + + const isLastFewFrames = + !isHtmlTagsWarning && index >= componentList.length - 6 + + const adjProps = getAdjacentProps(isAdjacentTag) + + if ((isHtmlTagsWarning && isAdjacentTag) || isLastFewFrames) { + const codeLine = ( + + {spaces} + + {`<${component}>\n`} + + + ) + lastText = component + + const wrappedCodeLine = ( + + {codeLine} + {/* Add ^^^^ to the target tags used for snapshots but not displayed for users */} + {isHighlightedTag && ( + + {spaces + '^'.repeat(component.length + 2) + '\n'} + + )} + + ) + nestedHtmlStack.push(wrappedCodeLine) + } else { + if ( + nestedHtmlStack.length >= MAX_NON_COLLAPSED_FRAMES && + isHtmlCollapsed + ) { + return + } + + if (!isHtmlCollapsed || isLastFewFrames) { + nestedHtmlStack.push( + + {spaces} + {'<' + component + '>\n'} + + ) + } else if (isHtmlCollapsed && lastText !== '...') { + lastText = '...' + nestedHtmlStack.push( + + {spaces} + {'...\n'} + + ) + } + } + }) const spaces = ' '.repeat(nestedHtmlStack.length * 2) let wrappedCodeLine if (hydrationMismatchType === 'text') { // 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`} 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 8e9ec07791633..f68ac1582b067 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 731bba840c363..9a4e164897344 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,40 @@ +import { + getHydrationErrorStackInfo, + isReactHydrationErrorStack, +} 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