Skip to content

Commit 1c8cc6d

Browse files
huozhistipsan
authored andcommitted
Display the stitched error instead of react error (vercel#72106)
1 parent b349d86 commit 1c8cc6d

File tree

13 files changed

+418
-40
lines changed

13 files changed

+418
-40
lines changed

packages/next/src/client/components/react-dev-overlay/app/ReactDevOverlay.tsx

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import React from 'react'
2-
import { ACTION_UNHANDLED_ERROR, type OverlayState } from '../shared'
2+
import type { OverlayState } from '../shared'
33
import { ShadowPortal } from '../internal/components/ShadowPortal'
44
import { BuildError } from '../internal/container/BuildError'
5-
import { Errors, type SupportedErrorEvent } from '../internal/container/Errors'
6-
import { parseStack } from '../internal/helpers/parse-stack'
5+
import { Errors } from '../internal/container/Errors'
76
import { StaticIndicator } from '../internal/container/StaticIndicator'
87
import { Base } from '../internal/styles/Base'
98
import { ComponentStyles } from '../internal/styles/ComponentStyles'
@@ -13,7 +12,7 @@ import type { Dispatcher } from './hot-reloader-client'
1312
import { RuntimeErrorHandler } from '../internal/helpers/runtime-error-handler'
1413

1514
interface ReactDevOverlayState {
16-
reactError: SupportedErrorEvent | null
15+
isReactError: boolean
1716
}
1817
export default class ReactDevOverlay extends React.PureComponent<
1918
{
@@ -23,27 +22,20 @@ export default class ReactDevOverlay extends React.PureComponent<
2322
},
2423
ReactDevOverlayState
2524
> {
26-
state = { reactError: null }
25+
state = { isReactError: false }
2726

2827
static getDerivedStateFromError(error: Error): ReactDevOverlayState {
29-
if (!error.stack) return { reactError: null }
28+
if (!error.stack) return { isReactError: false }
3029

3130
RuntimeErrorHandler.hadRuntimeError = true
3231
return {
33-
reactError: {
34-
id: 0,
35-
event: {
36-
type: ACTION_UNHANDLED_ERROR,
37-
reason: error,
38-
frames: parseStack(error.stack),
39-
},
40-
},
32+
isReactError: true,
4133
}
4234
}
4335

4436
render() {
4537
const { state, children, dispatcher } = this.props
46-
const { reactError } = this.state
38+
const { isReactError } = this.state
4739

4840
const hasBuildError = state.buildError != null
4941
const hasRuntimeErrors = Boolean(state.errors.length)
@@ -52,7 +44,7 @@ export default class ReactDevOverlay extends React.PureComponent<
5244

5345
return (
5446
<>
55-
{reactError ? (
47+
{isReactError ? (
5648
<html>
5749
<head></head>
5850
<body></body>
@@ -78,8 +70,10 @@ export default class ReactDevOverlay extends React.PureComponent<
7870
{hasRuntimeErrors ? (
7971
<Errors
8072
isAppDir={true}
81-
initialDisplayState={reactError ? 'fullscreen' : 'minimized'}
82-
errors={reactError ? [reactError] : state.errors}
73+
initialDisplayState={
74+
isReactError ? 'fullscreen' : 'minimized'
75+
}
76+
errors={state.errors}
8377
versionInfo={state.versionInfo}
8478
hasStaticIndicator={hasStaticIndicator}
8579
debugInfo={debugInfo}

packages/next/src/client/react-client-callbacks/shared.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,21 @@ import type { HydrationOptions } from 'react-dom/client'
44
import { isBailoutToCSRError } from '../../shared/lib/lazy-dynamic/bailout-to-csr'
55
import { reportGlobalError } from './report-global-error'
66
import { getReactStitchedError } from '../components/react-dev-overlay/internal/helpers/stitched-error'
7+
import isError from '../../lib/is-error'
78

89
export const onRecoverableError: HydrationOptions['onRecoverableError'] = (
9-
err,
10+
error,
1011
errorInfo
1112
) => {
12-
const stitchedError = getReactStitchedError(err)
13+
// x-ref: https://github.com/facebook/react/pull/28736
14+
const cause = isError(error) && 'cause' in error ? error.cause : error
15+
const stitchedError = getReactStitchedError(cause)
1316
// In development mode, pass along the component stack to the error
1417
if (process.env.NODE_ENV === 'development' && errorInfo.componentStack) {
1518
;(stitchedError as any)._componentStack = errorInfo.componentStack
1619
}
1720
// Skip certain custom errors which are not expected to be reported on client
18-
if (isBailoutToCSRError(err)) return
21+
if (isBailoutToCSRError(cause)) return
1922

2023
reportGlobalError(stitchedError)
2124
}

test/development/acceptance-app/dynamic-error.test.ts

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,31 +7,30 @@ import { outdent } from 'outdent'
77
describe('dynamic = "error" in devmode', () => {
88
const { next } = nextTestSetup({
99
files: new FileRef(path.join(__dirname, 'fixtures', 'default-template')),
10-
skipStart: true,
1110
})
1211

1312
it('should show error overlay when dynamic is forced', async () => {
14-
const { session, cleanup } = await sandbox(next, undefined, '/server')
15-
16-
// dynamic = "error" and force dynamic
17-
await session.patch(
18-
'app/server/page.js',
19-
outdent`
20-
import { cookies } from 'next/headers';
21-
22-
import Component from '../../index'
23-
24-
export default async function Page() {
25-
await cookies()
26-
return <Component />
27-
}
28-
29-
export const dynamic = "error"
30-
`
13+
const { session, cleanup } = await sandbox(
14+
next,
15+
new Map([
16+
[
17+
'app/server/page.js',
18+
outdent`
19+
import { cookies } from 'next/headers';
20+
21+
export default async function Page() {
22+
await cookies()
23+
return null
24+
}
25+
26+
export const dynamic = "error"
27+
`,
28+
],
29+
]),
30+
'/server'
3131
)
3232

3333
await session.assertHasRedbox()
34-
console.log(await session.getRedboxDescription())
3534
expect(await session.getRedboxDescription()).toMatchInlineSnapshot(
3635
`"[ Server ] Error: Route /server with \`dynamic = "error"\` couldn't be rendered statically because it used \`cookies\`. See more info here: https://nextjs.org/docs/app/building-your-application/rendering/static-and-dynamic#dynamic-rendering"`
3736
)
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
'use client'
2+
3+
import Foo from '../foo'
4+
5+
export default function BrowserOnly() {
6+
return (
7+
<div>
8+
<Foo />
9+
</div>
10+
)
11+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
'use client'
2+
3+
import dynamic from 'next/dynamic'
4+
5+
const BrowserOnly = dynamic(() => import('./browser-only'), {
6+
ssr: false,
7+
})
8+
9+
// Intermediate component for testing owner stack
10+
function Inner() {
11+
return <BrowserOnly />
12+
}
13+
14+
export default function Page() {
15+
return <Inner />
16+
}

test/development/app-dir/owner-stack-invalid-element-type/app/foo.js

Whitespace-only changes.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { ReactNode } from 'react'
2+
export default function Root({ children }: { children: ReactNode }) {
3+
return (
4+
<html>
5+
<body>{children}</body>
6+
</html>
7+
)
8+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import Foo from '../foo'
2+
3+
// Intermediate component for testing owner stack
4+
function Inner() {
5+
return <Foo />
6+
}
7+
8+
export default function Page() {
9+
return (
10+
<div>
11+
<Inner />
12+
</div>
13+
)
14+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
'use client'
2+
3+
import Foo from '../foo'
4+
5+
// Intermediate component for testing owner stack
6+
function Inner() {
7+
return <Foo />
8+
}
9+
10+
export default function Page() {
11+
return (
12+
<div>
13+
<Inner />
14+
</div>
15+
)
16+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
process.env.TEST_OWNER_STACK = 'false'
2+
3+
import { nextTestSetup } from 'e2e-utils'
4+
import {
5+
assertHasRedbox,
6+
getRedboxSource,
7+
getStackFramesContent,
8+
} from 'next-test-utils'
9+
10+
// TODO: When owner stack is enabled by default, remove the condition and only keep one test
11+
const isOwnerStackEnabled =
12+
process.env.TEST_OWNER_STACK !== 'false' ||
13+
process.env.__NEXT_EXPERIMENTAL_PPR === 'true'
14+
15+
;(isOwnerStackEnabled ? describe.skip : describe)(
16+
'app-dir - invalid-element-type',
17+
() => {
18+
const { next } = nextTestSetup({
19+
files: __dirname,
20+
})
21+
22+
it('should catch invalid element from a browser only component', async () => {
23+
const browser = await next.browser('/browser')
24+
25+
await assertHasRedbox(browser)
26+
const source = await getRedboxSource(browser)
27+
28+
const stackFramesContent = await getStackFramesContent(browser)
29+
if (process.env.TURBOPACK) {
30+
expect(stackFramesContent).toMatchInlineSnapshot(`""`)
31+
expect(source).toMatchInlineSnapshot(`
32+
"app/browser/browser-only.js (8:7) @ BrowserOnly
33+
34+
6 | return (
35+
7 | <div>
36+
> 8 | <Foo />
37+
| ^
38+
9 | </div>
39+
10 | )
40+
11 | }"
41+
`)
42+
} else {
43+
expect(stackFramesContent).toMatchInlineSnapshot(`""`)
44+
// FIXME: the methodName should be `@ BrowserOnly` instead of `@ Foo`
45+
expect(source).toMatchInlineSnapshot(`
46+
"app/browser/browser-only.js (8:8) @ Foo
47+
48+
6 | return (
49+
7 | <div>
50+
> 8 | <Foo />
51+
| ^
52+
9 | </div>
53+
10 | )
54+
11 | }"
55+
`)
56+
}
57+
})
58+
59+
it('should catch invalid element from a rsc component', async () => {
60+
const browser = await next.browser('/rsc')
61+
62+
await assertHasRedbox(browser)
63+
const stackFramesContent = await getStackFramesContent(browser)
64+
const source = await getRedboxSource(browser)
65+
66+
if (process.env.TURBOPACK) {
67+
expect(stackFramesContent).toMatchInlineSnapshot(`""`)
68+
expect(source).toMatchInlineSnapshot(`
69+
"app/rsc/page.js (5:10) @ Inner
70+
71+
3 | // Intermediate component for testing owner stack
72+
4 | function Inner() {
73+
> 5 | return <Foo />
74+
| ^
75+
6 | }
76+
7 |
77+
8 | export default function Page() {"
78+
`)
79+
} else {
80+
expect(stackFramesContent).toMatchInlineSnapshot(`""`)
81+
// FIXME: the methodName should be `@ Inner` instead of `@ Foo`
82+
expect(source).toMatchInlineSnapshot(`
83+
"app/rsc/page.js (5:11) @ Foo
84+
85+
3 | // Intermediate component for testing owner stack
86+
4 | function Inner() {
87+
> 5 | return <Foo />
88+
| ^
89+
6 | }
90+
7 |
91+
8 | export default function Page() {"
92+
`)
93+
}
94+
})
95+
96+
it('should catch invalid element from on ssr client component', async () => {
97+
const browser = await next.browser('/ssr')
98+
99+
await assertHasRedbox(browser)
100+
101+
const stackFramesContent = await getStackFramesContent(browser)
102+
const source = await getRedboxSource(browser)
103+
if (process.env.TURBOPACK) {
104+
expect(stackFramesContent).toMatchInlineSnapshot(`""`)
105+
expect(source).toMatchInlineSnapshot(`
106+
"app/ssr/page.js (7:10) @ Inner
107+
108+
5 | // Intermediate component for testing owner stack
109+
6 | function Inner() {
110+
> 7 | return <Foo />
111+
| ^
112+
8 | }
113+
9 |
114+
10 | export default function Page() {"
115+
`)
116+
} else {
117+
expect(stackFramesContent).toMatchInlineSnapshot(`""`)
118+
// FIXME: the methodName should be `@ Inner` instead of `@ Foo`
119+
expect(source).toMatchInlineSnapshot(`
120+
"app/ssr/page.js (7:11) @ Foo
121+
122+
5 | // Intermediate component for testing owner stack
123+
6 | function Inner() {
124+
> 7 | return <Foo />
125+
| ^
126+
8 | }
127+
9 |
128+
10 | export default function Page() {"
129+
`)
130+
}
131+
})
132+
}
133+
)

0 commit comments

Comments
 (0)