Skip to content

Commit

Permalink
feat: display text diff for text mismatch hydration errors (#62684)
Browse files Browse the repository at this point in the history
### What

Keep improving the hydration erros. Currently we divide the hydration
mismatch types into two categories, html tag mismatch and text mismatch.
We're displaying the mismatched text content between server and client
here since we have it in the component stack and warnings.

We've already made some improvements in #62590 , here we carry on
improving the highlited text into red and bold that is much easier for
you to spot on.

This updated a few long snapshots that we could collapse and show only
the text content difference instead of all the component stack.

### Screenshots

(Dark and light modes)

#### Mismatch html tags
<img width="360"
src="https://github.com/vercel/next.js/assets/4800338/f721b374-69cc-4600-a09d-bef87e885fab"><img
width="360"
src="https://github.com/vercel/next.js/assets/4800338/1abf2572-2be8-4359-a652-8ba39aaccfd3">


#### Mismatch text content
<img width="360"
src="https://github.com/vercel/next.js/assets/4800338/7f0d2215-8bc0-4fba-9c92-6c44efa29531"><img
width="360"
src="https://github.com/vercel/next.js/assets/4800338/656d1e1a-3157-4bcf-a239-74bb81fcb4c4">


#### Large content mismatch

### Why

I was intended to bring a html diff between server and client html
content but turns out the diff result could be giant and not ideal due
to few reasons. So we switched to the path of leveraging component stack
and mismatch contents.
React reordering the tags after hydration. For instance the `script` or
`link` tags could be hoist by React Float, so the lines of html is are
to preserved. so the diff is hard to be super accurate unless your
mismatch is small. If you're mismatch a component with rich html
content, it could be a pretty large diff.

Another case is if you have a bad nesting html like `<p> ...<span>...
<p>my text</p> ...</span>... <p>` where there're many span in between,
the final different could also be hudge as browser will close the first
`<p>` tag and the rest content will go into the diff. Hence we're going
with the component and text content diff.


Closes NEXT-2645
  • Loading branch information
huozhi authored Feb 29, 2024
1 parent 28c2832 commit ce42224
Show file tree
Hide file tree
Showing 9 changed files with 197 additions and 117 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ 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 { PseudoHtml } from './RuntimeError/component-stack-pseudo-html'
import { PseudoHtmlDiff } from './RuntimeError/component-stack-pseudo-html'
import {
isHtmlTagsWarning,
type HydrationErrorState,
Expand Down Expand Up @@ -274,11 +274,14 @@ export function Errors({
{hydrationWarning && activeError.componentStackFrames && (
<>
<p id="nextjs__container_errors__extra">{hydrationWarning}</p>
<PseudoHtml
<PseudoHtmlDiff
className="nextjs__container_errors__extra_code"
hydrationMismatchType={
isHtmlTagsWarningTemplate ? 'tag' : 'text'
}
componentStackFrames={activeError.componentStackFrames}
serverTagName={isHtmlTagsWarningTemplate ? serverContent : ''}
clientTagName={isHtmlTagsWarningTemplate ? clientContent : ''}
serverContent={serverContent}
clientContent={clientContent}
/>
</>
)}
Expand Down Expand Up @@ -351,6 +354,9 @@ export const styles = css`
}
.nextjs__container_errors__extra_code {
margin: 20px 0;
padding: 12px 32px;
color: var(--color-ansi-fg);
background: var(--color-ansi-bg);
}
.nextjs-toast-errors-parent {
cursor: pointer;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@ import type { StackFramesGroup } from '../../helpers/group-stack-frames-by-frame
import { CallStackFrame } from './CallStackFrame'
import { FrameworkIcon } from './FrameworkIcon'

export function CollapseIcon(
{ collapsed }: { collapsed?: boolean } = { collapsed: false }
) {
export function CollapseIcon({ collapsed }: { collapsed?: boolean } = {}) {
// If is not collapsed, rotate 90 degrees
return (
<svg
Expand All @@ -20,7 +18,9 @@ export function CollapseIcon(
strokeWidth="2"
viewBox="0 0 24 24"
// rotate 90 degrees if not collapsed
style={{ transform: collapsed ? undefined : 'rotate(90deg)' }}
{...(typeof collapsed === 'boolean'
? { style: { transform: collapsed ? undefined : 'rotate(90deg)' } }
: {})}
>
<path d="M9 18l6-6-6-6" />
</svg>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,45 +2,62 @@ import { useMemo, Fragment, useState } from 'react'
import type { ComponentStackFrame } from '../../helpers/parse-component-stack'
import { CollapseIcon } from './GroupedStackFrames'

// In total it will display 6 rows.
const MAX_NON_COLLAPSED_FRAMES = 6

/**
*
* Format component stack into pseudo HTML
* component stack is an array of strings, e.g.: ['p', 'p', 'Page', ...]
*
* Will render it for the code block
* For html tags mismatch, it will render it for the code block
*
* <pre>
* <code>{`
* <Page>
* <p>
* ^^^^
* ^^^
* <p>
* ^^^^
* ^^^
* `}</code>
* </pre>
*
* For text mismatch, it will render it for the code block
*
* <pre>
* <code>{`
* <Page>
* <p>
* "Server Text" (red text as removed)
* "Client Text" (green text as added)
* </p>
* </Page>
* `}</code>
*
*
*/
export function PseudoHtml({
export function PseudoHtmlDiff({
componentStackFrames,
serverTagName,
clientTagName,
serverContent,
clientContent,
hydrationMismatchType,
...props
}: {
componentStackFrames: ComponentStackFrame[]
serverTagName?: string
clientTagName?: string
serverContent: string
clientContent: string
[prop: string]: any
hydrationMismatchType: 'tag' | 'text'
}) {
const isHtmlTagsWarning = serverTagName || clientTagName
const isHtmlTagsWarning = hydrationMismatchType === 'tag'
const shouldCollapse = componentStackFrames.length > MAX_NON_COLLAPSED_FRAMES
const [isHtmlCollapsed, toggleCollapseHtml] = useState(shouldCollapse)

const htmlComponents = useMemo(() => {
const tagNames = [serverTagName, clientTagName]
const tagNames = isHtmlTagsWarning ? [serverContent, clientContent] : []
const nestedHtmlStack: React.ReactNode[] = []
let lastText = ''

componentStackFrames
.map((frame) => frame.component)
.reverse()
Expand All @@ -56,23 +73,31 @@ export function PseudoHtml({
tagNames.includes(prevComponent) ||
tagNames.includes(nextComponent)

const isLastFewFrames =
!isHtmlTagsWarning && index >= componentList.length - 6
const reachedMaxDisplayFrames =
nestedHtmlStack.length >= MAX_NON_COLLAPSED_FRAMES

if (
nestedHtmlStack.length >= MAX_NON_COLLAPSED_FRAMES &&
isHtmlCollapsed
) {
return
}
if (isRelatedTag) {
const TextWrap = isHighlightedTag ? 'b' : Fragment
if ((isHtmlTagsWarning && isRelatedTag) || isLastFewFrames) {
const codeLine = (
<span>
<span>{spaces}</span>
<TextWrap>
{'<'}
{component}
{'>'}
{'\n'}
</TextWrap>
<span
{...(isHighlightedTag
? {
['data-nextjs-container-errors-pseudo-html--tag-error']:
true,
}
: undefined)}
>
{`<${component}>\n`}
</span>
</span>
)
lastText = component
Expand All @@ -88,32 +113,46 @@ export function PseudoHtml({
)
nestedHtmlStack.push(wrappedCodeLine)
} else {
if (!isHtmlCollapsed || !isHtmlTagsWarning) {
if ((isHtmlTagsWarning && !isHtmlCollapsed) || isLastFewFrames) {
nestedHtmlStack.push(
<span key={nestedHtmlStack.length}>
{spaces}
{'<' + component + '>\n'}
</span>
)
} else if (lastText !== '...') {
} else if (isHtmlCollapsed && lastText !== '...') {
lastText = '...'
nestedHtmlStack.push(
<span key={nestedHtmlStack.length}>
{spaces}
{'...'}
{'\n'}
{'...\n'}
</span>
)
}
}
})

if (hydrationMismatchType === 'text') {
const spaces = ' '.repeat(nestedHtmlStack.length * 2)
const wrappedCodeLine = (
<Fragment key={nestedHtmlStack.length}>
<span data-nextjs-container-errors-pseudo-html--diff-remove>
{spaces + `"${serverContent}"` + '\n'}
</span>
<span data-nextjs-container-errors-pseudo-html--diff-add>
{spaces + `"${clientContent}"` + '\n'}
</span>
</Fragment>
)
nestedHtmlStack.push(wrappedCodeLine)
}

return nestedHtmlStack
}, [
componentStackFrames,
isHtmlCollapsed,
clientTagName,
serverTagName,
clientContent,
serverContent,
isHtmlTagsWarning,
])

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,11 +190,20 @@ export const styles = css`
[data-nextjs-container-errors-pseudo-html] {
position: relative;
padding-left: var(--size-gap-triple);
}
[data-nextjs-container-errors-pseudo-html-collapse] {
position: absolute;
left: 0;
left: 10px;
top: 10px;
}
[data-nextjs-container-errors-pseudo-html--diff-add] {
color: var(--color-ansi-green);
}
[data-nextjs-container-errors-pseudo-html--diff-remove] {
color: var(--color-ansi-red);
}
[data-nextjs-container-errors-pseudo-html--tag-error] {
color: var(--color-ansi-red);
font-weight: bold;
}
`
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,26 @@ export type HydrationErrorState = {
clientContent?: string
}

type NullableText = string | null | undefined

export const isHtmlTagsWarning = (msg: NullableText) =>
Boolean(msg && htmlTagsWarnings.has(msg))

export const isTextMismatchWarning = (msg: NullableText) =>
Boolean(msg && textMismatchWarnings.has(msg))

const isKnownHydrationWarning = (msg: NullableText) =>
isHtmlTagsWarning(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: In HTML, %s cannot be a descendant of <%s>.\nThis will cause a hydration error.%s',
'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',
])
// https://github.com/facebook/react/blob/main/packages/react-dom/src/__tests__/ReactDOMHydrationDiff-test.js used as a reference
const knownHydrationWarnings = new Set([
...htmlTagsWarnings,
const textMismatchWarnings = new Set([
'Warning: Text content did not match. Server: "%s" Client: "%s"%s',
'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',
Expand All @@ -30,7 +40,7 @@ const knownHydrationWarnings = new Set([
export function patchConsoleError() {
const prev = console.error
console.error = function (msg, serverContent, clientContent, componentStack) {
if (knownHydrationWarnings.has(msg)) {
if (isKnownHydrationWarning(msg)) {
hydrationErrorState.warning = [
// remove the last %s from the message
msg,
Expand All @@ -46,6 +56,3 @@ export function patchConsoleError() {
prev.apply(console, arguments)
}
}

export const isHtmlTagsWarning = (msg: any) =>
Boolean(msg && htmlTagsWarnings.has(msg))
58 changes: 19 additions & 39 deletions test/development/acceptance-app/component-stack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,56 +21,36 @@ describe('Component Stack in error overlay', () => {
// If it's too long we can collapse
if (process.env.TURBOPACK) {
expect(await session.getRedboxComponentStack()).toMatchInlineSnapshot(`
"<Root>
<ServerRoot>
<AppRouter>
<ErrorBoundary>
<ErrorBoundaryHandler>
<Router>"
"...
<InnerLayoutRouter>
<Mismatch>
<main>
<Component>
<div>
"server"
"client""
`)

await session.toggleComponentStack()
expect(await session.getRedboxComponentStack()).toMatchInlineSnapshot(`
"<Root>
<ServerRoot>
<AppRouter>
<ErrorBoundary>
<ErrorBoundaryHandler>
<Router>
<HotReload>
<ReactDevOverlay>
<DevRootNotFoundBoundary>
<NotFoundBoundary>
<NotFoundErrorBoundary>
<RedirectBoundary>
<RedirectErrorBoundary>
<RootLayout>
<html>
<body>
<OuterLayoutRouter>
<RenderFromTemplateContext>
<ScrollAndFocusHandler>
<InnerScrollAndFocusHandler>
<ErrorBoundary>
<LoadingBoundary>
<NotFoundBoundary>
<NotFoundErrorBoundary>
<RedirectBoundary>
<RedirectErrorBoundary>
<InnerLayoutRouter>
<Mismatch>
<main>
<Component>
<div>
<p>"
"<InnerLayoutRouter>
<Mismatch>
<main>
<Component>
<div>
<p>
"server"
"client""
`)
} else {
expect(await session.getRedboxComponentStack()).toMatchInlineSnapshot(`
"<Mismatch>
<main>
<Component>
<div>
<p>"
<p>
"server"
"client""
`)
}

Expand Down
Loading

0 comments on commit ce42224

Please sign in to comment.