Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions packages/plugin-rsc/e2e/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@
using _ = expectNoPageError(page)
await page.goto(f.url())
await waitForHydration(page)
expect(f.proc().stderr()).toBe('')

Check failure on line 212 in packages/plugin-rsc/e2e/basic.test.ts

View workflow job for this annotation

GitHub Actions / test-rsc (ubuntu-latest / chromium) (rolldown)

[chromium] › e2e/basic.test.ts:208:3 › build-default › basic

2) [chromium] › e2e/basic.test.ts:208:3 › build-default › basic ────────────────────────────────── Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(received).toBe(expected) // Object.is equality Expected: "" Received: "`optimizeDeps.rollupOptions` / `ssr.optimizeDeps.rollupOptions` is deprecated. Use `optimizeDeps.rolldownOptions` instead. Note that this option may be set by a plugin. Set VITE_DEPRECATION_TRACE=1 to see where it is called. " 210 | await page.goto(f.url()) 211 | await waitForHydration(page) > 212 | expect(f.proc().stderr()).toBe('') | ^ 213 | }) 214 | 215 | test('client component', async ({ page }) => { at /home/runner/work/vite-plugin-react/vite-plugin-react/packages/plugin-rsc/e2e/basic.test.ts:212:31

Check failure on line 212 in packages/plugin-rsc/e2e/basic.test.ts

View workflow job for this annotation

GitHub Actions / test-rsc (ubuntu-latest / chromium) (rolldown)

[chromium] › e2e/basic.test.ts:208:3 › build-default › basic

2) [chromium] › e2e/basic.test.ts:208:3 › build-default › basic ────────────────────────────────── Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(received).toBe(expected) // Object.is equality Expected: "" Received: "`optimizeDeps.rollupOptions` / `ssr.optimizeDeps.rollupOptions` is deprecated. Use `optimizeDeps.rolldownOptions` instead. Note that this option may be set by a plugin. Set VITE_DEPRECATION_TRACE=1 to see where it is called. " 210 | await page.goto(f.url()) 211 | await waitForHydration(page) > 212 | expect(f.proc().stderr()).toBe('') | ^ 213 | }) 214 | 215 | test('client component', async ({ page }) => { at /home/runner/work/vite-plugin-react/vite-plugin-react/packages/plugin-rsc/e2e/basic.test.ts:212:31

Check failure on line 212 in packages/plugin-rsc/e2e/basic.test.ts

View workflow job for this annotation

GitHub Actions / test-rsc (ubuntu-latest / chromium) (rolldown)

[chromium] › e2e/basic.test.ts:208:3 › build-default › basic

2) [chromium] › e2e/basic.test.ts:208:3 › build-default › basic ────────────────────────────────── Error: expect(received).toBe(expected) // Object.is equality Expected: "" Received: "`optimizeDeps.rollupOptions` / `ssr.optimizeDeps.rollupOptions` is deprecated. Use `optimizeDeps.rolldownOptions` instead. Note that this option may be set by a plugin. Set VITE_DEPRECATION_TRACE=1 to see where it is called. " 210 | await page.goto(f.url()) 211 | await waitForHydration(page) > 212 | expect(f.proc().stderr()).toBe('') | ^ 213 | }) 214 | 215 | test('client component', async ({ page }) => { at /home/runner/work/vite-plugin-react/vite-plugin-react/packages/plugin-rsc/e2e/basic.test.ts:212:31

Check failure on line 212 in packages/plugin-rsc/e2e/basic.test.ts

View workflow job for this annotation

GitHub Actions / test-rsc (ubuntu-latest / chromium) (rolldown)

[chromium] › e2e/basic.test.ts:208:3 › dev-default › basic

1) [chromium] › e2e/basic.test.ts:208:3 › dev-default › basic ──────────────────────────────────── Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(received).toBe(expected) // Object.is equality Expected: "" Received: "`optimizeDeps.rollupOptions` / `ssr.optimizeDeps.rollupOptions` is deprecated. Use `optimizeDeps.rolldownOptions` instead. Note that this option may be set by a plugin. Set VITE_DEPRECATION_TRACE=1 to see where it is called. " 210 | await page.goto(f.url()) 211 | await waitForHydration(page) > 212 | expect(f.proc().stderr()).toBe('') | ^ 213 | }) 214 | 215 | test('client component', async ({ page }) => { at /home/runner/work/vite-plugin-react/vite-plugin-react/packages/plugin-rsc/e2e/basic.test.ts:212:31

Check failure on line 212 in packages/plugin-rsc/e2e/basic.test.ts

View workflow job for this annotation

GitHub Actions / test-rsc (ubuntu-latest / chromium) (rolldown)

[chromium] › e2e/basic.test.ts:208:3 › dev-default › basic

