diff --git a/packages/next/src/build/output/index.ts b/packages/next/src/build/output/index.ts index 10745f2e1a..4b7da14820 100644 --- a/packages/next/src/build/output/index.ts +++ b/packages/next/src/build/output/index.ts @@ -2,7 +2,7 @@ import { bold, red, yellow } from '../../lib/picocolors' import stripAnsi from 'next/dist/compiled/strip-ansi' import textTable from 'next/dist/compiled/text-table' import createStore from 'next/dist/compiled/unistore' -import formatWebpackMessages from '../../client/dev/error-overlay/format-webpack-messages' +import formatWebpackMessages from '../../client/components/react-dev-overlay/internal/helpers/format-webpack-messages' import { store as consoleStore } from './store' import type { OutputState } from './store' import type { webpack } from 'next/dist/compiled/webpack/webpack' diff --git a/packages/next/src/build/webpack-build/impl.ts b/packages/next/src/build/webpack-build/impl.ts index da83d67487..2bbcf61ab8 100644 --- a/packages/next/src/build/webpack-build/impl.ts +++ b/packages/next/src/build/webpack-build/impl.ts @@ -1,6 +1,6 @@ import type { webpack } from 'next/dist/compiled/webpack/webpack' import { red } from '../../lib/picocolors' -import formatWebpackMessages from '../../client/dev/error-overlay/format-webpack-messages' +import formatWebpackMessages from '../../client/components/react-dev-overlay/internal/helpers/format-webpack-messages' import { nonNullable } from '../../lib/non-nullable' import type { COMPILER_INDEXES } from '../../shared/lib/constants' import { diff --git a/packages/next/src/client/app-index.tsx b/packages/next/src/client/app-index.tsx index f47e8dce6e..e4c9d7632d 100644 --- a/packages/next/src/client/app-index.tsx +++ b/packages/next/src/client/app-index.tsx @@ -165,7 +165,7 @@ export function hydrate() { ) - const rootLayoutMissingTags = window.__next_root_layout_missing_tags || null + const rootLayoutMissingTags = window.__next_root_layout_missing_tags const hasMissingTags = !!rootLayoutMissingTags?.length const options = { onRecoverableError } satisfies ReactDOMClient.RootOptions @@ -190,8 +190,8 @@ export function hydrate() { require('./components/react-dev-overlay/app/ReactDevOverlay') .default as typeof import('./components/react-dev-overlay/app/ReactDevOverlay').default - const INITIAL_OVERLAY_STATE: typeof import('./components/react-dev-overlay/app/error-overlay-reducer').INITIAL_OVERLAY_STATE = - require('./components/react-dev-overlay/app/error-overlay-reducer').INITIAL_OVERLAY_STATE + const INITIAL_OVERLAY_STATE: typeof import('./components/react-dev-overlay/shared').INITIAL_OVERLAY_STATE = + require('./components/react-dev-overlay/shared').INITIAL_OVERLAY_STATE const getSocketUrl: typeof import('./components/react-dev-overlay/internal/helpers/get-socket-url').getSocketUrl = require('./components/react-dev-overlay/internal/helpers/get-socket-url') @@ -207,10 +207,7 @@ export function hydrate() { const errorTree = ( {}} > {reactEl} diff --git a/packages/next/src/client/components/react-dev-overlay/app/ReactDevOverlay.tsx b/packages/next/src/client/components/react-dev-overlay/app/ReactDevOverlay.tsx index 01b360aed0..032b0bc079 100644 --- a/packages/next/src/client/components/react-dev-overlay/app/ReactDevOverlay.tsx +++ b/packages/next/src/client/components/react-dev-overlay/app/ReactDevOverlay.tsx @@ -1,9 +1,5 @@ import * as React from 'react' -import { ACTION_UNHANDLED_ERROR } from './error-overlay-reducer' -import type { - OverlayState, - UnhandledErrorAction, -} from './error-overlay-reducer' +import { ACTION_UNHANDLED_ERROR, type OverlayState } from '../shared' import { ShadowPortal } from '../internal/components/ShadowPortal' import { BuildError } from '../internal/container/BuildError' @@ -18,7 +14,7 @@ import { RootLayoutMissingTagsError } from '../internal/container/root-layout-mi interface ReactDevOverlayState { reactError: SupportedErrorEvent | null } -class ReactDevOverlay extends React.PureComponent< +export default class ReactDevOverlay extends React.PureComponent< { state: OverlayState children: React.ReactNode @@ -29,17 +25,17 @@ class ReactDevOverlay extends React.PureComponent< state = { reactError: null } static getDerivedStateFromError(error: Error): ReactDevOverlayState { - const e = error - const event: UnhandledErrorAction = { - type: ACTION_UNHANDLED_ERROR, - reason: error, - frames: parseStack(e.stack!), + if (!error.stack) return { reactError: null } + return { + reactError: { + id: 0, + event: { + type: ACTION_UNHANDLED_ERROR, + reason: error, + frames: parseStack(error.stack), + }, + }, } - const errorEvent: SupportedErrorEvent = { - id: 0, - event, - } - return { reactError: errorEvent } } componentDidCatch(componentErr: Error) { @@ -52,7 +48,7 @@ class ReactDevOverlay extends React.PureComponent< const hasBuildError = state.buildError != null const hasRuntimeErrors = Boolean(state.errors.length) - const hasMissingTags = Boolean(state.rootLayoutMissingTags) + const hasMissingTags = Boolean(state.rootLayoutMissingTags?.length) const isMounted = hasBuildError || hasRuntimeErrors || reactError || hasMissingTags @@ -71,7 +67,7 @@ class ReactDevOverlay extends React.PureComponent< - {state.rootLayoutMissingTags ? ( + {state.rootLayoutMissingTags?.length ? ( @@ -101,5 +97,3 @@ class ReactDevOverlay extends React.PureComponent< ) } } - -export default ReactDevOverlay diff --git a/packages/next/src/client/components/react-dev-overlay/app/error-overlay-reducer.ts b/packages/next/src/client/components/react-dev-overlay/app/error-overlay-reducer.ts deleted file mode 100644 index 28dcc79536..0000000000 --- a/packages/next/src/client/components/react-dev-overlay/app/error-overlay-reducer.ts +++ /dev/null @@ -1,165 +0,0 @@ -import type { StackFrame } from 'next/dist/compiled/stacktrace-parser' -import type { VersionInfo } from '../../../../server/dev/parse-version-info' -import type { SupportedErrorEvent } from '../internal/container/Errors' -import type { ComponentStackFrame } from '../internal/helpers/parse-component-stack' - -export const ACTION_BUILD_OK = 'build-ok' -export const ACTION_BUILD_ERROR = 'build-error' -export const ACTION_BEFORE_REFRESH = 'before-fast-refresh' -export const ACTION_REFRESH = 'fast-refresh' -export const ACTION_UNHANDLED_ERROR = 'unhandled-error' -export const ACTION_UNHANDLED_REJECTION = 'unhandled-rejection' -export const ACTION_VERSION_INFO = 'version-info' -export const INITIAL_OVERLAY_STATE: OverlayState = { - nextId: 1, - buildError: null, - errors: [], - notFound: false, - refreshState: { type: 'idle' }, - versionInfo: { installed: '0.0.0', staleness: 'unknown' }, - rootLayoutMissingTags: null, -} - -interface BuildOkAction { - type: typeof ACTION_BUILD_OK -} -interface BuildErrorAction { - type: typeof ACTION_BUILD_ERROR - message: string -} -interface BeforeFastRefreshAction { - type: typeof ACTION_BEFORE_REFRESH -} -interface FastRefreshAction { - type: typeof ACTION_REFRESH -} - -export interface UnhandledErrorAction { - type: typeof ACTION_UNHANDLED_ERROR - reason: Error - frames: StackFrame[] - componentStackFrames?: ComponentStackFrame[] - warning?: [string, string, string] -} -export interface UnhandledRejectionAction { - type: typeof ACTION_UNHANDLED_REJECTION - reason: Error - frames: StackFrame[] -} - -interface VersionInfoAction { - type: typeof ACTION_VERSION_INFO - versionInfo: VersionInfo -} - -export type FastRefreshState = - | { - type: 'idle' - } - | { - type: 'pending' - errors: SupportedErrorEvent[] - } - -export interface OverlayState { - nextId: number - buildError: string | null - errors: SupportedErrorEvent[] - rootLayoutMissingTags: string[] | null - refreshState: FastRefreshState - versionInfo: VersionInfo - notFound: boolean -} - -function pushErrorFilterDuplicates( - errors: SupportedErrorEvent[], - err: SupportedErrorEvent -): SupportedErrorEvent[] { - return [ - ...errors.filter((e) => { - // Filter out duplicate errors - return e.event.reason !== err.event.reason - }), - err, - ] -} - -export const errorOverlayReducer: React.Reducer< - Readonly, - Readonly< - | BuildOkAction - | BuildErrorAction - | BeforeFastRefreshAction - | FastRefreshAction - | UnhandledErrorAction - | UnhandledRejectionAction - | VersionInfoAction - > -> = (state, action) => { - switch (action.type) { - case ACTION_BUILD_OK: { - return { ...state, buildError: null } - } - case ACTION_BUILD_ERROR: { - return { ...state, buildError: action.message } - } - case ACTION_BEFORE_REFRESH: { - return { ...state, refreshState: { type: 'pending', errors: [] } } - } - case ACTION_REFRESH: { - return { - ...state, - buildError: null, - errors: - // Errors can come in during updates. In this case, UNHANDLED_ERROR - // and UNHANDLED_REJECTION events might be dispatched between the - // BEFORE_REFRESH and the REFRESH event. We want to keep those errors - // around until the next refresh. Otherwise we run into a race - // condition where those errors would be cleared on refresh completion - // before they can be displayed. - state.refreshState.type === 'pending' - ? state.refreshState.errors - : [], - refreshState: { type: 'idle' }, - } - } - case ACTION_UNHANDLED_ERROR: - case ACTION_UNHANDLED_REJECTION: { - switch (state.refreshState.type) { - case 'idle': { - return { - ...state, - nextId: state.nextId + 1, - errors: pushErrorFilterDuplicates(state.errors, { - id: state.nextId, - event: action, - }), - } - } - case 'pending': { - return { - ...state, - nextId: state.nextId + 1, - refreshState: { - ...state.refreshState, - errors: pushErrorFilterDuplicates(state.refreshState.errors, { - id: state.nextId, - event: action, - }), - }, - } - } - default: - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const _: never = state.refreshState - return state - } - } - case ACTION_VERSION_INFO: { - return { ...state, versionInfo: action.versionInfo } - } - default: { - return state - } - } -} diff --git a/packages/next/src/client/components/react-dev-overlay/app/hot-reloader-client.tsx b/packages/next/src/client/components/react-dev-overlay/app/hot-reloader-client.tsx index f4c710962b..5f64427182 100644 --- a/packages/next/src/client/components/react-dev-overlay/app/hot-reloader-client.tsx +++ b/packages/next/src/client/components/react-dev-overlay/app/hot-reloader-client.tsx @@ -1,27 +1,18 @@ import type { ReactNode } from 'react' -import React, { - useCallback, - useEffect, - useReducer, - useMemo, - startTransition, -} from 'react' +import { useCallback, useEffect, startTransition, useMemo } from 'react' import stripAnsi from 'next/dist/compiled/strip-ansi' -import formatWebpackMessages from '../../../dev/error-overlay/format-webpack-messages' +import formatWebpackMessages from '../internal/helpers/format-webpack-messages' import { useRouter } from '../../navigation' import { - ACTION_VERSION_INFO, - INITIAL_OVERLAY_STATE, - errorOverlayReducer, -} from './error-overlay-reducer' -import { - ACTION_BUILD_OK, - ACTION_BUILD_ERROR, ACTION_BEFORE_REFRESH, + ACTION_BUILD_ERROR, + ACTION_BUILD_OK, ACTION_REFRESH, ACTION_UNHANDLED_ERROR, ACTION_UNHANDLED_REJECTION, -} from './error-overlay-reducer' + ACTION_VERSION_INFO, + useErrorOverlayReducer, +} from '../shared' import { parseStack } from '../internal/helpers/parseStack' import ReactDevOverlay from './ReactDevOverlay' import { useErrorHandler } from '../internal/helpers/use-error-handler' @@ -40,9 +31,8 @@ import type { TurbopackMsgToBrowser, } from '../../../../server/dev/hot-reloader-types' import { extractModulesFromTurbopackMessage } from '../../../../server/dev/extract-modules-from-turbopack-message' -import { REACT_REFRESH_FULL_RELOAD_FROM_ERROR } from '../../../dev/error-overlay/messages' +import { REACT_REFRESH_FULL_RELOAD_FROM_ERROR } from '../shared' import type { HydrationErrorState } from '../internal/helpers/hydration-error-info' - interface Dispatcher { onBuildOk(): void onBuildError(message: string): void @@ -244,6 +234,7 @@ function tryApplyUpdates( ) } +/** Handles messages from the sevrer for the App Router. */ function processMessage( obj: HMR_ACTION_TYPES, sendMessage: (message: string) => void, @@ -314,9 +305,8 @@ function processMessage( const { errors, warnings } = obj // Is undefined when it's a 'built' event - if ('versionInfo' in obj) { - dispatcher.onVersionInfo(obj.versionInfo) - } + if ('versionInfo' in obj) dispatcher.onVersionInfo(obj.versionInfo) + const hasErrors = Boolean(errors && errors.length) // Compilation with errors (e.g. syntax error or missing modules). if (hasErrors) { @@ -464,10 +454,8 @@ export default function HotReload({ assetPrefix: string children?: ReactNode }) { - const [state, dispatch] = useReducer( - errorOverlayReducer, - INITIAL_OVERLAY_STATE - ) + const [state, dispatch] = useErrorOverlayReducer() + const dispatcher = useMemo(() => { return { onBuildOk() { @@ -486,32 +474,38 @@ export default function HotReload({ dispatch({ type: ACTION_VERSION_INFO, versionInfo }) }, } - }, []) + }, [dispatch]) - const handleOnUnhandledError = useCallback((error: Error): void => { - const errorDetails = (error as any).details as - | HydrationErrorState - | undefined - // Component stack is added to the error in use-error-handler in case there was a hydration errror - const componentStack = errorDetails?.componentStack - const warning = errorDetails?.warning - dispatch({ - type: ACTION_UNHANDLED_ERROR, - reason: error, - frames: parseStack(error.stack!), - componentStackFrames: componentStack - ? parseComponentStack(componentStack) - : undefined, - warning, - }) - }, []) - const handleOnUnhandledRejection = useCallback((reason: Error): void => { - dispatch({ - type: ACTION_UNHANDLED_REJECTION, - reason: reason, - frames: parseStack(reason.stack!), - }) - }, []) + const handleOnUnhandledError = useCallback( + (error: Error): void => { + const errorDetails = (error as any).details as + | HydrationErrorState + | undefined + // Component stack is added to the error in use-error-handler in case there was a hydration errror + const componentStack = errorDetails?.componentStack + const warning = errorDetails?.warning + dispatch({ + type: ACTION_UNHANDLED_ERROR, + reason: error, + frames: parseStack(error.stack!), + componentStackFrames: componentStack + ? parseComponentStack(componentStack) + : undefined, + warning, + }) + }, + [dispatch] + ) + const handleOnUnhandledRejection = useCallback( + (reason: Error): void => { + dispatch({ + type: ACTION_UNHANDLED_REJECTION, + reason: reason, + frames: parseStack(reason.stack!), + }) + }, + [dispatch] + ) const handleOnReactError = useCallback(() => { RuntimeErrorHandler.hadRuntimeError = true }, []) diff --git a/packages/next/src/client/components/react-dev-overlay/internal/container/Errors.tsx b/packages/next/src/client/components/react-dev-overlay/internal/container/Errors.tsx index f5fb01b24e..d197214241 100644 --- a/packages/next/src/client/components/react-dev-overlay/internal/container/Errors.tsx +++ b/packages/next/src/client/components/react-dev-overlay/internal/container/Errors.tsx @@ -2,11 +2,9 @@ import { useState, useEffect, useMemo, useCallback } from 'react' import { ACTION_UNHANDLED_ERROR, ACTION_UNHANDLED_REJECTION, -} from '../../app/error-overlay-reducer' -import type { - UnhandledErrorAction, - UnhandledRejectionAction, -} from '../../app/error-overlay-reducer' + type UnhandledErrorAction, + type UnhandledRejectionAction, +} from '../../shared' import { Dialog, DialogBody, diff --git a/packages/next/src/client/components/react-dev-overlay/internal/container/root-layout-missing-tags-error.tsx b/packages/next/src/client/components/react-dev-overlay/internal/container/root-layout-missing-tags-error.tsx index d52dccc5e0..4cb31c0fa7 100644 --- a/packages/next/src/client/components/react-dev-overlay/internal/container/root-layout-missing-tags-error.tsx +++ b/packages/next/src/client/components/react-dev-overlay/internal/container/root-layout-missing-tags-error.tsx @@ -5,7 +5,7 @@ import { Overlay } from '../components/Overlay' import { VersionStalenessInfo } from '../components/VersionStalenessInfo' import { HotlinkedText } from '../components/hot-linked-text' -export type RootLayoutMissingTagsErrorProps = { +type RootLayoutMissingTagsErrorProps = { missingTags: string[] versionInfo?: VersionInfo } diff --git a/packages/next/src/client/dev/error-overlay/format-webpack-messages.ts b/packages/next/src/client/components/react-dev-overlay/internal/helpers/format-webpack-messages.ts similarity index 100% rename from packages/next/src/client/dev/error-overlay/format-webpack-messages.ts rename to packages/next/src/client/components/react-dev-overlay/internal/helpers/format-webpack-messages.ts diff --git a/packages/next/src/client/components/react-dev-overlay/internal/helpers/getErrorByType.ts b/packages/next/src/client/components/react-dev-overlay/internal/helpers/getErrorByType.ts index 0d72a1474f..2b8157e35e 100644 --- a/packages/next/src/client/components/react-dev-overlay/internal/helpers/getErrorByType.ts +++ b/packages/next/src/client/components/react-dev-overlay/internal/helpers/getErrorByType.ts @@ -1,7 +1,7 @@ import { ACTION_UNHANDLED_ERROR, ACTION_UNHANDLED_REJECTION, -} from '../../app/error-overlay-reducer' +} from '../../shared' import type { SupportedErrorEvent } from '../container/Errors' import { getOriginalStackFrames } from './stack-frame' import type { OriginalStackFrame } from './stack-frame' 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 3eb007ce0e..2cd1939b54 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 @@ -32,9 +32,7 @@ function getOriginalStackFrame( `${ process.env.__NEXT_ROUTER_BASEPATH || '' }/__nextjs_original-stack-frame?${params.toString()}`, - { - signal: controller.signal, - } + { signal: controller.signal } ) .finally(() => { clearTimeout(tm) diff --git a/packages/next/src/client/components/react-dev-overlay/pages/ErrorBoundary.tsx b/packages/next/src/client/components/react-dev-overlay/pages/ErrorBoundary.tsx index 429c7cefa4..a640082253 100644 --- a/packages/next/src/client/components/react-dev-overlay/pages/ErrorBoundary.tsx +++ b/packages/next/src/client/components/react-dev-overlay/pages/ErrorBoundary.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import * as React from 'react' type ErrorBoundaryProps = { children?: React.ReactNode @@ -8,7 +8,7 @@ type ErrorBoundaryProps = { } type ErrorBoundaryState = { error: Error | null } -class ErrorBoundary extends React.PureComponent< +export class ErrorBoundary extends React.PureComponent< ErrorBoundaryProps, ErrorBoundaryState > { @@ -47,5 +47,3 @@ class ErrorBoundary extends React.PureComponent< ) } } - -export { ErrorBoundary } diff --git a/packages/next/src/client/components/react-dev-overlay/pages/ReactDevOverlay.tsx b/packages/next/src/client/components/react-dev-overlay/pages/ReactDevOverlay.tsx index 482850275f..61233b98a3 100644 --- a/packages/next/src/client/components/react-dev-overlay/pages/ReactDevOverlay.tsx +++ b/packages/next/src/client/components/react-dev-overlay/pages/ReactDevOverlay.tsx @@ -3,117 +3,12 @@ import * as React from 'react' import * as Bus from './bus' import { ShadowPortal } from '../internal/components/ShadowPortal' import { BuildError } from '../internal/container/BuildError' -import type { SupportedErrorEvent } from '../internal/container/Errors' import { Errors } from '../internal/container/Errors' import { ErrorBoundary } from './ErrorBoundary' import { Base } from '../internal/styles/Base' import { ComponentStyles } from '../internal/styles/ComponentStyles' import { CssReset } from '../internal/styles/CssReset' -import type { VersionInfo } from '../../../../server/dev/parse-version-info' - -type RefreshState = - | { - // No refresh in progress. - type: 'idle' - } - | { - // The refresh process has been triggered, but the new code has not been - // executed yet. - type: 'pending' - errors: SupportedErrorEvent[] - } - -type OverlayState = { - nextId: number - buildError: string | null - errors: SupportedErrorEvent[] - refreshState: RefreshState - versionInfo: VersionInfo -} - -function pushErrorFilterDuplicates( - errors: SupportedErrorEvent[], - err: SupportedErrorEvent -): SupportedErrorEvent[] { - return [ - ...errors.filter((e) => { - // Filter out duplicate errors - return e.event.reason !== err.event.reason - }), - err, - ] -} - -function reducer(state: OverlayState, ev: Bus.BusEvent): OverlayState { - switch (ev.type) { - case Bus.TYPE_BUILD_OK: { - return { ...state, buildError: null } - } - case Bus.TYPE_BUILD_ERROR: { - return { ...state, buildError: ev.message } - } - case Bus.TYPE_BEFORE_REFRESH: { - return { ...state, refreshState: { type: 'pending', errors: [] } } - } - case Bus.TYPE_REFRESH: { - return { - ...state, - buildError: null, - errors: - // Errors can come in during updates. In this case, UNHANDLED_ERROR - // and UNHANDLED_REJECTION events might be dispatched between the - // BEFORE_REFRESH and the REFRESH event. We want to keep those errors - // around until the next refresh. Otherwise we run into a race - // condition where those errors would be cleared on refresh completion - // before they can be displayed. - state.refreshState.type === 'pending' - ? state.refreshState.errors - : [], - refreshState: { type: 'idle' }, - } - } - case Bus.TYPE_UNHANDLED_ERROR: - case Bus.TYPE_UNHANDLED_REJECTION: { - switch (state.refreshState.type) { - case 'idle': { - return { - ...state, - nextId: state.nextId + 1, - errors: pushErrorFilterDuplicates(state.errors, { - id: state.nextId, - event: ev, - }), - } - } - case 'pending': { - return { - ...state, - nextId: state.nextId + 1, - refreshState: { - ...state.refreshState, - errors: pushErrorFilterDuplicates(state.refreshState.errors, { - id: state.nextId, - event: ev, - }), - }, - } - } - default: - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const _: never = state.refreshState - return state - } - } - case Bus.TYPE_VERSION_INFO: { - return { ...state, versionInfo: ev.versionInfo } - } - default: { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const _: never = ev - return state - } - } -} +import { useErrorOverlayReducer } from '../shared' type ErrorType = 'runtime' | 'build' @@ -127,83 +22,74 @@ const shouldPreventDisplay = ( return preventType.includes(errorType) } -type ReactDevOverlayProps = { +interface ReactDevOverlayProps { children?: React.ReactNode preventDisplay?: ErrorType[] globalOverlay?: boolean } -const ReactDevOverlay: React.FunctionComponent = - function ReactDevOverlay({ children, preventDisplay, globalOverlay }) { - const [state, dispatch] = React.useReducer< - React.Reducer - >(reducer, { - nextId: 1, - buildError: null, - errors: [], - refreshState: { - type: 'idle', - }, - versionInfo: { installed: '0.0.0', staleness: 'unknown' }, - }) +export default function ReactDevOverlay({ + children, + preventDisplay, + globalOverlay, +}: ReactDevOverlayProps) { + const [state, dispatch] = useErrorOverlayReducer() - React.useEffect(() => { - Bus.on(dispatch) - return function () { - Bus.off(dispatch) - } - }, [dispatch]) + React.useEffect(() => { + Bus.on(dispatch) + return function () { + Bus.off(dispatch) + } + }, [dispatch]) - const onComponentError = React.useCallback( - (_error: Error, _componentStack: string | null) => { - // TODO: special handling - }, - [] - ) + const onComponentError = React.useCallback( + (_error: Error, _componentStack: string | null) => { + // TODO: special handling + }, + [] + ) - const hasBuildError = state.buildError != null - const hasRuntimeErrors = Boolean(state.errors.length) - const errorType = hasBuildError - ? 'build' - : hasRuntimeErrors - ? 'runtime' - : null - const isMounted = errorType !== null + const hasBuildError = state.buildError != null + const hasRuntimeErrors = Boolean(state.errors.length) + const errorType = hasBuildError + ? 'build' + : hasRuntimeErrors + ? 'runtime' + : null + const isMounted = errorType !== null - const displayPrevented = shouldPreventDisplay(errorType, preventDisplay) + const displayPrevented = shouldPreventDisplay(errorType, preventDisplay) - return ( - - - {children ?? null} - - {isMounted ? ( - - - - + return ( + <> + + {children ?? null} + + {isMounted ? ( + + + + - {displayPrevented ? null : hasBuildError ? ( - - ) : hasRuntimeErrors ? ( - - ) : undefined} - - ) : undefined} - - ) - } - -export default ReactDevOverlay + {displayPrevented ? null : hasBuildError ? ( + + ) : hasRuntimeErrors ? ( + + ) : undefined} + + ) : undefined} + + ) +} diff --git a/packages/next/src/client/components/react-dev-overlay/pages/bus.ts b/packages/next/src/client/components/react-dev-overlay/pages/bus.ts index 7accde729d..47835b047b 100644 --- a/packages/next/src/client/components/react-dev-overlay/pages/bus.ts +++ b/packages/next/src/client/components/react-dev-overlay/pages/bus.ts @@ -1,47 +1,4 @@ -import type { StackFrame } from 'next/dist/compiled/stacktrace-parser' -import type { ComponentStackFrame } from '../internal/helpers/parse-component-stack' -import type { VersionInfo } from '../../../../server/dev/parse-version-info' - -export const TYPE_BUILD_OK = 'build-ok' -export const TYPE_BUILD_ERROR = 'build-error' -export const TYPE_REFRESH = 'fast-refresh' -export const TYPE_BEFORE_REFRESH = 'before-fast-refresh' -export const TYPE_UNHANDLED_ERROR = 'unhandled-error' -export const TYPE_UNHANDLED_REJECTION = 'unhandled-rejection' -export const TYPE_VERSION_INFO = 'version-info' - -export type BuildOk = { type: typeof TYPE_BUILD_OK } -export type BuildError = { - type: typeof TYPE_BUILD_ERROR - message: string -} -export type BeforeFastRefresh = { type: typeof TYPE_BEFORE_REFRESH } -export type FastRefresh = { type: typeof TYPE_REFRESH } -export type UnhandledError = { - type: typeof TYPE_UNHANDLED_ERROR - reason: Error - frames: StackFrame[] - componentStackFrames?: ComponentStackFrame[] -} -export type UnhandledRejection = { - type: typeof TYPE_UNHANDLED_REJECTION - reason: Error - frames: StackFrame[] -} - -export type VersionInfoEvent = { - type: typeof TYPE_VERSION_INFO - versionInfo: VersionInfo -} - -export type BusEvent = - | BuildOk - | BuildError - | FastRefresh - | BeforeFastRefresh - | UnhandledError - | UnhandledRejection - | VersionInfoEvent +import type { BusEvent } from '../shared' export type BusEventHandler = (ev: BusEvent) => void diff --git a/packages/next/src/client/components/react-dev-overlay/pages/client.ts b/packages/next/src/client/components/react-dev-overlay/pages/client.ts index 56a41803c2..01fdfdfbc2 100644 --- a/packages/next/src/client/components/react-dev-overlay/pages/client.ts +++ b/packages/next/src/client/components/react-dev-overlay/pages/client.ts @@ -5,6 +5,15 @@ import { hydrationErrorState, patchConsoleError, } from '../internal/helpers/hydration-error-info' +import { + ACTION_BEFORE_REFRESH, + ACTION_BUILD_ERROR, + ACTION_BUILD_OK, + ACTION_REFRESH, + ACTION_UNHANDLED_ERROR, + ACTION_UNHANDLED_REJECTION, + ACTION_VERSION_INFO, +} from '../shared' import type { VersionInfo } from '../../../../server/dev/parse-version-info' // Patch console.error to collect information about hydration errors @@ -46,7 +55,7 @@ function onUnhandledError(ev: ErrorEvent) { // This is to avoid same error as different type showing up on client to cause flashing. if (e.name !== 'ModuleBuildError' && e.name !== 'ModuleNotFoundError') { Bus.emit({ - type: Bus.TYPE_UNHANDLED_ERROR, + type: ACTION_UNHANDLED_ERROR, reason: error, frames: parseStack(e.stack!), componentStackFrames, @@ -67,13 +76,13 @@ function onUnhandledRejection(ev: PromiseRejectionEvent) { const e = reason Bus.emit({ - type: Bus.TYPE_UNHANDLED_REJECTION, + type: ACTION_UNHANDLED_REJECTION, reason: reason, frames: parseStack(e.stack!), }) } -function register() { +export function register() { if (isRegistered) { return } @@ -89,7 +98,7 @@ function register() { window.addEventListener('unhandledrejection', onUnhandledRejection) } -function unregister() { +export function unregister() { if (!isRegistered) { return } @@ -106,35 +115,26 @@ function unregister() { window.removeEventListener('unhandledrejection', onUnhandledRejection) } -function onBuildOk() { - Bus.emit({ type: Bus.TYPE_BUILD_OK }) +export function onBuildOk() { + Bus.emit({ type: ACTION_BUILD_OK }) } -function onBuildError(message: string) { - Bus.emit({ type: Bus.TYPE_BUILD_ERROR, message }) +export function onBuildError(message: string) { + Bus.emit({ type: ACTION_BUILD_ERROR, message }) } -function onRefresh() { - Bus.emit({ type: Bus.TYPE_REFRESH }) +export function onRefresh() { + Bus.emit({ type: ACTION_REFRESH }) } -function onBeforeRefresh() { - Bus.emit({ type: Bus.TYPE_BEFORE_REFRESH }) +export function onBeforeRefresh() { + Bus.emit({ type: ACTION_BEFORE_REFRESH }) } -function onVersionInfo(versionInfo: VersionInfo) { - Bus.emit({ type: Bus.TYPE_VERSION_INFO, versionInfo }) +export function onVersionInfo(versionInfo: VersionInfo) { + Bus.emit({ type: ACTION_VERSION_INFO, versionInfo }) } export { getErrorByType } from '../internal/helpers/getErrorByType' export { getServerError } from '../internal/helpers/nodeStackFrames' export { default as ReactDevOverlay } from './ReactDevOverlay' -export { - onBuildOk, - onBuildError, - register, - unregister, - onBeforeRefresh, - onRefresh, - onVersionInfo, -} diff --git a/packages/next/src/client/dev/error-overlay/hot-dev-client.ts b/packages/next/src/client/components/react-dev-overlay/pages/hot-reloader-client.ts similarity index 96% rename from packages/next/src/client/dev/error-overlay/hot-dev-client.ts rename to packages/next/src/client/components/react-dev-overlay/pages/hot-reloader-client.ts index 180b2d569f..5bf2491edc 100644 --- a/packages/next/src/client/dev/error-overlay/hot-dev-client.ts +++ b/packages/next/src/client/components/react-dev-overlay/pages/hot-reloader-client.ts @@ -35,17 +35,17 @@ import { onBeforeRefresh, onRefresh, onVersionInfo, -} from '../../components/react-dev-overlay/pages/client' +} from './client' import stripAnsi from 'next/dist/compiled/strip-ansi' import { addMessageListener, sendMessage } from './websocket' -import formatWebpackMessages from './format-webpack-messages' -import { HMR_ACTIONS_SENT_TO_BROWSER } from '../../../server/dev/hot-reloader-types' +import formatWebpackMessages from '../internal/helpers/format-webpack-messages' +import { HMR_ACTIONS_SENT_TO_BROWSER } from '../../../../server/dev/hot-reloader-types' import type { HMR_ACTION_TYPES, TurbopackMsgToBrowser, -} from '../../../server/dev/hot-reloader-types' -import { extractModulesFromTurbopackMessage } from '../../../server/dev/extract-modules-from-turbopack-message' -import { REACT_REFRESH_FULL_RELOAD_FROM_ERROR } from './messages' +} from '../../../../server/dev/hot-reloader-types' +import { extractModulesFromTurbopackMessage } from '../../../../server/dev/extract-modules-from-turbopack-message' +import { REACT_REFRESH_FULL_RELOAD_FROM_ERROR } from '../shared' // This alternative WebpackDevServer combines the functionality of: // https://github.com/webpack/webpack-dev-server/blob/webpack-1/client/index.js // https://github.com/webpack/webpack/blob/webpack-1/hot/dev-server.js @@ -258,7 +258,7 @@ function handleAvailableHash(hash: string) { mostRecentCompilationHash = hash } -// Handle messages from the server. +/** Handles messages from the sevrer for the Pages Router. */ function processMessage(obj: HMR_ACTION_TYPES) { if (!('action' in obj)) { return @@ -273,9 +273,7 @@ function processMessage(obj: HMR_ACTION_TYPES) { } case HMR_ACTIONS_SENT_TO_BROWSER.BUILT: case HMR_ACTIONS_SENT_TO_BROWSER.SYNC: { - if (obj.hash) { - handleAvailableHash(obj.hash) - } + if (obj.hash) handleAvailableHash(obj.hash) const { errors, warnings } = obj diff --git a/packages/next/src/client/dev/error-overlay/websocket.ts b/packages/next/src/client/components/react-dev-overlay/pages/websocket.ts similarity index 96% rename from packages/next/src/client/dev/error-overlay/websocket.ts rename to packages/next/src/client/components/react-dev-overlay/pages/websocket.ts index 07d9fbec60..315c4dfc97 100644 --- a/packages/next/src/client/dev/error-overlay/websocket.ts +++ b/packages/next/src/client/components/react-dev-overlay/pages/websocket.ts @@ -1,4 +1,4 @@ -import type { HMR_ACTION_TYPES } from '../../../server/dev/hot-reloader-types' +import type { HMR_ACTION_TYPES } from '../../../../server/dev/hot-reloader-types' let source: WebSocket diff --git a/packages/next/src/client/components/react-dev-overlay/shared.ts b/packages/next/src/client/components/react-dev-overlay/shared.ts new file mode 100644 index 0000000000..3151721c09 --- /dev/null +++ b/packages/next/src/client/components/react-dev-overlay/shared.ts @@ -0,0 +1,168 @@ +import { useReducer } from 'react' + +import type { StackFrame } from 'next/dist/compiled/stacktrace-parser' +import type { VersionInfo } from '../../../server/dev/parse-version-info' +import type { SupportedErrorEvent } from './internal/container/Errors' +import type { ComponentStackFrame } from './internal/helpers/parse-component-stack' + +type FastRefreshState = + /** No refresh in progress. */ + | { type: 'idle' } + /** The refresh process has been triggered, but the new code has not been executed yet. */ + | { type: 'pending'; errors: SupportedErrorEvent[] } + +export interface OverlayState { + nextId: number + buildError: string | null + errors: SupportedErrorEvent[] + refreshState: FastRefreshState + rootLayoutMissingTags: typeof window.__next_root_layout_missing_tags + versionInfo: VersionInfo + notFound: boolean +} + +export const ACTION_BUILD_OK = 'build-ok' +export const ACTION_BUILD_ERROR = 'build-error' +export const ACTION_BEFORE_REFRESH = 'before-fast-refresh' +export const ACTION_REFRESH = 'fast-refresh' +export const ACTION_VERSION_INFO = 'version-info' +export const ACTION_UNHANDLED_ERROR = 'unhandled-error' +export const ACTION_UNHANDLED_REJECTION = 'unhandled-rejection' + +interface BuildOkAction { + type: typeof ACTION_BUILD_OK +} +interface BuildErrorAction { + type: typeof ACTION_BUILD_ERROR + message: string +} +interface BeforeFastRefreshAction { + type: typeof ACTION_BEFORE_REFRESH +} +interface FastRefreshAction { + type: typeof ACTION_REFRESH +} + +export interface UnhandledErrorAction { + type: typeof ACTION_UNHANDLED_ERROR + reason: Error + frames: StackFrame[] + componentStackFrames?: ComponentStackFrame[] + warning?: [string, string, string] +} +export interface UnhandledRejectionAction { + type: typeof ACTION_UNHANDLED_REJECTION + reason: Error + frames: StackFrame[] +} + +interface VersionInfoAction { + type: typeof ACTION_VERSION_INFO + versionInfo: VersionInfo +} + +export type BusEvent = + | BuildOkAction + | BuildErrorAction + | BeforeFastRefreshAction + | FastRefreshAction + | UnhandledErrorAction + | UnhandledRejectionAction + | VersionInfoAction + +function pushErrorFilterDuplicates( + errors: SupportedErrorEvent[], + err: SupportedErrorEvent +): SupportedErrorEvent[] { + return [ + ...errors.filter((e) => { + // Filter out duplicate errors + return e.event.reason !== err.event.reason + }), + err, + ] +} + +export const INITIAL_OVERLAY_STATE: OverlayState = { + nextId: 1, + buildError: null, + errors: [], + notFound: false, + refreshState: { type: 'idle' }, + rootLayoutMissingTags: [], + versionInfo: { installed: '0.0.0', staleness: 'unknown' }, +} + +export function useErrorOverlayReducer() { + return useReducer>((_state, action) => { + switch (action.type) { + case ACTION_BUILD_OK: { + return { ..._state, buildError: null } + } + case ACTION_BUILD_ERROR: { + return { ..._state, buildError: action.message } + } + case ACTION_BEFORE_REFRESH: { + return { ..._state, refreshState: { type: 'pending', errors: [] } } + } + case ACTION_REFRESH: { + return { + ..._state, + buildError: null, + errors: + // Errors can come in during updates. In this case, UNHANDLED_ERROR + // and UNHANDLED_REJECTION events might be dispatched between the + // BEFORE_REFRESH and the REFRESH event. We want to keep those errors + // around until the next refresh. Otherwise we run into a race + // condition where those errors would be cleared on refresh completion + // before they can be displayed. + _state.refreshState.type === 'pending' + ? _state.refreshState.errors + : [], + refreshState: { type: 'idle' }, + } + } + case ACTION_UNHANDLED_ERROR: + case ACTION_UNHANDLED_REJECTION: { + switch (_state.refreshState.type) { + case 'idle': { + return { + ..._state, + nextId: _state.nextId + 1, + errors: pushErrorFilterDuplicates(_state.errors, { + id: _state.nextId, + event: action, + }), + } + } + case 'pending': { + return { + ..._state, + nextId: _state.nextId + 1, + refreshState: { + ..._state.refreshState, + errors: pushErrorFilterDuplicates(_state.refreshState.errors, { + id: _state.nextId, + event: action, + }), + }, + } + } + default: + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _: never = _state.refreshState + return _state + } + } + case ACTION_VERSION_INFO: { + return { ..._state, versionInfo: action.versionInfo } + } + default: { + return _state + } + } + }, INITIAL_OVERLAY_STATE) +} + +export const REACT_REFRESH_FULL_RELOAD_FROM_ERROR = + '[Fast Refresh] performing full reload because your application had an unrecoverable error' diff --git a/packages/next/src/client/dev/amp-dev.ts b/packages/next/src/client/dev/amp-dev.ts index 540e66c377..46facd18bd 100644 --- a/packages/next/src/client/dev/amp-dev.ts +++ b/packages/next/src/client/dev/amp-dev.ts @@ -1,7 +1,10 @@ /* globals __webpack_hash__ */ import { displayContent } from './fouc' import initOnDemandEntries from './on-demand-entries-client' -import { addMessageListener, connectHMR } from './error-overlay/websocket' +import { + addMessageListener, + connectHMR, +} from '../components/react-dev-overlay/pages/websocket' import { HMR_ACTIONS_SENT_TO_BROWSER } from '../../server/dev/hot-reloader-types' declare global { diff --git a/packages/next/src/client/dev/dev-build-watcher.ts b/packages/next/src/client/dev/dev-build-watcher.ts index 9566ab78f5..c168aeeeff 100644 --- a/packages/next/src/client/dev/dev-build-watcher.ts +++ b/packages/next/src/client/dev/dev-build-watcher.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-use-before-define */ import { HMR_ACTIONS_SENT_TO_BROWSER } from '../../server/dev/hot-reloader-types' import type { HMR_ACTION_TYPES } from '../../server/dev/hot-reloader-types' -import { addMessageListener } from './error-overlay/websocket' +import { addMessageListener } from '../components/react-dev-overlay/pages/websocket' type VerticalPosition = 'top' | 'bottom' type HorizonalPosition = 'left' | 'right' diff --git a/packages/next/src/client/dev/error-overlay/messages.ts b/packages/next/src/client/dev/error-overlay/messages.ts deleted file mode 100644 index dacbfca069..0000000000 --- a/packages/next/src/client/dev/error-overlay/messages.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const REACT_REFRESH_FULL_RELOAD_FROM_ERROR = - '[Fast Refresh] performing full reload because your application had an unrecoverable error' diff --git a/packages/next/src/client/dev/hot-middleware-client.ts b/packages/next/src/client/dev/hot-middleware-client.ts index 922fa041ff..13c7be5803 100644 --- a/packages/next/src/client/dev/hot-middleware-client.ts +++ b/packages/next/src/client/dev/hot-middleware-client.ts @@ -1,5 +1,5 @@ -import connect from './error-overlay/hot-dev-client' -import { sendMessage } from './error-overlay/websocket' +import connect from '../components/react-dev-overlay/pages/hot-reloader-client' +import { sendMessage } from '../components/react-dev-overlay/pages/websocket' let reloading = false diff --git a/packages/next/src/client/dev/on-demand-entries-client.ts b/packages/next/src/client/dev/on-demand-entries-client.ts index b9ead0f7e8..cea83ee899 100644 --- a/packages/next/src/client/dev/on-demand-entries-client.ts +++ b/packages/next/src/client/dev/on-demand-entries-client.ts @@ -1,5 +1,5 @@ import Router from '../router' -import { sendMessage } from './error-overlay/websocket' +import { sendMessage } from '../components/react-dev-overlay/pages/websocket' export default async (page?: string) => { if (page) { diff --git a/packages/next/src/client/page-bootstrap.ts b/packages/next/src/client/page-bootstrap.ts index 74a30d1195..c8d0d61591 100644 --- a/packages/next/src/client/page-bootstrap.ts +++ b/packages/next/src/client/page-bootstrap.ts @@ -3,7 +3,10 @@ import initOnDemandEntries from './dev/on-demand-entries-client' import initializeBuildWatcher from './dev/dev-build-watcher' import type { ShowHideHandler } from './dev/dev-build-watcher' import { displayContent } from './dev/fouc' -import { connectHMR, addMessageListener } from './dev/error-overlay/websocket' +import { + connectHMR, + addMessageListener, +} from './components/react-dev-overlay/pages/websocket' import { assign, urlQueryToSearchParams, @@ -29,79 +32,86 @@ export function pageBootrap(assetPrefix: string) { addMessageListener((payload) => { if (reloading) return if ('action' in payload) { - if (payload.action === HMR_ACTIONS_SENT_TO_BROWSER.SERVER_ERROR) { - const { stack, message } = JSON.parse(payload.errorJSON) - const error = new Error(message) - error.stack = stack - throw error - } else if (payload.action === HMR_ACTIONS_SENT_TO_BROWSER.RELOAD_PAGE) { - reloading = true - window.location.reload() - } else if ( - payload.action === - HMR_ACTIONS_SENT_TO_BROWSER.DEV_PAGES_MANIFEST_UPDATE - ) { - fetch( - `${assetPrefix}/_next/static/development/_devPagesManifest.json` - ) - .then((res) => res.json()) - .then((manifest) => { - window.__DEV_PAGES_MANIFEST = manifest - }) - .catch((err) => { - console.log(`Failed to fetch devPagesManifest`, err) - }) + switch (payload.action) { + case HMR_ACTIONS_SENT_TO_BROWSER.SERVER_ERROR: { + const { stack, message } = JSON.parse(payload.errorJSON) + const error = new Error(message) + error.stack = stack + throw error + } + case HMR_ACTIONS_SENT_TO_BROWSER.RELOAD_PAGE: { + reloading = true + window.location.reload() + break + } + case HMR_ACTIONS_SENT_TO_BROWSER.DEV_PAGES_MANIFEST_UPDATE: { + fetch( + `${assetPrefix}/_next/static/development/_devPagesManifest.json` + ) + .then((res) => res.json()) + .then((manifest) => { + window.__DEV_PAGES_MANIFEST = manifest + }) + .catch((err) => { + console.log(`Failed to fetch devPagesManifest`, err) + }) + break + } + default: + break } } else if ('event' in payload) { - if (payload.event === HMR_ACTIONS_SENT_TO_BROWSER.MIDDLEWARE_CHANGES) { - return window.location.reload() - } else if ( - payload.event === HMR_ACTIONS_SENT_TO_BROWSER.CLIENT_CHANGES - ) { - const isOnErrorPage = window.next.router.pathname === '/_error' - // On the error page we want to reload the page when a page was changed - if (isOnErrorPage) { + switch (payload.event) { + case HMR_ACTIONS_SENT_TO_BROWSER.MIDDLEWARE_CHANGES: { return window.location.reload() } - } else if ( - payload.event === HMR_ACTIONS_SENT_TO_BROWSER.SERVER_ONLY_CHANGES - ) { - const { pages } = payload - - // Make sure to reload when the dev-overlay is showing for an - // API route - // TODO: Fix `__NEXT_PAGE` type - if (pages.includes(router.query.__NEXT_PAGE as string)) { - return window.location.reload() + case HMR_ACTIONS_SENT_TO_BROWSER.CLIENT_CHANGES: { + const isOnErrorPage = window.next.router.pathname === '/_error' + // On the error page we want to reload the page when a page was changed + if (isOnErrorPage) return window.location.reload() + break } + case HMR_ACTIONS_SENT_TO_BROWSER.SERVER_ONLY_CHANGES: { + const { pages } = payload - if (!router.clc && pages.includes(router.pathname)) { - console.log('Refreshing page data due to server-side change') + // Make sure to reload when the dev-overlay is showing for an + // API route + // TODO: Fix `__NEXT_PAGE` type + if (pages.includes(router.query.__NEXT_PAGE as string)) { + return window.location.reload() + } - buildIndicatorHandler?.show() + if (!router.clc && pages.includes(router.pathname)) { + console.log('Refreshing page data due to server-side change') - const clearIndicator = () => buildIndicatorHandler?.hide() + buildIndicatorHandler?.show() - router - .replace( - router.pathname + - '?' + - String( - assign( - urlQueryToSearchParams(router.query), - new URLSearchParams(location.search) - ) - ), - router.asPath, - { scroll: false } - ) - .catch(() => { - // trigger hard reload when failing to refresh data - // to show error overlay properly - location.reload() - }) - .finally(clearIndicator) + const clearIndicator = () => buildIndicatorHandler?.hide() + + router + .replace( + router.pathname + + '?' + + String( + assign( + urlQueryToSearchParams(router.query), + new URLSearchParams(location.search) + ) + ), + router.asPath, + { scroll: false } + ) + .catch(() => { + // trigger hard reload when failing to refresh data + // to show error overlay properly + location.reload() + }) + .finally(clearIndicator) + } + break } + default: + break } } }) diff --git a/packages/next/src/client/tracing/report-to-socket.ts b/packages/next/src/client/tracing/report-to-socket.ts index 493bd3aa98..9a4ef4fe04 100644 --- a/packages/next/src/client/tracing/report-to-socket.ts +++ b/packages/next/src/client/tracing/report-to-socket.ts @@ -1,4 +1,4 @@ -import { sendMessage } from '../dev/error-overlay/websocket' +import { sendMessage } from '../components/react-dev-overlay/pages/websocket' import type { Span } from './tracer' export default function reportToSocket(span: Span) {