diff --git a/packages/next/src/next-devtools/dev-overlay/components/devtools-panel/devtools-panel-tab/devtools-panel-tab.tsx b/packages/next/src/next-devtools/dev-overlay/components/devtools-panel/devtools-panel-tab/devtools-panel-tab.tsx index 580c8f67abb..ff316ee9a8a 100644 --- a/packages/next/src/next-devtools/dev-overlay/components/devtools-panel/devtools-panel-tab/devtools-panel-tab.tsx +++ b/packages/next/src/next-devtools/dev-overlay/components/devtools-panel/devtools-panel-tab/devtools-panel-tab.tsx @@ -1,20 +1,32 @@ import type { DevToolsPanelTabType } from '../devtools-panel' -import type { Corners } from '../../../shared' +import type { Corners, OverlayState } from '../../../shared' +import type { DebugInfo } from '../../../../shared/types' +import type { ReadyRuntimeError } from '../../../utils/get-error-by-type' +import type { HydrationErrorState } from '../../../../shared/hydration-error' import { SettingsTab } from './settings-tab' +import { IssuesTab } from './issues-tab/issues-tab' export function DevToolsPanelTab({ activeTab, + runtimeErrors, devToolsPosition, scale, + debugInfo, + buildError, handlePositionChange, handleScaleChange, + getSquashedHydrationErrorDetails, }: { activeTab: DevToolsPanelTabType + runtimeErrors: ReadyRuntimeError[] devToolsPosition: Corners scale: number + debugInfo: DebugInfo + buildError: OverlayState['buildError'] handlePositionChange: (e: React.ChangeEvent) => void handleScaleChange: (e: React.ChangeEvent) => void + getSquashedHydrationErrorDetails: (error: Error) => HydrationErrorState | null }) { switch (activeTab) { case 'settings': @@ -29,7 +41,14 @@ export function DevToolsPanelTab({ case 'route': return
Route
case 'issues': - return
Issues
+ return ( + + ) default: return null } diff --git a/packages/next/src/next-devtools/dev-overlay/components/devtools-panel/devtools-panel-tab/issues-tab/issues-tab-content.tsx b/packages/next/src/next-devtools/dev-overlay/components/devtools-panel/devtools-panel-tab/issues-tab/issues-tab-content.tsx new file mode 100644 index 00000000000..151d1259e2b --- /dev/null +++ b/packages/next/src/next-devtools/dev-overlay/components/devtools-panel/devtools-panel-tab/issues-tab/issues-tab-content.tsx @@ -0,0 +1,120 @@ +import type { ReadyRuntimeError } from '../../../../utils/get-error-by-type' +import type { OverlayState } from '../../../../shared' + +import { Suspense, useMemo, useState } from 'react' + +import { Terminal } from '../../../terminal' +import { HotlinkedText } from '../../../hot-linked-text' +import { PseudoHtmlDiff } from '../../../../container/runtime-error/component-stack-pseudo-html' +import { useFrames } from '../../../../utils/get-error-by-type' +import { CodeFrame } from '../../../code-frame/code-frame' +import { CallStack } from '../../../call-stack/call-stack' +import { NEXTJS_HYDRATION_ERROR_LINK } from '../../../../../shared/react-19-hydration-error' +import { css } from '../../../../utils/css' + +export function IssuesTabContent({ + notes, + buildError, + hydrationWarning, + errorDetails, + activeError, +}: { + notes: string | null + buildError: OverlayState['buildError'] + hydrationWarning: string | null + errorDetails: { + hydrationWarning: string | null + notes: string | null + reactOutputComponentDiff: string | null + } + activeError: ReadyRuntimeError +}) { + if (buildError) { + return + } + + return ( + <> +
+ {notes ? ( + <> +

+ {notes} +

+ + ) : null} + {hydrationWarning ? ( + + ) : null} +
+ {errorDetails.reactOutputComponentDiff ? ( + + ) : null} + {/* TODO: Loading state */} + }> + + + + ) +} + +/* Ported the content from container/runtime-error/index.tsx */ +function RuntimeError({ error }: { error: ReadyRuntimeError }) { + const [isIgnoreListOpen, setIsIgnoreListOpen] = useState(false) + const frames = useFrames(error) + + const ignoredFramesTally = useMemo(() => { + return frames.reduce((tally, frame) => tally + (frame.ignored ? 1 : 0), 0) + }, [frames]) + + const firstFrame = useMemo(() => { + const firstFirstPartyFrameIndex = frames.findIndex( + (entry) => + !entry.ignored && + Boolean(entry.originalCodeFrame) && + Boolean(entry.originalStackFrame) + ) + + return frames[firstFirstPartyFrameIndex] ?? null + }, [frames]) + + return ( + <> + {firstFrame && ( + + )} + + {frames.length > 0 && ( + setIsIgnoreListOpen(!isIgnoreListOpen)} + ignoredFramesTally={ignoredFramesTally} + /> + )} + + ) +} + +/* Shares the style with container/errors.tsx and errors/dialog/header.tsx */ +export const DEVTOOLS_PANEL_TAB_ISSUES_CONTENT_STYLES = css` + [data-nextjs-devtools-panel-tab-issues-content] { + width: 100%; + padding: 14px; + } +` diff --git a/packages/next/src/next-devtools/dev-overlay/components/devtools-panel/devtools-panel-tab/issues-tab/issues-tab-sidebar.tsx b/packages/next/src/next-devtools/dev-overlay/components/devtools-panel/devtools-panel-tab/issues-tab/issues-tab-sidebar.tsx new file mode 100644 index 00000000000..1b30b2ce393 --- /dev/null +++ b/packages/next/src/next-devtools/dev-overlay/components/devtools-panel/devtools-panel-tab/issues-tab/issues-tab-sidebar.tsx @@ -0,0 +1,148 @@ +import type { ReadyRuntimeError } from '../../../../utils/get-error-by-type' + +import { Suspense, useMemo } from 'react' + +import { css } from '../../../../utils/css' +import { getFrameSource } from '../../../../../shared/stack-frame' +import { useFrames } from '../../../../utils/get-error-by-type' + +export function IssuesTabSidebar({ + runtimeErrors, + errorType, + activeIdx, + setActiveIndex, +}: { + runtimeErrors: ReadyRuntimeError[] + errorType: string | null + activeIdx: number + setActiveIndex: (idx: number) => void +}) { + return ( + + ) +} + +function IssuesTabSidebarFrame({ + runtimeError, + errorType, + idx, + activeIdx, + setActiveIndex, +}: { + runtimeError: ReadyRuntimeError + errorType: string | null + idx: number + activeIdx: number + setActiveIndex: (idx: number) => void +}) { + const frames = useFrames(runtimeError) + + const firstFrame = useMemo(() => { + const firstFirstPartyFrameIndex = frames.findIndex( + (entry) => + !entry.ignored && + Boolean(entry.originalCodeFrame) && + Boolean(entry.originalStackFrame) + ) + + return frames[firstFirstPartyFrameIndex] ?? null + }, [frames]) + + const frameSource = getFrameSource(firstFrame.originalStackFrame!) + return ( + + ) +} + +export const DEVTOOLS_PANEL_TAB_ISSUES_SIDEBAR_STYLES = css` + [data-nextjs-devtools-panel-tab-issues-sidebar] { + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px; + border-right: 1px solid var(--color-gray-400); + + min-width: 128px; + + @media (min-width: 576px) { + max-width: 138px; + width: 100%; + } + + @media (min-width: 768px) { + max-width: 172.5px; + width: 100%; + } + + @media (min-width: 992px) { + max-width: 230px; + width: 100%; + } + } + + [data-nextjs-devtools-panel-tab-issues-sidebar-frame] { + display: flex; + flex-direction: column; + padding: 10px 8px; + border-radius: var(--rounded-lg); + transition: background-color 0.2s ease-in-out; + + &:hover { + background-color: var(--color-gray-200); + } + + &:active { + background-color: var(--color-gray-300); + } + } + + [data-nextjs-devtools-panel-tab-issues-sidebar-frame-active='true'] { + background-color: var(--color-gray-100); + } + + [data-nextjs-devtools-panel-tab-issues-sidebar-frame-error-type] { + display: inline-block; + align-self: flex-start; + color: var(--color-gray-1000); + font-size: var(--size-14); + font-weight: 500; + line-height: var(--size-20); + } + + [data-nextjs-devtools-panel-tab-issues-sidebar-frame-source] { + display: inline-block; + align-self: flex-start; + color: var(--color-gray-900); + font-size: var(--size-13); + line-height: var(--size-18); + } +` diff --git a/packages/next/src/next-devtools/dev-overlay/components/devtools-panel/devtools-panel-tab/issues-tab/issues-tab.tsx b/packages/next/src/next-devtools/dev-overlay/components/devtools-panel/devtools-panel-tab/issues-tab/issues-tab.tsx new file mode 100644 index 00000000000..c39c30aae6b --- /dev/null +++ b/packages/next/src/next-devtools/dev-overlay/components/devtools-panel/devtools-panel-tab/issues-tab/issues-tab.tsx @@ -0,0 +1,104 @@ +import type { OverlayState } from '../../../../shared' +import type { DebugInfo } from '../../../../../shared/types' +import type { ReadyRuntimeError } from '../../../../utils/get-error-by-type' +import type { HydrationErrorState } from '../../../../../shared/hydration-error' + +import { IssuesTabContent } from './issues-tab-content' +import { IssuesTabSidebar } from './issues-tab-sidebar' +import { + GenericErrorDescription, + HydrationErrorDescription, +} from '../../../../container/errors' +import { EnvironmentNameLabel } from '../../../errors/environment-name-label/environment-name-label' +import { ErrorMessage } from '../../../errors/error-message/error-message' +import { ErrorOverlayToolbar } from '../../../errors/error-overlay-toolbar/error-overlay-toolbar' +import { ErrorTypeLabel } from '../../../errors/error-type-label/error-type-label' +import { css } from '../../../../utils/css' +import { useActiveRuntimeError } from '../../../../hooks/use-active-runtime-error' + +export function IssuesTab({ + debugInfo, + buildError, + runtimeErrors, + getSquashedHydrationErrorDetails, +}: { + debugInfo: DebugInfo + buildError: OverlayState['buildError'] + runtimeErrors: ReadyRuntimeError[] + getSquashedHydrationErrorDetails: (error: Error) => HydrationErrorState | null +}) { + const { + isLoading, + errorCode, + errorType, + notes, + hydrationWarning, + activeIdx, + errorDetails, + activeError, + setActiveIndex, + } = useActiveRuntimeError({ runtimeErrors, getSquashedHydrationErrorDetails }) + + if (isLoading) { + // TODO: better loading state + return null + } + + if (!activeError) { + return null + } + + const errorMessage = hydrationWarning ? ( + + ) : ( + + ) + + return ( +
+ +
+
+
+ + + {activeError.error.environmentName && ( + + )} + + +
+ +
+ + +
+
+ ) +} + +export const DEVTOOLS_PANEL_TAB_ISSUES_STYLES = css` + [data-nextjs-devtools-panel-tab-issues] { + display: flex; + } +` diff --git a/packages/next/src/next-devtools/dev-overlay/components/devtools-panel/devtools-panel.stories.tsx b/packages/next/src/next-devtools/dev-overlay/components/devtools-panel/devtools-panel.stories.tsx index 9578637b3b7..b22c8242ab0 100644 --- a/packages/next/src/next-devtools/dev-overlay/components/devtools-panel/devtools-panel.stories.tsx +++ b/packages/next/src/next-devtools/dev-overlay/components/devtools-panel/devtools-panel.stories.tsx @@ -27,6 +27,49 @@ const state: OverlayState = { expected: '15.0.0', staleness: 'fresh', }, + errors: [ + { + id: 1, + error: Object.assign(new Error('First error message'), { + __NEXT_ERROR_CODE: 'E001', + }), + componentStackFrames: [ + { + file: 'app/page.tsx', + component: 'Home', + lineNumber: 10, + column: 5, + canOpenInEditor: true, + }, + ], + frames: [ + { + file: 'app/page.tsx', + methodName: 'Home', + arguments: [], + lineNumber: 10, + column: 5, + }, + ], + type: 'runtime', + }, + { + id: 2, + error: Object.assign(new Error('Second error message'), { + __NEXT_ERROR_CODE: 'E002', + }), + frames: [], + type: 'runtime', + }, + { + id: 3, + error: Object.assign(new Error('Third error message'), { + __NEXT_ERROR_CODE: 'E003', + }), + frames: [], + type: 'runtime', + }, + ], } export const Default: Story = { @@ -34,14 +77,101 @@ export const Default: Story = { state, dispatch: () => {}, issueCount: 0, + runtimeErrors: [], + getSquashedHydrationErrorDetails: () => null, }, } +const frame = { + originalStackFrame: { + file: './app/page.tsx', + methodName: 'MyComponent', + arguments: [], + lineNumber: 10, + column: 5, + ignored: false, + }, + sourceStackFrame: { + file: './app/page.tsx', + methodName: 'MyComponent', + arguments: [], + lineNumber: 10, + column: 5, + }, + originalCodeFrame: 'export default function MyComponent() {', + error: false, + reason: null, + external: false, + ignored: false, +} + +const ignoredFrame = { + ...frame, + ignored: true, +} + export const WithIssues: Story = { args: { state, dispatch: () => {}, issueCount: 3, + runtimeErrors: [ + { + id: 1, + runtime: true, + error: new Error('First error message'), + frames: () => + Promise.resolve([ + frame, + { + ...frame, + originalStackFrame: { + ...frame.originalStackFrame, + methodName: 'ParentComponent', + lineNumber: 5, + }, + }, + { + ...frame, + originalStackFrame: { + ...frame.originalStackFrame, + methodName: 'GrandparentComponent', + lineNumber: 1, + }, + }, + ...Array(20).fill(ignoredFrame), + ]), + type: 'runtime', + }, + { + id: 2, + runtime: true, + error: new Error('Second error message'), + frames: () => + Promise.resolve([ + frame, + { + ...frame, + originalStackFrame: { + ...frame.originalStackFrame, + methodName: 'ParentComponent', + lineNumber: 5, + }, + }, + { + ...frame, + originalStackFrame: { + ...frame.originalStackFrame, + methodName: 'GrandparentComponent', + lineNumber: 1, + }, + }, + ...Array(20).fill(ignoredFrame), + ]), + type: 'runtime', + }, + ], + getSquashedHydrationErrorDetails: () => null, }, } @@ -58,6 +188,8 @@ export const Turbopack: Story = { state, dispatch: () => {}, issueCount: 0, + runtimeErrors: [], + getSquashedHydrationErrorDetails: () => null, }, } diff --git a/packages/next/src/next-devtools/dev-overlay/components/devtools-panel/devtools-panel.tsx b/packages/next/src/next-devtools/dev-overlay/components/devtools-panel/devtools-panel.tsx index 1c885ed9f4d..c119de33dd8 100644 --- a/packages/next/src/next-devtools/dev-overlay/components/devtools-panel/devtools-panel.tsx +++ b/packages/next/src/next-devtools/dev-overlay/components/devtools-panel/devtools-panel.tsx @@ -1,4 +1,6 @@ import type { OverlayDispatch, OverlayState, Corners } from '../../shared' +import type { ReadyRuntimeError } from '../../utils/get-error-by-type' +import type { HydrationErrorState } from '../../../shared/hydration-error' import { useState } from 'react' @@ -26,10 +28,14 @@ export function DevToolsPanel({ state, dispatch, issueCount, + runtimeErrors, + getSquashedHydrationErrorDetails, }: { state: OverlayState dispatch: OverlayDispatch issueCount: number + runtimeErrors: ReadyRuntimeError[] + getSquashedHydrationErrorDetails: (error: Error) => HydrationErrorState | null }) { const [activeTab, setActiveTab] = useState<'issues' | 'route' | 'settings'>( 'settings' @@ -139,10 +145,16 @@ export function DevToolsPanel({ @@ -155,6 +167,11 @@ export function DevToolsPanel({ } export const DEVTOOLS_PANEL_STYLES = css` + /* TODO: Better override dialog header style */ + [data-nextjs-devtools-panel-dialog-header] { + margin-bottom: 0 !important; + } + [data-nextjs-devtools-panel-overlay] { padding: initial; margin: auto; diff --git a/packages/next/src/next-devtools/dev-overlay/components/dialog/dialog-header.tsx b/packages/next/src/next-devtools/dev-overlay/components/dialog/dialog-header.tsx index c53a613cf9e..271ea7287cb 100644 --- a/packages/next/src/next-devtools/dev-overlay/components/dialog/dialog-header.tsx +++ b/packages/next/src/next-devtools/dev-overlay/components/dialog/dialog-header.tsx @@ -1,17 +1,11 @@ import * as React from 'react' -export type DialogHeaderProps = { - children?: React.ReactNode - className?: string -} +export type DialogHeaderProps = React.HTMLAttributes -const DialogHeader: React.FC = function DialogHeader({ - children, - className, -}) { +const DialogHeader: React.FC = function DialogHeader(props) { return ( -
- {children} +
+ {props.children}
) } diff --git a/packages/next/src/next-devtools/dev-overlay/container/errors.tsx b/packages/next/src/next-devtools/dev-overlay/container/errors.tsx index c9674c3efe1..556d225e4fa 100644 --- a/packages/next/src/next-devtools/dev-overlay/container/errors.tsx +++ b/packages/next/src/next-devtools/dev-overlay/container/errors.tsx @@ -30,11 +30,11 @@ function isNextjsLink(text: string): boolean { return text.startsWith('https://nextjs.org') } -function HydrationErrorDescription({ message }: { message: string }) { +export function HydrationErrorDescription({ message }: { message: string }) { return } -function GenericErrorDescription({ error }: { error: Error }) { +export function GenericErrorDescription({ error }: { error: Error }) { const environmentName = 'environmentName' in error ? error.environmentName : '' const envPrefix = environmentName ? `[ ${environmentName} ] ` : '' diff --git a/packages/next/src/next-devtools/dev-overlay/dev-overlay.tsx b/packages/next/src/next-devtools/dev-overlay/dev-overlay.tsx index e1bbed1ba0d..8c3d52226cb 100644 --- a/packages/next/src/next-devtools/dev-overlay/dev-overlay.tsx +++ b/packages/next/src/next-devtools/dev-overlay/dev-overlay.tsx @@ -54,6 +54,10 @@ export function DevOverlay({ state={state} dispatch={dispatch} issueCount={totalErrorCount} + runtimeErrors={runtimeErrors} + getSquashedHydrationErrorDetails={ + getSquashedHydrationErrorDetails + } /> )} diff --git a/packages/next/src/next-devtools/dev-overlay/styles/component-styles.tsx b/packages/next/src/next-devtools/dev-overlay/styles/component-styles.tsx index 1c262fe33e4..c3a81fdd70e 100644 --- a/packages/next/src/next-devtools/dev-overlay/styles/component-styles.tsx +++ b/packages/next/src/next-devtools/dev-overlay/styles/component-styles.tsx @@ -29,6 +29,9 @@ import { DEVTOOLS_PANEL_FOOTER_STYLES } from '../components/devtools-panel/devto import { DEVTOOLS_PANEL_VERSION_INFO_STYLES } from '../components/devtools-panel/devtools-panel-version-info' import { DEVTOOLS_PANEL_TAB_SETTINGS_STYLES } from '../components/devtools-panel/devtools-panel-tab/settings-tab' import { CALL_STACK_STYLES } from '../components/call-stack/call-stack' +import { DEVTOOLS_PANEL_TAB_ISSUES_CONTENT_STYLES } from '../components/devtools-panel/devtools-panel-tab/issues-tab/issues-tab-content' +import { DEVTOOLS_PANEL_TAB_ISSUES_STYLES } from '../components/devtools-panel/devtools-panel-tab/issues-tab/issues-tab' +import { DEVTOOLS_PANEL_TAB_ISSUES_SIDEBAR_STYLES } from '../components/devtools-panel/devtools-panel-tab/issues-tab/issues-tab-sidebar' export function ComponentStyles() { return ( @@ -64,6 +67,9 @@ export function ComponentStyles() { ${DEVTOOLS_PANEL_FOOTER_STYLES} ${DEVTOOLS_PANEL_VERSION_INFO_STYLES} ${DEVTOOLS_PANEL_TAB_SETTINGS_STYLES} + ${DEVTOOLS_PANEL_TAB_ISSUES_STYLES} + ${DEVTOOLS_PANEL_TAB_ISSUES_CONTENT_STYLES} + ${DEVTOOLS_PANEL_TAB_ISSUES_SIDEBAR_STYLES} `} )