1) [chromium] › e2e/basic.test.ts:208:3 › dev-default › basic ──────────────────────────────────── Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(received).toBe(expected) // Object.is equality Expected: "" Received: "`optimizeDeps.rollupOptions` / `ssr.optimizeDeps.rollupOptions` is deprecated. Use `optimizeDeps.rolldownOptions` instead. Note that this option may be set by a plugin. Set VITE_DEPRECATION_TRACE=1 to see where it is called. " 210 | await page.goto(f.url()) 211 | await waitForHydration(page) > 212 | expect(f.proc().stderr()).toBe('') | ^ 213 | }) 214 | 215 | test('client component', async ({ page }) => { at /home/runner/work/vite-plugin-react/vite-plugin-react/packages/plugin-rsc/e2e/basic.test.ts:212:31

Check failure on line 212 in packages/plugin-rsc/e2e/basic.test.ts

View workflow job for this annotation

GitHub Actions / test-rsc (ubuntu-latest / chromium) (rolldown)

[chromium] › e2e/basic.test.ts:208:3 › dev-default › basic

1) [chromium] › e2e/basic.test.ts:208:3 › dev-default › basic ──────────────────────────────────── Error: expect(received).toBe(expected) // Object.is equality Expected: "" Received: "`optimizeDeps.rollupOptions` / `ssr.optimizeDeps.rollupOptions` is deprecated. Use `optimizeDeps.rolldownOptions` instead. Note that this option may be set by a plugin. Set VITE_DEPRECATION_TRACE=1 to see where it is called. " 210 | await page.goto(f.url()) 211 | await waitForHydration(page) > 212 | expect(f.proc().stderr()).toBe('') | ^ 213 | }) 214 | 215 | test('client component', async ({ page }) => { at /home/runner/work/vite-plugin-react/vite-plugin-react/packages/plugin-rsc/e2e/basic.test.ts:212:31
})

test('client component', async ({ page }) => {
Expand Down Expand Up @@ -1156,11 +1156,13 @@
.click()
const response = await responsePromise
expect(response.status()).toBe(500)
await expect(response.text()).resolves.toBe('Internal Server Error')
await expect(response.text()).resolves.toBe(
'Internal Server Error: server action failed',
)
})
})

