Skip to content
Open
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
77 changes: 77 additions & 0 deletions playground/ssr-react-error-handling/README.md
Original file line number Diff line number Diff line change
@@ -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(<App />, {
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
```
Original file line number Diff line number Diff line change
@@ -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('<h1>Home</h1>')
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('<div id="app">')
})

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')
})
12 changes: 12 additions & 0 deletions playground/ssr-react-error-handling/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SSR Error Handling Example</title>
</head>
<body>
<div id="app"><!--app-html--></div>
<script type="module" src="/src/entry-client.jsx"></script>
</body>
</html>
17 changes: 17 additions & 0 deletions playground/ssr-react-error-handling/package.json
Original file line number Diff line number Diff line change
@@ -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:*"
}
}
83 changes: 83 additions & 0 deletions playground/ssr-react-error-handling/src/App.jsx
Original file line number Diff line number Diff line change
@@ -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 <h1>Not found</h1>
}

/**
* @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 (
<>
<nav>
<ul>
{routes.map(({ name, path }) => {
return (
<li key={path}>
<a href={path}>{name}</a>
</li>
)
})}
</ul>
</nav>
<ErrorBoundary>
<Component />
</ErrorBoundary>
</>
)
}

/**
* @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)
}
}
52 changes: 52 additions & 0 deletions playground/ssr-react-error-handling/src/ErrorBoundary.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="error-fallback" data-testid="error-fallback">
<h2>Something went wrong</h2>
<pre>{this.state.error.message}</pre>
{this.state.error.digest && (
<p>
Error ID: <code>{this.state.error.digest}</code>
</p>
)}
<button onClick={() => this.setState({ error: null })}>
Try again
</button>
</div>
)
}

return this.props.children
}
}
27 changes: 27 additions & 0 deletions playground/ssr-react-error-handling/src/entry-client.jsx
Original file line number Diff line number Diff line change
@@ -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(<App url={new URL(window.location.href)} />)
} else {
// Normal path: hydrate the server-rendered HTML.
ReactDOM.hydrateRoot(container, <App url={new URL(window.location.href)} />, {
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')
65 changes: 65 additions & 0 deletions playground/ssr-react-error-handling/src/entry-server.jsx
Original file line number Diff line number Diff line change
@@ -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(
<App url={new URL(url, 'http://localhost')} />,
{
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 `<script>window.__SSR_ERROR__ = true</script>`
}
}
Loading