Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: app dir error-global component #44066

Merged
merged 7 commits into from Dec 16, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
timneutkens marked this conversation as resolved.
Show resolved Hide resolved
)

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