Skip to content

Commit 67427fc

Browse files
authored
chore(rsc): server function error handling example (#971)
1 parent 41cb823 commit 67427fc

File tree

18 files changed

+343
-42
lines changed

18 files changed

+343
-42
lines changed

packages/plugin-rsc/e2e/basic.test.ts

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { createHash } from 'node:crypto'
22
import { readFileSync } from 'node:fs'
3-
import { type Page, expect, test } from '@playwright/test'
3+
import {
4+
type Page,
5+
type Response as PlaywrightResponse,
6+
expect,
7+
test,
8+
} from '@playwright/test'
49
import { type Fixture, useCreateEditor, useFixture } from './fixture'
510
import {
611
expectNoPageError,
@@ -1107,14 +1112,74 @@ function defineTest(f: Fixture) {
11071112
// this need to be verified manually on browser devtools console.
11081113
await page.goto(f.url())
11091114
await waitForHydration(page)
1115+
const errorResponse = new Promise((resolve) => {
1116+
page.on('response', async (response) => {
1117+
if (response.request().method() === 'POST') {
1118+
resolve(response.status())
1119+
}
1120+
})
1121+
})
11101122
await page.getByRole('button', { name: 'test-server-action-error' }).click()
1111-
await expect(page.getByText('ErrorBoundary caught')).toBeVisible()
1123+
await expect(page.getByTestId('action-error-boundary')).toContainText(
1124+
'ErrorBoundary triggered',
1125+
)
1126+
await expect(errorResponse).resolves.toEqual(500)
1127+
if (f.mode === 'dev') {
1128+
await expect(page.getByTestId('action-error-boundary')).toContainText(
1129+
'(Error: boom!)',
1130+
)
1131+
} else {
1132+
await expect(page.getByTestId('action-error-boundary')).toContainText(
1133+
'(Error: An error occurred in the Server Components render.',
1134+
)
1135+
}
11121136
await page.getByRole('button', { name: 'reset-error' }).click()
11131137
await expect(
11141138
page.getByRole('button', { name: 'test-server-action-error' }),
11151139
).toBeVisible()
11161140
})
11171141

1142+
test.describe(() => {
1143+
test.use({ javaScriptEnabled: false })
1144+
1145+
test('server action error @nojs', async ({ page }) => {
1146+
await page.goto(f.url())
1147+
const responsePromise = new Promise<PlaywrightResponse>((resolve) => {
1148+
page.on('response', async (response) => {
1149+
if (response.request().method() === 'POST') {
1150+
resolve(response)
1151+
}
1152+
})
1153+
})
1154+
await page
1155+
.getByRole('button', { name: 'test-server-action-error' })
1156+
.click()
1157+
const response = await responsePromise
1158+
expect(response.status()).toBe(500)
1159+
await expect(response.text()).resolves.toBe('Internal Server Error')
1160+
})
1161+
})
1162+
1163+
test('client error', async ({ page }) => {
1164+
await page.goto(f.url())
1165+
await waitForHydration(page)
1166+
const locator = page.getByTestId('test-client-error')
1167+
await expect(locator).toHaveText('test-client-error: 0')
1168+
await locator.click()
1169+
await expect(locator).toHaveText('test-client-error: 1')
1170+
await locator.click()
1171+
await expect(page.getByText('Caught an unexpected error')).toBeVisible()
1172+
if (f.mode === 'dev') {
1173+
await expect(
1174+
page.getByText('Error: Client error triggered'),
1175+
).toBeVisible()
1176+
} else {
1177+
await expect(page.getByText('Error: (Unknown)')).toBeVisible()
1178+
}
1179+
await page.getByRole('button', { name: 'Reset' }).click()
1180+
await expect(locator).toHaveText('test-client-error: 0')
1181+
})
1182+
11181183
test('hydrate while streaming @js', async ({ page }) => {
11191184
// client is interactive before suspense is resolved
11201185
await page.goto(f.url('./?test-suspense=1000'), { waitUntil: 'commit' })

packages/plugin-rsc/examples/basic/src/framework/entry.browser.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import React from 'react'
99
import { hydrateRoot } from 'react-dom/client'
1010
import { rscStream } from 'rsc-html-stream/client'
1111
import type { RscPayload } from './entry.rsc'
12+
import { GlobalErrorBoundary } from './error-boundary'
1213

1314
async function main() {
1415
// stash `setPayload` function to trigger re-rendering
@@ -61,13 +62,17 @@ async function main() {
6162
{ temporaryReferences },
6263
)
6364
setPayload(payload)
64-
return payload.returnValue
65+
const { ok, data } = payload.returnValue!
66+
if (!ok) throw data
67+
return data
6568
})
6669

6770
// hydration
6871
const browserRoot = (
6972
<React.StrictMode>
70-
<BrowserRoot />
73+
<GlobalErrorBoundary>
74+
<BrowserRoot />
75+
</GlobalErrorBoundary>
7176
</React.StrictMode>
7277
)
7378
hydrateRoot(document, browserRoot, {

packages/plugin-rsc/examples/basic/src/framework/entry.rsc.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export type RscPayload = {
1717
// based on your own route conventions.
1818
root: React.ReactNode
1919
// server action return value of non-progressive enhancement case
20-
returnValue?: unknown
20+
returnValue?: { ok: boolean; data: unknown }
2121
// server action form state (e.g. useActionState) of progressive enhancement case
2222
formState?: ReactFormState
2323
}
@@ -36,7 +36,7 @@ export async function handleRequest({
3636
}): Promise<Response> {
3737
// handle server function request
3838
const isAction = request.method === 'POST'
39-
let returnValue: unknown | undefined
39+
let returnValue: RscPayload['returnValue'] | undefined
4040
let formState: ReactFormState | undefined
4141
let temporaryReferences: unknown | undefined
4242
if (isAction) {
@@ -50,7 +50,12 @@ export async function handleRequest({
5050
temporaryReferences = createTemporaryReferenceSet()
5151
const args = await decodeReply(body, { temporaryReferences })
5252
const action = await loadServerAction(actionId)
53-
returnValue = await action.apply(null, args)
53+
try {
54+
const data = await action.apply(null, args)
55+
returnValue = { ok: true, data }
56+
} catch (e) {
57+
returnValue = { ok: false, data: e }
58+
}
5459
} else {
5560
// otherwise server function is called via `<form action={...}>`
5661
// before hydration (e.g. when javascript is disabled).
@@ -77,6 +82,7 @@ export async function handleRequest({
7782

7883
if (isRscRequest) {
7984
return new Response(rscStream, {
85+
status: returnValue?.ok === false ? 500 : undefined,
8086
headers: {
8187
'content-type': 'text/x-component;charset=utf-8',
8288
vary: 'accept',
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
'use client'
2+
3+
import React from 'react'
4+
5+
// Minimal ErrorBoundary example to handel errors globally on browser
6+
export function GlobalErrorBoundary(props: { children?: React.ReactNode }) {
7+
return (
8+
<ErrorBoundary errorComponent={DefaultGlobalErrorPage}>
9+
{props.children}
10+
</ErrorBoundary>
11+
)
12+
}
13+
14+
// https://github.com/vercel/next.js/blob/33f8428f7066bf8b2ec61f025427ceb2a54c4bdf/packages/next/src/client/components/error-boundary.tsx
15+
// https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary
16+
class ErrorBoundary extends React.Component<{
17+
children?: React.ReactNode
18+
errorComponent: React.FC<{
19+
error: Error
20+
reset: () => void
21+
}>
22+
}> {
23+
state: { error?: Error } = {}
24+
25+
static getDerivedStateFromError(error: Error) {
26+
return { error }
27+
}
28+
29+
reset = () => {
30+
this.setState({ error: null })
31+
}
32+
33+
render() {
34+
const error = this.state.error
35+
if (error) {
36+
return <this.props.errorComponent error={error} reset={this.reset} />
37+
}
38+
return this.props.children
39+
}
40+
}
41+
42+
// https://github.com/vercel/next.js/blob/677c9b372faef680d17e9ba224743f44e1107661/packages/next/src/build/webpack/loaders/next-app-loader.ts#L73
43+
// https://github.com/vercel/next.js/blob/677c9b372faef680d17e9ba224743f44e1107661/packages/next/src/client/components/error-boundary.tsx#L145
44+
function DefaultGlobalErrorPage(props: { error: Error; reset: () => void }) {
45+
return (
46+
<html>
47+
<head>
48+
<title>Unexpected Error</title>
49+
</head>
50+
<body
51+
style={{
52+
height: '100vh',
53+
display: 'flex',
54+
flexDirection: 'column',
55+
placeContent: 'center',
56+
placeItems: 'center',
57+
fontSize: '16px',
58+
fontWeight: 400,
59+
lineHeight: '24px',
60+
}}
61+
>
62+
<p>Caught an unexpected error</p>
63+
<pre>
64+
Error:{' '}
65+
{import.meta.env.DEV && 'message' in props.error
66+
? props.error.message
67+
: '(Unknown)'}
68+
</pre>
69+
<button
70+
onClick={() => {
71+
React.startTransition(() => {
72+
props.reset()
73+
})
74+
}}
75+
>
76+
Reset
77+
</button>
78+
</body>
79+
</html>
80+
)
81+
}

packages/plugin-rsc/examples/basic/src/routes/action-error/error-boundary.tsx

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,10 @@
22

33
import * as React from 'react'
44

5-
interface Props {
5+
export default class ErrorBoundary extends React.Component<{
66
children?: React.ReactNode
7-
}
8-
9-
interface State {
10-
error: Error | null
11-
}
12-
13-
export default class ErrorBoundary extends React.Component<Props, State> {
14-
constructor(props: Props) {
15-
super(props)
16-
this.state = { error: null }
17-
}
7+
}> {
8+
state: { error?: Error } = {}
189

1910
static getDerivedStateFromError(error: Error) {
2011
return { error }
@@ -23,15 +14,16 @@ export default class ErrorBoundary extends React.Component<Props, State> {
2314
render() {
2415
if (this.state.error) {
2516
return (
26-
<div>
27-
ErrorBoundary caught '{this.state.error.message}'
17+
<div data-testid="action-error-boundary">
18+
ErrorBoundary triggered
2819
<button
2920
onClick={() => {
3021
this.setState({ error: null })
3122
}}
3223
>
3324
reset-error
3425
</button>
26+
(<code>Error: {this.state.error.message}</code>)
3527
</div>
3628
)
3729
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
'use client'
2+
3+
import React from 'react'
4+
5+
export function TestClientError() {
6+
const [count, setCount] = React.useState(0)
7+
8+
React.useEffect(() => {
9+
if (count === 2) {
10+
throw new Error('Client error triggered')
11+
}
12+
}, [count])
13+
14+
return (
15+
<button
16+
data-testid="test-client-error"
17+
onClick={() => {
18+
setCount((c) => c + 1)
19+
}}
20+
>
21+
test-client-error: {count}
22+
</button>
23+
)
24+
}

packages/plugin-rsc/examples/basic/src/routes/root.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import { TestHmrClientDep2 } from './hmr-client-dep2/client'
4646
import { TestHmrClientDep3 } from './hmr-client-dep3/server'
4747
import { TestChunk2 } from './chunk2/server'
4848
import { TestUseId } from './use-id/server'
49+
import { TestClientError } from './client-error/client'
4950

5051
export function Root(props: { url: URL }) {
5152
return (
@@ -78,6 +79,7 @@ export function Root(props: { url: URL }) {
7879
<TestHmrSwitchClient />
7980
<TestTemporaryReference />
8081
<TestServerActionError />
82+
<TestClientError />
8183
<TestReplayConsoleLogs url={props.url} />
8284
<TestSuspense url={props.url} />
8385
<TestActionFromClient />

packages/plugin-rsc/examples/browser-mode/src/framework/entry.browser.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,9 @@ export async function main() {
6262
{ temporaryReferences },
6363
)
6464
setPayload(payload)
65-
return payload.returnValue
65+
const { ok, data } = payload.returnValue!
66+
if (!ok) throw data
67+
return data
6668
})
6769

6870
const browserRoot = (

packages/plugin-rsc/examples/browser-mode/src/framework/entry.rsc.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import buildServerReferences from 'virtual:vite-rsc-browser-mode/build-server-re
1414

1515
export type RscPayload = {
1616
root: React.ReactNode
17-
returnValue?: unknown
17+
returnValue?: { ok: boolean; data: unknown }
1818
formState?: ReactFormState
1919
}
2020

@@ -38,7 +38,7 @@ export function initialize() {
3838

3939
export async function fetchServer(request: Request): Promise<Response> {
4040
const isAction = request.method === 'POST'
41-
let returnValue: unknown | undefined
41+
let returnValue: RscPayload['returnValue'] | undefined
4242
let formState: ReactFormState | undefined
4343
let temporaryReferences: unknown | undefined
4444
if (isAction) {
@@ -51,7 +51,12 @@ export async function fetchServer(request: Request): Promise<Response> {
5151
temporaryReferences = createTemporaryReferenceSet()
5252
const args = await decodeReply(body, { temporaryReferences })
5353
const action = await loadServerAction(actionId)
54-
returnValue = await action.apply(null, args)
54+
try {
55+
const data = await action.apply(null, args)
56+
returnValue = { ok: true, data }
57+
} catch (e) {
58+
returnValue = { ok: false, data: e }
59+
}
5560
} else {
5661
const formData = await request.formData()
5762
const decodedAction = await decodeAction(formData)
@@ -65,6 +70,7 @@ export async function fetchServer(request: Request): Promise<Response> {
6570
const rscStream = renderToReadableStream<RscPayload>(rscPayload, rscOptions)
6671

6772
return new Response(rscStream, {
73+
status: returnValue?.ok === false ? 500 : undefined,
6874
headers: {
6975
'content-type': 'text/x-component;charset=utf-8',
7076
vary: 'accept',

packages/plugin-rsc/examples/browser/src/framework/entry.browser.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,9 @@ async function main() {
6565
{ temporaryReferences },
6666
)
6767
setPayload(payload)
68-
return payload.returnValue
68+
const { ok, data } = payload.returnValue!
69+
if (!ok) throw data
70+
return data
6971
})
7072

7173
// hydration

0 commit comments

Comments
 (0)