Integrations

Solid

Solid is the one framework on this list whose errors reach you through a function you call rather than a callback you hand over, and that single difference decides most of what follows: onError and <ErrorBoundary> are alternatives, not complements, and neither of them sees an error thrown in an event handler. Every claim below was read out of [email protected] in this repository's node_modules — the source, not the documentation, which is silent on two of the four things that matter.

The integration #

tsx
// App.tsx — the component that wraps the app
import { ErrorBoundary, onError } from "solid-js";
import { mountBugbottle } from "bugbottle/ui";
import { htmlToImage } from "bugbottle/html-to-image"; // optional
import { da } from "bugbottle/locales";

const widget = mountBugbottle({
  endpoint: "/api/feedback",
  screenshot: htmlToImage,
  locale: da,
  openOnError: { prefill: true },
});

export default function App(props: { children?: JSX.Element }) {
  // Registered in the component, never at module scope. See below.
  onError((error) => {
    widget.open();
    // Keep the console. A handler is not a logger; it swallows the error.
    console.error(error);
  });

  return <ErrorBoundary fallback={(error) => <Crash error={error} />}>{props.children}</ErrorBoundary>;
}

onError sits in the component and not in the module because of the first trap. It catches render errors, and handleError reads the handler list off the owner it is called with — so it covers the component it is called in and the computations created under it, and nothing outside. A handler in the component that threw is too late; one at the top of the tree is right.

ErrorBoundary has no onerror prop, and it takes onError's place #

js
// node_modules/solid-js/dist/solid.js
function ErrorBoundary(props) {
  let err;
  if (sharedConfig.context && sharedConfig.load) err = sharedConfig.load(sharedConfig.getContextId());
  const [errored, setErrored] = createSignal(err, undefined);
  ...
  return createMemo(() => {
    let e;
    if (e = errored()) {
      const f = props.fallback;
      return typeof f === "function" && f.length ? untrack(() => f(e, () => setErrored())) : f;
    }
    return catchError(() => props.children, setErrored);
  }, undefined, undefined);
}

The props are fallback and children. There is no onerror to pass, and the handler the boundary installs for its own subtree is setErrored — it stores the error and renders your fallback, which is the whole job. Two details worth knowing: the fallback is called with (error, reset) only if it declares parameters (f.length is the test), so fallback={() => <p>Broken</p>} is rendered as a value rather than called; and catchError builds a fresh context for the subtree,

js
// node_modules/solid-js/dist/solid.js
function catchError(fn, handler) {
  ERROR || (ERROR = Symbol("error"));
  Owner = createComputation(undefined, undefined, true);
  Owner.context = { ...Owner.context, [ERROR]: [handler] };

so the boundary replaces the inherited onError list for everything inside it. Put onError above an ErrorBoundary expecting to be told about errors inside it and nothing happens: the boundary's setErrored is the only handler in that context, and the error never leaves. Pick the one you want — a report per caught error (onError, no boundary, the integration above) or a fallback that renders (ErrorBoundary, and read the error out of the render prop).

onError outside a component is discarded without a word #

js
// node_modules/solid-js/dist/solid.js
function onError(fn) {
  ERROR || (ERROR = Symbol("error"));
  if (Owner === null) ;else if (Owner.context === null || !Owner.context[ERROR]) {

The first branch is an empty statement. Call onError at module scope, in a createRoot callback that has already returned, or in a helper invoked from outside the component tree, and the handler is dropped: no warning, no error, and the error goes to the window instead. This is the Solid version of Svelte's boundary with only a pending snippet, and it is quieter — that one at least reaches the console.

An error in an event handler reaches neither of them #

js
// node_modules/solid-js/web/dist/web.js
const handleNode = () => {
  const handler = node[key];
  if (handler && !node.disabled) {
    const data = node[`${key}Data`];
    data !== undefined ? handler.call(node, data, e) : handler.call(node, e);

delegateEvents registers one eventHandler per event name on the document, and it calls your handler directly: no runUpdates wrapper, no owner set. The non-delegated path is the same — addEventListener wraps the listener in e => handlerFn.call(node, handler[1], e). So a throw inside an onClick leaves the DOM listener through the browser's own error path, which means ErrorBoundary never sees it and onError never runs. The only thing that catches it is the window.onerror patch initConsoleBuffer already does, which is why that call is not optional in a Solid application: it is the one part of the error path a boundary does not cover. onUncaughtError is not a Solid hook, so there is nothing to wire in its place.

What arrives is not what you threw #

js
// node_modules/solid-js/dist/solid.js
function castError(err) {
  if (err instanceof Error) return err;
  return new Error(typeof err === "string" ? err : "Unknown error", { cause: err });
}

An Error arrives as itself. Anything else is replaced by a new Error carrying the original as cause — and that new error's stack is created inside solid-js, so a report built from a thrown object or a thrown string carries Solid's frames rather than yours. Three consequences: console.error in the handler prints a copy whose stack points into the runtime, error instanceof TypeError is false for anything that was not a TypeError to begin with, and error.cause is where the value you actually threw is. Throw Error objects; a throw { code: 500 } costs you the stack.

The adapter #

createBugReport is the same state machine as the React hook, exposed as accessors — message(), elements(), statusMessage() — over a single signal, so state() in JSX tracks everything and nothing has to subscribe by hand. Two things are specific to it. It calls onCleanup(destroy) itself, so call it inside a component: outside an owner it returns a destroy you are expected to call, and the createSignal warns about the missing owner. And nothing browser-only runs when you create it — the machine builds its state without touching window, document or navigator — so a SolidStart or renderToString pass that constructs it will not fail. The panel and the screenshot do need the browser, which is what mountBugbottle and onMount are for.

Edit this page on GitHub