Skip to content

2.0.0-beta.15: bundle error handler receives internal StatusError wrapper instead of the thrown error #2840

Description

@yumemi-thomas

Describe the bug

The compute-phase error handler — since e2ebc11 (#2839) the pinned, documented recovery path for reactivity errors ("the handler receives the error") — actually receives Solid's internal StatusError wrapper rather than the error the user threw:

  1. err instanceof MyError is false in the handler — any instanceof / class-based error branching silently takes the wrong path.
  2. err.constructor.name is StatusError, an undocumented internal type; the original error is only reachable via the undocumented err.cause.
  3. createErrorBoundary unwraps the wrapper before handing the error to its fallback (src/boundaries.ts:327), so the same thrown error has different identities depending on which documented error surface catches it.

Your Example Website or App

https://stackblitz.com/edit/solidjs-templates-oyuluwgu?file=src%2FApp.tsx

import { Errored, Show, createEffect, createSignal } from "solid-js";

class MyError extends Error {}

function describeErr(err: unknown) {
  const cause = err instanceof Error ? (err as Error & { cause?: unknown }).cause : undefined;
  const constructor =
    err !== null && typeof err === "object" ? err.constructor.name : String(err);

  return [
    `instanceof MyError: ${err instanceof MyError}`,
    `constructor: ${constructor}`,
    `message: ${err instanceof Error ? err.message : String(err)}`,
    `cause instanceof MyError: ${cause instanceof MyError}`,
    `has internal .source: ${!!err && typeof err === "object" && "source" in err}`
  ].join("\n");
}

function ErroredControl() {
  const [armed, setArmed] = createSignal(false);

  function ThrowBoundaryError() {
    throw new MyError("boom-boundary");
  }

  return (
    <Errored
      fallback={err => (
        <pre>
          Errored fallback received:{"\n"}
          {describeErr(err())}
        </pre>
      )}
    >
      <button onClick={() => setArmed(true)}>throw under &lt;Errored&gt;</button>
      <Show when={armed()}>
        <ThrowBoundaryError />
      </Show>
    </Errored>
  );
}

export default function App() {
  const [effectArmed, setEffectArmed] = createSignal(false);
  let effectLog!: HTMLPreElement;

  createEffect(
    () => {
      if (effectArmed()) throw new MyError("boom-effect-compute");
    },
    {
      effect: () => {},
      // raw DOM write — a signal write here would throw
      // REACTIVE_WRITE_IN_OWNED_SCOPE in dev (the `error` handler runs in an
      // owned scope, unlike `effect`) and halt the app
      error: err => {
        effectLog.textContent = `EffectBundle.error received:\n${describeErr(err)}`;
      }
    }
  );

  return (
    <div style={{ "font-family": "system-ui", padding: "16px" }}>
      <h2>Error identity differs between effect `error` and &lt;Errored&gt;</h2>

      <button onClick={() => setEffectArmed(true)}>
        throw in effect compute phase
      </button>
      <pre ref={effectLog}>
        EffectBundle.error received:{"\n"}(not thrown yet)
      </pre>

      <ErroredControl />
    </div>
  );
}

Steps to Reproduce the Bug or Issue

  1. Click "throw in effect compute phase". Actual under EffectBundle.error:
instanceof MyError: false
constructor: StatusError
message: boom-effect-compute
cause instanceof MyError: true
has internal .source: true

The handler got a StatusError whose .cause is the actual MyError.

  1. Click "throw under ". Actual under <Errored>:
instanceof MyError: true
constructor: MyError
message: boom-boundary
cause instanceof MyError: false
has internal .source: false

The boundary gets the original error identity.

Expected behavior

The handler receives the error the user threw, as createErrorBoundary's fallback already does:

instanceof MyError: true
constructor: MyError
message: boom-effect-compute
cause instanceof MyError: false
has internal .source: false

Screenshots or Videos

No response

Platform

Additional context

Root cause

notifyStatus wraps user errors for source tracking (packages/solid-signals/src/core/async.ts:377-383 at e2ebc11):

if (
  status === STATUS_ERROR &&
  !(error instanceof StatusError) &&
  !(error instanceof NotReadyError)
)
  error = new StatusError(el, error);

createErrorBoundary unwraps before exposing it to user code (src/boundaries.ts:327):

setSignal(this._error!, (source._error as StatusError)?.cause ?? source._error);

…but notifyEffectStatus passes the raw wrapper straight to the bundle handler (src/core/effect.ts:89-96): this._errorFn(err, reset) with err still being the StatusError.

Suggested fix direction

Unwrap in notifyEffectStatus before invoking _errorFn, mirroring createErrorBoundary: const userErr = err instanceof StatusError ? err.cause : err. This also brings the behavior in line with the contract pinned in e2ebc11 (#2839): the new effect-error-phases.test.ts and JSDoc both describe the handler as receiving the error — the pinning test only asserts err.message, which the wrapper happens to mirror, so today's behavior passes the test while failing instanceof, constructor, and any .cause-free access to the original.

Related: #2839 landed the error-phase contract in e2ebc11 — the new JSDoc and effect-error-phases.test.ts both say the compute-phase handler "receives the error", and the pinning test asserts only err.message (which the wrapper mirrors), so the wrapper is invisible to it while instanceof / constructor / .cause all disagree with that contract's plain reading. This report is the remaining gap on that surface. One adjacent observation, worth a design ruling or a docs sentence rather than a bug report: the error handler runs inside an owned scope, so a signal write in it (the natural "set error state" pattern) trips the dev-only REACTIVE_WRITE_IN_OWNED_SCOPE guard, and the throw escalates like a handler rethrow. Its sibling effect callback runs imperatively and may write freely — both are user-facing callbacks of the same bundle. If the asymmetry is intended, the #2839-pinned docs could mention that handler writes need ownedWrite or deferral; if not, the handler may want the same imperative context as effect.

Does this exist in Solid 1.x?

Not applicable — 2.0-only surface. Both the createEffect error-handler bundle and StatusError are new in 2.0. For reference, 1.x's catchError delivers the original thrown error to the handler, and 2.0's own createErrorBoundary unwraps StatusError.cause before exposing it — the effect error handler is the one path handing users the internal wrapper.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions