Skip to content

Commit

Permalink
Improve hydration diff view (#75799)
Browse files Browse the repository at this point in the history
  • Loading branch information
huozhi authored Feb 11, 2025
1 parent 22e61a3 commit f5f7151
Show file tree
Hide file tree
Showing 16 changed files with 949 additions and 1,686 deletions.
Original file line number Diff line number Diff line change
@@ -1,46 +1,57 @@
import {
isHydrationError,
getDefaultHydrationErrorMessage,
testReactHydrationWarning,
} from '../is-hydration-error'
import {
hydrationErrorState,
getReactHydrationDiffSegments,
} from './hydration-error-info'

export function attachHydrationErrorState(error: Error) {
if (
isHydrationError(error) &&
!error.message.includes(
'https://nextjs.org/docs/messages/react-hydration-error'
)
) {
const reactHydrationDiffSegments = getReactHydrationDiffSegments(
error.message
)
let parsedHydrationErrorState: typeof hydrationErrorState = {}
if (reactHydrationDiffSegments) {
const reactHydrationDiffSegments = getReactHydrationDiffSegments(
error.message
)
let parsedHydrationErrorState: typeof hydrationErrorState = {}
const isHydrationWarning = testReactHydrationWarning(error.message)
// If the reactHydrationDiffSegments exists
// and the diff (reactHydrationDiffSegments[1]) exists
// e.g. the hydration diff log error.
if (reactHydrationDiffSegments && reactHydrationDiffSegments[1]) {
parsedHydrationErrorState = {
...(error as any).details,
...hydrationErrorState,
warning: hydrationErrorState.warning || [
getDefaultHydrationErrorMessage(),
],
notes: isHydrationWarning ? '' : reactHydrationDiffSegments[0],
reactOutputComponentDiff: reactHydrationDiffSegments[1],
}
// Cache the `reactOutputComponentDiff` into hydrationErrorState.
// This is only required for now when we still squashed the hydration diff log into hydration error.
// Once the all error is logged to dev overlay in order, this will go away.
hydrationErrorState.reactOutputComponentDiff =
parsedHydrationErrorState.reactOutputComponentDiff
} else {
// Normal runtime error, where it doesn't contain the hydration diff.

// If there's any extra information in the error message to display,
// append it to the error message details property
if (hydrationErrorState.warning) {
// The patched console.error found hydration errors logged by React
// Append the logged warning to the error message
parsedHydrationErrorState = {
...(error as any).details,
// It contains the warning, component stack, server and client tag names
...hydrationErrorState,
warning: hydrationErrorState.warning || [
getDefaultHydrationErrorMessage(),
],
notes: reactHydrationDiffSegments[0],
reactOutputComponentDiff: reactHydrationDiffSegments[1],
}
} else {
// If there's any extra information in the error message to display,
// append it to the error message details property
if (hydrationErrorState.warning) {
// The patched console.error found hydration errors logged by React
// Append the logged warning to the error message
parsedHydrationErrorState = {
...(error as any).details,
// It contains the warning, component stack, server and client tag names
...hydrationErrorState,
}
}
}
;(error as any).details = parsedHydrationErrorState
// Consume the cached hydration diff.
// This is only required for now when we still squashed the hydration diff log into hydration error.
// Once the all error is logged to dev overlay in order, this will go away.
if (hydrationErrorState.reactOutputComponentDiff) {
parsedHydrationErrorState.reactOutputComponentDiff =
hydrationErrorState.reactOutputComponentDiff
}
}
;(error as any).details = parsedHydrationErrorState
}
141 changes: 111 additions & 30 deletions packages/next/src/client/components/errors/hydration-error-info.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { getHydrationErrorStackInfo } from '../is-hydration-error'
import {
getHydrationErrorStackInfo,
testReactHydrationWarning,
} from '../is-hydration-error'

export type HydrationErrorState = {
// Hydration warning template format: <message> <serverContent> <clientContent>
Expand Down Expand Up @@ -28,8 +31,6 @@ 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
Expand All @@ -52,28 +53,9 @@ export const getHydrationWarningType = (

const isHtmlTagsWarning = (message: string) => htmlTagsWarnings.has(message)

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) {
const { message, diff } = getHydrationErrorStackInfo(msg)
Expand All @@ -90,16 +72,115 @@ export const getReactHydrationDiffSegments = (msg: NullableText) => {
*/

export function storeHydrationErrorStateFromConsoleArgs(...args: any[]) {
const [msg, serverContent, clientContent, componentStack] = args
if (isKnownHydrationWarning(msg)) {
hydrationErrorState.warning = [
let [msg, firstContent, secondContent, ...rest] = args
if (testReactHydrationWarning(msg)) {
// Some hydration warnings has 4 arguments, some has 3, fallback to the last argument
// when the 3rd argument is not the component stack but an empty string
const isReact18 = msg.startsWith('Warning: ')

// For some warnings, there's only 1 argument for template.
// The second argument is the diff or component stack.
if (args.length === 3) {
secondContent = ''
}

const warning: [string, string, string] = [
// remove the last %s from the message
msg,
serverContent,
clientContent,
firstContent,
secondContent,
]
hydrationErrorState.componentStack = componentStack
hydrationErrorState.serverContent = serverContent
hydrationErrorState.clientContent = clientContent

const lastArg = (rest[rest.length - 1] || '').trim()
if (!isReact18) {
hydrationErrorState.reactOutputComponentDiff = lastArg
} else {
hydrationErrorState.reactOutputComponentDiff =
generateHydrationDiffReact18(msg, firstContent, secondContent, lastArg)
}

hydrationErrorState.warning = warning
hydrationErrorState.serverContent = firstContent
hydrationErrorState.clientContent = secondContent
}
}

/*
* Some hydration errors in React 18 does not have the diff in the error message.
* Instead it has the error stack trace which is component stack that we can leverage.
* Will parse the diff from the error stack trace
* e.g.
* Warning: Expected server HTML to contain a matching <div> in <p>.
* at div
* at p
* at div
* at div
* at Page
* output:
* <Page>
* <div>
* <p>
* > <div>
*
*/
function generateHydrationDiffReact18(
message: string,
firstContent: string,
secondContent: string,
lastArg: string
) {
const componentStack = lastArg
let firstIndex = -1
let secondIndex = -1
const hydrationWarningType = getHydrationWarningType(message)

// at div\n at Foo\n at Bar (....)\n -> [div, Foo]
const components = componentStack
.split('\n')
// .reverse()
.map((line: string, index: number) => {
// `<space>at <component> (<location>)` -> `at <component> (<location>)`
line = line.trim()
// extract `<space>at <component>` to `<<component>>`
// e.g. ` at Foo` -> `<Foo>`
const [, component, location] = /at (\w+)( \((.*)\))?/.exec(line) || []
// If there's no location then it's user-land stack frame
if (!location) {
if (component === firstContent && firstIndex === -1) {
firstIndex = index
} else if (component === secondContent && secondIndex === -1) {
secondIndex = index
}
}
return location ? '' : component
})
.filter(Boolean)
.reverse()

let diff = ''
for (let i = 0; i < components.length; i++) {
const component = components[i]
const matchFirstContent =
hydrationWarningType === 'tag' && i === components.length - firstIndex - 1
const matchSecondContent =
hydrationWarningType === 'tag' &&
i === components.length - secondIndex - 1
if (matchFirstContent || matchSecondContent) {
const spaces = ' '.repeat(Math.max(i * 2 - 2, 0) + 2)
diff += `> ${spaces}<${component}>\n`
} else {
const spaces = ' '.repeat(i * 2 + 2)
diff += `${spaces}<${component}>\n`
}
}
if (hydrationWarningType === 'text') {
const spaces = ' '.repeat(components.length * 2)
diff += `+ ${spaces}"${firstContent}"\n`
diff += `- ${spaces}"${secondContent}"\n`
} else if (hydrationWarningType === 'text-in-tag') {
const spaces = ' '.repeat(components.length * 2)
diff += `> ${spaces}<${secondContent}>\n`
diff += `> ${spaces}"${firstContent}"\n`
}
return diff
}
45 changes: 43 additions & 2 deletions packages/next/src/client/components/is-hydration-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,57 @@ export function isReactHydrationErrorMessage(msg: string): boolean {
return reactHydrationStartMessages.some((prefix) => msg.startsWith(prefix))
}

const hydrationWarningRegexes = [
/^In HTML, (.+?) cannot be a child of <(.+?)>\.(.*)\nThis will cause a hydration error\.(.*)/,
/^In HTML, (.+?) cannot be a descendant of <(.+?)>\.\nThis will cause a hydration error\.(.*)/,
/^In HTML, text nodes cannot be a child of <(.+?)>\.\nThis will cause a hydration error\./,
/^In HTML, whitespace text nodes cannot be a child of <(.+?)>\. Make sure you don't have any extra whitespace between tags on each line of your source code\.\nThis will cause a hydration error\./,
/^Expected server HTML to contain a matching <(.+?)> in <(.+?)>\.(.*)/,
/^Did not expect server HTML to contain a <(.+?)> in <(.+?)>\.(.*)/,
/^Expected server HTML to contain a matching text node for "(.+?)" in <(.+?)>\.(.*)/,
/^Did not expect server HTML to contain the text node "(.+?)" in <(.+?)>\.(.*)/,
/^Text content did not match\. Server: "(.+?)" Client: "(.+?)"(.*)/,
]

export function testReactHydrationWarning(msg: string): boolean {
if (typeof msg !== 'string' || !msg) return false
// React 18 has the `Warning: ` prefix.
// React 19 does not.
if (msg.startsWith('Warning: ')) {
msg = msg.slice('Warning: '.length)
}
return hydrationWarningRegexes.some((regex) => regex.test(msg))
}

export function getHydrationErrorStackInfo(rawMessage: string): {
message: string | null
link?: string
stack?: string
diff?: string
} {
rawMessage = rawMessage.replace(/^Error: /, '')
if (!isReactHydrationErrorMessage(rawMessage)) {
return { message: null }
rawMessage = rawMessage.replace('Warning: ', '')
const isReactHydrationWarning = testReactHydrationWarning(rawMessage)

if (!isReactHydrationErrorMessage(rawMessage) && !isReactHydrationWarning) {
return {
message: null,
link: '',
stack: rawMessage,
diff: '',
}
}

if (isReactHydrationWarning) {
const [message, diffLog] = rawMessage.split('\n\n')
return {
message: message.trim(),
link: reactHydrationErrorDocLink,
stack: '',
diff: (diffLog || '').trim(),
}
}

const firstLineBreak = rawMessage.indexOf('\n')
rawMessage = rawMessage.slice(firstLineBreak + 1).trim()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,10 +186,9 @@ export function Errors({
<PseudoHtmlDiff
className="nextjs__container_errors__component-stack"
hydrationMismatchType={hydrationErrorType}
componentStackFrames={activeError.componentStackFrames || []}
firstContent={serverContent}
secondContent={clientContent}
reactOutputComponentDiff={errorDetails.reactOutputComponentDiff}
reactOutputComponentDiff={errorDetails.reactOutputComponentDiff || ''}
/>
) : null}
<Suspense fallback={<div data-nextjs-error-suspended />}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,28 +13,8 @@ const meta: Meta<typeof PseudoHtmlDiff> = {
export default meta
type Story = StoryObj<typeof PseudoHtmlDiff>

const sampleComponentStack = [
{
component: 'div',
canOpenInEditor: false,
},
{
component: 'article',
canOpenInEditor: false,
},
{
component: 'main',
canOpenInEditor: false,
},
{
component: 'Home',
canOpenInEditor: false,
},
]

export const TextMismatch: Story = {
args: {
componentStackFrames: sampleComponentStack,
firstContent: 'Server rendered content',
secondContent: 'Client rendered content',
hydrationMismatchType: 'text',
Expand All @@ -44,7 +24,6 @@ export const TextMismatch: Story = {

export const TextInTagMismatch: Story = {
args: {
componentStackFrames: sampleComponentStack,
firstContent: 'Mismatched content',
secondContent: 'p',
hydrationMismatchType: 'text-in-tag',
Expand All @@ -54,7 +33,6 @@ export const TextInTagMismatch: Story = {

export const ReactUnifiedMismatch: Story = {
args: {
componentStackFrames: sampleComponentStack,
hydrationMismatchType: 'tag',
reactOutputComponentDiff: `<Page>
<Layout>
Expand Down
Loading

0 comments on commit f5f7151

Please sign in to comment.