diff --git a/packages/next/build/webpack/loaders/next-app-loader.ts b/packages/next/build/webpack/loaders/next-app-loader.ts index 86c3aefab317c96..2fade9d19f35656 100644 --- a/packages/next/build/webpack/loaders/next-app-loader.ts +++ b/packages/next/build/webpack/loaders/next-app-loader.ts @@ -17,6 +17,8 @@ const FILE_TYPES = { 'not-found': 'not-found', } as const +const GLOBAL_ERROR_FILE_TYPE = 'global-error' + const PAGE_SEGMENT = 'page$' // TODO-APP: check if this can be narrowed. @@ -43,6 +45,7 @@ async function createTreeCodeFromPath({ const appDirPrefix = splittedPath[0] const pages: string[] = [] let rootLayout: string | undefined + let globalError: string | undefined async function createSubtreePropsFromSegmentPath( segments: string[] @@ -96,6 +99,12 @@ async function createTreeCodeFromPath({ )?.[1] } + if (!globalError) { + globalError = await resolve( + `${appDirPrefix}${parallelSegmentPath}/${GLOBAL_ERROR_FILE_TYPE}` + ) + } + props[parallelKey] = `[ '${parallelSegment}', ${subtree}, @@ -123,7 +132,7 @@ async function createTreeCodeFromPath({ } const tree = await createSubtreePropsFromSegmentPath([]) - return [`const tree = ${tree}.children;`, pages, rootLayout] + return [`const tree = ${tree}.children;`, pages, rootLayout, globalError] } function createAbsolutePath(appDir: string, pathToTurnAbsolute: string) { @@ -211,11 +220,12 @@ const nextAppLoader: webpack.LoaderDefinitionFunction<{ } } - const [treeCode, pages, rootLayout] = await createTreeCodeFromPath({ - pagePath, - resolve: resolver, - resolveParallelSegments, - }) + const [treeCode, pages, rootLayout, globalError] = + await createTreeCodeFromPath({ + pagePath, + resolve: resolver, + resolveParallelSegments, + }) if (!rootLayout) { const errorMessage = `${chalk.bold( @@ -248,6 +258,9 @@ const nextAppLoader: webpack.LoaderDefinitionFunction<{ export { default as AppRouter } from 'next/dist/client/components/app-router' export { default as LayoutRouter } from 'next/dist/client/components/layout-router' export { default as RenderFromTemplateContext } from 'next/dist/client/components/render-from-template-context' + export { default as GlobalError } from ${JSON.stringify( + globalError || 'next/dist/client/components/error-boundary' + )} export { staticGenerationAsyncStorage } from 'next/dist/client/components/static-generation-async-storage' export { requestAsyncStorage } from 'next/dist/client/components/request-async-storage' diff --git a/packages/next/client/components/app-router.tsx b/packages/next/client/components/app-router.tsx index 7de15e24bdd31cb..d3ad82e0a878ca4 100644 --- a/packages/next/client/components/app-router.tsx +++ b/packages/next/client/components/app-router.tsx @@ -14,6 +14,7 @@ import type { AppRouterInstance, } from '../../shared/lib/app-router-context' import type { FlightRouterState, FlightData } from '../../server/app-render' +import type { ErrorComponent } from './error-boundary' import { ACTION_NAVIGATE, ACTION_PREFETCH, @@ -30,7 +31,7 @@ import { // LayoutSegmentsContext, } from '../../shared/lib/hooks-client-context' import { useReducerWithReduxDevtools } from './use-reducer-with-devtools' -import { ErrorBoundary, GlobalErrorComponent } from './error-boundary' +import { ErrorBoundary } from './error-boundary' import { NEXT_ROUTER_PREFETCH, NEXT_ROUTER_STATE_TREE, @@ -426,10 +427,14 @@ function Router({ ) } -export default function AppRouter(props: AppRouterProps) { +export default function AppRouter( + props: AppRouterProps & { globalErrorComponent: ErrorComponent } +) { + const { globalErrorComponent, ...rest } = props + return ( - - + + ) } diff --git a/packages/next/client/components/error-boundary.tsx b/packages/next/client/components/error-boundary.tsx index 56d9efd5bf451f9..3fb7823c715e04d 100644 --- a/packages/next/client/components/error-boundary.tsx +++ b/packages/next/client/components/error-boundary.tsx @@ -1,19 +1,45 @@ +'use client' + import React from 'react' +const styles = { + error: { + fontFamily: + '-apple-system, BlinkMacSystemFont, Roboto, "Segoe UI", "Fira Sans", Avenir, "Helvetica Neue", "Lucida Grande", sans-serif', + height: '100vh', + textAlign: 'center', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + }, + desc: { + display: 'inline-block', + textAlign: 'left', + lineHeight: '49px', + height: '49px', + verticalAlign: 'middle', + }, + text: { + fontSize: '14px', + fontWeight: 'normal', + lineHeight: '49px', + margin: 0, + padding: 0, + }, +} as const + export type ErrorComponent = React.ComponentType<{ error: Error reset: () => void }> -interface ErrorBoundaryProps { + +export interface ErrorBoundaryProps { errorComponent: ErrorComponent errorStyles?: React.ReactNode | undefined } -/** - * Handles errors through `getDerivedStateFromError`. - * Renders the provided error component and provides a way to `reset` the error boundary state. - */ -class ErrorBoundaryHandler extends React.Component< +export class ErrorBoundaryHandler extends React.Component< ErrorBoundaryProps, { error: Error | null } > { @@ -47,6 +73,32 @@ class ErrorBoundaryHandler extends React.Component< } } +export default function GlobalError({ error }: { error: any }) { + return ( + + + +
+
+

