diff --git a/playground/ssr-react-error-handling/README.md b/playground/ssr-react-error-handling/README.md new file mode 100644 index 000000000..69a62783e --- /dev/null +++ b/playground/ssr-react-error-handling/README.md @@ -0,0 +1,77 @@ +# SSR Error Handling Example + +Demonstrates common error handling patterns for React 19 SSR with Vite. + +## Patterns covered + +### 1. `onError` callback (`entry-server.jsx`) + +The `onError` option on `renderToReadableStream` fires whenever a component +throws during server rendering. Use it to log errors, report to monitoring, +or return a digest string to the client. + +```js +const stream = await renderToReadableStream(, { + onError(error, errorInfo) { + console.error('[SSR]', error, errorInfo.componentStack) + }, +}) +``` + +### 2. `digest` for error identification + +When a thrown error has a `digest` property, React forwards it to the client +error boundary without exposing the full error message. This lets you +correlate server and client errors using an opaque ID. + +```js +onError(error) { + if (error?.digest) { + return error.digest // sent to client + } +} +``` + +### 3. `captureOwnerStack` for debugging (React 19+) + +`React.captureOwnerStack()` returns the component stack of the _owner_ that +rendered the failing component. This is often more useful than the regular +component stack for tracking down who passed bad props. + +```js +import React from 'react' + +onError(error) { + const ownerStack = React.captureOwnerStack?.() + console.error('[SSR]', error, ownerStack) +} +``` + +### 4. SSR-to-CSR fallback (`entry-server.jsx` + `entry-client.jsx`) + +If `renderToReadableStream` itself throws (the shell fails to render), the +server returns a minimal HTML page with `window.__SSR_ERROR__ = true`. The +client entry detects this flag and calls `createRoot` instead of +`hydrateRoot`, mounting the app from scratch on the client. + +### 5. `onRecoverableError` on the client (`entry-client.jsx`) + +Hydration mismatches and other non-fatal errors surface through the +`onRecoverableError` callback on `hydrateRoot`. This is the right place to +log warnings without crashing the app. + +## Pages + +| Route | Behavior | +| ---------------- | --------------------------------------------------------- | +| `/` | Home page with links to error scenarios | +| `/throws-render` | Throws during render (SSR + CSR), caught by ErrorBoundary | +| `/throws-effect` | Renders on server, throws in useEffect on client | + +## Running + +```bash +# from repo root +pnpm install +pnpm --filter @vitejs/test-ssr-react-error-handling dev +``` diff --git a/playground/ssr-react-error-handling/__tests__/ssr-react-error-handling.spec.ts b/playground/ssr-react-error-handling/__tests__/ssr-react-error-handling.spec.ts new file mode 100644 index 000000000..bdd02c343 --- /dev/null +++ b/playground/ssr-react-error-handling/__tests__/ssr-react-error-handling.spec.ts @@ -0,0 +1,52 @@ +import fetch from 'node-fetch' +import { expect, test } from 'vitest' +import { page, viteTestUrl as url } from '~utils' + +test('/ renders home page with navigation', async () => { + await page.goto(url) + await expect.poll(() => page.textContent('h1')).toMatch('Home') + + // raw http request confirms SSR + const html = await (await fetch(url)).text() + expect(html).toContain('

Home

