diff --git a/docs/01-app/04-api-reference/03-file-conventions/error.mdx b/docs/01-app/04-api-reference/03-file-conventions/error.mdx index c50fa0d93f79..417a2b90fde2 100644 --- a/docs/01-app/04-api-reference/03-file-conventions/error.mdx +++ b/docs/01-app/04-api-reference/03-file-conventions/error.mdx @@ -193,11 +193,12 @@ export default function GlobalError({ error, reset }) { } ``` -> **Good to know**: `global-error.js` is only enabled in production. In development, our error overlay will show instead. +> **Good to know**: `global-error.js` is always displayed In development, error overlay will show instead. ## Version History -| Version | Changes | -| --------- | -------------------------- | -| `v13.1.0` | `global-error` introduced. | -| `v13.0.0` | `error` introduced. | +| Version | Changes | +| --------- | ------------------------------------------- | +| `v15.2.0` | display `global-error` also in development. | +| `v13.1.0` | `global-error` introduced. | +| `v13.0.0` | `error` introduced. | diff --git a/packages/next/src/client/components/app-router.tsx b/packages/next/src/client/components/app-router.tsx index e319759869d5..c46f2441d995 100644 --- a/packages/next/src/client/components/app-router.tsx +++ b/packages/next/src/client/components/app-router.tsx @@ -40,7 +40,11 @@ import { PathParamsContext, } from '../../shared/lib/hooks-client-context.shared-runtime' import { useReducer, useUnwrapState } from './use-reducer' -import { ErrorBoundary, type ErrorComponent } from './error-boundary' +import { + ErrorBoundary, + type ErrorComponent, + type GlobalErrorComponent, +} from './error-boundary' import { isBot } from '../../shared/lib/router/utils/is-bot' import { addBasePath } from '../add-base-path' import { AppRouterAnnouncer } from './app-router-announcer' @@ -242,9 +246,11 @@ function Head({ function Router({ actionQueue, assetPrefix, + globalError, }: { actionQueue: AppRouterActionQueue assetPrefix: string + globalError: [GlobalErrorComponent, React.ReactNode] }) { const [state, dispatch] = useReducer(actionQueue) const { canonicalUrl } = useUnwrapState(state) @@ -622,7 +628,11 @@ function Router({ const HotReloader: typeof import('./react-dev-overlay/app/hot-reloader-client').default = require('./react-dev-overlay/app/hot-reloader-client').default - content = {content} + content = ( + + {content} + + ) } return ( @@ -654,17 +664,22 @@ export default function AppRouter({ assetPrefix, }: { actionQueue: AppRouterActionQueue - globalErrorComponentAndStyles: [ErrorComponent, React.ReactNode | undefined] + globalErrorComponentAndStyles: [GlobalErrorComponent, React.ReactNode] assetPrefix: string }) { useNavFailureHandler() return ( - + ) } diff --git a/packages/next/src/client/components/error-boundary.tsx b/packages/next/src/client/components/error-boundary.tsx index 4e04ba0b07a2..0cd844fea8bb 100644 --- a/packages/next/src/client/components/error-boundary.tsx +++ b/packages/next/src/client/components/error-boundary.tsx @@ -142,6 +142,9 @@ export class ErrorBoundaryHandler extends React.Component< } } +export type GlobalErrorComponent = React.ComponentType<{ + error: any +}> export function GlobalError({ error }: { error: any }) { const digest: string | undefined = error?.digest return ( 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 f77774307914..55f9e9cb7c31 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 @@ -12,19 +12,48 @@ 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, +}: { + globalError: [GlobalErrorComponent, React.ReactNode] + error: unknown +}) { + if (!error) { + return ( + + + + + ) + } + return ( + <> + {globalErrorStyles} + + + ) +} 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 = { isReactError: false } + state = { + reactError: null, + isReactError: false, + } static getDerivedStateFromError(error: Error): ReactDevOverlayState { if (!error.stack) return { isReactError: false } @@ -36,8 +65,8 @@ export default class ReactDevOverlay extends React.PureComponent< } render() { - const { state, children } = this.props - const { isReactError } = this.state + const { state, children, globalError } = this.props + const { isReactError, reactError } = this.state const hasBuildError = state.buildError != null const hasStaticIndicator = state.staticIndicator @@ -48,10 +77,7 @@ export default class ReactDevOverlay extends React.PureComponent< return ( <> {isReactError ? ( - - - - + ) : ( children )} diff --git a/packages/next/src/client/components/react-dev-overlay/app/client-entry.tsx b/packages/next/src/client/components/react-dev-overlay/app/client-entry.tsx index d34fff0888f1..b4f3e9b3d97a 100644 --- a/packages/next/src/client/components/react-dev-overlay/app/client-entry.tsx +++ b/packages/next/src/client/components/react-dev-overlay/app/client-entry.tsx @@ -3,6 +3,7 @@ import ReactDevOverlay from './react-dev-overlay' import { getSocketUrl } from '../internal/helpers/get-socket-url' import { INITIAL_OVERLAY_STATE } from '../shared' import { HMR_ACTIONS_SENT_TO_BROWSER } from '../../../../server/dev/hot-reloader-types' +import GlobalError from '../../error-boundary' // if an error is thrown while rendering an RSC stream, this will catch it in dev // and show the error overlay @@ -42,6 +43,7 @@ export function createRootLevelDevOverlayElement(reactEl: React.ReactElement) { {reactEl} 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 765976f9f5a1..082956ad6f31 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 @@ -47,6 +47,7 @@ import { useUntrackedPathname } from '../../navigation-untracked' import { getReactStitchedError } from '../../errors/stitched-error' import { shouldRenderRootLevelErrorOverlay } from '../../../lib/is-error-thrown-while-rendering-rsc' import { handleDevBuildIndicatorHmrEvents } from '../../../dev/dev-build-indicator/internal/handle-dev-build-indicator-hmr-events' +import type { GlobalErrorComponent } from '../../error-boundary' export interface Dispatcher { onBuildOk(): void @@ -538,9 +539,11 @@ function processMessage( export default function HotReload({ assetPrefix, children, + globalError, }: { assetPrefix: string - children?: ReactNode + children: ReactNode + globalError: [GlobalErrorComponent, React.ReactNode] }) { const [state, dispatch] = useErrorOverlayReducer() @@ -724,7 +727,11 @@ export default function HotReload({ if (shouldRenderErrorOverlay) { return ( - + {children} ) diff --git a/packages/next/src/client/components/react-dev-overlay/app/old-react-dev-overlay.tsx b/packages/next/src/client/components/react-dev-overlay/app/old-react-dev-overlay.tsx index b16971ffed49..bd1cab80422d 100644 --- a/packages/next/src/client/components/react-dev-overlay/app/old-react-dev-overlay.tsx +++ b/packages/next/src/client/components/react-dev-overlay/app/old-react-dev-overlay.tsx @@ -10,32 +10,62 @@ import { CssReset } from '../internal/styles/CssReset' import { RootLayoutMissingTagsError } from '../internal/container/RootLayoutMissingTagsError' import type { Dispatcher } from './hot-reloader-client' import { RuntimeErrorHandler } from '../../errors/runtime-error-handler' +import type { GlobalErrorComponent } from '../../error-boundary' + +function ErroredHtml({ + globalError: [GlobalError, globalErrorStyles], + error, +}: { + globalError: [GlobalErrorComponent, React.ReactNode] + error: unknown +}) { + if (!error) { + return ( + + + + + ) + } + return ( + <> + {globalErrorStyles} + + + ) +} interface ReactDevOverlayState { + reactError?: unknown isReactError: boolean } export default class ReactDevOverlay extends React.PureComponent< { state: OverlayState + globalError: [GlobalErrorComponent, React.ReactNode] dispatcher?: Dispatcher children: React.ReactNode }, ReactDevOverlayState > { - state = { isReactError: false } + state = { + reactError: null, + isReactError: false, + } static getDerivedStateFromError(error: Error): ReactDevOverlayState { if (!error.stack) return { isReactError: false } RuntimeErrorHandler.hadRuntimeError = true return { + reactError: error, isReactError: true, } } render() { - const { state, children, dispatcher } = this.props - const { isReactError } = this.state + const { state, children, dispatcher, globalError } = this.props + const { isReactError, reactError } = this.state const hasBuildError = state.buildError != null const hasRuntimeErrors = Boolean(state.errors.length) @@ -45,10 +75,7 @@ export default class ReactDevOverlay extends React.PureComponent< return ( <> {isReactError ? ( - - - - + ) : ( children )} diff --git a/test/e2e/app-dir/global-error/basic/app/ssr/server/page.js b/test/e2e/app-dir/global-error/basic/app/rsc/page.js similarity index 100% rename from test/e2e/app-dir/global-error/basic/app/ssr/server/page.js rename to test/e2e/app-dir/global-error/basic/app/rsc/page.js diff --git a/test/e2e/app-dir/global-error/basic/app/ssr/client/page.js b/test/e2e/app-dir/global-error/basic/app/ssr/page.js similarity index 100% rename from test/e2e/app-dir/global-error/basic/app/ssr/client/page.js rename to test/e2e/app-dir/global-error/basic/app/ssr/page.js diff --git a/test/e2e/app-dir/global-error/basic/index.test.ts b/test/e2e/app-dir/global-error/basic/index.test.ts index aabc0e282916..0c0f39251c1e 100644 --- a/test/e2e/app-dir/global-error/basic/index.test.ts +++ b/test/e2e/app-dir/global-error/basic/index.test.ts @@ -1,11 +1,6 @@ -import { assertHasRedbox, getRedboxHeader } from 'next-test-utils' +import { assertHasRedbox, getRedboxDescription } from 'next-test-utils' import { nextTestSetup } from 'e2e-utils' -async function testDev(browser, errorRegex) { - await assertHasRedbox(browser) - expect(await getRedboxHeader(browser)).toMatch(errorRegex) -} - describe('app dir - global error', () => { const { next, isNextDev } = nextTestSetup({ files: __dirname, @@ -19,42 +14,49 @@ describe('app dir - global error', () => { .click() if (isNextDev) { - await testDev(browser, /Error: Client error/) - } else { - await browser - expect(await browser.elementByCss('#error').text()).toBe( - 'Global error: Client error' - ) + await assertHasRedbox(browser) + const description = await getRedboxDescription(browser) + expect(description).toMatchInlineSnapshot(`"Error: Client error"`) } + expect(await browser.elementByCss('#error').text()).toBe( + 'Global error: Client error' + ) }) it('should render global error for error in server components', async () => { - const browser = await next.browser('/ssr/server') + const browser = await next.browser('/rsc') + expect(await browser.elementByCss('h1').text()).toBe('Global Error') if (isNextDev) { - await testDev(browser, /Error: server page error/) - } else { - expect(await browser.elementByCss('h1').text()).toBe('Global Error') - expect(await browser.elementByCss('#error').text()).toBe( - 'Global error: An error occurred in the Server Components render. The specific message is omitted in production builds to avoid leaking sensitive details. A digest property is included on this error instance which may provide additional details about the nature of the error.' + await assertHasRedbox(browser) + const description = await getRedboxDescription(browser) + expect(description).toMatchInlineSnapshot( + `"[ Server ] Error: server page error"` ) - expect(await browser.elementByCss('#digest').text()).toMatch(/\w+/) } + // Show original error message in dev mode, but hide with the react fallback RSC error message in production mode + expect(await browser.elementByCss('#error').text()).toBe( + isNextDev + ? 'Global error: server page error' + : 'Global error: An error occurred in the Server Components render. The specific message is omitted in production builds to avoid leaking sensitive details. A digest property is included on this error instance which may provide additional details about the nature of the error.' + ) + expect(await browser.elementByCss('#digest').text()).toMatch(/\w+/) }) - it('should render global error for error in client components', async () => { - const browser = await next.browser('/ssr/client') + it('should render global error for error in client components during SSR', async () => { + const browser = await next.browser('/ssr') if (isNextDev) { - await testDev(browser, /Error: client page error/) - } else { - expect(await browser.elementByCss('h1').text()).toBe('Global Error') - expect(await browser.elementByCss('#error').text()).toBe( - 'Global error: client page error' - ) - - expect(await browser.hasElementByCssSelector('#digest')).toBeFalsy() + await assertHasRedbox(browser) + const description = await getRedboxDescription(browser) + expect(description).toMatchInlineSnapshot(`"Error: client page error"`) } + expect(await browser.elementByCss('h1').text()).toBe('Global Error') + expect(await browser.elementByCss('#error').text()).toBe( + 'Global error: client page error' + ) + + expect(await browser.hasElementByCssSelector('#digest')).toBeFalsy() }) it('should catch metadata error in error boundary if presented', async () => { @@ -70,24 +72,30 @@ describe('app dir - global error', () => { const browser = await next.browser('/metadata-error-without-boundary') if (isNextDev) { - await testDev(browser, /Error: Metadata error/) - } else { - expect(await browser.elementByCss('h1').text()).toBe('Global Error') - expect(await browser.elementByCss('#error').text()).toBe( - 'Global error: An error occurred in the Server Components render. The specific message is omitted in production builds to avoid leaking sensitive details. A digest property is included on this error instance which may provide additional details about the nature of the error.' + await assertHasRedbox(browser) + const description = await getRedboxDescription(browser) + expect(description).toMatchInlineSnapshot( + `"[ Server ] Error: Metadata error"` ) } + expect(await browser.elementByCss('h1').text()).toBe('Global Error') + expect(await browser.elementByCss('#error').text()).toBe( + isNextDev + ? 'Global error: Metadata error' + : 'Global error: An error occurred in the Server Components render. The specific message is omitted in production builds to avoid leaking sensitive details. A digest property is included on this error instance which may provide additional details about the nature of the error.' + ) }) it('should catch the client error thrown in the nested routes', async () => { const browser = await next.browser('/nested/nested') if (isNextDev) { - await testDev(browser, /Error: nested error/) - } else { - expect(await browser.elementByCss('h1').text()).toBe('Global Error') - expect(await browser.elementByCss('#error').text()).toBe( - 'Global error: nested error' - ) + await assertHasRedbox(browser) + const description = await getRedboxDescription(browser) + expect(description).toMatchInlineSnapshot(`"Error: nested error"`) } + expect(await browser.elementByCss('h1').text()).toBe('Global Error') + expect(await browser.elementByCss('#error').text()).toBe( + 'Global error: nested error' + ) }) }) diff --git a/test/e2e/app-dir/global-error/catch-all/index.test.ts b/test/e2e/app-dir/global-error/catch-all/index.test.ts index 33da112f4081..5c88994110fa 100644 --- a/test/e2e/app-dir/global-error/catch-all/index.test.ts +++ b/test/e2e/app-dir/global-error/catch-all/index.test.ts @@ -1,7 +1,7 @@ import { nextTestSetup } from 'e2e-utils' describe('app dir - global error - with catch-all route', () => { - const { next, isNextStart, skipped } = nextTestSetup({ + const { next, skipped } = nextTestSetup({ files: __dirname, skipDeployment: true, }) @@ -18,12 +18,10 @@ describe('app dir - global error - with catch-all route', () => { expect(await next.render('/en')).toContain('This page could not be found.') }) - if (isNextStart) { - it('should render global error correctly', async () => { - const browser = await next.browser('/en/error') + it('should render global error correctly', async () => { + const browser = await next.browser('/en/error') - const text = await browser.elementByCss('#global-error').text() - expect(text).toBe('global-error') - }) - } + const text = await browser.elementByCss('#global-error').text() + expect(text).toMatchInlineSnapshot(`"global-error"`) + }) }) diff --git a/test/e2e/app-dir/global-error/layout-error/app/layout.js b/test/e2e/app-dir/global-error/layout-error/app/layout.js index 6fc4fecd68e1..59409612d9dc 100644 --- a/test/e2e/app-dir/global-error/layout-error/app/layout.js +++ b/test/e2e/app-dir/global-error/layout-error/app/layout.js @@ -1,5 +1,5 @@ export default function layout() { - throw new Error('Global error: layout error') + throw new Error('layout error') } export const revalidate = 0 diff --git a/test/e2e/app-dir/global-error/layout-error/index.test.ts b/test/e2e/app-dir/global-error/layout-error/index.test.ts index e042b84e8bce..b4e55851376c 100644 --- a/test/e2e/app-dir/global-error/layout-error/index.test.ts +++ b/test/e2e/app-dir/global-error/layout-error/index.test.ts @@ -1,11 +1,6 @@ -import { assertHasRedbox, getRedboxHeader } from 'next-test-utils' +import { assertHasRedbox, getRedboxDescription } from 'next-test-utils' import { nextTestSetup } from 'e2e-utils' -async function testDev(browser, errorRegex) { - await assertHasRedbox(browser) - expect(await getRedboxHeader(browser)).toMatch(errorRegex) -} - describe('app dir - global error - layout error', () => { const { next, isNextDev, skipped } = nextTestSetup({ files: __dirname, @@ -20,13 +15,19 @@ describe('app dir - global error - layout error', () => { const browser = await next.browser('/') if (isNextDev) { - await testDev(browser, /Global error: layout error/) - } else { - expect(await browser.elementByCss('h1').text()).toBe('Global Error') - expect(await browser.elementByCss('#error').text()).toBe( - 'Global error: An error occurred in the Server Components render. The specific message is omitted in production builds to avoid leaking sensitive details. A digest property is included on this error instance which may provide additional details about the nature of the error.' + await assertHasRedbox(browser) + const description = await getRedboxDescription(browser) + expect(description).toMatchInlineSnapshot( + `"[ Server ] Error: layout error"` ) - expect(await browser.elementByCss('#digest').text()).toMatch(/\w+/) } + + expect(await browser.elementByCss('h1').text()).toBe('Global Error') + expect(await browser.elementByCss('#error').text()).toBe( + isNextDev + ? 'Global error: layout error' + : 'Global error: An error occurred in the Server Components render. The specific message is omitted in production builds to avoid leaking sensitive details. A digest property is included on this error instance which may provide additional details about the nature of the error.' + ) + expect(await browser.elementByCss('#digest').text()).toMatch(/\w+/) }) }) diff --git a/test/e2e/app-dir/hello-world/app/global-error.tsx b/test/e2e/app-dir/hello-world/app/global-error.tsx new file mode 100644 index 000000000000..e54ec287f331 --- /dev/null +++ b/test/e2e/app-dir/hello-world/app/global-error.tsx @@ -0,0 +1,13 @@ +'use client' +export default function GlobalError() { + return ( + + + +
+

Something went wrong

+
+ + + ) +}