Skip to content

Commit

Permalink
feat: app dir error-global component (#44066)
Browse files Browse the repository at this point in the history
## Feature

NEXT-54

When there's an error in the one of the root level pages, there's no way to handle it. The team discussed this and decided there should be a global error boundary to pick up anything not handled further down in the tree. It can be called `global-error.js`.

Co-authored-by: Tim Neutkens <6324199+timneutkens@users.noreply.github.com>
  • Loading branch information
huozhi and timneutkens committed Dec 16, 2022
1 parent 20997f8 commit 595ecdb
Show file tree
Hide file tree
Showing 10 changed files with 172 additions and 67 deletions.
25 changes: 19 additions & 6 deletions packages/next/build/webpack/loaders/next-app-loader.ts
Expand Up @@ -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.
Expand All @@ -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[]
Expand Down Expand Up @@ -96,6 +99,12 @@ async function createTreeCodeFromPath({
)?.[1]
}

if (!globalError) {
globalError = await resolve(
`${appDirPrefix}${parallelSegmentPath}/${GLOBAL_ERROR_FILE_TYPE}`
)
}

props[parallelKey] = `[
'${parallelSegment}',
${subtree},
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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'
Expand Down
13 changes: 9 additions & 4 deletions packages/next/client/components/app-router.tsx
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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 (
<ErrorBoundary errorComponent={GlobalErrorComponent}>
<Router {...props} />
<ErrorBoundary errorComponent={globalErrorComponent}>
<Router {...rest} />
</ErrorBoundary>
)
}
112 changes: 58 additions & 54 deletions 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 }
> {
Expand Down Expand Up @@ -47,6 +73,32 @@ class ErrorBoundaryHandler extends React.Component<
}
}

export default function GlobalError({ error }: { error: any }) {
return (
<html>
<head></head>
<body>
<div style={styles.error}>
<div style={styles.desc}>
<h2 style={styles.text}>
Application error: a client-side exception has occurred (see the
browser console for more information).
</h2>
{error?.digest && (
<p style={styles.text}>{`Digest: ${error.digest}`}</p>
)}
</div>
</div>
</body>
</html>
)
}

/**
* 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.
Expand All @@ -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 (
<html>
<head></head>
<body>
<div style={styles.error}>
<div style={styles.desc}>
<h2 style={styles.text}>
Application error: a client-side exception has occurred (see the
browser console for more information).
</h2>
{error?.digest && (
<p style={styles.text}>{`Digest: ${error.digest}`}</p>
)}
</div>
</div>
</body>
</html>
)
}
8 changes: 7 additions & 1 deletion packages/next/server/app-render.tsx
Expand Up @@ -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
Expand All @@ -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') {
Expand Down Expand Up @@ -1682,6 +1687,7 @@ export async function renderToHTMLOrFlight(
initialCanonicalUrl={initialCanonicalUrl}
initialTree={initialTree}
initialHead={initialHead}
globalErrorComponent={GlobalError}
>
<ComponentTree />
</AppRouter>
Expand Down
35 changes: 35 additions & 0 deletions 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'
)
}
})
})
12 changes: 12 additions & 0 deletions test/e2e/app-dir/global-error/app/global-error.js
@@ -0,0 +1,12 @@
'use client'

export default function GlobalError({ error }) {
return (
<html>
<head></head>
<body>
<div id="error">{`Error message: ${error?.message}`}</div>
</body>
</html>
)
}
8 changes: 8 additions & 0 deletions test/e2e/app-dir/global-error/app/layout.js
@@ -0,0 +1,8 @@
export default function Layout({ children }) {
return (
<html>
<head></head>
<body>{children}</body>
</html>
)
}
20 changes: 20 additions & 0 deletions 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 (
<button
id="error-trigger-button"
onClick={() => {
setClicked(true)
}}
>
Trigger Error!
</button>
)
}
3 changes: 3 additions & 0 deletions test/e2e/app-dir/global-error/next.config.js
@@ -0,0 +1,3 @@
module.exports = {
experimental: { appDir: true },
}
3 changes: 1 addition & 2 deletions test/e2e/app-dir/rsc-errors.test.ts
Expand Up @@ -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
}
Expand Down

0 comments on commit 595ecdb

Please sign in to comment.