') + expect(html).toContain('Throws during render') +}) + +test('/throws-render triggers error boundary on client', async () => { + await page.goto(url + '/throws-render') + + // The error boundary should catch the render error and display fallback UI + await expect + .poll(() => page.textContent('[data-testid="error-fallback"]')) + .toContain('Something went wrong') + + // The digest should be visible in the fallback + await expect + .poll(() => page.textContent('[data-testid="error-fallback"]')) + .toContain('RENDER_ERROR_001') +}) + +test('/throws-render SSR response includes error script fallback', async () => { + // The raw SSR response should still be valid HTML (not a 500 error page). + // React streams the shell, then the error boundary catches the throw. + const res = await fetch(url + '/throws-render') + expect(res.status).toBe(200) + const html = await res.text() + // Should contain the HTML shell + expect(html).toContain('
') +}) + +test('/throws-effect renders on server, throws on client', async () => { + // Verify SSR produces valid HTML (effect does not run on server) + const html = await (await fetch(url + '/throws-effect')).text() + expect(html).toContain('Throws in Effect') + + // On the client, the effect triggers an error caught by ErrorBoundary + await page.goto(url + '/throws-effect') + await expect + .poll(() => page.textContent('[data-testid="error-fallback"]')) + .toContain('Something went wrong') + await expect + .poll(() => page.textContent('[data-testid="error-fallback"]')) + .toContain('Intentional effect error') +}) diff --git a/playground/ssr-react-error-handling/index.html b/playground/ssr-react-error-handling/index.html new file mode 100644 index 000000000..1504a1dd6 --- /dev/null +++ b/playground/ssr-react-error-handling/index.html @@ -0,0 +1,12 @@ + + + + + + SSR Error Handling Example + + +
+ + + diff --git a/playground/ssr-react-error-handling/package.json b/playground/ssr-react-error-handling/package.json new file mode 100644 index 000000000..c2c20a207 --- /dev/null +++ b/playground/ssr-react-error-handling/package.json @@ -0,0 +1,17 @@ +{ + "name": "@vitejs/test-ssr-react-error-handling", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.4", + "react-dom": "^19.2.4" + }, + "devDependencies": { + "@vitejs/plugin-react": "workspace:*" + } +} diff --git a/playground/ssr-react-error-handling/src/App.jsx b/playground/ssr-react-error-handling/src/App.jsx new file mode 100644 index 000000000..c0338309d --- /dev/null +++ b/playground/ssr-react-error-handling/src/App.jsx @@ -0,0 +1,83 @@ +import React from 'react' +import { ErrorBoundary } from './ErrorBoundary' + +// Auto generates routes from files under ./pages +// https://vite.dev/guide/features.html#glob-import +const pages = import.meta.glob('./pages/*.jsx', { eager: true }) + +const routes = Object.keys(pages).map((path) => { + const name = path.match(/\.\/pages\/(.*)\.jsx$/)[1] + return { + name, + path: name === 'Home' ? '/' : `/${name.toLowerCase()}`, + component: pages[path].default, + } +}) + +function NotFound() { + return

Not found

+} + +/** + * @param {{ url: URL }} props + */ +export function App(props) { + const [url, setUrl] = React.useState(props.url) + + React.useEffect(() => { + return listenNavigation(() => { + setUrl(new URL(window.location.href)) + }) + }, [setUrl]) + + const route = routes.find((route) => route.path === url.pathname) + const Component = route?.component ?? NotFound + return ( + <> + + + + + + ) +} + +/** + * @param {() => void} onNavigation + */ +function listenNavigation(onNavigation) { + /** + * @param {MouseEvent} e + */ + function onClick(e) { + const link = e.target.closest('a') + if ( + link && + link instanceof HTMLAnchorElement && + link.href && + (!link.target || link.target === '_self') && + link.origin === location.origin && + !link.hasAttribute('download') && + e.button === 0 && + !(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) + ) { + e.preventDefault() + history.pushState(null, '', link.href) + onNavigation() + } + } + document.addEventListener('click', onClick) + return () => { + document.removeEventListener('click', onClick) + } +} diff --git a/playground/ssr-react-error-handling/src/ErrorBoundary.jsx b/playground/ssr-react-error-handling/src/ErrorBoundary.jsx new file mode 100644 index 000000000..a3173481e --- /dev/null +++ b/playground/ssr-react-error-handling/src/ErrorBoundary.jsx @@ -0,0 +1,52 @@ +import React from 'react' + +/** + * A basic error boundary that catches render errors in its subtree. + * + * During SSR, React cannot run error boundaries (they are a client-only + * mechanism). If a component throws during SSR, the error is reported + * through renderToReadableStream's onError callback. When the same + * component throws during hydration or CSR, this boundary catches it and + * renders the fallback UI. + */ +export class ErrorBoundary extends React.Component { + constructor(props) { + super(props) + this.state = { error: null } + } + + static getDerivedStateFromError(error) { + return { error } + } + + componentDidCatch(error, errorInfo) { + console.error( + '[ErrorBoundary] Caught:', + error, + errorInfo.componentStack ?? '', + ) + } + + render() { + if (this.state.error) { + // Display a minimal fallback. In production you would show a + // user-friendly message and optionally a "retry" button. + return ( +
+

Something went wrong

+
{this.state.error.message}
+ {this.state.error.digest && ( +

+ Error ID: {this.state.error.digest} +

+ )} + +
+ ) + } + + return this.props.children + } +} diff --git a/playground/ssr-react-error-handling/src/entry-client.jsx b/playground/ssr-react-error-handling/src/entry-client.jsx new file mode 100644 index 000000000..5449cba5c --- /dev/null +++ b/playground/ssr-react-error-handling/src/entry-client.jsx @@ -0,0 +1,27 @@ +import '@vitejs/plugin-react/preamble' +import ReactDOM from 'react-dom/client' +import { App } from './App' + +const container = document.getElementById('app') + +if (window.__SSR_ERROR__) { + // SSR failed: mount fresh via createRoot (CSR fallback). + // This avoids hydration mismatches when the server sent an empty shell. + console.warn('[Client] SSR error detected, falling back to CSR') + const root = ReactDOM.createRoot(container) + root.render() +} else { + // Normal path: hydrate the server-rendered HTML. + ReactDOM.hydrateRoot(container, , { + onRecoverableError(error, errorInfo) { + // Hydration mismatches and other recoverable errors surface here. + // Log them so they are visible during development. + console.error( + '[Hydration] Recoverable error:', + error, + errorInfo.componentStack ?? '', + ) + }, + }) +} +console.log('mounted') diff --git a/playground/ssr-react-error-handling/src/entry-server.jsx b/playground/ssr-react-error-handling/src/entry-server.jsx new file mode 100644 index 000000000..edfac87db --- /dev/null +++ b/playground/ssr-react-error-handling/src/entry-server.jsx @@ -0,0 +1,65 @@ +import React from 'react' +import { renderToReadableStream } from 'react-dom/server' +import { App } from './App' + +/** + * Renders the app to a ReadableStream with error handling. + * + * Demonstrates: + * - onError callback for logging SSR render errors + * - digest for identifying errors without leaking details to the client + * - captureOwnerStack for enhanced debugging (React 19+) + * - SSR-to-CSR fallback when renderToReadableStream fails + */ +export async function render(url) { + const captureOwnerStack = React.captureOwnerStack + + try { + const stream = await renderToReadableStream( + , + { + onError(error, errorInfo) { + // Check for a digest property, which React attaches to errors + // thrown in server components or passed through Suspense boundaries. + // The digest acts as an opaque identifier: it can be sent to the + // client without leaking server internals. + if ( + error && + typeof error === 'object' && + 'digest' in error && + typeof error.digest === 'string' + ) { + console.error( + `[SSR] Error with digest "${error.digest}":`, + error.message, + ) + return error.digest + } + + // captureOwnerStack() returns the component stack of the owner + // that rendered the component where the error originated. This is + // more useful than errorInfo.componentStack for debugging because + // it traces the "who rendered this" chain rather than the "where + // in the tree" chain. + const ownerStack = captureOwnerStack?.() ?? '' + console.error( + '[SSR] Render error:', + error, + ownerStack ? `\nOwner stack:\n${ownerStack}` : '', + errorInfo.componentStack + ? `\nComponent stack:\n${errorInfo.componentStack}` + : '', + ) + }, + }, + ) + + return stream + } catch (error) { + // If renderToReadableStream itself throws (e.g. the shell fails to + // render), fall back to a CSR-only shell. The client entry will detect + // window.__SSR_ERROR__ and use createRoot instead of hydrateRoot. + console.error('[SSR] Shell render failed, falling back to CSR:', error) + return `` + } +} diff --git a/playground/ssr-react-error-handling/src/pages/Home.jsx b/playground/ssr-react-error-handling/src/pages/Home.jsx new file mode 100644 index 000000000..4bc7cc807 --- /dev/null +++ b/playground/ssr-react-error-handling/src/pages/Home.jsx @@ -0,0 +1,21 @@ +export default function Home() { + return ( + <> +

Home

+

+ This example demonstrates SSR error handling patterns with React 19 and + Vite. +

+ + + ) +} diff --git a/playground/ssr-react-error-handling/src/pages/ThrowsEffect.jsx b/playground/ssr-react-error-handling/src/pages/ThrowsEffect.jsx new file mode 100644 index 000000000..f996f58f0 --- /dev/null +++ b/playground/ssr-react-error-handling/src/pages/ThrowsEffect.jsx @@ -0,0 +1,32 @@ +import React from 'react' + +/** + * A component that throws inside a useEffect. + * + * Since useEffect does not run during SSR, this page renders successfully + * on the server. The error only surfaces on the client, where it is caught + * by the nearest ErrorBoundary. + * + * This pattern is common when server-rendered content is valid but a + * client-side side effect (e.g. a broken API call or a missing browser + * API) fails after hydration. + */ +export default function ThrowsEffect() { + const [ready, setReady] = React.useState(false) + + React.useEffect(() => { + // Simulate a client-only failure (e.g. a broken fetch or missing API) + setReady(true) + }, []) + + if (ready) { + throw new Error('Intentional effect error (client-only)') + } + + return ( + <> +

Throws in Effect

+

This page renders on the server but throws on the client.

+ + ) +} diff --git a/playground/ssr-react-error-handling/src/pages/ThrowsRender.jsx b/playground/ssr-react-error-handling/src/pages/ThrowsRender.jsx new file mode 100644 index 000000000..58b64c020 --- /dev/null +++ b/playground/ssr-react-error-handling/src/pages/ThrowsRender.jsx @@ -0,0 +1,15 @@ +/** + * A component that always throws during render. + * + * During SSR, this error is reported through the `onError` callback passed + * to `renderToReadableStream`. The stream is still sent to the client, and + * the ErrorBoundary catches the re-thrown error during hydration/CSR. + * + * The error includes a `digest` property that can be used to identify the + * error on the client side without leaking server details. + */ +export default function ThrowsRender() { + const error = new Error('Intentional render error') + error.digest = 'RENDER_ERROR_001' + throw error +} diff --git a/playground/ssr-react-error-handling/vite.config.js b/playground/ssr-react-error-handling/vite.config.js new file mode 100644 index 000000000..e4c033c2f --- /dev/null +++ b/playground/ssr-react-error-handling/vite.config.js @@ -0,0 +1,134 @@ +import fs from 'node:fs' +import path from 'node:path' +import url from 'node:url' +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' + +const _dirname = path.dirname(url.fileURLToPath(import.meta.url)) + +export default defineConfig({ + appType: 'custom', + build: { + minify: false, + }, + environments: { + client: { + build: { + outDir: 'dist/client', + }, + }, + ssr: { + build: { + outDir: 'dist/server', + rollupOptions: { + input: path.resolve(_dirname, 'src/entry-server.jsx'), + }, + }, + }, + }, + plugins: [ + react(), + { + name: 'ssr-middleware', + configureServer(server) { + return () => { + server.middlewares.use(async (req, res, next) => { + const url = req.originalUrl ?? '/' + try { + const { render } = await server.ssrLoadModule( + '/src/entry-server.jsx', + ) + const htmlStream = await render(url) + + const template = fs.readFileSync( + path.resolve(_dirname, 'index.html'), + 'utf-8', + ) + + // If render returned a string (CSR fallback), inject it directly + if (typeof htmlStream === 'string') { + const html = template.replace(``, htmlStream) + res.setHeader('content-type', 'text/html').end(html) + return + } + + // Stream the response for ReadableStream results + const [before, after] = template.split('') + res.setHeader('content-type', 'text/html') + res.write(before) + + const reader = htmlStream.getReader() + const decoder = new TextDecoder() + while (true) { + const { done, value } = await reader.read() + if (done) break + res.write(decoder.decode(value, { stream: true })) + } + + res.end(after) + } catch (e) { + // If SSR fails entirely, fall back to CSR shell + console.error('[SSR] Fatal error, falling back to CSR:', e) + const template = fs.readFileSync( + path.resolve(_dirname, 'index.html'), + 'utf-8', + ) + const html = template.replace( + ``, + ``, + ) + res.setHeader('content-type', 'text/html').end(html) + } + }) + } + }, + async configurePreviewServer(server) { + const template = fs.readFileSync( + path.resolve(_dirname, 'dist/client/index.html'), + 'utf-8', + ) + const { render } = await import( + url.pathToFileURL( + path.resolve(_dirname, './dist/server/entry-server.js'), + ) + ) + return () => { + server.middlewares.use(async (req, res, next) => { + const url = req.originalUrl ?? '/' + try { + const result = await render(url) + if (typeof result === 'string') { + const html = template.replace(``, result) + res.setHeader('content-type', 'text/html').end(html) + return + } + + const [before, after] = template.split('') + res.setHeader('content-type', 'text/html') + res.write(before) + + const reader = result.getReader() + const decoder = new TextDecoder() + while (true) { + const { done, value } = await reader.read() + if (done) break + res.write(decoder.decode(value, { stream: true })) + } + + res.end(after) + } catch (e) { + console.error('[SSR] Fatal error, falling back to CSR:', e) + const html = template.replace( + ``, + ``, + ) + res.setHeader('content-type', 'text/html').end(html) + } + }) + } + }, + }, + ], + // tell vitestSetup.ts to use buildApp API + builder: {}, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5934d68f5..703b9b44d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1102,6 +1102,19 @@ importers: specifier: workspace:* version: link:../../packages/plugin-react + playground/ssr-react-error-handling: + dependencies: + react: + specifier: ^19.2.4 + version: 19.2.4 + react-dom: + specifier: ^19.2.4 + version: 19.2.4(react@19.2.4) + devDependencies: + '@vitejs/plugin-react': + specifier: workspace:* + version: link:../../packages/plugin-react + playground/tsconfig-jsx-preserve: dependencies: react: