Port error overlay hydration error to pages directory (#46677)

Co-authored-by: JJ Kasper <jj@jjsweb.site>
This commit is contained in:
Hannes Bornö 2023-03-07 04:40:25 +01:00 committed by GitHub
parent 70d6438217
commit d59aa9655e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 174 additions and 5 deletions

View file

@ -18,7 +18,7 @@ export function parseComponentStack(
const webpackFile = match[3]
// Stop parsing the component stack if we reach a Next.js component
if (webpackFile?.includes('next/dist/client/components/')) {
if (webpackFile?.includes('next/dist')) {
break
}

View file

@ -1,5 +1,14 @@
import * as Bus from './internal/bus'
import { parseStack } from './internal/helpers/parseStack'
import { parseComponentStack } from 'next/dist/client/components/react-dev-overlay/internal/helpers/parse-component-stack'
import {
hydrationErrorComponentStack,
hydrationErrorWarning,
patchConsoleError,
} from 'next/dist/client/components/react-dev-overlay/internal/helpers/hydration-error-info'
// Patch console.error to collect information about hydration errors
patchConsoleError()
let isRegistered = false
let stackTraceLimit: number | undefined = undefined
@ -14,14 +23,24 @@ function onUnhandledError(ev: ErrorEvent) {
if (
error.message.match(/(hydration|content does not match|did not match)/i)
) {
if (hydrationErrorWarning) {
error.message += '\n\n' + hydrationErrorWarning
}
error.message += `\n\nSee more info here: https://nextjs.org/docs/messages/react-hydration-error`
}
const e = error
const componentStack =
typeof hydrationErrorComponentStack === 'string'
? parseComponentStack(hydrationErrorComponentStack).map(
(frame: any) => frame.component
)
: undefined
Bus.emit({
type: Bus.TYPE_UNHANDLED_ERROR,
reason: error,
frames: parseStack(e.stack!),
componentStack,
})
}

View file

@ -18,6 +18,7 @@ export type UnhandledError = {
type: typeof TYPE_UNHANDLED_ERROR
reason: Error
frames: StackFrame[]
componentStack?: string[]
}
export type UnhandledRejection = {
type: typeof TYPE_UNHANDLED_REJECTION

View file

@ -139,6 +139,18 @@ const RuntimeError: React.FC<RuntimeErrorProps> = function RuntimeError({
/>
</React.Fragment>
) : undefined}
{error.componentStack ? (
<>
<h5>Component Stack</h5>
{error.componentStack.map((component, index) => (
<div key={index} data-nextjs-component-stack-frame>
<h6>{component}</h6>
</div>
))}
</>
) : null}
{visibleCallStackFrames.length ? (
<React.Fragment>
<h5>Call Stack</h5>
@ -147,6 +159,7 @@ const RuntimeError: React.FC<RuntimeErrorProps> = function RuntimeError({
))}
</React.Fragment>
) : undefined}
{canShowMore ? (
<React.Fragment>
<button
@ -173,11 +186,13 @@ export const styles = css`
color: var(--color-accents-3);
}
[data-nextjs-call-stack-frame]:not(:last-child) {
[data-nextjs-call-stack-frame]:not(:last-child),
[data-nextjs-component-stack-frame]:not(:last-child) {
margin-bottom: var(--size-gap-double);
}
[data-nextjs-call-stack-frame] > h6 {
[data-nextjs-call-stack-frame] > h6,
[data-nextjs-component-stack-frame] > h6 {
margin-top: 0;
margin-bottom: var(--size-gap);
font-family: var(--font-stack-monospace);

View file

@ -8,6 +8,7 @@ export type ReadyRuntimeError = {
runtime: true
error: Error
frames: OriginalStackFrame[]
componentStack?: string[]
}
export async function getErrorByType(
@ -17,7 +18,7 @@ export async function getErrorByType(
switch (event.type) {
case TYPE_UNHANDLED_ERROR:
case TYPE_UNHANDLED_REJECTION: {
return {
const readyRuntimeError: ReadyRuntimeError = {
id,
runtime: true,
error: event.reason,
@ -27,6 +28,10 @@ export async function getErrorByType(
event.reason.toString()
),
}
if (event.type === TYPE_UNHANDLED_ERROR) {
readyRuntimeError.componentStack = event.componentStack
}
return readyRuntimeError
}
default: {
break

View file

@ -14,6 +14,6 @@
"skipLibCheck": true,
"moduleResolution": "Node16"
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"include": ["src/**/*.ts", "src/**/*.tsx", "types/local.d.ts"],
"exclude": ["node_modules"]
}

View file

@ -0,0 +1,7 @@
declare module 'next/dist/client/components/react-dev-overlay/internal/helpers/parse-component-stack' {
export * from 'next/src/client/components/react-dev-overlay/internal/helpers/parse-component-stack'
}
declare module 'next/dist/client/components/react-dev-overlay/internal/helpers/hydration-error-info' {
export * from 'next/src/client/components/react-dev-overlay/internal/helpers/hydration-error-info'
}

View file

@ -0,0 +1,62 @@
/* eslint-env jest */
import { sandbox } from './helpers'
import { createNextDescribe } from 'e2e-utils'
createNextDescribe(
'Component Stack in error overlay',
{
files: {},
dependencies: {
react: 'latest',
'react-dom': 'latest',
},
skipStart: true,
},
({ next }) => {
it('should show a component stack on hydration error', async () => {
const { cleanup, session } = await sandbox(
next,
new Map([
[
'component.js',
`
const isClient = typeof window !== 'undefined'
export default function Component() {
return (
<div>
<p>{isClient ? "client" : "server"}</p>
</div>
);
}
`,
],
[
'index.js',
`
import Component from './component'
export default function Mismatch() {
return (
<main>
<Component />
</main>
);
}
`,
],
])
)
expect(await session.hasRedbox(true)).toBe(true)
expect(await session.getRedboxComponentStack()).toMatchInlineSnapshot(`
"p
div
Component
main
Mismatch"
`)
await cleanup()
})
}
)

View file

@ -120,6 +120,17 @@ export async function sandbox(
}
return source
},
async getRedboxComponentStack() {
await browser.waitForElementByCss('[data-nextjs-component-stack-frame]')
const componentStackFrameElements = await browser.elementsByCss(
'[data-nextjs-component-stack-frame]'
)
const componentStackFrameTexts = await Promise.all(
componentStackFrameElements.map((f) => f.innerText())
)
return componentStackFrameTexts.join('\n')
},
},
async cleanup() {
await browser.close()

View file

@ -0,0 +1,49 @@
/* eslint-env jest */
import { sandbox } from './helpers'
import { createNextDescribe } from 'e2e-utils'
createNextDescribe(
'Error overlay for hydration errors',
{
files: {},
dependencies: {
react: 'latest',
'react-dom': 'latest',
},
skipStart: true,
},
({ next }) => {
it('should show correct hydration error when client and server render different text', async () => {
const { cleanup, session } = await sandbox(
next,
new Map([
[
'index.js',
`
const isClient = typeof window !== 'undefined'
export default function Mismatch() {
return (
<div className="parent">
<main className="child">{isClient ? "client" : "server"}</main>
</div>
);
}
`,
],
])
)
expect(await session.hasRedbox(true)).toBe(true)
expect(await session.getRedboxDescription()).toMatchInlineSnapshot(`
"Error: Text content does not match server-rendered HTML.
Warning: Text content did not match. Server: \\"server\\" Client: \\"client\\"
See more info here: https://nextjs.org/docs/messages/react-hydration-error"
`)
await cleanup()
})
}
)