Skip to content

Commit f140b2d

Browse files
authored
feature(error): capture ssr error in overlay during dev (#74983)
1 parent 9e8d6bc commit f140b2d

File tree

6 files changed

+108
-1
lines changed

6 files changed

+108
-1
lines changed

packages/next/src/client/app-index.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { createInitialRouterState } from './components/router-reducer/create-ini
2626
import { MissingSlotContext } from '../shared/lib/app-router-context.shared-runtime'
2727
import { setAppBuildId } from './app-build-id'
2828
import { shouldRenderRootLevelErrorOverlay } from './lib/is-error-thrown-while-rendering-rsc'
29+
import { handleClientError } from './components/errors/use-error-handler'
2930

3031
/// <reference types="react-dom/experimental" />
3132

@@ -229,6 +230,16 @@ function Root({ children }: React.PropsWithChildren<{}>) {
229230
}, [])
230231
}
231232

233+
if (process.env.NODE_ENV !== 'production') {
234+
const ssrError = devQueueSsrError()
235+
// eslint-disable-next-line react-hooks/rules-of-hooks
236+
React.useEffect(() => {
237+
if (ssrError) {
238+
handleClientError(ssrError, [])
239+
}
240+
}, [ssrError])
241+
}
242+
232243
return children
233244
}
234245

@@ -283,3 +294,18 @@ export function hydrate() {
283294
linkGc()
284295
}
285296
}
297+
298+
function devQueueSsrError(): Error | undefined {
299+
const ssrErrorTemplateTag = document.querySelector(
300+
'template[data-next-error-message]'
301+
)
302+
if (ssrErrorTemplateTag) {
303+
const message: string = ssrErrorTemplateTag.getAttribute(
304+
'data-next-error-message'
305+
)!
306+
const stack = ssrErrorTemplateTag.getAttribute('data-next-error-stack')
307+
const error = new Error(message)
308+
error.stack = stack || ''
309+
return error
310+
}
311+
}

packages/next/src/server/app-render/app-render.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ import {
183183
createRenderResumeDataCache,
184184
} from '../resume-data-cache/resume-data-cache'
185185
import type { MetadataErrorType } from '../../lib/metadata/resolve-metadata'
186+
import isError from '../../lib/is-error'
186187

187188
export type GetDynamicParamFromSegment = (
188189
// [slug] / [[slug]] / [...slug]
@@ -877,6 +878,7 @@ function Preloads({ preloadCallbacks }: { preloadCallbacks: Function[] }) {
877878
async function getErrorRSCPayload(
878879
tree: LoaderTree,
879880
ctx: AppRenderContext,
881+
ssrError: unknown,
880882
errorType: MetadataErrorType | 'redirect' | undefined
881883
) {
882884
const {
@@ -940,6 +942,11 @@ async function getErrorRSCPayload(
940942
query
941943
)
942944

945+
let err: Error | undefined = undefined
946+
if (ssrError) {
947+
err = isError(ssrError) ? ssrError : new Error(ssrError + '')
948+
}
949+
943950
// For metadata notFound error there's no global not found boundary on top
944951
// so we create a not found page with AppRouter
945952
const seedData: CacheNodeSeedData = [
@@ -948,7 +955,14 @@ async function getErrorRSCPayload(
948955
<head>
949956
<ErrorMetadataComponent />
950957
</head>
951-
<body />
958+
<body>
959+
{process.env.NODE_ENV !== 'production' && err ? (
960+
<template
961+
data-next-error-message={err.message}
962+
data-next-error-stack={err.stack}
963+
/>
964+
) : null}
965+
</body>
952966
</html>,
953967
{},
954968
null,
@@ -2004,6 +2018,7 @@ async function renderToStream(
20042018
getErrorRSCPayload,
20052019
tree,
20062020
ctx,
2021+
reactServerErrorsByDigest.has((err as any).digest) ? null : err,
20072022
errorType
20082023
)
20092024

@@ -3870,6 +3885,7 @@ async function prerenderToStream(
38703885
getErrorRSCPayload,
38713886
tree,
38723887
ctx,
3888+
reactServerErrorsByDigest.has((err as any).digest) ? undefined : err,
38733889
errorType
38743890
)
38753891

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { ReactNode } from 'react'
2+
export default function Root({ children }: { children: ReactNode }) {
3+
return (
4+
<html>
5+
<body>{children}</body>
6+
</html>
7+
)
8+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
'use client'
2+
3+
export default function Page() {
4+
if (typeof window === 'undefined') {
5+
throw new Error('SSR only error')
6+
}
7+
return <p>hello world</p>
8+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/**
2+
* @type {import('next').NextConfig}
3+
*/
4+
const nextConfig = {}
5+
6+
module.exports = nextConfig
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { nextTestSetup } from 'e2e-utils'
2+
import {
3+
getRedboxDescription,
4+
getRedboxSource,
5+
hasErrorToast,
6+
openRedbox,
7+
} from 'next-test-utils'
8+
9+
describe('ssr-only-error', () => {
10+
const { next } = nextTestSetup({
11+
files: __dirname,
12+
})
13+
14+
it('should show ssr only error in error overlay', async () => {
15+
const browser = await next.browser('/')
16+
17+
// Ensure it's not like server error that is shown by default
18+
await hasErrorToast(browser)
19+
20+
await openRedbox(browser)
21+
22+
const description = await getRedboxDescription(browser)
23+
const source = await getRedboxSource(browser)
24+
25+
expect({
26+
description,
27+
source,
28+
}).toMatchInlineSnapshot(`
29+
{
30+
"description": "Error: SSR only error",
31+
"source": "app/page.tsx (5:11) @ Page
32+
33+
3 | export default function Page() {
34+
4 | if (typeof window === 'undefined') {
35+
> 5 | throw new Error('SSR only error')
36+
| ^
37+
6 | }
38+
7 | return <p>hello world</p>
39+
8 | }",
40+
}
41+
`)
42+
})
43+
})

0 commit comments

Comments
 (0)