Skip to content

How to: catch render errors with a boundary

Svelte 5 and Solid both ship a real error-boundary primitive - <svelte:boundary> and solid-js’s <ErrorBoundary>, re-exported verbatim from the Solid adapter barrel. React and Vue don’t: React’s class-component componentDidCatch boundary is not exposed as a documented pattern in the React API, and Vue’s nearest tool, onErrorCaptured, is a different shape — a hook you write yourself, not a template construct with its own fallback UI. If you need the same fallback-UI behavior on those adapters today, you’re on your own.

Wrap the part of the tree that might throw during render, and provide a failed snippet as the fallback:

<svelte:boundary onerror={(error, reset) => console.error(error)}>
<Child />
{#snippet failed(error, reset)}
<view testID="failed">
<text>{error.message}</text>
</view>
{/snippet}
</svelte:boundary>
  • Nothing throws → the boundary paints no native node of its own; children commit exactly as if it weren’t there.
  • A child throws during render → its subtree is torn down completely (not just hidden), onerror(error, reset) fires once, and the failed snippet takes its place.
  • Calling reset() — handed to you by both onerror and the failed snippet — tears the failed subtree back down and re-mounts the children.

Source: adapters/svelte/src/boundary.smoke.test.ts, run against the real Svelte compiler and asserted against the exact committed Fabric tree, not a mock — these examples are transcribed from its real markup.

The trap: reset arrives as a snippet parameter, and this looks reasonable but silently doesn’t work:

{#snippet failed(error, reset)}
{@const captured = capture(reset)}
...
{/snippet}

{@const} compiles to $.derived(...), and a derived value nothing reads is never evaluated — the capture silently never runs. Route it through a prop bag expression instead, since set_custom_element_data always evaluates its argument:

{#snippet failed(error, reset)}
<symbiote-view p={control.failedBag(reset)}>
<symbiote-text p={{}}>{error.message}</symbiote-text>
</symbiote-view>
{/snippet}

where failedBag is a plain function stashing reset somewhere a retry button can reach it:

failedBag: reset => {
control.reset = reset;
return { testID: 'failed' };
},

<svelte:boundary> was compile-verified only before this — the preprocessor allowed it and tsc --build saw nothing, but nothing in the repo had ever run $.boundary for real. The smoke test covers, against exact committed child lists rather than toBeDefined():

  • transparent render when nothing throws — the boundary contributes no native node of its own;
  • a child component throwing during its own init: the failed snippet renders, onerror fires exactly once, and the thrown subtree is fully gone, not merely hidden behind the fallback;
  • reset() called from inside the failed snippet restores the children, without leaving the failed subtree behind as a duplicate;
  • a boundary wrapping a real {#each} list survives a reorder + grow + shrink in one update without losing track of its own anchor.

No adapter code at all - ErrorBoundary is solid-js’s own primitive, re-exported unchanged from @symbiote-native/solid. It catches only what throws while its subtree is rendering or updating; an error thrown from an event handler (an onPress) never reaches it, exactly like Svelte’s onerror.

import { ErrorBoundary, Show } from 'solid-js';
<ErrorBoundary
fallback={(error, reset) => (
<view>
<text testID="failed">{`caught: ${error.message}`}</text>
<ActionButton
title="reset"
onPress={() => {
setExploding(false);
reset();
}}
/>
</view>
)}
>
<Show when={exploding()} fallback={<text>subtree is healthy</text>}>
<Exploder />
</Show>
</ErrorBoundary>;
  • Nothing throws -> the boundary paints no native node of its own.
  • The child throws during render -> the subtree is torn down, fallback receives the error and a reset() callback, and paints in its place.
  • reset() re-runs the subtree - it doesn’t un-throw on its own, so clear whatever condition caused the throw (setExploding(false) above) before calling it, the same ordering Svelte’s retry button needs.

Event-handler errors need catchError, not the boundary

Section titled “Event-handler errors need catchError, not the boundary”

A throw inside onPress never reaches <ErrorBoundary> - solid-js’s catchError is the tool for that case instead:

import { catchError } from 'solid-js';
const onPress = () =>
catchError(
() => {
throw new Error('thrown inside a handler');
},
error => setCaught(error.message),
);

Source: examples/solid/components/api-playground/ErrorBoundaryDemo.tsx runs both side by side, since knowing which one applies is the whole difficulty.

adapters/solid/src/render-error-reporting.test.tsx runs a real <ErrorBoundary> against the engine’s fake Fabric and asserts, not just compiles:

  • a boundary-caught error stays off the native redbox reporter - writing a boundary is the app saying “I’m handling this,” so a full-screen redbox over the fallback it just painted would contradict that, matching React’s and Vue’s adapters;
  • the fallback commits as the real child in place of the thrown subtree.

Verified via vitest against the fake Fabric tree, not yet exercised on a real device.