Skip to content

Commit

Permalink
DX: display highlited pesudo html when bad nesting html error occurred (
Browse files Browse the repository at this point in the history
vercel#62590)

### What

When you have bad nesting html in your React code, React wil raise
hydration error since the browser html parser might parse it to sth else
different comparing to server html on client. Previously we're display
only the warning in the error description in dev overlay.

Now we introduced another format of displaying pesudo html that
representing your code, with highlighting the html tag that causes the
error. Since React might gives you the whole component stack of React
tree, so we also introduced a way that can collapse the error.

#### Example


https://github.com/vercel/next.js/assets/4800338/622122d6-4d2e-4c8e-95e8-4864343e478b

### Why
The reason we added this is that even we show the html diff, it could
super large due to React ordering the html on client, so the mismatch
might be a lot. The idea here is similar to what you saw when you passed
down a bad event handler into server component, we displayed a pesudo
html as it could hit your mind faster than just seeing the warning. The
best way is to display the source code, but before we can show the
source, getting component stack display as pseudo html instead of here
could be more helpful.



### After vs Before

<img width="400"
src="https://github.com/vercel/next.js/assets/4800338/714119ad-ff23-46a9-bc5a-5601eb390e71">
<img width="400"
src="https://github.com/vercel/next.js/assets/4800338/575f95fa-889e-4cee-ad19-9c2fea06519a">



Closes NEXT-2621
  • Loading branch information
huozhi committed Feb 28, 2024
1 parent 340125a commit 7525633
Show file tree
Hide file tree
Showing 16 changed files with 496 additions and 146 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export interface UnhandledErrorAction {
reason: Error
frames: StackFrame[]
componentStackFrames?: ComponentStackFrame[]
warning?: [string, string, string]
}
export interface UnhandledRejectionAction {
type: typeof ACTION_UNHANDLED_REJECTION
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import type {
} from '../../../../server/dev/hot-reloader-types'
import { extractModulesFromTurbopackMessage } from '../../../../server/dev/extract-modules-from-turbopack-message'
import { REACT_REFRESH_FULL_RELOAD_FROM_ERROR } from '../../../dev/error-overlay/messages'
import type { HydrationErrorState } from '../internal/helpers/hydration-error-info'

interface Dispatcher {
onBuildOk(): void
Expand Down Expand Up @@ -496,14 +497,20 @@ export default function HotReload({
}, [dispatch])

const handleOnUnhandledError = useCallback((error: Error): void => {
const errorDetails = (error as any).details as
| HydrationErrorState
| undefined
// Component stack is added to the error in use-error-handler in case there was a hydration errror
const componentStack = (error as any)._componentStack
const componentStack = errorDetails?.componentStack
const warning = errorDetails?.warning
dispatch({
type: ACTION_UNHANDLED_ERROR,
reason: error,
frames: parseStack(error.stack!),
componentStackFrames:
componentStack && parseComponentStack(componentStack),
componentStackFrames: componentStack
? parseComponentStack(componentStack)
: undefined,
warning,
})
}, [])
const handleOnUnhandledRejection = useCallback((reason: Error): void => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ 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 {
isHtmlTagsWarning,
type HydrationErrorState,
} from '../helpers/hydration-error-info'

export type SupportedErrorEvent = {
id: number
Expand Down Expand Up @@ -214,10 +219,24 @@ export function Errors({
)
}

const error = activeError.error
const isServerError = ['server', 'edge-server'].includes(
getErrorSource(activeError.error) || ''
getErrorSource(error) || ''
)

const errorDetails: HydrationErrorState = (error as any).details || {}
const [warningTemplate, serverContent, clientContent] =
errorDetails.warning || [null, '', '']

const isHtmlTagsWarningTemplate = isHtmlTagsWarning(warningTemplate)
const hydrationWarning = warningTemplate
? warningTemplate
.replace('%s', serverContent)
.replace('%s', clientContent)
.replace('%s', '') // remove the last %s for stack
.replace(/^Warning: /, '')
: null

return (
<Overlay>
<Dialog
Expand Down Expand Up @@ -246,10 +265,23 @@ export function Errors({
<h1 id="nextjs__container_errors_label">
{isServerError ? 'Server Error' : 'Unhandled Runtime Error'}
</h1>
<p id="nextjs__container_errors_desc">
{activeError.error.name}:{' '}
<HotlinkedText text={activeError.error.message} />
<p
id="nextjs__container_errors_desc"
className="nextjs__container_errors_desc nextjs__container_errors_desc--error"
>
{error.name}: <HotlinkedText text={error.message} />
</p>
{hydrationWarning && activeError.componentStackFrames && (
<>
<p id="nextjs__container_errors__extra">{hydrationWarning}</p>
<PseudoHtml
className="nextjs__container_errors__extra_code"
componentStackFrames={activeError.componentStackFrames}
serverTagName={isHtmlTagsWarningTemplate ? serverContent : ''}
clientTagName={isHtmlTagsWarningTemplate ? clientContent : ''}
/>
</>
)}
{isServerError ? (
<div>
<small>
Expand Down Expand Up @@ -284,16 +316,24 @@ export const styles = css`
.nextjs-container-errors-header small > span {
font-family: var(--font-stack-monospace);
}
.nextjs-container-errors-header > p {
.nextjs-container-errors-header p {
font-family: var(--font-stack-monospace);
font-size: var(--size-font-small);
line-height: var(--size-font-big);
font-weight: bold;
margin: 0;
margin-top: var(--size-gap-half);
color: var(--color-ansi-red);
white-space: pre-wrap;
}
.nextjs__container_errors_desc--error {
color: var(--color-ansi-red);
}
.nextjs__container_errors__extra {
margin: 20px 0;
}
nextjs__container_errors__extra__code {
margin: 10px 0;
}
.nextjs-container-errors-header > div > small {
margin: 0;
margin-top: var(--size-gap-half);
Expand All @@ -309,7 +349,9 @@ export const styles = css`
margin-bottom: var(--size-gap);
font-size: var(--size-font-big);
}
.nextjs__container_errors__extra_code {
margin: 20px 0;
}
.nextjs-toast-errors-parent {
cursor: pointer;
transition: transform 0.2s ease;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,31 @@ 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 }
) {
// If is not collapsed, rotate 90 degrees
return (
<svg
data-nextjs-call-stack-chevron-icon
data-collapsed={collapsed}
fill="none"
height="20"
width="20"
shapeRendering="geometricPrecision"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
// rotate 90 degrees if not collapsed
style={{ transform: collapsed ? undefined : 'rotate(90deg)' }}
>
<path d="M9 18l6-6-6-6" />
</svg>
)
}

function FrameworkGroup({
framework,
stackFrames,
Expand All @@ -13,20 +38,7 @@ function FrameworkGroup({
<details data-nextjs-collapsed-call-stack-details>
{/* Match CallStackFrame tabIndex */}
<summary tabIndex={10}>
<svg
data-nextjs-call-stack-chevron-icon
fill="none"
height="20"
width="20"
shapeRendering="geometricPrecision"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="M9 18l6-6-6-6" />
</svg>
<CollapseIcon />
<FrameworkIcon framework={framework} />
{framework === 'react' ? 'React' : 'Next.js'}
</summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { useMemo, Fragment, useState } from 'react'
import type { ComponentStackFrame } from '../../helpers/parse-component-stack'
import { CollapseIcon } from './GroupedStackFrames'

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
*
* <pre>
* <code>{`
* <Page>
* <p>
* ^^^^
* <p>
* ^^^^
* `}</code>
* </pre>
*
*/
export function PseudoHtml({
componentStackFrames,
serverTagName,
clientTagName,
...props
}: {
componentStackFrames: ComponentStackFrame[]
serverTagName?: string
clientTagName?: string
[prop: string]: any
}) {
const isHtmlTagsWarning = serverTagName || clientTagName
const shouldCollapse = componentStackFrames.length > MAX_NON_COLLAPSED_FRAMES
const [isHtmlCollapsed, toggleCollapseHtml] = useState(shouldCollapse)

const htmlComponents = useMemo(() => {
const tagNames = [serverTagName, clientTagName]
const nestedHtmlStack: React.ReactNode[] = []
let lastText = ''
componentStackFrames
.map((frame) => frame.component)
.reverse()
.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

const isHighlightedTag = tagNames.includes(component)
const isRelatedTag =
isHighlightedTag ||
tagNames.includes(prevComponent) ||
tagNames.includes(nextComponent)

if (
nestedHtmlStack.length >= MAX_NON_COLLAPSED_FRAMES &&
isHtmlCollapsed
) {
return
}
if (isRelatedTag) {
const TextWrap = isHighlightedTag ? 'b' : Fragment
const codeLine = (
<span>
<span>{spaces}</span>
<TextWrap>
{'<'}
{component}
{'>'}
{'\n'}
</TextWrap>
</span>
)
lastText = component

const wrappedCodeLine = (
<Fragment key={nestedHtmlStack.length}>
{codeLine}
{/* Add ^^^^ to the target tags */}
{isHighlightedTag && (
<span>{spaces + '^'.repeat(component.length + 2) + '\n'}</span>
)}
</Fragment>
)
nestedHtmlStack.push(wrappedCodeLine)
} else {
if (!isHtmlCollapsed || !isHtmlTagsWarning) {
nestedHtmlStack.push(
<span key={nestedHtmlStack.length}>
{spaces}
{'<' + component + '>\n'}
</span>
)
} else if (lastText !== '...') {
lastText = '...'
nestedHtmlStack.push(
<span key={nestedHtmlStack.length}>
{spaces}
{'...'}
{'\n'}
</span>
)
}
}
})

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

return (
<div data-nextjs-container-errors-pseudo-html>
<span
data-nextjs-container-errors-pseudo-html-collapse
onClick={() => toggleCollapseHtml(!isHtmlCollapsed)}
>
<CollapseIcon collapsed={isHtmlCollapsed} />
</span>
<pre {...props}>
<code>{htmlComponents}</code>
</pre>
</div>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import type { ReadyRuntimeError } from '../../helpers/getErrorByType'
import { noop as css } from '../../helpers/noop-template'
import { groupStackFramesByFramework } from '../../helpers/group-stack-frames-by-framework'
import { GroupedStackFrames } from './GroupedStackFrames'
import { ComponentStackFrameRow } from './ComponentStackFrameRow'

export type RuntimeErrorProps = { error: ReadyRuntimeError }

Expand Down Expand Up @@ -77,18 +76,6 @@ export function RuntimeError({ error }: RuntimeErrorProps) {
</React.Fragment>
) : undefined}

{error.componentStackFrames ? (
<>
<h2>Component Stack</h2>
{error.componentStackFrames.map((componentStackFrame, index) => (
<ComponentStackFrameRow
key={index}
componentStackFrame={componentStackFrame}
/>
))}
</>
) : null}

{stackFramesGroupedByFramework.length ? (
<React.Fragment>
<h2>Call Stack</h2>
Expand Down Expand Up @@ -200,4 +187,14 @@ export const styles = css`
[data-nextjs-collapsed-call-stack-details] [data-nextjs-call-stack-frame] {
margin-bottom: var(--size-gap-double);
}
[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;
}
`
Loading

0 comments on commit 7525633

Please sign in to comment.