diff --git a/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/CallStackFrame.tsx b/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/CallStackFrame.tsx
new file mode 100644
index 00000000000000..96c79102f2e6c3
--- /dev/null
+++ b/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/CallStackFrame.tsx
@@ -0,0 +1,69 @@
+import React from 'react'
+import type { StackFrame } from 'next/dist/compiled/stacktrace-parser'
+import {
+ getFrameSource,
+ type OriginalStackFrame,
+} from '../../helpers/stack-frame'
+
+export const CallStackFrame: React.FC<{ frame: OriginalStackFrame }> =
+ function CallStackFrame({ frame }) {
+ // TODO: ability to expand resolved frames
+ // TODO: render error or external indicator
+
+ const f: StackFrame = frame.originalStackFrame ?? frame.sourceStackFrame
+ const hasSource = Boolean(frame.originalCodeFrame)
+
+ const open = React.useCallback(() => {
+ if (!hasSource) return
+
+ const params = new URLSearchParams()
+ for (const key in f) {
+ params.append(key, ((f as any)[key] ?? '').toString())
+ }
+
+ self
+ .fetch(
+ `${
+ process.env.__NEXT_ROUTER_BASEPATH || ''
+ }/__nextjs_launch-editor?${params.toString()}`
+ )
+ .then(
+ () => {},
+ () => {
+ console.error(
+ 'There was an issue opening this code in your editor.'
+ )
+ }
+ )
+ }, [hasSource, f])
+
+ return (
+
+
+ {f.methodName}
+
+
+
{getFrameSource(f)}
+
+
+
+ )
+ }
diff --git a/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/FrameworkIcon.tsx b/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/FrameworkIcon.tsx
new file mode 100644
index 00000000000000..bbdabf365a11df
--- /dev/null
+++ b/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/FrameworkIcon.tsx
@@ -0,0 +1,102 @@
+import React from 'react'
+import type { StackFramesGroup } from '../../helpers/group-stack-frames-by-framework'
+
+export function FrameworkIcon({
+ framework,
+}: {
+ framework: NonNullable
+}) {
+ if (framework === 'react') {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/GroupedStackFrames.tsx b/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/GroupedStackFrames.tsx
new file mode 100644
index 00000000000000..1bd38c1a6dfc93
--- /dev/null
+++ b/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/GroupedStackFrames.tsx
@@ -0,0 +1,86 @@
+import React from 'react'
+import type { StackFramesGroup } from '../../helpers/group-stack-frames-by-framework'
+import { CallStackFrame } from './CallStackFrame'
+import { FrameworkIcon } from './FrameworkIcon'
+
+function FrameworkGroup({
+ framework,
+ stackFrames,
+ all,
+}: {
+ framework: NonNullable
+ stackFrames: StackFramesGroup['stackFrames']
+ all: boolean
+}) {
+ const [open, setOpen] = React.useState(false)
+ const toggleOpen = React.useCallback(() => setOpen((v) => !v), [])
+
+ return (
+ <>
+
+ {open
+ ? stackFrames.map((frame, index) => (
+
+ ))
+ : null}
+ >
+ )
+}
+
+export function GroupedStackFrames({
+ groupedStackFrames,
+ all,
+}: {
+ groupedStackFrames: StackFramesGroup[]
+ all: boolean
+}) {
+ return (
+ <>
+ {groupedStackFrames.map((stackFramesGroup, groupIndex) => {
+ // Collapse React and Next.js frames
+ if (stackFramesGroup.framework) {
+ return (
+
+ )
+ }
+
+ return (
+ // Don't group non React and Next.js frames
+ stackFramesGroup.stackFrames.map((frame, frameIndex) => (
+
+ ))
+ )
+ })}
+ >
+ )
+}
diff --git a/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError.tsx b/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/index.tsx
similarity index 63%
rename from packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError.tsx
rename to packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/index.tsx
index 0e6bd72ed374a4..5cf93b575233c5 100644
--- a/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError.tsx
+++ b/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/index.tsx
@@ -1,74 +1,14 @@
import * as React from 'react'
-import { StackFrame } from 'next/dist/compiled/stacktrace-parser'
-import { CodeFrame } from '../components/CodeFrame'
-import { ReadyRuntimeError } from '../helpers/getErrorByType'
-import { noop as css } from '../helpers/noop-template'
-import { getFrameSource, OriginalStackFrame } from '../helpers/stack-frame'
+import { CodeFrame } from '../../components/CodeFrame'
+import { ReadyRuntimeError } from '../../helpers/getErrorByType'
+import { noop as css } from '../../helpers/noop-template'
+import { OriginalStackFrame } from '../../helpers/stack-frame'
+import { groupStackFramesByFramework } from '../../helpers/group-stack-frames-by-framework'
+import { CallStackFrame } from './CallStackFrame'
+import { GroupedStackFrames } from './GroupedStackFrames'
export type RuntimeErrorProps = { error: ReadyRuntimeError }
-const CallStackFrame: React.FC<{
- frame: OriginalStackFrame
-}> = function CallStackFrame({ frame }) {
- // TODO: ability to expand resolved frames
- // TODO: render error or external indicator
-
- const f: StackFrame = frame.originalStackFrame ?? frame.sourceStackFrame
- const hasSource = Boolean(frame.originalCodeFrame)
-
- const open = React.useCallback(() => {
- if (!hasSource) return
-
- const params = new URLSearchParams()
- for (const key in f) {
- params.append(key, ((f as any)[key] ?? '').toString())
- }
-
- self
- .fetch(
- `${
- process.env.__NEXT_ROUTER_BASEPATH || ''
- }/__nextjs_launch-editor?${params.toString()}`
- )
- .then(
- () => {},
- () => {
- console.error('There was an issue opening this code in your editor.')
- }
- )
- }, [hasSource, f])
-
- return (
-
-
- {f.methodName}
-
-
-
{getFrameSource(f)}
-
-
-
- )
-}
-
const RuntimeError: React.FC = function RuntimeError({
error,
}) {
@@ -122,6 +62,11 @@ const RuntimeError: React.FC = function RuntimeError({
visibleCallStackFrames.length,
])
+ const stackFramesGroupedByFramework = React.useMemo(
+ () => groupStackFramesByFramework(visibleCallStackFrames),
+ [visibleCallStackFrames]
+ )
+
return (
{firstFrame ? (
@@ -139,12 +84,13 @@ const RuntimeError: React.FC = function RuntimeError({
/>
) : undefined}
- {visibleCallStackFrames.length ? (
+ {stackFramesGroupedByFramework.length ? (
Call Stack
- {visibleCallStackFrames.map((frame, index) => (
-
- ))}
+
) : undefined}
{canShowMore ? (
@@ -210,6 +156,28 @@ export const styles = css`
[data-nextjs-call-stack-frame] > div[data-has-source] > svg {
display: unset;
}
+
+ [data-nextjs-call-stack-framework-button] {
+ border: none;
+ background: none;
+ display: flex;
+ align-items: center;
+ padding: 0;
+ margin: var(--size-gap-double) 0;
+ }
+ [data-nextjs-call-stack-framework-icon] {
+ margin-right: var(--size-gap);
+ }
+ [data-nextjs-call-stack-framework-icon='next'] > mask {
+ mask-type: alpha;
+ }
+ [data-nextjs-call-stack-framework-icon='react'] {
+ color: rgb(20, 158, 202);
+ }
+ [data-nextjs-call-stack-framework-button][data-state='open']
+ > [data-nextjs-call-stack-chevron-icon] {
+ transform: rotate(90deg);
+ }
`
export { RuntimeError }
diff --git a/packages/next/src/client/components/react-dev-overlay/internal/helpers/group-stack-frames-by-framework.ts b/packages/next/src/client/components/react-dev-overlay/internal/helpers/group-stack-frames-by-framework.ts
new file mode 100644
index 00000000000000..8d689617a54822
--- /dev/null
+++ b/packages/next/src/client/components/react-dev-overlay/internal/helpers/group-stack-frames-by-framework.ts
@@ -0,0 +1,71 @@
+import type { OriginalStackFrame } from './stack-frame'
+
+export type StackFramesGroup = {
+ framework?: 'next' | 'react'
+ stackFrames: OriginalStackFrame[]
+}
+
+/**
+ * Get the origin framework of the stack frame by package name.
+ */
+function getFramework(
+ sourcePackage: string | undefined
+): StackFramesGroup['framework'] {
+ if (!sourcePackage) return undefined
+
+ if (
+ /^(react|react-dom|react-is|react-refresh|react-server-dom-webpack|scheduler)$/.test(
+ sourcePackage
+ )
+ ) {
+ return 'react'
+ } else if (sourcePackage === 'next') {
+ return 'next'
+ }
+
+ return undefined
+}
+
+/**
+ * Group sequences of stack frames by framework.
+ *
+ * Given the following stack frames:
+ * Error
+ * user code
+ * user code
+ * react
+ * react
+ * next
+ * next
+ * react
+ * react
+ *
+ * The grouped stack frames would be:
+ * > user code
+ * > react
+ * > next
+ * > react
+ *
+ */
+export function groupStackFramesByFramework(
+ stackFrames: OriginalStackFrame[]
+): StackFramesGroup[] {
+ const stackFramesGroupedByFramework: StackFramesGroup[] = []
+
+ for (const stackFrame of stackFrames) {
+ const currentGroup =
+ stackFramesGroupedByFramework[stackFramesGroupedByFramework.length - 1]
+ const framework = getFramework(stackFrame.sourcePackage)
+
+ if (currentGroup && currentGroup.framework === framework) {
+ currentGroup.stackFrames.push(stackFrame)
+ } else {
+ stackFramesGroupedByFramework.push({
+ framework: framework,
+ stackFrames: [stackFrame],
+ })
+ }
+ }
+
+ return stackFramesGroupedByFramework
+}
diff --git a/packages/next/src/client/components/react-dev-overlay/internal/helpers/stack-frame.ts b/packages/next/src/client/components/react-dev-overlay/internal/helpers/stack-frame.ts
index b26cc1642c1cc1..c02e961aeeca64 100644
--- a/packages/next/src/client/components/react-dev-overlay/internal/helpers/stack-frame.ts
+++ b/packages/next/src/client/components/react-dev-overlay/internal/helpers/stack-frame.ts
@@ -10,6 +10,7 @@ export type OriginalStackFrame =
sourceStackFrame: StackFrame
originalStackFrame: null
originalCodeFrame: null
+ sourcePackage?: string
}
| {
error: false
@@ -19,6 +20,7 @@ export type OriginalStackFrame =
sourceStackFrame: StackFrame
originalStackFrame: StackFrame
originalCodeFrame: string | null
+ sourcePackage?: string
}
| {
error: false
@@ -28,6 +30,7 @@ export type OriginalStackFrame =
sourceStackFrame: StackFrame
originalStackFrame: null
originalCodeFrame: null
+ sourcePackage?: string
}
export function getOriginalStackFrame(
@@ -77,6 +80,7 @@ export function getOriginalStackFrame(
sourceStackFrame: source,
originalStackFrame: body.originalStackFrame,
originalCodeFrame: body.originalCodeFrame || null,
+ sourcePackage: body.sourcePackage,
}
}
diff --git a/packages/react-dev-overlay/src/middleware.ts b/packages/react-dev-overlay/src/middleware.ts
index 86b8f3c38fc490..2586015e4aba1e 100644
--- a/packages/react-dev-overlay/src/middleware.ts
+++ b/packages/react-dev-overlay/src/middleware.ts
@@ -29,6 +29,7 @@ export type OverlayMiddlewareOptions = {
export type OriginalStackFrameResponse = {
originalStackFrame: StackFrame
originalCodeFrame: string | null
+ sourcePackage?: string
}
type Source = { map: () => RawSourceMap } | null
@@ -121,10 +122,22 @@ function findOriginalSourcePositionAndContentFromCompilation(
return module?.buildInfo?.importLocByPath?.get(importedModule) ?? null
}
+function findCallStackFramePackage(
+ id: string,
+ compilation?: webpack.Compilation
+): string | undefined {
+ if (!compilation) {
+ return undefined
+ }
+ const module = getModuleById(id, compilation)
+ return (module as any)?.resourceResolveData?.descriptionFileData?.name
+}
+
export async function createOriginalStackFrame({
line,
column,
source,
+ sourcePackage,
moduleId,
modulePath,
rootDirectory,
@@ -137,6 +150,7 @@ export async function createOriginalStackFrame({
line: number
column: number | null
source: any
+ sourcePackage?: string
moduleId?: string
modulePath?: string
rootDirectory: string
@@ -241,6 +255,7 @@ export async function createOriginalStackFrame({
return {
originalStackFrame: originalFrame,
originalCodeFrame,
+ sourcePackage,
}
}
@@ -325,6 +340,7 @@ function getOverlayMiddleware(options: OverlayMiddlewareOptions) {
)
let source: Source = null
+ let sourcePackage: string | undefined = undefined
const clientCompilation = options.stats()?.compilation
const serverCompilation = options.serverStats()?.compilation
const edgeCompilation = options.edgeServerStats()?.compilation
@@ -338,6 +354,7 @@ function getOverlayMiddleware(options: OverlayMiddlewareOptions) {
moduleId,
clientCompilation
)
+ sourcePackage = findCallStackFramePackage(moduleId, clientCompilation)
}
// Try Server Compilation
// In `pages` this could be something imported in getServerSideProps/getStaticProps as the code for those is tree-shaken.
@@ -348,6 +365,7 @@ function getOverlayMiddleware(options: OverlayMiddlewareOptions) {
moduleId,
serverCompilation
)
+ sourcePackage = findCallStackFramePackage(moduleId, serverCompilation)
}
// Try Edge Server Compilation
// Both cases are the same as Server Compilation, main difference is that it covers `runtime: 'edge'` pages/app routes.
@@ -357,6 +375,7 @@ function getOverlayMiddleware(options: OverlayMiddlewareOptions) {
moduleId,
edgeCompilation
)
+ sourcePackage = findCallStackFramePackage(moduleId, edgeCompilation)
}
} catch (err) {
console.log('Failed to get source map:', err)
@@ -385,6 +404,7 @@ function getOverlayMiddleware(options: OverlayMiddlewareOptions) {
line: frameLine,
column: frameColumn,
source,
+ sourcePackage,
frame,
moduleId,
modulePath,
diff --git a/test/development/acceptance-app/ReactRefreshLogBox.test.ts b/test/development/acceptance-app/ReactRefreshLogBox.test.ts
index e00e32ab27b852..7005557d976683 100644
--- a/test/development/acceptance-app/ReactRefreshLogBox.test.ts
+++ b/test/development/acceptance-app/ReactRefreshLogBox.test.ts
@@ -1232,73 +1232,67 @@ describe('ReactRefreshLogBox app', () => {
await cleanup()
})
- test('Call stack count is correct for server error', async () => {
- const { session, browser, cleanup } = await sandbox(
- next,
- new Map([
- [
- 'app/page.js',
- `
- export default function Page() {
- throw new Error('Server error')
+ test.each([['server'], ['client']])(
+ 'Call stack count is correct for %s error',
+ async (pageType: string) => {
+ const fixture =
+ pageType === 'server'
+ ? new Map([
+ [
+ 'app/page.js',
+ `
+ export default function Page() {
+ throw new Error('Server error')
+ }
+`,
+ ],
+ ])
+ : new Map([
+ [
+ 'app/page.js',
+ `
+ 'use client'
+ export default function Page() {
+ if (typeof window !== 'undefined') {
+ throw new Error('Client error')
}
+ return null
+ }
`,
- ],
- ])
- )
+ ],
+ ])
- expect(await session.hasRedbox(true)).toBe(true)
+ const { session, browser, cleanup } = await sandbox(next, fixture)
- // Open full Call Stack
- await browser
- .elementByCss('[data-nextjs-data-runtime-error-collapsed-action]')
- .click()
- const callStackCount = (
- await browser.elementsByCss('[data-nextjs-call-stack-frame]')
- ).length
+ const getCallStackCount = async () =>
+ (await browser.elementsByCss('[data-nextjs-call-stack-frame]')).length
- // Expect more than the default amount of frames
- // The default stackTraceLimit results in max 9 [data-nextjs-call-stack-frame] elements
- expect(callStackCount).toBeGreaterThan(9)
+ expect(await session.hasRedbox(true)).toBe(true)
- await cleanup()
- })
+ // Open full Call Stack
+ await browser
+ .elementByCss('[data-nextjs-data-runtime-error-collapsed-action]')
+ .click()
- test('Call stack count is correct for client error', async () => {
- const { session, browser, cleanup } = await sandbox(
- next,
- new Map([
- [
- 'app/page.js',
- `
- 'use client'
- export default function Page() {
- if (typeof window !== 'undefined') {
- throw new Error('Client error')
- }
- return null
- }
-`,
- ],
- ])
- )
-
- expect(await session.hasRedbox(true)).toBe(true)
-
- // Open full Call Stack
- await browser
- .elementByCss('[data-nextjs-data-runtime-error-collapsed-action]')
- .click()
- const callStackCount = (
- await browser.elementsByCss('[data-nextjs-call-stack-frame]')
- ).length
+ const collapsedFrameworkGroups = await browser.elementsByCss(
+ "[data-nextjs-call-stack-framework-button][data-state='closed']"
+ )
+ for (const collapsedFrameworkButton of collapsedFrameworkGroups) {
+ // Open the collapsed framework groups, the callstack count should increase with each opened group
+ const callStackCountBeforeGroupOpened = await getCallStackCount()
+ await collapsedFrameworkButton.click()
+ expect(await getCallStackCount()).toBeGreaterThan(
+ callStackCountBeforeGroupOpened
+ )
+ }
- // Expect more than the default amount of frames
- // The default stackTraceLimit results in max 9 [data-nextjs-call-stack-frame] elements
- expect(callStackCount).toBeGreaterThan(9)
+ // Expect more than the default amount of frames
+ // The default stackTraceLimit results in max 9 [data-nextjs-call-stack-frame] elements
+ expect(await getCallStackCount()).toBeGreaterThan(9)
- await cleanup()
- })
+ await cleanup()
+ }
+ )
test('Server component errors should open up in fullscreen', async () => {
const { session, browser, cleanup } = await sandbox(