Skip to content

Commit 2ee93c3

Browse files
authored
Append owner stack for captured string console error (#72115)
1 parent 90c0a76 commit 2ee93c3

File tree

9 files changed

+304
-13
lines changed

9 files changed

+304
-13
lines changed

packages/next/src/client/components/react-dev-overlay/internal/helpers/stitched-error.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ const REACT_ERROR_STACK_BOTTOM_FRAME_REGEX = new RegExp(
66
`(at ${REACT_ERROR_STACK_BOTTOM_FRAME} )|(${REACT_ERROR_STACK_BOTTOM_FRAME}\\@)`
77
)
88

9+
const captureOwnerStack = (React as any).captureOwnerStack
10+
? (React as any).captureOwnerStack
11+
: () => ''
12+
913
export function getReactStitchedError<T = unknown>(err: T): Error | T {
1014
if (typeof (React as any).captureOwnerStack !== 'function') {
1115
return err
@@ -28,12 +32,18 @@ export function getReactStitchedError<T = unknown>(err: T): Error | T {
2832
newError.stack = newStack
2933

3034
// Avoid duplicate overriding stack frames
31-
const ownerStack = (React as any).captureOwnerStack()
32-
if (ownerStack && newStack.endsWith(ownerStack) === false) {
33-
newStack += ownerStack
34-
// Override stack
35-
newError.stack = newStack
36-
}
35+
appendOwnerStack(newError)
3736

3837
return newError
3938
}
39+
40+
function appendOwnerStack(error: Error) {
41+
let stack = error.stack || ''
42+
// Avoid duplicate overriding stack frames
43+
const ownerStack = captureOwnerStack()
44+
if (ownerStack && stack.endsWith(ownerStack) === false) {
45+
stack += ownerStack
46+
// Override stack
47+
error.stack = stack
48+
}
49+
}

packages/next/src/client/components/react-dev-overlay/internal/helpers/use-error-handler.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { formatConsoleArgs } from '../../../../lib/console'
66
import isError from '../../../../../lib/is-error'
77
import { createUnhandledError } from './console-error'
88
import { enqueueConsecutiveDedupedError } from './enqueue-client-error'
9+
import { getReactStitchedError } from './stitched-error'
910

1011
export type ErrorHandler = (error: Error) => void
1112

@@ -22,7 +23,7 @@ export function handleClientError(
2223
if (!originError || !isError(originError)) {
2324
// If it's not an error, format the args into an error
2425
const formattedErrorMessage = formatConsoleArgs(consoleErrorArgs)
25-
error = createUnhandledError(formattedErrorMessage)
26+
error = getReactStitchedError(createUnhandledError(formattedErrorMessage))
2627
} else {
2728
error = originError
2829
}

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

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,10 @@ const isOwnerStackEnabled =
4141
11 | }"
4242
`)
4343
} else {
44+
// FIXME: the owner stack method names should be `Inner > Page`
4445
expect(stackFramesContent).toMatchInlineSnapshot(`
45-
"at BrowserOnly (app/browser/page.js (11:11))
46-
at Inner (app/browser/page.js (15:11))"
46+
"at BrowserOnly (app/browser/page.js (11:11))
47+
at Inner (app/browser/page.js (15:11))"
4748
`)
4849
// FIXME: the methodName should be `@ BrowserOnly` instead of `@ Foo`
4950
expect(source).toMatchInlineSnapshot(`
@@ -83,11 +84,11 @@ const isOwnerStackEnabled =
8384
8 | export default function Page() {"
8485
`)
8586
} else {
86-
// FIXME: the methodName should be `@ Page` instead of `@ Inner`
87+
// FIXME: the owner stack method names should be `Page`
8788
expect(stackFramesContent).toMatchInlineSnapshot(
8889
`"at Inner (app/rsc/page.js (11:8))"`
8990
)
90-
// FIXME: the methodName should be `@ Inner` instead of `@ Foo`
91+
// FIXME: the methodName should be `@ Inner`
9192
expect(source).toMatchInlineSnapshot(`
9293
"app/rsc/page.js (5:11) @ Foo
9394
@@ -125,11 +126,11 @@ const isOwnerStackEnabled =
125126
10 | export default function Page() {"
126127
`)
127128
} else {
128-
// FIXME: the methodName should be `@ Inner` instead of `@ Foo`
129+
// FIXME: the owner stack method names should be `Page`
129130
expect(stackFramesContent).toMatchInlineSnapshot(
130131
`"at Inner (app/ssr/page.js (13:8))"`
131132
)
132-
// FIXME: the methodName should be `@ Inner` instead of `@ Foo`
133+
// FIXME: the methodName should be `@ Inner`
133134
expect(source).toMatchInlineSnapshot(`
134135
"app/ssr/page.js (7:11) @ Foo
135136
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: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
const list = [1, 2, 3]
2+
3+
export default function Page() {
4+
return (
5+
<div>
6+
{list.map((item, index) => (
7+
<span>{item}</span>
8+
))}
9+
</div>
10+
)
11+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
'use client'
2+
3+
const list = [1, 2, 3]
4+
5+
export default function Page() {
6+
return (
7+
<div>
8+
{list.map((item, index) => (
9+
<p>{item}</p>
10+
))}
11+
</div>
12+
)
13+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* @type {import('next').NextConfig}
3+
*/
4+
const nextConfig = {
5+
experimental: {
6+
reactOwnerStack: true,
7+
},
8+
}
9+
10+
module.exports = nextConfig
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { nextTestSetup } from 'e2e-utils'
2+
import {
3+
getRedboxSource,
4+
waitForAndOpenRuntimeError,
5+
getStackFramesContent,
6+
} from 'next-test-utils'
7+
8+
// TODO: When owner stack is enabled by default, remove the condition and only keep one test
9+
const isOwnerStackEnabled =
10+
process.env.TEST_OWNER_STACK !== 'false' ||
11+
process.env.__NEXT_EXPERIMENTAL_PPR === 'true'
12+
13+
;(isOwnerStackEnabled ? describe : describe.skip)(
14+
'app-dir - owner-stack-react-missing-key-prop',
15+
() => {
16+
const { next } = nextTestSetup({
17+
files: __dirname,
18+
})
19+
20+
it('should catch invalid element from on rsc component', async () => {
21+
const browser = await next.browser('/rsc')
22+
await waitForAndOpenRuntimeError(browser)
23+
24+
const stackFramesContent = await getStackFramesContent(browser)
25+
const source = await getRedboxSource(browser)
26+
27+
if (process.env.TURBOPACK) {
28+
expect(stackFramesContent).toMatchInlineSnapshot(
29+
`"at Page (app/rsc/page.tsx (6:13))"`
30+
)
31+
expect(source).toMatchInlineSnapshot(`
32+
"app/rsc/page.tsx (7:9) @ <anonymous>
33+
34+
5 | <div>
35+
6 | {list.map((item, index) => (
36+
> 7 | <span>{item}</span>
37+
| ^
38+
8 | ))}
39+
9 | </div>
40+
10 | )"
41+
`)
42+
} else {
43+
// FIXME: the owner stack method names should be `Page` instead of `map`
44+
expect(stackFramesContent).toMatchInlineSnapshot(
45+
`"at map (app/rsc/page.tsx (6:13))"`
46+
)
47+
// FIXME: the methodName should be `@ <anonymous>` instead of `@ span`
48+
expect(source).toMatchInlineSnapshot(`
49+
"app/rsc/page.tsx (7:10) @ span
50+
51+
5 | <div>
52+
6 | {list.map((item, index) => (
53+
> 7 | <span>{item}</span>
54+
| ^
55+
8 | ))}
56+
9 | </div>
57+
10 | )"
58+
`)
59+
}
60+
})
61+
62+
it('should catch invalid element from on ssr client component', async () => {
63+
const browser = await next.browser('/ssr')
64+
await waitForAndOpenRuntimeError(browser)
65+
66+
const stackFramesContent = await getStackFramesContent(browser)
67+
const source = await getRedboxSource(browser)
68+
if (process.env.TURBOPACK) {
69+
expect(stackFramesContent).toMatchInlineSnapshot(
70+
`"at Page (app/ssr/page.tsx (8:13))"`
71+
)
72+
expect(source).toMatchInlineSnapshot(`
73+
"app/ssr/page.tsx (9:9) @ <unknown>
74+
75+
7 | <div>
76+
8 | {list.map((item, index) => (
77+
> 9 | <p>{item}</p>
78+
| ^
79+
10 | ))}
80+
11 | </div>
81+
12 | )"
82+
`)
83+
} else {
84+
// FIXME: the owner stack method names should be `Page` instead of `map`
85+
expect(stackFramesContent).toMatchInlineSnapshot(
86+
`"at map (app/ssr/page.tsx (8:13))"`
87+
)
88+
// FIXME: the methodName should be `@ <unknown>` instead of `@ p`
89+
expect(source).toMatchInlineSnapshot(`
90+
"app/ssr/page.tsx (9:10) @ p
91+
92+
7 | <div>
93+
8 | {list.map((item, index) => (
94+
> 9 | <p>{item}</p>
95+
| ^
96+
10 | ))}
97+
11 | </div>
98+
12 | )"
99+
`)
100+
}
101+
})
102+
}
103+
)
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { nextTestSetup } from 'e2e-utils'
2+
import { getRedboxSource, waitForAndOpenRuntimeError } from 'next-test-utils'
3+
4+
// TODO: When owner stack is enabled by default, remove the condition and only keep one test
5+
const isOwnerStackEnabled =
6+
process.env.TEST_OWNER_STACK !== 'false' ||
7+
process.env.__NEXT_EXPERIMENTAL_PPR === 'true'
8+
9+
async function getStackFramesContent(browser) {
10+
const stackFrameElements = await browser.elementsByCss(
11+
'[data-nextjs-call-stack-frame]'
12+
)
13+
const stackFramesContent = (
14+
await Promise.all(
15+
stackFrameElements.map(async (frame) => {
16+
const functionNameEl = await frame.$('[data-nextjs-frame-expanded]')
17+
const sourceEl = await frame.$('[data-has-source]')
18+
const functionName = functionNameEl
19+
? await functionNameEl.innerText()
20+
: ''
21+
const source = sourceEl ? await sourceEl.innerText() : ''
22+
23+
if (!functionName) {
24+
return ''
25+
}
26+
return `at ${functionName} (${source})`
27+
})
28+
)
29+
)
30+
.filter(Boolean)
31+
.join('\n')
32+
33+
return stackFramesContent
34+
}
35+
36+
;(isOwnerStackEnabled ? describe.skip : describe)(
37+
'app-dir - react-missing-key-prop',
38+
() => {
39+
const { next } = nextTestSetup({
40+
files: __dirname,
41+
})
42+
43+
let nextConfig: string = ''
44+
beforeAll(async () => {
45+
await next.stop()
46+
await next.patchFile('next.config.js', (content: string) => {
47+
nextConfig = content
48+
return content.replace(
49+
`reactOwnerStack: true`,
50+
`reactOwnerStack: false`
51+
)
52+
})
53+
await next.start()
54+
})
55+
afterAll(async () => {
56+
await next.stop()
57+
// Restore original next.config.js
58+
await next.patchFile('next.config.js', nextConfig)
59+
})
60+
61+
it('should catch invalid element from on rsc component', async () => {
62+
const browser = await next.browser('/rsc')
63+
await waitForAndOpenRuntimeError(browser)
64+
65+
const stackFramesContent = await getStackFramesContent(browser)
66+
const source = await getRedboxSource(browser)
67+
68+
if (process.env.TURBOPACK) {
69+
expect(stackFramesContent).toMatchInlineSnapshot(`""`)
70+
expect(source).toMatchInlineSnapshot(`
71+
"app/rsc/page.tsx (5:5) @ Page
72+
73+
3 | export default function Page() {
74+
4 | return (
75+
> 5 | <div>
76+
| ^
77+
6 | {list.map((item, index) => (
78+
7 | <span>{item}</span>
79+
8 | ))}"
80+
`)
81+
} else {
82+
expect(stackFramesContent).toMatchInlineSnapshot(`""`)
83+
// FIXME: the methodName should be `@ Page` instead of `@ div`
84+
expect(source).toMatchInlineSnapshot(`
85+
"app/rsc/page.tsx (5:6) @ div
86+
87+
3 | export default function Page() {
88+
4 | return (
89+
> 5 | <div>
90+
| ^
91+
6 | {list.map((item, index) => (
92+
7 | <span>{item}</span>
93+
8 | ))}"
94+
`)
95+
}
96+
})
97+
98+
it('should catch invalid element from on ssr client component', async () => {
99+
const browser = await next.browser('/ssr')
100+
await waitForAndOpenRuntimeError(browser)
101+
102+
const stackFramesContent = await getStackFramesContent(browser)
103+
const source = await getRedboxSource(browser)
104+
if (process.env.TURBOPACK) {
105+
expect(stackFramesContent).toMatchInlineSnapshot(`""`)
106+
expect(source).toMatchInlineSnapshot(`
107+
"app/ssr/page.tsx (7:5) @ Page
108+
109+
5 | export default function Page() {
110+
6 | return (
111+
> 7 | <div>
112+
| ^
113+
8 | {list.map((item, index) => (
114+
9 | <p>{item}</p>
115+
10 | ))}"
116+
`)
117+
} else {
118+
expect(stackFramesContent).toMatchInlineSnapshot(`""`)
119+
// FIXME: the methodName should be `@ Page` instead of `@ div`
120+
expect(source).toMatchInlineSnapshot(`
121+
"app/ssr/page.tsx (7:6) @ div
122+
123+
5 | export default function Page() {
124+
6 | return (
125+
> 7 | <div>
126+
| ^
127+
8 | {list.map((item, index) => (
128+
9 | <p>{item}</p>
129+
10 | ))}"
130+
`)
131+
}
132+
})
133+
}
134+
)

0 commit comments

Comments
 (0)