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

DX: display highlited pesudo html when bad nesting html error occurred #62590

Merged
merged 15 commits into from
Feb 28, 2024
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;
}
`