+ Application error: a client-side exception has occurred (see the + browser console for more information). +

+ {error?.digest && ( +

{`Digest: ${error.digest}`}

+ )} +
+
+ + + ) +} + +/** + * Handles errors through `getDerivedStateFromError`. + * Renders the provided error component and provides a way to `reset` the error boundary state. + */ + /** * Renders error boundary with the provided "errorComponent" property as the fallback. * If no "errorComponent" property is provided it renders the children without an error boundary. @@ -69,51 +121,3 @@ export function ErrorBoundary({ return <>{children} } - -const styles = { - error: { - fontFamily: - '-apple-system, BlinkMacSystemFont, Roboto, "Segoe UI", "Fira Sans", Avenir, "Helvetica Neue", "Lucida Grande", sans-serif', - height: '100vh', - textAlign: 'center', - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - justifyContent: 'center', - }, - desc: { - display: 'inline-block', - textAlign: 'left', - lineHeight: '49px', - height: '49px', - verticalAlign: 'middle', - }, - text: { - fontSize: '14px', - fontWeight: 'normal', - lineHeight: '49px', - margin: 0, - padding: 0, - }, -} as const - -export function GlobalErrorComponent({ error }: { error: any }) { - return ( - - - -
-
-

- Application error: a client-side exception has occurred (see the - browser console for more information). -

- {error?.digest && ( -

{`Digest: ${error.digest}`}

- )} -
-
- - - ) -} diff --git a/packages/next/server/app-render.tsx b/packages/next/server/app-render.tsx index ef6ed8766ca7359..193b23526fe8a68 100644 --- a/packages/next/server/app-render.tsx +++ b/packages/next/server/app-render.tsx @@ -1628,6 +1628,11 @@ export async function renderToHTMLOrFlight( const AppRouter = ComponentMod.AppRouter as typeof import('../client/components/app-router').default + const GlobalError = interopDefault( + /** GlobalError can be either the default error boundary or the overwritten app/global-error.js **/ + ComponentMod.GlobalError as typeof import('../client/components/error-boundary').default + ) + let serverComponentsInlinedTransformStream: TransformStream< Uint8Array, Uint8Array @@ -1636,7 +1641,7 @@ export async function renderToHTMLOrFlight( // TODO-APP: validate req.url as it gets passed to render. const initialCanonicalUrl = req.url! - // Get the nonce from the incomming request if it has one. + // Get the nonce from the incoming request if it has one. const csp = req.headers['content-security-policy'] let nonce: string | undefined if (csp && typeof csp === 'string') { @@ -1682,6 +1687,7 @@ export async function renderToHTMLOrFlight( initialCanonicalUrl={initialCanonicalUrl} initialTree={initialTree} initialHead={initialHead} + globalErrorComponent={GlobalError} > diff --git a/test/e2e/app-dir/global-error.test.ts b/test/e2e/app-dir/global-error.test.ts new file mode 100644 index 000000000000000..2f6b37f29ae03b5 --- /dev/null +++ b/test/e2e/app-dir/global-error.test.ts @@ -0,0 +1,35 @@ +import path from 'path' +import { getRedboxHeader, hasRedbox } from 'next-test-utils' +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import webdriver from 'next-webdriver' + +describe('app dir - global error', () => { + let next: NextInstance + const isDev = (global as any).isNextDev + + beforeAll(async () => { + next = await createNext({ + files: new FileRef(path.join(__dirname, './global-error')), + }) + }) + afterAll(() => next.destroy()) + + it('should trigger error component when an error happens during rendering', async () => { + const browser = await webdriver(next.url, '/throw') + await browser + .waitForElementByCss('#error-trigger-button') + .elementByCss('#error-trigger-button') + .click() + + if (isDev) { + expect(await hasRedbox(browser)).toBe(true) + expect(await getRedboxHeader(browser)).toMatch(/Error: Client error/) + } else { + await browser + expect(await browser.elementByCss('#error').text()).toBe( + 'Error message: Client error' + ) + } + }) +}) diff --git a/test/e2e/app-dir/global-error/app/global-error.js b/test/e2e/app-dir/global-error/app/global-error.js new file mode 100644 index 000000000000000..5440155259e6ced --- /dev/null +++ b/test/e2e/app-dir/global-error/app/global-error.js @@ -0,0 +1,12 @@ +'use client' + +export default function GlobalError({ error }) { + return ( + + + +
{`Error message: ${error?.message}`}
+ + + ) +} diff --git a/test/e2e/app-dir/global-error/app/layout.js b/test/e2e/app-dir/global-error/app/layout.js new file mode 100644 index 000000000000000..762515029332e8c --- /dev/null +++ b/test/e2e/app-dir/global-error/app/layout.js @@ -0,0 +1,8 @@ +export default function Layout({ children }) { + return ( + + + {children} + + ) +} diff --git a/test/e2e/app-dir/global-error/app/throw/page.js b/test/e2e/app-dir/global-error/app/throw/page.js new file mode 100644 index 000000000000000..6f0fa5a919407bd --- /dev/null +++ b/test/e2e/app-dir/global-error/app/throw/page.js @@ -0,0 +1,20 @@ +'use client' + +import { useState } from 'react' + +export default function Page() { + const [clicked, setClicked] = useState(false) + if (clicked) { + throw new Error('Client error') + } + return ( + + ) +} diff --git a/test/e2e/app-dir/global-error/next.config.js b/test/e2e/app-dir/global-error/next.config.js new file mode 100644 index 000000000000000..8e2a6c369174469 --- /dev/null +++ b/test/e2e/app-dir/global-error/next.config.js @@ -0,0 +1,3 @@ +module.exports = { + experimental: { appDir: true }, +} diff --git a/test/e2e/app-dir/rsc-errors.test.ts b/test/e2e/app-dir/rsc-errors.test.ts index 4a895a93fb2a5b5..1c3e1c485424c22 100644 --- a/test/e2e/app-dir/rsc-errors.test.ts +++ b/test/e2e/app-dir/rsc-errors.test.ts @@ -7,8 +7,7 @@ describe('app dir - rsc errors', () => { let next: NextInstance const { isNextDeploy, isNextDev } = global as any - const isReact17 = process.env.NEXT_TEST_REACT_VERSION === '^17' - if (isNextDeploy || isReact17) { + if (isNextDeploy) { it('should skip tests for next-deploy and react 17', () => {}) return }