Skip to content

Commit 120c145

Browse files
hi-ogawaclaude
andauthored
chore(rsc/example): fallback to CSR on SSR error (#973)
Co-authored-by: Claude <[email protected]>
1 parent fc76c72 commit 120c145

File tree

19 files changed

+417
-75
lines changed

19 files changed

+417
-75
lines changed

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

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1156,11 +1156,13 @@ function defineTest(f: Fixture) {
11561156
.click()
11571157
const response = await responsePromise
11581158
expect(response.status()).toBe(500)
1159-
await expect(response.text()).resolves.toBe('Internal Server Error')
1159+
await expect(response.text()).resolves.toBe(
1160+
'Internal Server Error: server action failed',
1161+
)
11601162
})
11611163
})
11621164

1163-
test('client error', async ({ page }) => {
1165+
test('client component error', async ({ page }) => {
11641166
await page.goto(f.url())
11651167
await waitForHydration(page)
11661168
const locator = page.getByTestId('test-client-error')
@@ -1180,6 +1182,23 @@ function defineTest(f: Fixture) {
11801182
await expect(locator).toHaveText('test-client-error: 0')
11811183
})
11821184

1185+
test('server component error', async ({ page }) => {
1186+
await page.goto(f.url())
1187+
await waitForHydration(page)
1188+
1189+
const expectedText =
1190+
f.mode === 'dev' ? 'Error: test-server-error!' : 'Error: (Unknown)'
1191+
1192+
// trigger client navigation error
1193+
await page.getByRole('link', { name: 'test-server-error' }).click()
1194+
await page.getByText(expectedText).click()
1195+
1196+
// trigger SSR error
1197+
const res = await page.goto(f.url('./?test-server-error'))
1198+
await page.getByText(expectedText).click()
1199+
expect(res?.status()).toBe(500)
1200+
})
1201+
11831202
test('hydrate while streaming @js', async ({ page }) => {
11841203
// client is interactive before suspense is resolved
11851204
await page.goto(f.url('./?test-suspense=1000'), { waitUntil: 'commit' })

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

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
encodeReply,
77
} from '@vitejs/plugin-rsc/browser'
88
import React from 'react'
9-
import { hydrateRoot } from 'react-dom/client'
9+
import { createRoot, hydrateRoot } from 'react-dom/client'
1010
import { rscStream } from 'rsc-html-stream/client'
1111
import type { RscPayload } from './entry.rsc'
1212
import { GlobalErrorBoundary } from './error-boundary'
@@ -75,9 +75,13 @@ async function main() {
7575
</GlobalErrorBoundary>
7676
</React.StrictMode>
7777
)
78-
hydrateRoot(document, browserRoot, {
79-
formState: initialPayload.formState,
80-
})
78+
if ('__NO_HYDRATE' in globalThis) {
79+
createRoot(document).render(browserRoot)
80+
} else {
81+
hydrateRoot(document, browserRoot, {
82+
formState: initialPayload.formState,
83+
})
84+
}
8185

8286
// implement server HMR by trigering re-fetch/render of RSC upon server code change
8387
if (import.meta.hot) {

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

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export async function handleRequest({
3939
let returnValue: RscPayload['returnValue'] | undefined
4040
let formState: ReactFormState | undefined
4141
let temporaryReferences: unknown | undefined
42+
let actionStatus: number | undefined
4243
if (isAction) {
4344
// x-rsc-action header exists when action is called via `ReactClient.setServerCallback`.
4445
const actionId = request.headers.get('x-rsc-action')
@@ -55,15 +56,24 @@ export async function handleRequest({
5556
returnValue = { ok: true, data }
5657
} catch (e) {
5758
returnValue = { ok: false, data: e }
59+
actionStatus = 500
5860
}
5961
} else {
6062
// otherwise server function is called via `<form action={...}>`
6163
// before hydration (e.g. when javascript is disabled).
6264
// aka progressive enhancement.
6365
const formData = await request.formData()
6466
const decodedAction = await decodeAction(formData)
65-
const result = await decodedAction()
66-
formState = await decodeFormState(result, formData)
67+
try {
68+
const result = await decodedAction()
69+
formState = await decodeFormState(result, formData)
70+
} catch (e) {
71+
// there's no single general obvious way to surface this error,
72+
// so explicitly return classic 500 response.
73+
return new Response('Internal Server Error: server action failed', {
74+
status: 500,
75+
})
76+
}
6777
}
6878
}
6979

@@ -82,7 +92,7 @@ export async function handleRequest({
8292

8393
if (isRscRequest) {
8494
return new Response(rscStream, {
85-
status: returnValue?.ok === false ? 500 : undefined,
95+
status: actionStatus,
8696
headers: {
8797
'content-type': 'text/x-component;charset=utf-8',
8898
vary: 'accept',
@@ -97,15 +107,16 @@ export async function handleRequest({
97107
const ssrEntryModule = await import.meta.viteRsc.loadModule<
98108
typeof import('./entry.ssr.tsx')
99109
>('ssr', 'index')
100-
const htmlStream = await ssrEntryModule.renderHTML(rscStream, {
110+
const ssrResult = await ssrEntryModule.renderHTML(rscStream, {
101111
formState,
102112
nonce,
103113
// allow quick simulation of javscript disabled browser
104114
debugNojs: url.searchParams.has('__nojs'),
105115
})
106116

107117
// respond html
108-
return new Response(htmlStream, {
118+
return new Response(ssrResult.stream, {
119+
status: ssrResult.status,
109120
headers: {
110121
'content-type': 'text/html;charset=utf-8',
111122
vary: 'accept',

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

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export async function renderHTML(
1212
nonce?: string
1313
debugNojs?: boolean
1414
},
15-
) {
15+
): Promise<{ stream: ReadableStream<Uint8Array>; status?: number }> {
1616
// duplicate one RSC stream into two.
1717
// - one for SSR (ReactClient.createFromReadableStream below)
1818
// - another for browser hydration payload by injecting <script>...FLIGHT_DATA...</script>.
@@ -30,13 +30,34 @@ export async function renderHTML(
3030
// render html (traditional SSR)
3131
const bootstrapScriptContent =
3232
await import.meta.viteRsc.loadBootstrapScriptContent('index')
33-
const htmlStream = await renderToReadableStream(<SsrRoot />, {
34-
bootstrapScriptContent: options?.debugNojs
35-
? undefined
36-
: bootstrapScriptContent,
37-
nonce: options?.nonce,
38-
formState: options?.formState,
39-
})
33+
let htmlStream: ReadableStream<Uint8Array>
34+
let status: number | undefined
35+
try {
36+
htmlStream = await renderToReadableStream(<SsrRoot />, {
37+
bootstrapScriptContent: options?.debugNojs
38+
? undefined
39+
: bootstrapScriptContent,
40+
nonce: options?.nonce,
41+
formState: options?.formState,
42+
})
43+
} catch (e) {
44+
// fallback to render an empty shell and run pure CSR on browser,
45+
// which can replay server component error and trigger error boundary.
46+
status = 500
47+
htmlStream = await renderToReadableStream(
48+
<html>
49+
<body>
50+
<noscript>Internal Server Error: SSR failed</noscript>
51+
</body>
52+
</html>,
53+
{
54+
bootstrapScriptContent:
55+
`self.__NO_HYDRATE=1;` +
56+
(options?.debugNojs ? '' : bootstrapScriptContent),
57+
nonce: options?.nonce,
58+
},
59+
)
60+
}
4061

4162
let responseStream: ReadableStream<Uint8Array> = htmlStream
4263
if (!options?.debugNojs) {
@@ -48,5 +69,5 @@ export async function renderHTML(
4869
)
4970
}
5071

51-
return responseStream
72+
return { stream: responseStream, status }
5273
}

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import React from 'react'
44

5-
// Minimal ErrorBoundary example to handel errors globally on browser
5+
// Minimal ErrorBoundary example to handle errors globally on browser
66
export function GlobalErrorBoundary(props: { children?: React.ReactNode }) {
77
return (
88
<ErrorBoundary errorComponent={DefaultGlobalErrorPage}>
@@ -41,6 +41,7 @@ class ErrorBoundary extends React.Component<{
4141

4242
// https://github.com/vercel/next.js/blob/677c9b372faef680d17e9ba224743f44e1107661/packages/next/src/build/webpack/loaders/next-app-loader.ts#L73
4343
// https://github.com/vercel/next.js/blob/677c9b372faef680d17e9ba224743f44e1107661/packages/next/src/client/components/error-boundary.tsx#L145
44+
// https://github.com/vercel/next.js/blob/473ae4b70dd781cc8b2620c95766f827296e689a/packages/next/src/client/components/builtin/global-error.tsx
4445
function DefaultGlobalErrorPage(props: { error: Error; reset: () => void }) {
4546
return (
4647
<html>
@@ -49,17 +50,20 @@ function DefaultGlobalErrorPage(props: { error: Error; reset: () => void }) {
4950
</head>
5051
<body
5152
style={{
53+
fontFamily:
54+
'system-ui,"Segoe UI",Roboto,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"',
5255
height: '100vh',
56+
margin: 0,
5357
display: 'flex',
5458
flexDirection: 'column',
5559
placeContent: 'center',
5660
placeItems: 'center',
5761
fontSize: '16px',
5862
fontWeight: 400,
59-
lineHeight: '24px',
63+
lineHeight: '28px',
6064
}}
6165
>
62-
<p>Caught an unexpected error</p>
66+
<div>Caught an unexpected error</div>
6367
<pre>
6468
Error:{' '}
6569
{import.meta.env.DEV && 'message' in props.error

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import { TestHmrClientDep3 } from './hmr-client-dep3/server'
4747
import { TestChunk2 } from './chunk2/server'
4848
import { TestUseId } from './use-id/server'
4949
import { TestClientError } from './client-error/client'
50+
import { TestServerError } from './server-error/server'
5051

5152
export function Root(props: { url: URL }) {
5253
return (
@@ -80,6 +81,7 @@ export function Root(props: { url: URL }) {
8081
<TestTemporaryReference />
8182
<TestServerActionError />
8283
<TestClientError />
84+
<TestServerError url={props.url} />
8385
<TestReplayConsoleLogs url={props.url} />
8486
<TestSuspense url={props.url} />
8587
<TestActionFromClient />
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export async function TestServerError(props: { url: URL }) {
2+
if (props.url.searchParams.has('test-server-error')) {
3+
throw new Error('test-server-error!')
4+
}
5+
return (
6+
<div>
7+
<a href="?test-server-error">test-server-error</a>
8+
</div>
9+
)
10+
}

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

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@ import {
33
createFromReadableStream,
44
} from '@vitejs/plugin-rsc/browser'
55
import React from 'react'
6-
import { hydrateRoot } from 'react-dom/client'
6+
import { createRoot, hydrateRoot } from 'react-dom/client'
77
import { rscStream } from 'rsc-html-stream/client'
88
import { RSC_POSTFIX, type RscPayload } from './shared'
9+
import { GlobalErrorBoundary } from './error-boundary'
910

1011
async function hydrate(): Promise<void> {
1112
async function onNavigation() {
@@ -35,11 +36,17 @@ async function hydrate(): Promise<void> {
3536

3637
const browserRoot = (
3738
<React.StrictMode>
38-
<BrowserRoot />
39+
<GlobalErrorBoundary>
40+
<BrowserRoot />
41+
</GlobalErrorBoundary>
3942
</React.StrictMode>
4043
)
4144

42-
hydrateRoot(document, browserRoot)
45+
if ('__NO_HYDRATE' in globalThis) {
46+
createRoot(document).render(browserRoot)
47+
} else {
48+
hydrateRoot(document, browserRoot)
49+
}
4350

4451
if (import.meta.hot) {
4552
import.meta.hot.on('rsc:update', () => {

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,10 @@ export default async function handler(request: Request): Promise<Response> {
2727
const ssr = await import.meta.viteRsc.loadModule<
2828
typeof import('./entry.ssr')
2929
>('ssr', 'index')
30-
const htmlStream = await ssr.renderHtml(rscStream)
30+
const ssrResult = await ssr.renderHtml(rscStream)
3131

32-
return new Response(htmlStream, {
32+
return new Response(ssrResult.stream, {
33+
status: ssrResult.status,
3334
headers: {
3435
'content-type': 'text/html;charset=utf-8',
3536
vary: 'accept',
@@ -50,11 +51,11 @@ export async function handleSsg(request: Request): Promise<{
5051
const ssr = await import.meta.viteRsc.loadModule<
5152
typeof import('./entry.ssr')
5253
>('ssr', 'index')
53-
const htmlStream = await ssr.renderHtml(rscStream1, {
54+
const ssrResult = await ssr.renderHtml(rscStream1, {
5455
ssg: true,
5556
})
5657

57-
return { html: htmlStream, rsc: rscStream2 }
58+
return { html: ssrResult.stream, rsc: rscStream2 }
5859
}
5960

6061
if (import.meta.hot) {

packages/plugin-rsc/examples/ssg/src/framework/entry.ssr.tsx

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export async function renderHtml(
1010
options?: {
1111
ssg?: boolean
1212
},
13-
) {
13+
): Promise<{ stream: ReadableStream<Uint8Array>; status?: number }> {
1414
const [rscStream1, rscStream2] = rscStream.tee()
1515

1616
let payload: Promise<RscPayload>
@@ -23,18 +23,38 @@ export async function renderHtml(
2323
await import.meta.viteRsc.loadBootstrapScriptContent('index')
2424

2525
let htmlStream: ReadableStream<Uint8Array>
26+
let status: number | undefined
2627
if (options?.ssg) {
28+
// for static site generation, let errors throw to fail the build
2729
const prerenderResult = await prerender(<SsrRoot />, {
2830
bootstrapScriptContent,
2931
})
3032
htmlStream = prerenderResult.prelude
3133
} else {
32-
htmlStream = await renderToReadableStream(<SsrRoot />, {
33-
bootstrapScriptContent,
34-
})
34+
// for regular SSR, catch errors and fallback to CSR
35+
try {
36+
htmlStream = await renderToReadableStream(<SsrRoot />, {
37+
bootstrapScriptContent,
38+
})
39+
} catch (e) {
40+
// fallback to render an empty shell and run pure CSR on browser,
41+
// which can replay server component error and trigger error boundary.
42+
status = 500
43+
htmlStream = await renderToReadableStream(
44+
<html>
45+
<body>
46+
<noscript>Internal Server Error: SSR failed</noscript>
47+
</body>
48+
</html>,
49+
{
50+
bootstrapScriptContent:
51+
`self.__NO_HYDRATE=1;` + bootstrapScriptContent,
52+
},
53+
)
54+
}
3555
}
3656

3757
let responseStream: ReadableStream<Uint8Array> = htmlStream
3858
responseStream = responseStream.pipeThrough(injectRSCPayload(rscStream2))
39-
return responseStream
59+
return { stream: responseStream, status }
4060
}

0 commit comments

Comments
 (0)