Skip to content

Commit

Permalink
Apply react 19 stack and diff (#65276)
Browse files Browse the repository at this point in the history
  • Loading branch information
huozhi authored and eps1lon committed May 3, 2024
1 parent 557cabd commit ab6b2c5
Show file tree
Hide file tree
Showing 16 changed files with 345 additions and 351 deletions.
6 changes: 4 additions & 2 deletions packages/next/src/client/app-index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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

Expand Down
56 changes: 56 additions & 0 deletions packages/next/src/client/components/is-hydration-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, '', '']

Expand All @@ -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 (
Expand Down Expand Up @@ -272,28 +274,36 @@ export function Errors({
id="nextjs__container_errors_desc"
className="nextjs__container_errors_desc"
>
{error.name}:{' '}
<HotlinkedText text={error.message} matcher={isNextjsLink} />
{/* If there's hydration warning, skip displaying the error name */}
{hydrationWarning ? '' : error.name + ': '}
<HotlinkedText
text={hydrationWarning || error.message}
matcher={isNextjsLink}
/>
</p>
{hydrationWarning && (
{notes ? (
<>
<p
id="nextjs__container_errors__notes"
className="nextjs__container_errors__notes"
>
{hydrationWarning}
{notes}
</p>
{activeError.componentStackFrames?.length ? (
<PseudoHtmlDiff
className="nextjs__container_errors__component-stack"
hydrationMismatchType={hydrationErrorType}
componentStackFrames={activeError.componentStackFrames}
firstContent={serverContent}
secondContent={clientContent}
/>
) : null}
</>
)}
) : null}

{hydrationWarning &&
(activeError.componentStackFrames?.length ||
!!errorDetails.reactOutputComponentDiff) ? (
<PseudoHtmlDiff
className="nextjs__container_errors__component-stack"
hydrationMismatchType={hydrationErrorType}
componentStackFrames={activeError.componentStackFrames || []}
firstContent={serverContent}
secondContent={clientContent}
reactOutputComponentDiff={errorDetails.reactOutputComponentDiff}
/>
) : null}
{isServerError ? (
<div>
<small>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLPreElement>) {
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(
<span
key={'comp-diff' + index}
data-nextjs-container-errors-pseudo-html--diff={
sign === '+' ? 'add' : 'remove'
}
>
{sign}
{spaces}
{trimmedLine}
{'\n'}
</span>
)
} 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(
<span key={'comp-diff' + index}>
{spaces}
{trimmedLine}
{'\n'}
</span>
)
}
}
})
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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -181,7 +231,6 @@ export function PseudoHtmlDiff({
}
}
})

// Hydration mismatch: text or text-tag
if (!isHtmlTagsWarning) {
const spaces = ' '.repeat(nestedHtmlStack.length * 2)
Expand All @@ -190,22 +239,22 @@ export function PseudoHtmlDiff({
// hydration type is "text", represent [server content, client content]
wrappedCodeLine = (
<Fragment key={nestedHtmlStack.length}>
<span data-nextjs-container-errors-pseudo-html--diff-remove>
<span data-nextjs-container-errors-pseudo-html--diff="remove">
{spaces + `"${firstContent}"\n`}
</span>
<span data-nextjs-container-errors-pseudo-html--diff-add>
<span data-nextjs-container-errors-pseudo-html--diff="add">
{spaces + `"${secondContent}"\n`}
</span>
</Fragment>
)
} else {
} else if (hydrationMismatchType === 'text-in-tag') {
// hydration type is "text-in-tag", represent [parent tag, mismatch content]
wrappedCodeLine = (
<Fragment key={nestedHtmlStack.length}>
<span data-nextjs-container-errors-pseudo-html--tag-adjacent>
{spaces + `<${secondContent}>\n`}
</span>
<span data-nextjs-container-errors-pseudo-html--diff-remove>
<span data-nextjs-container-errors-pseudo-html--diff="remove">
{spaces + ` "${firstContent}"\n`}
</span>
</Fragment>
Expand All @@ -223,6 +272,8 @@ export function PseudoHtmlDiff({
isHtmlTagsWarning,
hydrationMismatchType,
MAX_NON_COLLAPSED_FRAMES,
isReactHydrationDiff,
reactOutputComponentDiff,
])

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,37 @@
import { getHydrationErrorStackInfo } from '../../../is-hydration-error'

export type HydrationErrorState = {
// [message, serverContent, clientContent]
// Hydration warning template format: <message> <serverContent> <clientContent>
warning?: [string, string, string]
componentStack?: string
serverContent?: string
clientContent?: string
// React 19 hydration diff format: <notes> <link> <component diff?>
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 <script> outside the main document without knowing its order. Try adding async="" or moving it into the root <head> tag.%s',
'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 = (
msg: NullableText
): 'tag' | 'text' | 'text-in-tag' => {
Expand All @@ -28,24 +52,13 @@ const isKnownHydrationWarning = (msg: NullableText) =>
isTextInTagsMismatchWarning(msg) ||
isTextMismatchWarning(msg)

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 <script> outside the main document without knowing its order. Try adding async="" or moving it into the root <head> tag.%s',
'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 getReactHydrationDiffSegments = (msg: NullableText) => {
if (msg) {
const { message, diff } = getHydrationErrorStackInfo(msg)
if (message) return [message, diff]
}
return undefined
}

/**
* Patch console.error to capture hydration errors.
Expand Down
Loading

0 comments on commit ab6b2c5

Please sign in to comment.