Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Apply react 19 stack and diff #65276

Merged
merged 16 commits into from
May 2, 2024
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
Loading