Cookbook for <ErrorBoundary> placements and error reporting in the admin app. Where boundaries live, what location tags to use, and how to add a new one.
The codebase uses one error boundary primitive — src/ui/components/ErrorBoundary/. Every architectural seam where a render-time failure could blank a tree the user expects to be independent wraps with it. The error-boundary-coverage.test.ts gate enforces the placements.
- Primitive:
<ErrorBoundary location="...">from@ui/components/ErrorBoundary. - Required placements (gated): admin shell, per-route, canvas, per-node renderer, plugin page, plugin editor panel, plugin canvas overlay.
- React 19 root callbacks (
onCaughtError,onUncaughtError,onRecoverableError) wired insrc/admin/main.tsx. - Caught errors log with
[<module>]prefix; uncaught ones additionally show a toast. flattenErrorChain(err)walkserror.causeso domain-typed errors surface their full provenance.
src/ui/components/ErrorBoundary/ErrorBoundary.tsx:
interface ErrorBoundaryProps {
/** Unique location tag — appears in logs and the dev fallback */
location: string
/** Optional values that, when changed, reset the boundary */
resetKeys?: unknown[]
/** Optional custom fallback */
fallback?: (info: ErrorBoundaryFallbackInfo) => ReactNode
children: ReactNode
}
<ErrorBoundary location="my-feature" resetKeys={[someKey]}>
<MyFeature />
</ErrorBoundary>When the boundary catches an error:
- It calls
logErrorChain('[my-feature]', flattenErrorChain(err), info.componentStack). - It surfaces a
pushToast({ kind: 'error', title: ..., body: ..., location: 'my-feature' }). - It renders the fallback (or the default — "Something broke in this view. Try refreshing.").
When resetKeys change, the boundary resets — useful for per-route boundaries that should clear when the route changes.
Gated by src/__tests__/architecture/error-boundary-coverage.test.ts. The gate scans for the literal location="..." strings; renaming a tag or removing a placement fails the build.
src/admin/main.tsx:
<ErrorBoundary location="admin-shell">
<Router>
<AdminRoutes />
</Router>
</ErrorBoundary>Catches anything not handled by inner boundaries. The fallback is a plain "Something went wrong" full-page surface — at this level, navigation may be unsafe, so the user reloads.
src/admin/router.tsx:
function RouteBoundary({ children }: { children: ReactNode }) {
const { pathname } = useLocation()
return (
<ErrorBoundary location="admin-route" resetKeys={[pathname]}>
{children}
</ErrorBoundary>
)
}Wraps every <Route>. resetKeys={[pathname]} means navigating away from a broken route clears the error — the user isn't stuck on the broken page forever.
src/admin/pages/site/canvas/CanvasRoot.tsx:
<ErrorBoundary location="canvas">
<CanvasTransformLayer>...</CanvasTransformLayer>
</ErrorBoundary>A bad render inside the canvas (e.g. a module render throws) doesn't break the editor chrome. The user can switch to a different page, fix the bad node in the DOM panel, or reload.
src/admin/pages/site/canvas/NodeRenderer.tsx:
<ErrorBoundary location="node-renderer">
<ModuleRender ... />
</ErrorBoundary>A single broken module renders an error placeholder; sibling nodes render fine. Essential for canvas robustness — the editor doesn't crash because one plugin module has a bug.
src/admin/pages/plugins/components/PluginPageRenderer/PluginPageRenderer.tsx:
<ErrorBoundary location="plugin-page">
<PluginPageMount ... />
</ErrorBoundary>A bad plugin admin page doesn't take down the admin shell — the user sees the error placeholder + a link to disable the plugin.
src/admin/pages/site/panels/PluginEditorPanel/PluginEditorPanel.tsx:
<ErrorBoundary location="plugin-editor-panel">
<PluginPanelMount ... />
</ErrorBoundary>Same idea, scoped to plugin-registered editor panels.
<ErrorBoundary location="plugin-canvas-overlay">
<PluginOverlay ... />
</ErrorBoundary>For editor.canvas permission overlays (annotation pins, custom selection adornments).
src/admin/main.tsx wires the React 19 root-level error callbacks:
const root = createRoot(rootElement, {
onCaughtError: (error, info) => {
handleRootError('react-root:caught', error, info, null)
},
onUncaughtError: (error, info) => {
handleRootError('react-root:uncaught', error, info, 'Unhandled render error')
},
onRecoverableError: (error, info) => {
handleRootError('react-root:recoverable', error, info, null)
},
})| Callback | When it fires | Toast? |
|---|---|---|
onCaughtError |
After an <ErrorBoundary> catches |
No (the boundary already toasted) |
onUncaughtError |
No boundary caught — the whole tree is broken | Yes — loud |
onRecoverableError |
React recovered (e.g. failed hydration → client render) | No (logged) |
handleRootError walks the error.cause chain via flattenErrorChain, logs the whole chain, and (if asked) pushes a toast.
src/ui/components/ErrorBoundary/errorReporting.ts:
flattenErrorChain(input) → ErrorChainEntry[]
logErrorChain(prefix, chain, info?) → void
formatErrorReport(chain, info?) → stringWalks error.cause recursively, collecting each link as { name, message, stack }. Handles cycles defensively (won't loop forever on a malformed chain).
Calls console.error with a [prefix] tag, the full chain, and the React component stack. Used by every error boundary's componentDidCatch.
Returns a human-readable string of the chain — used in the dev fallback UI and in copy-to-clipboard affordances.
- Pick a unique
locationtag — kebab-case, namespaced by feature (my-feature-something). - Wrap the seam:
<ErrorBoundary location="my-feature"> <MyFeature /> </ErrorBoundary>
- If the boundary is at one of the gated seams, update
error-boundary-coverage.test.ts'sREQUIRED_BOUNDARIESarray to include the new placement. Otherwise it's not gated (free placement). - The boundary auto-logs and auto-toasts on catch.
const { pathname } = useLocation()
<ErrorBoundary location="my-route" resetKeys={[pathname]}>
<RouteContent />
</ErrorBoundary>Any change to resetKeys resets the boundary's internal error state. Use it for per-route boundaries, per-id surfaces (a workspace selector that needs to reset when the user picks a new item), etc.
<ErrorBoundary
location="my-feature"
fallback={({ error, reset, componentStack }) => (
<div role="alert">
<h2>This panel didn't load</h2>
<p>{error.message}</p>
<Button variant="primary" onClick={reset}>Retry</Button>
</div>
)}
>
<MyFeature />
</ErrorBoundary>The default fallback is fine for most cases — only customize when the surface really needs something specific (e.g. a plugin page that should offer "Disable plugin" as a recovery action).
throw new SiteValidationError('Page tree node has invalid shape', { path: ['nodes', nodeId] })The boundary catches it like any error. The chain includes the typed class name (SiteValidationError) and the path field via the formatErrorReport helper.
React error boundaries don't catch errors thrown in async event handlers / setTimeout / promise rejections. For those:
const handler = useCallback(async () => {
try {
await doSomethingThatMightFail()
} catch (err) {
console.error('[my-feature] save failed:', err)
pushToast({ kind: 'error', title: 'Save failed', body: getErrorMessage(err, 'Save failed') })
setError(err)
}
}, [])Pattern: try/catch in async handlers, log with [<module>] prefix, surface via toast + component state. See docs/reference/typebox-patterns.md and CLAUDE.md's "Error handling" rules.
<ErrorBoundary location="optional-feature" fallback={() => null}>
<OptionalFeature />
</ErrorBoundary>If the optional feature throws, render nothing. The error still logs (for diagnostics) but the user doesn't see anything.
| Source | Prefix |
|---|---|
| Error boundary | [<location>], e.g. [admin-route], [canvas] |
| React root | [react-root:caught], [react-root:uncaught], [react-root:recoverable] |
| Async handler | [<module>] <description>:, e.g. [toolbar] Manual save failed: |
| Plugin worker | [plugin:<pluginId>], e.g. [plugin:acme.workflow] |
| Server | [<module>], e.g. [router], [server], [plugin-host] |
The square-bracket prefix is load-bearing — log scrapers in production filter on it. Don't drop it.
| Pattern | Use instead |
|---|---|
| Inventing a new error-boundary primitive | One primitive — @ui/components/ErrorBoundary |
Multiple boundaries with the same location value |
Each location must be unique (gated) |
| Removing a gated boundary placement | Update the gate at the same time, with reason |
catch (err) {} (silently swallowing in async) |
Name it catch (_err) with a comment, or handle it |
console.log for errors |
console.error with [<module>] prefix |
| Throwing without a useful message | Include the path / context ('failed to load page X') |
Re-throwing without cause |
throw new Error('wrapped', { cause: err }) preserves the chain |
Using error.message directly without instanceof Error check |
getErrorMessage(err, 'Unknown error') from @core/utils/errorMessage |
Using alert(error.message) |
pushToast or the error boundary fallback |
- docs/architecture.md — system layer overview
- docs/editor.md — boundaries inside the editor (canvas, node-renderer)
- docs/features/plugin-system.md — plugin-page / plugin-editor-panel / plugin-canvas-overlay boundaries
- docs/reference/typebox-patterns.md — async error handling patterns
- docs/reference/ui-primitives.md —
Toastfor surfacing errors - Source-of-truth files:
src/ui/components/ErrorBoundary/ErrorBoundary.tsx— the primitivesrc/ui/components/ErrorBoundary/errorReporting.ts— chain + log helperssrc/admin/main.tsx— React root callbacks + outermost boundarysrc/admin/router.tsx—RouteBoundaryper routesrc/core/utils/errorMessage.ts—getErrorMessageutility
- Gate tests:
src/__tests__/architecture/error-boundary-coverage.test.ts