diff --git a/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/ComponentStackFrameRow.tsx b/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/ComponentStackFrameRow.tsx
index f711c441e9305..606930f438fb9 100644
--- a/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/ComponentStackFrameRow.tsx
+++ b/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/ComponentStackFrameRow.tsx
@@ -2,9 +2,11 @@ import React from 'react'
import type { ComponentStackFrame } from '../../helpers/parse-component-stack'
import { useOpenInEditor } from '../../helpers/use-open-in-editor'
-export function ComponentStackFrameRow({
- componentStackFrame: { component, file, lineNumber, column },
+function EditorLink({
+ children,
+ componentStackFrame: { file, column, lineNumber },
}: {
+ children: React.ReactNode
componentStackFrame: ComponentStackFrame
}) {
const open = useOpenInEditor({
@@ -13,34 +15,87 @@ export function ComponentStackFrameRow({
lineNumber,
})
+ return (
+
+ )
+}
+
+function formatLineNumber(lineNumber: number, column: number | undefined) {
+ if (!column) {
+ return lineNumber
+ }
+
+ return `${lineNumber}:${column}`
+}
+
+function LocationLine({
+ componentStackFrame,
+}: {
+ componentStackFrame: ComponentStackFrame
+}) {
+ const { file, lineNumber, column } = componentStackFrame
+ return (
+ <>
+ {file} {lineNumber ? `(${formatLineNumber(lineNumber, column)})` : ''}
+ >
+ )
+}
+
+function SourceLocation({
+ componentStackFrame,
+}: {
+ componentStackFrame: ComponentStackFrame
+}) {
+ const { file, canOpenInEditor } = componentStackFrame
+
+ if (file && canOpenInEditor) {
+ return (
+
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+ )
+}
+
+export function ComponentStackFrameRow({
+ componentStackFrame,
+}: {
+ componentStackFrame: ComponentStackFrame
+}) {
+ const { component } = componentStackFrame
+
return (
{component}
- {file ? (
-
-
- {file} ({lineNumber}:{column})
-
-
-
- ) : null}
+
)
}
diff --git a/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/index.tsx b/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/index.tsx
index bc348bbb4e7d4..4ab07c4ad6164 100644
--- a/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/index.tsx
+++ b/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/index.tsx
@@ -158,7 +158,7 @@ export const styles = css`
color: #999;
}
[data-nextjs-call-stack-frame] > div > svg,
- [data-nextjs-component-stack-frame] > div > svg {
+ [data-nextjs-component-stack-frame] > [role='link'] > svg {
width: auto;
height: var(--size-font-small);
margin-left: var(--size-gap);
@@ -168,15 +168,15 @@ export const styles = css`
}
[data-nextjs-call-stack-frame] > div[data-has-source],
- [data-nextjs-component-stack-frame] > div {
+ [data-nextjs-component-stack-frame] > [role='link'] {
cursor: pointer;
}
[data-nextjs-call-stack-frame] > div[data-has-source]:hover,
- [data-nextjs-component-stack-frame] > div:hover {
+ [data-nextjs-component-stack-frame] > [role='link']:hover {
text-decoration: underline dotted;
}
[data-nextjs-call-stack-frame] > div[data-has-source] > svg,
- [data-nextjs-component-stack-frame] > div > svg {
+ [data-nextjs-component-stack-frame] > [role='link'] > svg {
display: unset;
}
diff --git a/packages/next/src/client/components/react-dev-overlay/internal/helpers/parse-component-stack.ts b/packages/next/src/client/components/react-dev-overlay/internal/helpers/parse-component-stack.ts
index 938a8abe14988..c7d2d843a355f 100644
--- a/packages/next/src/client/components/react-dev-overlay/internal/helpers/parse-component-stack.ts
+++ b/packages/next/src/client/components/react-dev-overlay/internal/helpers/parse-component-stack.ts
@@ -1,38 +1,100 @@
export type ComponentStackFrame = {
+ canOpenInEditor: boolean
component: string
file?: string
lineNumber?: number
column?: number
}
+enum LocationType {
+ FILE = 'file',
+ WEBPACK_INTERNAL = 'webpack-internal',
+ HTTP = 'http',
+ PROTOCOL_RELATIVE = 'protocol-relative',
+ UNKNOWN = 'unknown',
+}
+
+/**
+ * Get the type of frame line based on the location
+ */
+function getLocationType(location: string): LocationType {
+ if (location.startsWith('file://')) {
+ return LocationType.FILE
+ }
+ if (location.startsWith('webpack-internal://')) {
+ return LocationType.WEBPACK_INTERNAL
+ }
+ if (location.startsWith('http://') || location.startsWith('https://')) {
+ return LocationType.HTTP
+ }
+ if (location.startsWith('//')) {
+ return LocationType.PROTOCOL_RELATIVE
+ }
+ return LocationType.UNKNOWN
+}
+
+function parseStackFrameLocation(
+ location: string
+): Omit {
+ const locationType = getLocationType(location)
+
+ const modulePath = location?.replace(
+ /^(webpack-internal:\/\/\/|file:\/\/)(\(.*\)\/)?/,
+ ''
+ )
+ const [, file, lineNumber, column] =
+ modulePath?.match(/^(.+):(\d+):(\d+)/) ?? []
+
+ switch (locationType) {
+ case LocationType.FILE:
+ case LocationType.WEBPACK_INTERNAL:
+ return {
+ canOpenInEditor: true,
+ file,
+ lineNumber: lineNumber ? Number(lineNumber) : undefined,
+ column: column ? Number(column) : undefined,
+ }
+ // When the location is a URL we only show the file
+ // TODO: Resolve http(s) URLs through sourcemaps
+ case LocationType.HTTP:
+ case LocationType.PROTOCOL_RELATIVE:
+ case LocationType.UNKNOWN:
+ default: {
+ return {
+ canOpenInEditor: false,
+ }
+ }
+ }
+}
+
export function parseComponentStack(
componentStack: string
): ComponentStackFrame[] {
const componentStackFrames: ComponentStackFrame[] = []
-
for (const line of componentStack.trim().split('\n')) {
// Get component and file from the component stack line
const match = /at ([^ ]+)( \((.*)\))?/.exec(line)
if (match?.[1]) {
const component = match[1]
- const webpackFile = match[3]
+ const location = match[3]
+
+ if (!location) {
+ componentStackFrames.push({
+ canOpenInEditor: false,
+ component,
+ })
+ continue
+ }
// Stop parsing the component stack if we reach a Next.js component
- if (webpackFile?.includes('next/dist')) {
+ if (location?.includes('next/dist')) {
break
}
- const modulePath = webpackFile?.replace(
- /^(webpack-internal:\/\/\/|file:\/\/)(\(.*\)\/)?/,
- ''
- )
- const [file, lineNumber, column] = modulePath?.split(':', 3) ?? []
-
+ const frameLocation = parseStackFrameLocation(location)
componentStackFrames.push({
component,
- file,
- lineNumber: lineNumber ? Number(lineNumber) : undefined,
- column: column ? Number(column) : undefined,
+ ...frameLocation,
})
}
}
diff --git a/test/development/acceptance-app/component-stack.test.ts b/test/development/acceptance-app/component-stack.test.ts
index 21c7b39d52950..388a7432288d1 100644
--- a/test/development/acceptance-app/component-stack.test.ts
+++ b/test/development/acceptance-app/component-stack.test.ts
@@ -2,11 +2,10 @@
import { sandbox } from 'development-sandbox'
import { FileRef, nextTestSetup } from 'e2e-utils'
import path from 'path'
-import { outdent } from 'outdent'
describe('Component Stack in error overlay', () => {
const { next } = nextTestSetup({
- files: new FileRef(path.join(__dirname, 'fixtures', 'default-template')),
+ files: new FileRef(path.join(__dirname, 'fixtures', 'component-stack')),
dependencies: {
react: 'latest',
'react-dom': 'latest',
@@ -15,48 +14,52 @@ describe('Component Stack in error overlay', () => {
})
it('should show a component stack on hydration error', async () => {
- const { cleanup, session } = await sandbox(
- next,
- new Map([
- [
- 'app/component.js',
- outdent`
- 'use client'
- const isClient = typeof window !== 'undefined'
- export default function Component() {
- return (
-
-
{isClient ? "client" : "server"}
-
- );
- }
- `,
- ],
- [
- 'app/page.js',
- outdent`
- import Component from './component'
- export default function Mismatch() {
- return (
-
-
-
- );
- }
- `,
- ],
- ])
- )
+ const { cleanup, session } = await sandbox(next)
await session.waitForAndOpenRuntimeError()
- const expected = `p
-div
-Component
-main`
- expect((await session.getRedboxComponentStack()).trim()).toStartWith(
- expected
- )
+ if (process.env.TURBOPACK) {
+ expect(await session.getRedboxComponentStack()).toMatchInlineSnapshot(`
+ "p
+ div
+ Component
+ main
+ InnerLayoutRouter
+ RedirectErrorBoundary
+ RedirectBoundary
+ NotFoundErrorBoundary
+ NotFoundBoundary
+ LoadingBoundary
+ ErrorBoundary
+ InnerScrollAndFocusHandler
+ ScrollAndFocusHandler
+ RenderFromTemplateContext
+ OuterLayoutRouter
+ body
+ html
+ RedirectErrorBoundary
+ RedirectBoundary
+ NotFoundErrorBoundary
+ NotFoundBoundary
+ DevRootNotFoundBoundary
+ ReactDevOverlay
+ HotReload
+ Router
+ ErrorBoundaryHandler
+ ErrorBoundary
+ AppRouter
+ ServerRoot
+ RSCComponent
+ Root"
+ `)
+ } else {
+ expect(await session.getRedboxComponentStack()).toMatchInlineSnapshot(`
+ "p
+ div
+ Component
+ main"
+ `)
+ }
await cleanup()
})
diff --git a/test/development/acceptance-app/fixtures/component-stack/app/component.js b/test/development/acceptance-app/fixtures/component-stack/app/component.js
new file mode 100644
index 0000000000000..51c8e53cf0f56
--- /dev/null
+++ b/test/development/acceptance-app/fixtures/component-stack/app/component.js
@@ -0,0 +1,9 @@
+'use client'
+const isClient = typeof window !== 'undefined'
+export default function Component() {
+ return (
+
+
{isClient ? 'client' : 'server'}
+
+ )
+}
diff --git a/test/development/acceptance-app/fixtures/component-stack/app/layout.js b/test/development/acceptance-app/fixtures/component-stack/app/layout.js
new file mode 100644
index 0000000000000..747270b45987a
--- /dev/null
+++ b/test/development/acceptance-app/fixtures/component-stack/app/layout.js
@@ -0,0 +1,8 @@
+export default function RootLayout({ children }) {
+ return (
+
+
+ {children}
+
+ )
+}
diff --git a/test/development/acceptance-app/fixtures/component-stack/app/page.js b/test/development/acceptance-app/fixtures/component-stack/app/page.js
new file mode 100644
index 0000000000000..74edbac739834
--- /dev/null
+++ b/test/development/acceptance-app/fixtures/component-stack/app/page.js
@@ -0,0 +1,8 @@
+import Component from './component'
+export default function Mismatch() {
+ return (
+
+
+
+ )
+}
diff --git a/test/development/acceptance/component-stack.test.ts b/test/development/acceptance/component-stack.test.ts
index f44db96c6b0e2..e588b26c1435f 100644
--- a/test/development/acceptance/component-stack.test.ts
+++ b/test/development/acceptance/component-stack.test.ts
@@ -14,14 +14,30 @@ createNextDescribe(
expect(await hasRedbox(browser)).toBe(true)
- const expected = `p
-div
-Component
-main
-Mismatch`
- expect((await getRedboxComponentStack(browser)).trim()).toStartWith(
- expected
- )
+ if (process.env.TURBOPACK) {
+ expect(await getRedboxComponentStack(browser)).toMatchInlineSnapshot(`
+ "p
+ div
+ Component
+ main
+ Mismatch
+ App
+ PathnameContextProviderAdapter
+ ErrorBoundary
+ ReactDevOverlay
+ Container
+ AppContainer
+ Root"
+ `)
+ } else {
+ expect(await getRedboxComponentStack(browser)).toMatchInlineSnapshot(`
+ "p
+ div
+ Component
+ main
+ Mismatch"
+ `)
+ }
})
}
)
diff --git a/test/lib/next-test-utils.ts b/test/lib/next-test-utils.ts
index 32460a90dbbb2..9c9114e993dcf 100644
--- a/test/lib/next-test-utils.ts
+++ b/test/lib/next-test-utils.ts
@@ -1028,7 +1028,7 @@ export async function getRedboxComponentStack(
componentStackFrameElements.map((f) => f.innerText())
)
- return componentStackFrameTexts.join('\n')
+ return componentStackFrameTexts.join('\n').trim()
}
/**