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(