Port error overlay hydration error to pages directory (#46677)
Co-authored-by: JJ Kasper <jj@jjsweb.site>
This commit is contained in:
parent
70d6438217
commit
d59aa9655e
10 changed files with 174 additions and 5 deletions
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"]
|
||||
}
|
||||
|
|
7
packages/react-dev-overlay/types/local.d.ts
vendored
Normal file
7
packages/react-dev-overlay/types/local.d.ts
vendored
Normal 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'
|
||||
}
|
62
test/development/acceptance/component-stack.test.ts
Normal file
62
test/development/acceptance/component-stack.test.ts
Normal 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()
|
||||
})
|
||||
}
|
||||
)
|
|
@ -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()
|
||||
|
|
49
test/development/acceptance/hydration-error.test.ts
Normal file
49
test/development/acceptance/hydration-error.test.ts
Normal 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()
|
||||
})
|
||||
}
|
||||
)
|
Loading…
Reference in a new issue