diff --git a/packages/next/src/client/components/react-dev-overlay/_experimental/app/error-boundary.tsx b/packages/next/src/client/components/react-dev-overlay/_experimental/app/error-boundary.tsx new file mode 100644 index 000000000000..804918cda20a --- /dev/null +++ b/packages/next/src/client/components/react-dev-overlay/_experimental/app/error-boundary.tsx @@ -0,0 +1,73 @@ +import type { GlobalErrorComponent } from '../../../error-boundary' + +import { PureComponent } from 'react' +import { RuntimeErrorHandler } from '../../../errors/runtime-error-handler' + +type DevToolsErrorBoundaryProps = { + children: React.ReactNode + onError: (value: boolean) => void + globalError: [GlobalErrorComponent, React.ReactNode] +} + +type DevToolsErrorBoundaryState = { + isReactError: boolean + reactError: unknown +} + +function ErroredHtml({ + globalError: [GlobalError, globalErrorStyles], + error, +}: { + globalError: [GlobalErrorComponent, React.ReactNode] + error: unknown +}) { + if (!error) { + return ( + + + + + ) + } + return ( + <> + {globalErrorStyles} + + + ) +} + +export class DevToolsErrorBoundary extends PureComponent< + DevToolsErrorBoundaryProps, + DevToolsErrorBoundaryState +> { + state = { isReactError: false, reactError: null } + + static getDerivedStateFromError(error: Error) { + if (!error.stack) { + return { isReactError: false, reactError: null } + } + + RuntimeErrorHandler.hadRuntimeError = true + + return { + isReactError: true, + reactError: error, + } + } + + componentDidCatch() { + this.props.onError(this.state.isReactError) + } + + render() { + const fallback = ( + + ) + + return this.state.isReactError ? fallback : this.props.children + } +} diff --git a/packages/next/src/client/components/react-dev-overlay/_experimental/app/react-dev-overlay.stories.tsx b/packages/next/src/client/components/react-dev-overlay/_experimental/app/react-dev-overlay.stories.tsx new file mode 100644 index 000000000000..4da058f9efa0 --- /dev/null +++ b/packages/next/src/client/components/react-dev-overlay/_experimental/app/react-dev-overlay.stories.tsx @@ -0,0 +1,85 @@ +import type { Meta, StoryObj } from '@storybook/react' +import type { OverlayState } from '../../shared' + +import ReactDevOverlay from './react-dev-overlay' +import { ACTION_UNHANDLED_ERROR } from '../../shared' + +const meta: Meta = { + component: ReactDevOverlay, + parameters: { + layout: 'fullscreen', + }, +} + +export default meta +type Story = StoryObj + +const state: OverlayState = { + nextId: 0, + buildError: null, + errors: [ + { + id: 1, + event: { + type: ACTION_UNHANDLED_ERROR, + reason: 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, + }, + ], + }, + }, + { + id: 2, + event: { + type: ACTION_UNHANDLED_ERROR, + reason: Object.assign(new Error('Second error message'), { + __NEXT_ERROR_CODE: 'E002', + }), + frames: [], + }, + }, + { + id: 3, + event: { + type: ACTION_UNHANDLED_ERROR, + reason: Object.assign(new Error('Third error message'), { + __NEXT_ERROR_CODE: 'E003', + }), + frames: [], + }, + }, + ], + refreshState: { type: 'idle' }, + rootLayoutMissingTags: [], + notFound: false, + staticIndicator: false, + debugInfo: { devtoolsFrontendUrl: undefined }, + versionInfo: { + installed: '14.2.0', + staleness: 'fresh', + }, +} + +export const Default: Story = { + args: { + state, + children:
Application Content
, + }, +} diff --git a/packages/next/src/client/components/react-dev-overlay/_experimental/app/react-dev-overlay.tsx b/packages/next/src/client/components/react-dev-overlay/_experimental/app/react-dev-overlay.tsx index 55f9e9cb7c31..589d0a56a157 100644 --- a/packages/next/src/client/components/react-dev-overlay/_experimental/app/react-dev-overlay.tsx +++ b/packages/next/src/client/components/react-dev-overlay/_experimental/app/react-dev-overlay.tsx @@ -1,115 +1,57 @@ import type { OverlayState } from '../../shared' -import type { Dispatcher } from '../../app/hot-reloader-client' - -import React from 'react' +import type { GlobalErrorComponent } from '../../../error-boundary' +import { useState } from 'react' +import { DevToolsErrorBoundary } from './error-boundary' import { ShadowPortal } from '../internal/components/shadow-portal' -import { BuildError } from '../internal/container/build-error' -import { Errors } from '../internal/container/errors' import { Base } from '../internal/styles/base' import { ComponentStyles } from '../internal/styles/component-styles' import { CssReset } from '../internal/styles/css-reset' -import { RootLayoutMissingTagsError } from '../internal/container/root-layout-missing-tags-error' -import { RuntimeErrorHandler } from '../internal/helpers/runtime-error-handler' import { Colors } from '../internal/styles/colors' -import type { GlobalErrorComponent } from '../../../error-boundary' - -function ErroredHtml({ - globalError: [GlobalError, globalErrorStyles], - error, +import { ErrorOverlay } from '../internal/components/errors/error-overlay/error-overlay' +import { DevToolsIndicator } from '../internal/components/errors/dev-tools-indicator/dev-tools-indicator' +import { useErrorHook } from '../internal/container/runtime-error/use-error-hook' + +export default function ReactDevOverlay({ + state, + globalError, + children, }: { + state: OverlayState globalError: [GlobalErrorComponent, React.ReactNode] - error: unknown + children: React.ReactNode }) { - if (!error) { - return ( - - - - - ) - } + const [isErrorOverlayOpen, setIsErrorOverlayOpen] = useState(false) + const { readyErrors } = useErrorHook({ errors: state.errors, isAppDir: true }) + return ( <> - {globalErrorStyles} - + + {children} + + + + + + + + + + + + ) } - -interface ReactDevOverlayState { - reactError?: unknown - isReactError: boolean -} -export default class ReactDevOverlay extends React.PureComponent< - { - state: OverlayState - dispatcher?: Dispatcher - globalError: [GlobalErrorComponent, React.ReactNode] - children: React.ReactNode - }, - ReactDevOverlayState -> { - state = { - reactError: null, - isReactError: false, - } - - static getDerivedStateFromError(error: Error): ReactDevOverlayState { - if (!error.stack) return { isReactError: false } - - RuntimeErrorHandler.hadRuntimeError = true - return { - isReactError: true, - } - } - - render() { - const { state, children, globalError } = this.props - const { isReactError, reactError } = this.state - - const hasBuildError = state.buildError != null - const hasStaticIndicator = state.staticIndicator - const debugInfo = state.debugInfo - - const isTurbopack = !!process.env.TURBOPACK - - return ( - <> - {isReactError ? ( - - ) : ( - children - )} - - - - - - {state.rootLayoutMissingTags?.length ? ( - - ) : hasBuildError ? ( - - ) : ( - - )} - - - ) - } -} diff --git a/packages/next/src/client/components/react-dev-overlay/_experimental/internal/components/errors/dev-tools-indicator/dev-tools-indicator.stories.tsx b/packages/next/src/client/components/react-dev-overlay/_experimental/internal/components/errors/dev-tools-indicator/dev-tools-indicator.stories.tsx index 1525b1539fb9..16660b354ac8 100644 --- a/packages/next/src/client/components/react-dev-overlay/_experimental/internal/components/errors/dev-tools-indicator/dev-tools-indicator.stories.tsx +++ b/packages/next/src/client/components/react-dev-overlay/_experimental/internal/components/errors/dev-tools-indicator/dev-tools-indicator.stories.tsx @@ -2,6 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react' import { DevToolsIndicator } from './dev-tools-indicator' import { withShadowPortal } from '../../../storybook/with-shadow-portal' import type { VersionInfo } from '../../../../../../../../server/dev/parse-version-info' +import type { OverlayState } from '../../../../../shared' const meta: Meta = { component: DevToolsIndicator, @@ -42,65 +43,51 @@ const mockVersionInfo: VersionInfo = { staleness: 'stale-major', } -// Mock error for stories -const mockError = { - id: 1, - runtime: true as const, - error: new Error('Test error'), - frames: [ - { - error: true, - reason: null, - external: false, - ignored: false, - sourceStackFrame: { - file: 'test.js', - methodName: '', - arguments: [], - lineNumber: 1, - column: 1, - }, - }, - ], +const state: OverlayState = { + nextId: 1, + buildError: null, + errors: [], + refreshState: { type: 'idle' }, + rootLayoutMissingTags: [], + versionInfo: mockVersionInfo, + notFound: false, + staticIndicator: false, + debugInfo: { devtoolsFrontendUrl: undefined }, } export const NoErrors: Story = { args: { - hasStaticIndicator: false, - readyErrors: [], - fullscreen: () => console.log('Fullscreen clicked'), - hide: () => console.log('Hide clicked'), - versionInfo: mockVersionInfo, - isTurbopack: false, + readyErrorsLength: 0, + state, + setIsErrorOverlayOpen: () => {}, }, } export const SingleError: Story = { args: { - hasStaticIndicator: false, - readyErrors: [mockError], - fullscreen: () => console.log('Fullscreen clicked'), - hide: () => console.log('Hide clicked'), - versionInfo: mockVersionInfo, + readyErrorsLength: 1, + state, + setIsErrorOverlayOpen: () => {}, }, } export const MultipleErrors: Story = { args: { - hasStaticIndicator: false, - readyErrors: [mockError, { ...mockError, id: 2 }, { ...mockError, id: 3 }], - fullscreen: () => console.log('Fullscreen clicked'), - hide: () => console.log('Hide clicked'), - versionInfo: mockVersionInfo, + readyErrorsLength: 3, + state, + setIsErrorOverlayOpen: () => {}, }, } export const WithStaticIndicator: Story = { args: { - hasStaticIndicator: true, - readyErrors: [mockError], - fullscreen: () => console.log('Fullscreen clicked'), - hide: () => console.log('Hide clicked'), - versionInfo: mockVersionInfo, + readyErrorsLength: 3, + state: { + ...state, + staticIndicator: true, + }, + setIsErrorOverlayOpen: () => { + console.log('setIsErrorOverlayOpen called') + }, }, } diff --git a/packages/next/src/client/components/react-dev-overlay/_experimental/internal/components/errors/dev-tools-indicator/dev-tools-indicator.tsx b/packages/next/src/client/components/react-dev-overlay/_experimental/internal/components/errors/dev-tools-indicator/dev-tools-indicator.tsx index 3e80ee2bcc09..23145883d633 100644 --- a/packages/next/src/client/components/react-dev-overlay/_experimental/internal/components/errors/dev-tools-indicator/dev-tools-indicator.tsx +++ b/packages/next/src/client/components/react-dev-overlay/_experimental/internal/components/errors/dev-tools-indicator/dev-tools-indicator.tsx @@ -1,39 +1,52 @@ -import type { VersionInfo } from '../../../../../../../../server/dev/parse-version-info' -import type { ReadyRuntimeError } from '../../../helpers/get-error-by-type' +import type { OverlayState } from '../../../../../shared' + +import { useState, useEffect, useRef } from 'react' import { Toast } from '../../toast' -import React, { useState, useEffect, useRef } from 'react' import { NextLogo } from './internal/next-logo' import { useIsDevBuilding } from '../../../../../../../dev/dev-build-indicator/internal/initialize-for-new-overlay' import { useIsDevRendering } from './internal/dev-render-indicator' import { useDelayedRender } from './internal/use-delayed-render' +import { useKeyboardShortcut } from '../../../hooks/use-keyboard-shortcut' +import { MODIFIERS } from '../../../hooks/use-keyboard-shortcut' // TODO: test a11y // TODO: add E2E tests to cover different scenarios export function DevToolsIndicator({ - versionInfo, - hasStaticIndicator, - readyErrors, - fullscreen, - hide, - isTurbopack, + state, + readyErrorsLength, + setIsErrorOverlayOpen, }: { - versionInfo: VersionInfo | undefined - readyErrors: ReadyRuntimeError[] - fullscreen: () => void - hide: () => void - hasStaticIndicator?: boolean - isTurbopack: boolean + state: OverlayState + readyErrorsLength: number + setIsErrorOverlayOpen: (value: boolean) => void }) { + const [isDevToolsIndicatorOpen, setIsDevToolsIndicatorOpen] = useState(true) + // Register `(cmd|ctrl) + .` to show/hide the error indicator. + useKeyboardShortcut({ + key: '.', + modifiers: [MODIFIERS.CTRL_CMD], + callback: () => { + setIsDevToolsIndicatorOpen(!isDevToolsIndicatorOpen) + setIsErrorOverlayOpen(!isDevToolsIndicatorOpen) + }, + }) + return ( - + isDevToolsIndicatorOpen && ( + { + setIsErrorOverlayOpen(true) + }} + issueCount={readyErrorsLength} + isStaticRoute={state.staticIndicator} + hide={() => { + setIsDevToolsIndicatorOpen(false) + }} + isTurbopack={!!process.env.TURBOPACK} + /> + ) ) } diff --git a/packages/next/src/client/components/react-dev-overlay/_experimental/internal/components/errors/error-overlay/error-overlay.tsx b/packages/next/src/client/components/react-dev-overlay/_experimental/internal/components/errors/error-overlay/error-overlay.tsx new file mode 100644 index 000000000000..3cb7574bc8fd --- /dev/null +++ b/packages/next/src/client/components/react-dev-overlay/_experimental/internal/components/errors/error-overlay/error-overlay.tsx @@ -0,0 +1,63 @@ +import type { OverlayState } from '../../../../../shared' +import type { ReadyRuntimeError } from '../../../helpers/get-error-by-type' + +import { BuildError } from '../../../container/build-error' +import { Errors } from '../../../container/errors' +import { RootLayoutMissingTagsError } from '../../../container/root-layout-missing-tags-error' + +export function ErrorOverlay({ + state, + readyErrors, + isErrorOverlayOpen, + setIsErrorOverlayOpen, +}: { + state: OverlayState + readyErrors: ReadyRuntimeError[] + isErrorOverlayOpen: boolean + setIsErrorOverlayOpen: (value: boolean) => void +}) { + const isTurbopack = !!process.env.TURBOPACK + + if (!!state.rootLayoutMissingTags?.length) { + return ( + + ) + } + + if (state.buildError !== null) { + return ( + + ) + } + + // No Runtime Errors. + if (!state.errors.length) { + return null + } + + if (!isErrorOverlayOpen) { + return null + } + + return ( + { + setIsErrorOverlayOpen(false) + }} + /> + ) +} diff --git a/packages/next/src/client/components/react-dev-overlay/_experimental/internal/container/errors.stories.tsx b/packages/next/src/client/components/react-dev-overlay/_experimental/internal/container/errors.stories.tsx index 1aa85b2329a0..c65058931d2c 100644 --- a/packages/next/src/client/components/react-dev-overlay/_experimental/internal/container/errors.stories.tsx +++ b/packages/next/src/client/components/react-dev-overlay/_experimental/internal/container/errors.stories.tsx @@ -1,4 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react' +import type { SupportedErrorEvent } from '../../../internal/container/Errors' +import type { ReadyRuntimeError } from '../helpers/get-error-by-type' + import { Errors } from './errors' import { withShadowPortal } from '../storybook/with-shadow-portal' import { ACTION_UNHANDLED_ERROR } from '../../../shared' @@ -14,75 +17,87 @@ const meta: Meta = { export default meta type Story = StoryObj -export const Default: Story = { - args: { - isAppDir: true, - errors: [ - { - id: 1, - event: { - type: ACTION_UNHANDLED_ERROR, - reason: 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, - }, - ], - }, - }, - { - id: 2, - event: { - type: ACTION_UNHANDLED_ERROR, - reason: Object.assign(new Error('Second error message'), { - __NEXT_ERROR_CODE: 'E002', - }), - frames: [], - }, - }, - { - id: 3, - event: { - type: ACTION_UNHANDLED_ERROR, - reason: Object.assign(new Error('Third error message'), { - __NEXT_ERROR_CODE: 'E003', - }), - frames: [], +const errors: SupportedErrorEvent[] = [ + { + id: 1, + event: { + type: ACTION_UNHANDLED_ERROR, + reason: Object.assign(new Error('First error message'), { + __NEXT_ERROR_CODE: 'E001', + }), + componentStackFrames: [ + { + file: 'app/page.tsx', + component: 'Home', + lineNumber: 10, + column: 5, + canOpenInEditor: true, }, - }, - { - id: 4, - event: { - type: ACTION_UNHANDLED_ERROR, - reason: Object.assign(new Error('Fourth error message'), { - __NEXT_ERROR_CODE: 'E004', - }), - frames: [], + ], + frames: [ + { + file: 'app/page.tsx', + methodName: 'Home', + arguments: [], + lineNumber: 10, + column: 5, }, - }, - ], + ], + }, + }, + { + id: 2, + event: { + type: ACTION_UNHANDLED_ERROR, + reason: Object.assign(new Error('Second error message'), { + __NEXT_ERROR_CODE: 'E002', + }), + frames: [], + }, + }, + { + id: 3, + event: { + type: ACTION_UNHANDLED_ERROR, + reason: Object.assign(new Error('Third error message'), { + __NEXT_ERROR_CODE: 'E003', + }), + frames: [], + }, + }, + { + id: 4, + event: { + type: ACTION_UNHANDLED_ERROR, + reason: Object.assign(new Error('Fourth error message'), { + __NEXT_ERROR_CODE: 'E004', + }), + frames: [], + }, + }, +] + +const readyErrors: ReadyRuntimeError[] = [ + { + id: 1, + runtime: true, + error: errors[0].event.reason, + frames: [], + }, +] + +export const Default: Story = { + args: { + errors, + readyErrors, versionInfo: { installed: '15.0.0', staleness: 'fresh', }, - initialDisplayState: 'fullscreen', hasStaticIndicator: true, isTurbopack: true, + debugInfo: { devtoolsFrontendUrl: undefined }, + onClose: () => {}, }, } @@ -96,13 +111,11 @@ export const Turbopack: Story = { export const Minimized: Story = { args: { ...Default.args, - initialDisplayState: 'minimized', }, } export const WithHydrationWarning: Story = { args: { - isAppDir: true, errors: [ { id: 1, @@ -140,5 +153,8 @@ export const WithHydrationWarning: Story = { }, }, ], + readyErrors: [], + debugInfo: { devtoolsFrontendUrl: undefined }, + onClose: () => {}, }, } diff --git a/packages/next/src/client/components/react-dev-overlay/_experimental/internal/container/errors.tsx b/packages/next/src/client/components/react-dev-overlay/_experimental/internal/container/errors.tsx index 60294c556799..7be1332d6f83 100644 --- a/packages/next/src/client/components/react-dev-overlay/_experimental/internal/container/errors.tsx +++ b/packages/next/src/client/components/react-dev-overlay/_experimental/internal/container/errors.tsx @@ -1,13 +1,6 @@ -import { useState, useEffect, useMemo, useCallback } from 'react' -import { - ACTION_UNHANDLED_ERROR, - ACTION_UNHANDLED_REJECTION, - type UnhandledErrorAction, - type UnhandledRejectionAction, -} from '../../../shared' +import { useState, useMemo, useEffect } from 'react' import type { DebugInfo } from '../../../types' import { Overlay } from '../components/overlay' -import { getErrorByType } from '../helpers/get-error-by-type' import type { ReadyRuntimeError } from '../helpers/get-error-by-type' import { noop as css } from '../helpers/noop-template' import { RuntimeError } from './runtime-error' @@ -24,29 +17,21 @@ import { isUnhandledConsoleOrRejection, } from '../../../../errors/console-error' import { extractNextErrorCode } from '../../../../../../lib/error-telemetry-utils' -import { DevToolsIndicator } from '../components/errors/dev-tools-indicator/dev-tools-indicator' import { ErrorOverlayLayout } from '../components/errors/error-overlay-layout/error-overlay-layout' -import { useKeyboardShortcut } from '../hooks/use-keyboard-shortcut' -import { MODIFIERS } from '../hooks/use-keyboard-shortcut' +import type { SupportedErrorEvent } from './runtime-error/use-error-hook' -export type SupportedErrorEvent = { - id: number - event: UnhandledErrorAction | UnhandledRejectionAction -} export type ErrorsProps = { - isAppDir: boolean errors: SupportedErrorEvent[] - initialDisplayState: DisplayState + readyErrors: ReadyRuntimeError[] isTurbopack: boolean - versionInfo?: VersionInfo - hasStaticIndicator?: boolean - debugInfo?: DebugInfo + versionInfo: VersionInfo + hasStaticIndicator: boolean + debugInfo: DebugInfo + onClose: () => void } type ReadyErrorEvent = ReadyRuntimeError -type DisplayState = 'minimized' | 'fullscreen' | 'hidden' - function isNextjsLink(text: string): boolean { return text.startsWith('https://nextjs.org') } @@ -89,96 +74,30 @@ function ErrorDescription({ ) } -function getErrorSignature(ev: SupportedErrorEvent): string { - const { event } = ev - switch (event.type) { - case ACTION_UNHANDLED_ERROR: - case ACTION_UNHANDLED_REJECTION: { - return `${event.reason.name}::${event.reason.message}::${event.reason.stack}` - } - default: { - } - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const _: never = event as never - return '' -} - export function Errors({ - isAppDir, errors, - initialDisplayState, - hasStaticIndicator, + readyErrors, debugInfo, versionInfo, isTurbopack, + onClose, }: ErrorsProps) { - const [lookups, setLookups] = useState( - {} as { [eventId: string]: ReadyErrorEvent } - ) - - const [readyErrors, nextError] = useMemo< - [ReadyErrorEvent[], SupportedErrorEvent | null] - >(() => { - let ready: ReadyErrorEvent[] = [] - let next: SupportedErrorEvent | null = null - - // Ensure errors are displayed in the order they occurred in: - for (let idx = 0; idx < errors.length; ++idx) { - const e = errors[idx] - const { id } = e - if (id in lookups) { - ready.push(lookups[id]) - continue - } - - // Check for duplicate errors - if (idx > 0) { - const prev = errors[idx - 1] - if (getErrorSignature(prev) === getErrorSignature(e)) { - continue - } + useEffect(() => { + // Close the error overlay when pressing escape + function handleKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') { + onClose() } - - next = e - break } - return [ready, next] - }, [errors, lookups]) + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [onClose]) const isLoading = useMemo(() => { return readyErrors.length < 1 && Boolean(errors.length) }, [errors.length, readyErrors.length]) - useEffect(() => { - if (nextError == null) { - return - } - let mounted = true - - getErrorByType(nextError, isAppDir).then( - (resolved) => { - // We don't care if the desired error changed while we were resolving, - // thus we're not tracking it using a ref. Once the work has been done, - // we'll store it. - if (mounted) { - setLookups((m) => ({ ...m, [resolved.id]: resolved })) - } - }, - () => { - // TODO: handle this, though an edge case - } - ) - - return () => { - mounted = false - } - }, [nextError, isAppDir]) - - const [displayState, setDisplayState] = - useState(initialDisplayState) const [activeIdx, setActiveIndex] = useState(0) const activeError = useMemo( @@ -186,67 +105,15 @@ export function Errors({ [activeIdx, readyErrors] ) - const minimize = useCallback(() => setDisplayState('minimized'), []) - - // Reset component state when there are no errors to be displayed. - // Note: We show the dev tools indicator in minimized state even with no errors - // as it serves as a persistent development tools access point - useEffect(() => { - if (errors.length < 1) { - setLookups({}) - minimize() - setActiveIndex(0) - } - }, [errors.length, minimize]) - - useEffect(() => { - // Close the error overlay when pressing escape - function handleKeyDown(event: KeyboardEvent) { - if (event.key === 'Escape') { - setDisplayState('minimized') - } - } - - document.addEventListener('keydown', handleKeyDown) - return () => document.removeEventListener('keydown', handleKeyDown) - }, []) - - const hide = useCallback(() => setDisplayState('hidden'), []) - const fullscreen = useCallback(() => setDisplayState('fullscreen'), []) - - // Register `(cmd|ctrl) + .` to show/hide the error indicator. - useKeyboardShortcut({ - key: '.', - modifiers: [MODIFIERS.CTRL_CMD], - callback: () => { - setDisplayState((prev) => (prev === 'hidden' ? 'minimized' : 'hidden')) - }, - }) - - if (displayState === 'hidden') { - return null - } - - const noIssues = errors.length < 1 || activeError == null - - if (noIssues || displayState === 'minimized') { - return ( - - ) - } - if (isLoading) { // TODO: better loading state return } + if (!activeError) { + return null + } + const error = activeError.error const isServerError = ['server', 'edge-server'].includes( getErrorSource(error) || '' @@ -287,7 +154,7 @@ export function Errors({ errorMessage={ } - onClose={isServerError ? undefined : minimize} + onClose={isServerError ? undefined : onClose} debugInfo={debugInfo} error={error} readyErrors={readyErrors} diff --git a/packages/next/src/client/components/react-dev-overlay/_experimental/internal/container/runtime-error/use-error-hook.ts b/packages/next/src/client/components/react-dev-overlay/_experimental/internal/container/runtime-error/use-error-hook.ts new file mode 100644 index 000000000000..907e7888e14a --- /dev/null +++ b/packages/next/src/client/components/react-dev-overlay/_experimental/internal/container/runtime-error/use-error-hook.ts @@ -0,0 +1,103 @@ +import type { ReadyRuntimeError } from '../../helpers/get-error-by-type' +import type { + UnhandledErrorAction, + UnhandledRejectionAction, +} from '../../../../shared' + +import { useMemo, useState, useEffect } from 'react' +import { getErrorByType } from '../../helpers/get-error-by-type' +import { + ACTION_UNHANDLED_ERROR, + ACTION_UNHANDLED_REJECTION, +} from '../../../../shared' + +export type SupportedErrorEvent = { + id: number + event: UnhandledErrorAction | UnhandledRejectionAction +} + +function getErrorSignature(ev: SupportedErrorEvent): string { + const { event } = ev + switch (event.type) { + case ACTION_UNHANDLED_ERROR: + case ACTION_UNHANDLED_REJECTION: { + return `${event.reason.name}::${event.reason.message}::${event.reason.stack}` + } + default: { + } + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _: never = event as never + return '' +} + +export function useErrorHook({ + errors, + isAppDir, +}: { + errors: SupportedErrorEvent[] + isAppDir: boolean +}) { + const [lookups, setLookups] = useState<{ + [eventId: string]: ReadyRuntimeError + }>({}) + const [readyErrors, nextError] = useMemo< + [ReadyRuntimeError[], SupportedErrorEvent | null] + >(() => { + let ready: ReadyRuntimeError[] = [] + let next: SupportedErrorEvent | null = null + + // Ensure errors are displayed in the order they occurred in: + for (let idx = 0; idx < errors.length; ++idx) { + const e = errors[idx] + const { id } = e + if (id in lookups) { + ready.push(lookups[id]) + continue + } + + // Check for duplicate errors + if (idx > 0) { + const prev = errors[idx - 1] + if (getErrorSignature(prev) === getErrorSignature(e)) { + continue + } + } + + next = e + break + } + + return [ready, next] + }, [errors, lookups]) + + useEffect(() => { + if (nextError == null) { + return + } + let mounted = true + + getErrorByType(nextError, isAppDir).then( + (resolved) => { + // We don't care if the desired error changed while we were resolving, + // thus we're not tracking it using a ref. Once the work has been done, + // we'll store it. + if (mounted) { + setLookups((m) => ({ ...m, [resolved.id]: resolved })) + } + }, + () => { + // TODO: handle this, though an edge case + } + ) + + return () => { + mounted = false + } + }, [nextError, isAppDir]) + + return { + readyErrors, + } +} diff --git a/packages/next/src/client/components/react-dev-overlay/_experimental/internal/helpers/get-error-by-type.ts b/packages/next/src/client/components/react-dev-overlay/_experimental/internal/helpers/get-error-by-type.ts index d3a9d2eb8809..50b9c45a71c5 100644 --- a/packages/next/src/client/components/react-dev-overlay/_experimental/internal/helpers/get-error-by-type.ts +++ b/packages/next/src/client/components/react-dev-overlay/_experimental/internal/helpers/get-error-by-type.ts @@ -2,7 +2,7 @@ import { ACTION_UNHANDLED_ERROR, ACTION_UNHANDLED_REJECTION, } from '../../../shared' -import type { SupportedErrorEvent } from '../container/errors' +import type { SupportedErrorEvent } from '../container/runtime-error/use-error-hook' import { getOriginalStackFrames } from './stack-frame' import type { OriginalStackFrame } from './stack-frame' import type { ComponentStackFrame } from './parse-component-stack' diff --git a/packages/next/src/client/components/react-dev-overlay/_experimental/internal/helpers/runtime-error-handler.ts b/packages/next/src/client/components/react-dev-overlay/_experimental/internal/helpers/runtime-error-handler.ts deleted file mode 100644 index 36622efd1cb1..000000000000 --- a/packages/next/src/client/components/react-dev-overlay/_experimental/internal/helpers/runtime-error-handler.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const RuntimeErrorHandler = { - hadRuntimeError: false, -} diff --git a/packages/next/src/client/components/react-dev-overlay/_experimental/pages/error-boundary.tsx b/packages/next/src/client/components/react-dev-overlay/_experimental/pages/error-boundary.tsx new file mode 100644 index 000000000000..8eda7feccdfa --- /dev/null +++ b/packages/next/src/client/components/react-dev-overlay/_experimental/pages/error-boundary.tsx @@ -0,0 +1,35 @@ +import * as React from 'react' + +type DevToolsErrorBoundaryProps = { + children?: React.ReactNode + onError: (error: Error, componentStack: string | null) => void + isMounted?: boolean +} +type DevToolsErrorBoundaryState = { error: Error | null } + +export class DevToolsErrorBoundary extends React.PureComponent< + DevToolsErrorBoundaryProps, + DevToolsErrorBoundaryState +> { + state = { error: null } + + static getDerivedStateFromError(error: Error) { + return { error } + } + + componentDidCatch( + error: Error, + // Loosely typed because it depends on the React version and was + // accidentally excluded in some versions. + errorInfo?: { componentStack?: string | null } + ) { + this.props.onError(error, errorInfo?.componentStack || null) + this.setState({ error }) + } + + // Explicit type is needed to avoid the generated `.d.ts` having a wide return type that could be specific to the `@types/react` version. + render(): React.ReactNode { + // The component has to be unmounted or else it would continue to error + return this.state.error ? null : this.props.children + } +} diff --git a/packages/next/src/client/components/react-dev-overlay/_experimental/pages/react-dev-overlay.tsx b/packages/next/src/client/components/react-dev-overlay/_experimental/pages/react-dev-overlay.tsx index 9f0cd44097e0..7cf676c91ff1 100644 --- a/packages/next/src/client/components/react-dev-overlay/_experimental/pages/react-dev-overlay.tsx +++ b/packages/next/src/client/components/react-dev-overlay/_experimental/pages/react-dev-overlay.tsx @@ -1,15 +1,17 @@ import * as React from 'react' +import { useState } from 'react' import { ShadowPortal } from '../internal/components/shadow-portal' -import { BuildError } from '../internal/container/build-error' -import { Errors } from '../internal/container/errors' import { Base } from '../internal/styles/base' import { ComponentStyles } from '../internal/styles/component-styles' import { CssReset } from '../internal/styles/css-reset' -import { ErrorBoundary } from '../../pages/error-boundary' +import { DevToolsErrorBoundary } from './error-boundary' import { usePagesReactDevOverlay } from '../../pages/hooks' import { Colors } from '../internal/styles/colors' +import { ErrorOverlay } from '../internal/components/errors/error-overlay/error-overlay' +import { DevToolsIndicator } from '../internal/components/errors/dev-tools-indicator/dev-tools-indicator' +import { useErrorHook } from '../internal/container/runtime-error/use-error-hook' export type ErrorType = 'runtime' | 'build' @@ -18,50 +20,40 @@ interface ReactDevOverlayProps { } export default function ReactDevOverlay({ children }: ReactDevOverlayProps) { - const { - isMounted, - hasBuildError, - hasRuntimeErrors, - state, - onComponentError, - } = usePagesReactDevOverlay() + const { isMounted, state, onComponentError, hasRuntimeErrors } = + usePagesReactDevOverlay() - const isTurbopack = !!process.env.TURBOPACK + const [isErrorOverlayOpen, setIsErrorOverlayOpen] = useState(hasRuntimeErrors) + + const { readyErrors } = useErrorHook({ + errors: state.errors, + isAppDir: false, + }) return ( <> - + {children ?? null} - + + - {hasBuildError ? ( - - ) : hasRuntimeErrors ? ( - - ) : ( - - )} + + + ) diff --git a/packages/next/src/client/components/react-dev-overlay/shared.ts b/packages/next/src/client/components/react-dev-overlay/shared.ts index b465a8f661c3..77540bb7e6ba 100644 --- a/packages/next/src/client/components/react-dev-overlay/shared.ts +++ b/packages/next/src/client/components/react-dev-overlay/shared.ts @@ -21,7 +21,7 @@ export interface OverlayState { versionInfo: VersionInfo notFound: boolean staticIndicator: boolean - debugInfo: DebugInfo | undefined + debugInfo: DebugInfo } export const ACTION_STATIC_INDICATOR = 'static-indicator' @@ -109,7 +109,7 @@ export const INITIAL_OVERLAY_STATE: OverlayState = { refreshState: { type: 'idle' }, rootLayoutMissingTags: [], versionInfo: { installed: '0.0.0', staleness: 'unknown' }, - debugInfo: undefined, + debugInfo: { devtoolsFrontendUrl: undefined }, } export function useErrorOverlayReducer() {