Skip to content

Commit 4ea4b79

Browse files
committed
feat(next/error): Add retry(), componentStack, and ownerStack to error.js
1 parent 9a2113c commit 4ea4b79

5 files changed

Lines changed: 110 additions & 1 deletion

File tree

packages/next/src/client/components/error-boundary.tsx

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
'use client'
22

3-
import React, { type JSX } from 'react'
3+
import React, { startTransition, type JSX } from 'react'
44
import { useUntrackedPathname } from './navigation-untracked'
55
import { isNextRouterError } from './is-next-router-error'
66
import { handleHardNavError } from './nav-failure-handler'
77
import { HandleISRError } from './handle-isr-error'
88
import { isBot } from '../../shared/lib/router/utils/is-bot'
9+
import { publicAppRouterInstance } from './app-router-instance'
10+
import {
11+
AppRouterContext,
12+
type AppRouterInstance,
13+
} from '../../shared/lib/app-router-context.shared-runtime'
914

1015
const isBotUserAgent =
1116
typeof window !== 'undefined' && isBot(window.navigator.userAgent)
@@ -15,6 +20,9 @@ export type ErrorComponent = React.ComponentType<{
1520
// global-error, there's no `reset` function;
1621
// regular error boundary, there's a `reset` function.
1722
reset?: () => void
23+
retry?: () => void
24+
componentStack?: string
25+
ownerStack?: string
1826
}>
1927

2028
export interface ErrorBoundaryProps {
@@ -32,17 +40,30 @@ interface ErrorBoundaryHandlerProps extends ErrorBoundaryProps {
3240
interface ErrorBoundaryHandlerState {
3341
error: Error | null
3442
previousPathname: string | null
43+
componentStack?: string
44+
ownerStack?: string
3545
}
3646

3747
export class ErrorBoundaryHandler extends React.Component<
3848
ErrorBoundaryHandlerProps,
3949
ErrorBoundaryHandlerState
4050
> {
51+
static contextType = AppRouterContext
52+
declare context: AppRouterInstance | null
53+
4154
constructor(props: ErrorBoundaryHandlerProps) {
4255
super(props)
4356
this.state = { error: null, previousPathname: this.props.pathname }
4457
}
4558

59+
componentDidCatch(_error: Error, info: React.ErrorInfo) {
60+
this.setState({
61+
componentStack: info.componentStack || undefined,
62+
// @ts-expect-error ownerStack is not yet in @types/react
63+
ownerStack: info.ownerStack,
64+
})
65+
}
66+
4667
static getDerivedStateFromError(error: Error) {
4768
if (isNextRouterError(error)) {
4869
// Re-throw if an expected internal Next.js router error occurs
@@ -95,6 +116,14 @@ export class ErrorBoundaryHandler extends React.Component<
95116
this.setState({ error: null })
96117
}
97118

119+
retry = () => {
120+
startTransition(() => {
121+
publicAppRouterInstance.refresh()
122+
this.context?.refresh()
123+
this.reset()
124+
})
125+
}
126+
98127
// Explicit type is needed to avoid the generated `.d.ts` having a wide return type that could be specific to the `@types/react` version.
99128
render(): React.ReactNode {
100129
//When it's bot request, segment level error boundary will keep rendering the children,
@@ -108,6 +137,9 @@ export class ErrorBoundaryHandler extends React.Component<
108137
<this.props.errorComponent
109138
error={this.state.error}
110139
reset={this.reset}
140+
retry={this.retry}
141+
componentStack={this.state.componentStack}
142+
ownerStack={this.state.ownerStack}
111143
/>
112144
</>
113145
)
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
'use client'
2+
3+
export default function Error({
4+
error,
5+
reset,
6+
retry,
7+
componentStack,
8+
ownerStack,
9+
}) {
10+
return (
11+
<div>
12+
<p id="error-message">{error.message}</p>
13+
<div id="component-stack">{componentStack}</div>
14+
<div id="owner-stack">{ownerStack}</div>
15+
<button id="btn-reset" onClick={() => reset()}>
16+
Reset
17+
</button>
18+
<button id="btn-retry" onClick={() => (retry ? retry() : null)}>
19+
Retry
20+
</button>
21+
</div>
22+
)
23+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export default function Layout({ children }) {
2+
return (
3+
<html>
4+
<body>{children}</body>
5+
</html>
6+
)
7+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { cookies } from 'next/headers'
2+
3+
export default async function Page() {
4+
const c = await cookies()
5+
const shouldFail = c.get('force-error')?.value === 'true'
6+
7+
if (shouldFail) {
8+
throw new Error('Server Error Forced')
9+
}
10+
11+
return <div id="success">Content Loaded Successfully</div>
12+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { nextTestSetup } from 'e2e-utils'
2+
3+
describe('error-retry', () => {
4+
const { next } = nextTestSetup({
5+
files: __dirname,
6+
})
7+
8+
it('should recover from server error using retry()', async () => {
9+
const browser = await next.browser('/')
10+
11+
// 1. Force error
12+
await browser.addCookie({ name: 'force-error', value: 'true' })
13+
await browser.refresh()
14+
15+
const text = await browser.elementById('error-message').text()
16+
expect(text).toMatch(
17+
/Server Error Forced|An error occurred in the Server Components render/
18+
)
19+
20+
// Check component stack presence
21+
const stack = await browser.elementById('component-stack').text()
22+
expect(stack).toBeTruthy()
23+
24+
// 2. Fix error condition
25+
await browser.addCookie({ name: 'force-error', value: 'false' })
26+
27+
// 3. Try retry (should work because it refreshes data)
28+
await browser.elementById('btn-retry').click()
29+
30+
await browser.waitForElementByCss('#success')
31+
expect(await browser.elementById('success').text()).toBe(
32+
'Content Loaded Successfully'
33+
)
34+
})
35+
})

0 commit comments

Comments
 (0)