Skip to content

Commit 65f7c43

Browse files
huozhieps1lon
authored andcommitted
Apply react 19 stack and diff (#65276)
1 parent 4f620d0 commit 65f7c43

File tree

16 files changed

+345
-351
lines changed

16 files changed

+345
-351
lines changed

packages/next/src/client/app-index.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import React, { use } from 'react'
77
import { createFromReadableStream } from 'react-server-dom-webpack/client'
88

99
import { HeadManagerContext } from '../shared/lib/head-manager-context.shared-runtime'
10-
import onRecoverableError from './on-recoverable-error'
10+
import { onRecoverableError } from './on-recoverable-error'
1111
import { callServer } from './app-call-server'
1212
import { isNextRouterError } from './components/is-next-router-error'
1313
import {
@@ -165,7 +165,9 @@ export function hydrate() {
165165
const rootLayoutMissingTags = window.__next_root_layout_missing_tags
166166
const hasMissingTags = !!rootLayoutMissingTags?.length
167167

168-
const options = { onRecoverableError } satisfies ReactDOMClient.RootOptions
168+
const options = {
169+
onRecoverableError,
170+
} satisfies ReactDOMClient.RootOptions
169171
const isError =
170172
document.documentElement.id === '__next_error__' || hasMissingTags
171173

packages/next/src/client/components/is-hydration-error.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,62 @@ import isError from '../../lib/is-error'
33
const hydrationErrorRegex =
44
/hydration failed|while hydrating|content does not match|did not match/i
55

6+
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`
7+
8+
const reactHydrationErrorDocLink = 'https://react.dev/link/hydration-mismatch'
9+
10+
export const getDefaultHydrationErrorMessage = () => {
11+
return (
12+
reactUnifiedMismatchWarning +
13+
'\nSee more info here: https://nextjs.org/docs/messages/react-hydration-error'
14+
)
15+
}
16+
617
export function isHydrationError(error: unknown): boolean {
718
return isError(error) && hydrationErrorRegex.test(error.message)
819
}
20+
21+
export function isReactHydrationErrorStack(stack: string): boolean {
22+
return stack.startsWith(reactUnifiedMismatchWarning)
23+
}
24+
25+
export function getHydrationErrorStackInfo(rawMessage: string): {
26+
message: string | null
27+
link?: string
28+
stack?: string
29+
diff?: string
30+
} {
31+
rawMessage = rawMessage.replace(/^Error: /, '')
32+
if (!isReactHydrationErrorStack(rawMessage)) {
33+
return { message: null }
34+
}
35+
rawMessage = rawMessage.slice(reactUnifiedMismatchWarning.length + 1).trim()
36+
const [message, trailing] = rawMessage.split(`${reactHydrationErrorDocLink}`)
37+
const trimmedMessage = message.trim()
38+
// React built-in hydration diff starts with a newline, checking if length is > 1
39+
if (trailing && trailing.length > 1) {
40+
const stacks: string[] = []
41+
const diffs: string[] = []
42+
trailing.split('\n').forEach((line) => {
43+
if (line.trim() === '') return
44+
if (line.trim().startsWith('at ')) {
45+
stacks.push(line)
46+
} else {
47+
diffs.push(line)
48+
}
49+
})
50+
51+
return {
52+
message: trimmedMessage,
53+
link: reactHydrationErrorDocLink,
54+
diff: diffs.join('\n'),
55+
stack: stacks.join('\n'),
56+
}
57+
} else {
58+
return {
59+
message: trimmedMessage,
60+
link: reactHydrationErrorDocLink,
61+
stack: trailing, // without hydration diff
62+
}
63+
}
64+
}

packages/next/src/client/components/react-dev-overlay/app/hot-reloader-client.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -483,15 +483,17 @@ export default function HotReload({
483483
| HydrationErrorState
484484
| undefined
485485
// Component stack is added to the error in use-error-handler in case there was a hydration errror
486-
const componentStack = errorDetails?.componentStack
486+
const componentStackTrace =
487+
(error as any)._componentStack || errorDetails?.componentStack
487488
const warning = errorDetails?.warning
488489
dispatch({
489490
type: ACTION_UNHANDLED_ERROR,
490491
reason: error,
491492
frames: parseStack(error.stack!),
492-
componentStackFrames: componentStack
493-
? parseComponentStack(componentStack)
494-
: undefined,
493+
componentStackFrames:
494+
typeof componentStackTrace === 'string'
495+
? parseComponentStack(componentStackTrace)
496+
: undefined,
495497
warning,
496498
})
497499
},

packages/next/src/client/components/react-dev-overlay/internal/container/Errors.tsx

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ export function Errors({
227227
)
228228

229229
const errorDetails: HydrationErrorState = (error as any).details || {}
230+
const notes = errorDetails.notes || ''
230231
const [warningTemplate, serverContent, clientContent] =
231232
errorDetails.warning || [null, '', '']
232233

@@ -238,6 +239,7 @@ export function Errors({
238239
.replace('%s', '') // remove the %s for stack
239240
.replace(/%s$/, '') // If there's still a %s at the end, remove it
240241
.replace(/^Warning: /, '')
242+
.replace(/^Error: /, '')
241243
: null
242244

243245
return (
@@ -272,28 +274,36 @@ export function Errors({
272274
id="nextjs__container_errors_desc"
273275
className="nextjs__container_errors_desc"
274276
>
275-
{error.name}:{' '}
276-
<HotlinkedText text={error.message} matcher={isNextjsLink} />
277+
{/* If there's hydration warning, skip displaying the error name */}
278+
{hydrationWarning ? '' : error.name + ': '}
279+
<HotlinkedText
280+
text={hydrationWarning || error.message}
281+
matcher={isNextjsLink}
282+
/>
277283
</p>
278-
{hydrationWarning && (
284+
{notes ? (
279285
<>
280286
<p
281287
id="nextjs__container_errors__notes"
282288
className="nextjs__container_errors__notes"
283289
>
284-
{hydrationWarning}
290+
{notes}
285291
</p>
286-
{activeError.componentStackFrames?.length ? (
287-
<PseudoHtmlDiff
288-
className="nextjs__container_errors__component-stack"
289-
hydrationMismatchType={hydrationErrorType}
290-
componentStackFrames={activeError.componentStackFrames}
291-
firstContent={serverContent}
292-
secondContent={clientContent}
293-
/>
294-
) : null}
295292
</>
296-
)}
293+
) : null}
294+
295+
{hydrationWarning &&
296+
(activeError.componentStackFrames?.length ||
297+
!!errorDetails.reactOutputComponentDiff) ? (
298+
<PseudoHtmlDiff
299+
className="nextjs__container_errors__component-stack"
300+
hydrationMismatchType={hydrationErrorType}
301+
componentStackFrames={activeError.componentStackFrames || []}
302+
firstContent={serverContent}
303+
secondContent={clientContent}
304+
reactOutputComponentDiff={errorDetails.reactOutputComponentDiff}
305+
/>
306+
) : null}
297307
{isServerError ? (
298308
<div>
299309
<small>

packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/component-stack-pseudo-html.tsx

Lines changed: 60 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -59,25 +59,77 @@ export function PseudoHtmlDiff({
5959
firstContent,
6060
secondContent,
6161
hydrationMismatchType,
62+
reactOutputComponentDiff,
6263
...props
6364
}: {
6465
componentStackFrames: ComponentStackFrame[]
6566
firstContent: string
6667
secondContent: string
68+
reactOutputComponentDiff: string | undefined
6769
hydrationMismatchType: 'tag' | 'text' | 'text-in-tag'
6870
} & React.HTMLAttributes<HTMLPreElement>) {
6971
const isHtmlTagsWarning = hydrationMismatchType === 'tag'
72+
const isReactHydrationDiff = !!reactOutputComponentDiff
73+
7074
// For text mismatch, mismatched text will take 2 rows, so we display 4 rows of component stack
7175
const MAX_NON_COLLAPSED_FRAMES = isHtmlTagsWarning ? 6 : 4
7276
const shouldCollapse = componentStackFrames.length > MAX_NON_COLLAPSED_FRAMES
7377
const [isHtmlCollapsed, toggleCollapseHtml] = useState(shouldCollapse)
7478

7579
const htmlComponents = useMemo(() => {
80+
const componentStacks: React.ReactNode[] = []
81+
// React 19 unified mismatch
82+
if (isReactHydrationDiff) {
83+
let currentComponentIndex = componentStackFrames.length - 1
84+
const reactComponentDiffLines = reactOutputComponentDiff.split('\n')
85+
const diffHtmlStack: React.ReactNode[] = []
86+
reactComponentDiffLines.forEach((line, index) => {
87+
let trimmedLine = line.trim()
88+
const isDiffLine = trimmedLine[0] === '+' || trimmedLine[0] === '-'
89+
const spaces = ' '.repeat(componentStacks.length * 2)
90+
91+
if (isDiffLine) {
92+
const sign = trimmedLine[0]
93+
trimmedLine = trimmedLine.slice(1).trim() // trim spaces after sign
94+
diffHtmlStack.push(
95+
<span
96+
key={'comp-diff' + index}
97+
data-nextjs-container-errors-pseudo-html--diff={
98+
sign === '+' ? 'add' : 'remove'
99+
}
100+
>
101+
{sign}
102+
{spaces}
103+
{trimmedLine}
104+
{'\n'}
105+
</span>
106+
)
107+
} else if (currentComponentIndex >= 0) {
108+
const isUserLandComponent = trimmedLine.startsWith(
109+
'<' + componentStackFrames[currentComponentIndex].component
110+
)
111+
// If it's matched userland component or it's ... we will keep the component stack in diff
112+
if (isUserLandComponent || trimmedLine === '...') {
113+
currentComponentIndex--
114+
componentStacks.push(
115+
<span key={'comp-diff' + index}>
116+
{spaces}
117+
{trimmedLine}
118+
{'\n'}
119+
</span>
120+
)
121+
}
122+
}
123+
})
124+
return componentStacks.concat(diffHtmlStack)
125+
}
126+
127+
const nestedHtmlStack: React.ReactNode[] = []
76128
const tagNames = isHtmlTagsWarning
77129
? // tags could have < or > in the name, so we always remove them to match
78130
[firstContent.replace(/<|>/g, ''), secondContent.replace(/<|>/g, '')]
79131
: []
80-
const nestedHtmlStack: React.ReactNode[] = []
132+
81133
let lastText = ''
82134

83135
const componentStack = componentStackFrames
@@ -105,10 +157,8 @@ export function PseudoHtmlDiff({
105157

106158
componentStack.forEach((component, index, componentList) => {
107159
const spaces = ' '.repeat(nestedHtmlStack.length * 2)
108-
// const prevComponent = componentList[index - 1]
109-
// const nextComponent = componentList[index + 1]
110-
// When component is the server or client tag name, highlight it
111160

161+
// When component is the server or client tag name, highlight it
112162
const isHighlightedTag = isHtmlTagsWarning
113163
? index === matchedIndex[0] || index === matchedIndex[1]
114164
: tagNames.includes(component)
@@ -181,7 +231,6 @@ export function PseudoHtmlDiff({
181231
}
182232
}
183233
})
184-
185234
// Hydration mismatch: text or text-tag
186235
if (!isHtmlTagsWarning) {
187236
const spaces = ' '.repeat(nestedHtmlStack.length * 2)
@@ -190,22 +239,22 @@ export function PseudoHtmlDiff({
190239
// hydration type is "text", represent [server content, client content]
191240
wrappedCodeLine = (
192241
<Fragment key={nestedHtmlStack.length}>
193-
<span data-nextjs-container-errors-pseudo-html--diff-remove>
242+
<span data-nextjs-container-errors-pseudo-html--diff="remove">
194243
{spaces + `"${firstContent}"\n`}
195244
</span>
196-
<span data-nextjs-container-errors-pseudo-html--diff-add>
245+
<span data-nextjs-container-errors-pseudo-html--diff="add">
197246
{spaces + `"${secondContent}"\n`}
198247
</span>
199248
</Fragment>
200249
)
201-
} else {
250+
} else if (hydrationMismatchType === 'text-in-tag') {
202251
// hydration type is "text-in-tag", represent [parent tag, mismatch content]
203252
wrappedCodeLine = (
204253
<Fragment key={nestedHtmlStack.length}>
205254
<span data-nextjs-container-errors-pseudo-html--tag-adjacent>
206255
{spaces + `<${secondContent}>\n`}
207256
</span>
208-
<span data-nextjs-container-errors-pseudo-html--diff-remove>
257+
<span data-nextjs-container-errors-pseudo-html--diff="remove">
209258
{spaces + ` "${firstContent}"\n`}
210259
</span>
211260
</Fragment>
@@ -223,6 +272,8 @@ export function PseudoHtmlDiff({
223272
isHtmlTagsWarning,
224273
hydrationMismatchType,
225274
MAX_NON_COLLAPSED_FRAMES,
275+
isReactHydrationDiff,
276+
reactOutputComponentDiff,
226277
])
227278

228279
return (

packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,10 +200,10 @@ export const styles = css`
200200
border: none;
201201
padding: 0;
202202
}
203-
[data-nextjs-container-errors-pseudo-html--diff-add] {
203+
[data-nextjs-container-errors-pseudo-html--diff='add'] {
204204
color: var(--color-ansi-green);
205205
}
206-
[data-nextjs-container-errors-pseudo-html--diff-remove] {
206+
[data-nextjs-container-errors-pseudo-html--diff='remove'] {
207207
color: var(--color-ansi-red);
208208
}
209209
[data-nextjs-container-errors-pseudo-html--tag-error] {

packages/next/src/client/components/react-dev-overlay/internal/helpers/hydration-error-info.ts

Lines changed: 32 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,37 @@
1+
import { getHydrationErrorStackInfo } from '../../../is-hydration-error'
2+
13
export type HydrationErrorState = {
2-
// [message, serverContent, clientContent]
4+
// Hydration warning template format: <message> <serverContent> <clientContent>
35
warning?: [string, string, string]
46
componentStack?: string
57
serverContent?: string
68
clientContent?: string
9+
// React 19 hydration diff format: <notes> <link> <component diff?>
10+
notes?: string
11+
reactOutputComponentDiff?: string
712
}
813

914
type NullableText = string | null | undefined
1015

16+
export const hydrationErrorState: HydrationErrorState = {}
17+
18+
// https://github.com/facebook/react/blob/main/packages/react-dom/src/__tests__/ReactDOMHydrationDiff-test.js used as a reference
19+
const htmlTagsWarnings = new Set([
20+
'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',
21+
'Warning: In HTML, %s cannot be a child of <%s>.%s\nThis will cause a hydration error.%s',
22+
'Warning: In HTML, %s cannot be a descendant of <%s>.\nThis will cause a hydration error.%s',
23+
'Warning: In HTML, text nodes cannot be a child of <%s>.\nThis will cause a hydration error.',
24+
"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.",
25+
'Warning: Expected server HTML to contain a matching <%s> in <%s>.%s',
26+
'Warning: Did not expect server HTML to contain a <%s> in <%s>.%s',
27+
])
28+
const textAndTagsMismatchWarnings = new Set([
29+
'Warning: Expected server HTML to contain a matching text node for "%s" in <%s>.%s',
30+
'Warning: Did not expect server HTML to contain the text node "%s" in <%s>.%s',
31+
])
32+
const textMismatchWarning =
33+
'Warning: Text content did not match. Server: "%s" Client: "%s"%s'
34+
1135
export const getHydrationWarningType = (
1236
msg: NullableText
1337
): 'tag' | 'text' | 'text-in-tag' => {
@@ -28,24 +52,13 @@ const isKnownHydrationWarning = (msg: NullableText) =>
2852
isTextInTagsMismatchWarning(msg) ||
2953
isTextMismatchWarning(msg)
3054

31-
export const hydrationErrorState: HydrationErrorState = {}
32-
33-
// https://github.com/facebook/react/blob/main/packages/react-dom/src/__tests__/ReactDOMHydrationDiff-test.js used as a reference
34-
const htmlTagsWarnings = new Set([
35-
'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',
36-
'Warning: In HTML, %s cannot be a child of <%s>.%s\nThis will cause a hydration error.%s',
37-
'Warning: In HTML, %s cannot be a descendant of <%s>.\nThis will cause a hydration error.%s',
38-
'Warning: In HTML, text nodes cannot be a child of <%s>.\nThis will cause a hydration error.',
39-
"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.",
40-
'Warning: Expected server HTML to contain a matching <%s> in <%s>.%s',
41-
'Warning: Did not expect server HTML to contain a <%s> in <%s>.%s',
42-
])
43-
const textAndTagsMismatchWarnings = new Set([
44-
'Warning: Expected server HTML to contain a matching text node for "%s" in <%s>.%s',
45-
'Warning: Did not expect server HTML to contain the text node "%s" in <%s>.%s',
46-
])
47-
const textMismatchWarning =
48-
'Warning: Text content did not match. Server: "%s" Client: "%s"%s'
55+
export const getReactHydrationDiffSegments = (msg: NullableText) => {
56+
if (msg) {
57+
const { message, diff } = getHydrationErrorStackInfo(msg)
58+
if (message) return [message, diff]
59+
}
60+
return undefined
61+
}
4962

5063
/**
5164
* Patch console.error to capture hydration errors.

0 commit comments

Comments
 (0)