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:
err instanceof MyError is false in the handler — any instanceof / class-based error branching silently takes the wrong path.
err.constructor.name is StatusError, an undocumented internal type; the original error is only reachable via the undocumented err.cause.
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 <Errored></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 <Errored></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
- 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.
- 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.
Describe the bug
The compute-phase
errorhandler — since e2ebc11 (#2839) the pinned, documented recovery path for reactivity errors ("the handler receives the error") — actually receives Solid's internalStatusErrorwrapper rather than the error the user threw:err instanceof MyErrorisfalsein the handler — anyinstanceof/ class-based error branching silently takes the wrong path.err.constructor.nameisStatusError, an undocumented internal type; the original error is only reachable via the undocumentederr.cause.createErrorBoundaryunwraps 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
Steps to Reproduce the Bug or Issue
EffectBundle.error:The handler got a
StatusErrorwhose.causeis the actualMyError.<Errored>:The boundary gets the original error identity.
Expected behavior
The handler receives the error the user threw, as
createErrorBoundary's fallback already does:Screenshots or Videos
No response
Platform
next@e2ebc11c— i.e. after the 2.0.0-beta.15:EffectBundle.errordoes not intercept effect-phase throws — incomplete wiring, or a JSDoc error? #2839 contract pinning; the wrapper still arrives)Additional context
Root cause
notifyStatuswraps user errors for source tracking (packages/solid-signals/src/core/async.ts:377-383at e2ebc11):createErrorBoundaryunwraps before exposing it to user code (src/boundaries.ts:327):…but
notifyEffectStatuspasses the raw wrapper straight to the bundle handler (src/core/effect.ts:89-96):this._errorFn(err, reset)witherrstill being theStatusError.Suggested fix direction
Unwrap in
notifyEffectStatusbefore invoking_errorFn, mirroringcreateErrorBoundary:const userErr = err instanceof StatusError ? err.cause : err. This also brings the behavior in line with the contract pinned in e2ebc11 (#2839): the neweffect-error-phases.test.tsand JSDoc both describe the handler as receiving the error — the pinning test only assertserr.message, which the wrapper happens to mirror, so today's behavior passes the test while failinginstanceof,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.tsboth say the compute-phase handler "receives the error", and the pinning test asserts onlyerr.message(which the wrapper mirrors), so the wrapper is invisible to it whileinstanceof/constructor/.causeall 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: theerrorhandler runs inside an owned scope, so a signal write in it (the natural "set error state" pattern) trips the dev-onlyREACTIVE_WRITE_IN_OWNED_SCOPEguard, and the throw escalates like a handler rethrow. Its siblingeffectcallback 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 needownedWriteor deferral; if not, the handler may want the same imperative context aseffect.Does this exist in Solid 1.x?
Not applicable — 2.0-only surface. Both the
createEffecterror-handler bundle andStatusErrorare new in 2.0. For reference, 1.x'scatchErrordelivers the original thrown error to the handler, and 2.0's owncreateErrorBoundaryunwrapsStatusError.causebefore exposing it — the effect error handler is the one path handing users the internal wrapper.