test('client error', async ({ page }) => {
test('client component error', async ({ page }) => {
await page.goto(f.url())
await waitForHydration(page)
const locator = page.getByTestId('test-client-error')
Expand All @@ -1180,6 +1182,23 @@
await expect(locator).toHaveText('test-client-error: 0')
})

test('server component error', async ({ page }) => {
await page.goto(f.url())
await waitForHydration(page)

const expectedText =
f.mode === 'dev' ? 'Error: test-server-error!' : 'Error: (Unknown)'

// trigger client navigation error
await page.getByRole('link', { name: 'test-server-error' }).click()
await page.getByText(expectedText).click()

// trigger SSR error
const res = await page.goto(f.url('./?test-server-error'))
await page.getByText(expectedText).click()
expect(res?.status()).toBe(500)
})

test('hydrate while streaming @js', async ({ page }) => {
// client is interactive before suspense is resolved
await page.goto(f.url('./?test-suspense=1000'), { waitUntil: 'commit' })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
encodeReply,
} from '@vitejs/plugin-rsc/browser'
import React from 'react'
import { hydrateRoot } from 'react-dom/client'
import { createRoot, hydrateRoot } from 'react-dom/client'
import { rscStream } from 'rsc-html-stream/client'
import type { RscPayload } from './entry.rsc'
import { GlobalErrorBoundary } from './error-boundary'
Expand Down Expand Up @@ -75,9 +75,13 @@ async function main() {
</GlobalErrorBoundary>
</React.StrictMode>
)
hydrateRoot(document, browserRoot, {
formState: initialPayload.formState,
})
if ('__NO_HYDRATE' in globalThis) {
createRoot(document).render(browserRoot)
} else {
hydrateRoot(document, browserRoot, {
formState: initialPayload.formState,
})
}

// implement server HMR by trigering re-fetch/render of RSC upon server code change
if (import.meta.hot) {
Expand Down
21 changes: 16 additions & 5 deletions packages/plugin-rsc/examples/basic/src/framework/entry.rsc.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export async function handleRequest({
let returnValue: RscPayload['returnValue'] | undefined
let formState: ReactFormState | undefined
let temporaryReferences: unknown | undefined
let actionStatus: number | undefined
if (isAction) {
// x-rsc-action header exists when action is called via `ReactClient.setServerCallback`.
const actionId = request.headers.get('x-rsc-action')
Expand All @@ -55,15 +56,24 @@ export async function handleRequest({
returnValue = { ok: true, data }
} catch (e) {
returnValue = { ok: false, data: e }
actionStatus = 500
}
} else {
// otherwise server function is called via `<form action={...}>`
// before hydration (e.g. when javascript is disabled).
// aka progressive enhancement.
const formData = await request.formData()
const decodedAction = await decodeAction(formData)
const result = await decodedAction()
formState = await decodeFormState(result, formData)
try {
const result = await decodedAction()
formState = await decodeFormState(result, formData)
} catch (e) {
// there's no single general obvious way to surface this error,
// so explicitly return classic 500 response.
return new Response('Internal Server Error: server action failed', {
status: 500,
})
}
}
}

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

if (isRscRequest) {
return new Response(rscStream, {
status: returnValue?.ok === false ? 500 : undefined,
status: actionStatus,
headers: {
'content-type': 'text/x-component;charset=utf-8',
vary: 'accept',
Expand All @@ -97,15 +107,16 @@ export async function handleRequest({
const ssrEntryModule = await import.meta.viteRsc.loadModule<
typeof import('./entry.ssr.tsx')
>('ssr', 'index')
const htmlStream = await ssrEntryModule.renderHTML(rscStream, {
const ssrResult = await ssrEntryModule.renderHTML(rscStream, {
formState,
nonce,
// allow quick simulation of javscript disabled browser
debugNojs: url.searchParams.has('__nojs'),
})

// respond html
return new Response(htmlStream, {
return new Response(ssrResult.stream, {
status: ssrResult.status,
headers: {
'content-type': 'text/html;charset=utf-8',
vary: 'accept',
Expand Down
39 changes: 30 additions & 9 deletions packages/plugin-rsc/examples/basic/src/framework/entry.ssr.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export async function renderHTML(
nonce?: string
debugNojs?: boolean
},
) {
): Promise<{ stream: ReadableStream<Uint8Array>; status?: number }> {
// duplicate one RSC stream into two.
// - one for SSR (ReactClient.createFromReadableStream below)
// - another for browser hydration payload by injecting <script>...FLIGHT_DATA...</script>.
Expand All @@ -30,13 +30,34 @@ export async function renderHTML(
// render html (traditional SSR)
const bootstrapScriptContent =
await import.meta.viteRsc.loadBootstrapScriptContent('index')
const htmlStream = await renderToReadableStream(<SsrRoot />, {
bootstrapScriptContent: options?.debugNojs
? undefined
: bootstrapScriptContent,
nonce: options?.nonce,
formState: options?.formState,
})
let htmlStream: ReadableStream<Uint8Array>
let status: number | undefined
try {
htmlStream = await renderToReadableStream(<SsrRoot />, {
bootstrapScriptContent: options?.debugNojs
? undefined
: bootstrapScriptContent,
nonce: options?.nonce,
formState: options?.formState,
})
} catch (e) {
// fallback to render an empty shell and run pure CSR on browser,
// which can replay server component error and trigger error boundary.
status = 500
htmlStream = await renderToReadableStream(
<html>
<body>
<noscript>Internal Server Error: SSR failed</noscript>
</body>
</html>,
{
bootstrapScriptContent:
`self.__NO_HYDRATE=1;` +
(options?.debugNojs ? '' : bootstrapScriptContent),
nonce: options?.nonce,
},
)
}

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

return responseStream
return { stream: responseStream, status }
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import React from 'react'

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

// https://github.com/vercel/next.js/blob/677c9b372faef680d17e9ba224743f44e1107661/packages/next/src/build/webpack/loaders/next-app-loader.ts#L73
// https://github.com/vercel/next.js/blob/677c9b372faef680d17e9ba224743f44e1107661/packages/next/src/client/components/error-boundary.tsx#L145
// https://github.com/vercel/next.js/blob/473ae4b70dd781cc8b2620c95766f827296e689a/packages/next/src/client/components/builtin/global-error.tsx
function DefaultGlobalErrorPage(props: { error: Error; reset: () => void }) {
return (
<html>
Expand All @@ -49,17 +50,20 @@ function DefaultGlobalErrorPage(props: { error: Error; reset: () => void }) {
</head>
<body
style={{
fontFamily:
'system-ui,"Segoe UI",Roboto,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"',
height: '100vh',
margin: 0,
display: 'flex',
flexDirection: 'column',
placeContent: 'center',
placeItems: 'center',
fontSize: '16px',
fontWeight: 400,
lineHeight: '24px',
lineHeight: '28px',
}}
>
<p>Caught an unexpected error</p>
<div>Caught an unexpected error</div>
<pre>
Error:{' '}
{import.meta.env.DEV && 'message' in props.error
Expand Down
2 changes: 2 additions & 0 deletions packages/plugin-rsc/examples/basic/src/routes/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import { TestHmrClientDep3 } from './hmr-client-dep3/server'
import { TestChunk2 } from './chunk2/server'
import { TestUseId } from './use-id/server'
import { TestClientError } from './client-error/client'
import { TestServerError } from './server-error/server'

export function Root(props: { url: URL }) {
return (
Expand Down Expand Up @@ -80,6 +81,7 @@ export function Root(props: { url: URL }) {
<TestTemporaryReference />
<TestServerActionError />
<TestClientError />
<TestServerError url={props.url} />
<TestReplayConsoleLogs url={props.url} />
<TestSuspense url={props.url} />
<TestActionFromClient />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export async function TestServerError(props: { url: URL }) {
if (props.url.searchParams.has('test-server-error')) {
throw new Error('test-server-error!')
}
return (
<div>
<a href="?test-server-error">test-server-error</a>
</div>
)
}
13 changes: 10 additions & 3 deletions packages/plugin-rsc/examples/ssg/src/framework/entry.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import {
createFromReadableStream,
} from '@vitejs/plugin-rsc/browser'
import React from 'react'
import { hydrateRoot } from 'react-dom/client'
import { createRoot, hydrateRoot } from 'react-dom/client'
import { rscStream } from 'rsc-html-stream/client'
import { RSC_POSTFIX, type RscPayload } from './shared'
import { GlobalErrorBoundary } from './error-boundary'

async function hydrate(): Promise<void> {
async function onNavigation() {
Expand Down Expand Up @@ -35,11 +36,17 @@ async function hydrate(): Promise<void> {

const browserRoot = (
<React.StrictMode>
<BrowserRoot />
<GlobalErrorBoundary>
<BrowserRoot />
</GlobalErrorBoundary>
</React.StrictMode>
)

hydrateRoot(document, browserRoot)
if ('__NO_HYDRATE' in globalThis) {
createRoot(document).render(browserRoot)
} else {
hydrateRoot(document, browserRoot)
}

if (import.meta.hot) {
import.meta.hot.on('rsc:update', () => {
Expand Down
9 changes: 5 additions & 4 deletions packages/plugin-rsc/examples/ssg/src/framework/entry.rsc.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,10 @@ export default async function handler(request: Request): Promise<Response> {
const ssr = await import.meta.viteRsc.loadModule<
typeof import('./entry.ssr')
>('ssr', 'index')
const htmlStream = await ssr.renderHtml(rscStream)
const ssrResult = await ssr.renderHtml(rscStream)

return new Response(htmlStream, {
return new Response(ssrResult.stream, {
status: ssrResult.status,
headers: {
'content-type': 'text/html;charset=utf-8',
vary: 'accept',
Expand All @@ -50,11 +51,11 @@ export async function handleSsg(request: Request): Promise<{
const ssr = await import.meta.viteRsc.loadModule<
typeof import('./entry.ssr')
>('ssr', 'index')
const htmlStream = await ssr.renderHtml(rscStream1, {
const ssrResult = await ssr.renderHtml(rscStream1, {
ssg: true,
})

return { html: htmlStream, rsc: rscStream2 }
return { html: ssrResult.stream, rsc: rscStream2 }
}

if (import.meta.hot) {
Expand Down
30 changes: 25 additions & 5 deletions packages/plugin-rsc/examples/ssg/src/framework/entry.ssr.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export async function renderHtml(
options?: {
ssg?: boolean
},
) {
): Promise<{ stream: ReadableStream<Uint8Array>; status?: number }> {
const [rscStream1, rscStream2] = rscStream.tee()

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

let htmlStream: ReadableStream<Uint8Array>
let status: number | undefined
if (options?.ssg) {
// for static site generation, let errors throw to fail the build
const prerenderResult = await prerender(<SsrRoot />, {
bootstrapScriptContent,
})
htmlStream = prerenderResult.prelude
} else {
htmlStream = await renderToReadableStream(<SsrRoot />, {
bootstrapScriptContent,
})
// for regular SSR, catch errors and fallback to CSR
try {
htmlStream = await renderToReadableStream(<SsrRoot />, {
bootstrapScriptContent,
})
} catch (e) {
// fallback to render an empty shell and run pure CSR on browser,
// which can replay server component error and trigger error boundary.
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The caught exception e is not logged or used in any way. Consider logging it for debugging purposes, especially since this is a fallback scenario. This would help developers understand what caused the SSR to fail.

Suggested change
// which can replay server component error and trigger error boundary.
// which can replay server component error and trigger error boundary.
console.error('SSR failed, falling back to CSR:', e)

Copilot uses AI. Check for mistakes.
status = 500
htmlStream = await renderToReadableStream(
<html>
<body>
<noscript>Internal Server Error: SSR failed</noscript>
</body>
</html>,
{
bootstrapScriptContent:
`self.__NO_HYDRATE=1;` + bootstrapScriptContent,
},
)
}
}

let responseStream: ReadableStream<Uint8Array> = htmlStream
responseStream = responseStream.pipeThrough(injectRSCPayload(rscStream2))
return responseStream
return { stream: responseStream, status }
}
Loading
Loading