From 3c33f0ade595c327d041397d8100b36592db38f7 Mon Sep 17 00:00:00 2001
From: Jiachi Liu
Date: Mon, 20 Jan 2025 13:00:33 +0100
Subject: [PATCH 1/3] display global-error along dev overlay during development
---
.../next/src/client/components/app-router.tsx | 25 ++++--
.../src/client/components/error-boundary.tsx | 3 +
.../_experimental/app/react-dev-overlay.tsx | 40 +++++++--
.../react-dev-overlay/app/client-entry.tsx | 2 +
.../app/hot-reloader-client.tsx | 11 ++-
.../app/old-react-dev-overlay.tsx | 41 ++++++++--
.../app-dir/global-error/basic/index.test.ts | 82 ++++++++++---------
.../global-error/catch-all/index.test.ts | 14 ++--
.../global-error/layout-error/app/layout.js | 2 +-
.../global-error/layout-error/index.test.ts | 25 +++---
10 files changed, 166 insertions(+), 79 deletions(-)
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 (
+
+
+
-
+
) : (
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/index.test.ts b/test/e2e/app-dir/global-error/basic/index.test.ts
index aabc0e282916..66cf92d27c9f 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')
+ 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')
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+/)
})
})
From 74efc8a8a0bec9a64f0e3ab12d049294a10307aa Mon Sep 17 00:00:00 2001
From: Jiachi Liu
Date: Mon, 20 Jan 2025 13:24:20 +0100
Subject: [PATCH 2/3] docs: update global-error behavior
---
.../04-api-reference/03-file-conventions/error.mdx | 11 ++++++-----
test/e2e/app-dir/hello-world/app/global-error.tsx | 13 +++++++++++++
2 files changed, 19 insertions(+), 5 deletions(-)
create mode 100644 test/e2e/app-dir/hello-world/app/global-error.tsx
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/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
+
+
+
+ )
+}
From 4f617b7c99a218d34b3a959c24d7b97e0b637114 Mon Sep 17 00:00:00 2001
From: Jiachi Liu
Date: Mon, 20 Jan 2025 13:29:17 +0100
Subject: [PATCH 3/3] improve tests readability
---
.../global-error/basic/app/{ssr/server => rsc}/page.js | 0
.../app-dir/global-error/basic/app/ssr/{client => }/page.js | 0
test/e2e/app-dir/global-error/basic/index.test.ts | 6 +++---
3 files changed, 3 insertions(+), 3 deletions(-)
rename test/e2e/app-dir/global-error/basic/app/{ssr/server => rsc}/page.js (100%)
rename test/e2e/app-dir/global-error/basic/app/ssr/{client => }/page.js (100%)
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 66cf92d27c9f..0c0f39251c1e 100644
--- a/test/e2e/app-dir/global-error/basic/index.test.ts
+++ b/test/e2e/app-dir/global-error/basic/index.test.ts
@@ -24,7 +24,7 @@ describe('app dir - global 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) {
@@ -43,8 +43,8 @@ describe('app dir - global 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 assertHasRedbox(browser)