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 ( +
+ {children} + + + + + +
+ ) +} + +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() } /**