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

feat: display text diff for text mismatch hydration errors #62684

Merged
merged 6 commits into from
Feb 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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