Skip to content

feat(core): core ErrorBoundary in SSR and CSR - #8745

Open
maiieul wants to merge 278 commits into
mainfrom
claude/gracious-poitras-0f1723
Open

feat(core): core ErrorBoundary in SSR and CSR#8745
maiieul wants to merge 278 commits into
mainfrom
claude/gracious-poitras-0f1723

Conversation

@maiieul

@maiieul maiieul commented Jun 18, 2026

Copy link
Copy Markdown
Member

What is it?

  • Feature / enhancement

Description

Moves ErrorBoundary from @qwik.dev/router to @qwik.dev/core so it ships with the framework, and makes it the single error-boundary surface. The boundary is stateless across the wire: no error state serializes, and errored boundaries re-derive on the client. Includes PublicError (folded from #8868).

ErrorBoundary — design & mechanism

<ErrorBoundary> in @qwik.dev/core. Experimental: gated on the errorBoundary Vite flag (the component throws a clear error with the flag off). Works in in-order and out-of-order streaming SSR, CSR, and resumed CSR.

Public API

// @qwik.dev/core
export interface ErrorBoundaryProps {
  /** REQUIRED. Lazily loaded — only fetched when the subtree errors. Receives an `Error`
   *  (`{error.message}` is always safe): a non-Error throw is wrapped, and prod redacts to a
   *  generic message + `digest`. A re-derived client fallback recomputes `digest`, so it can
   *  differ from the server-rendered value. */
  fallback$: QRL<(error: Error, reset: QRL<() => void>) => JSXOutput>;
  /** Telemetry side-effect; never affects rendering. Receives the original `Error` instance;
   *  a non-Error throw arrives wrapped in an `Error` whose `cause` is the raw value.
   *  `info` carries `phase` + `boundaryId`. Fires at most once per errored episode per
   *  environment — an SSR-reported error fires again when the client re-derives it. */
  onError$?: QRL<(error: Error, info: ErrorBoundaryInfo) => void>;
}
export const ErrorBoundary: Component<ErrorBoundaryProps>;

// Deliberately public errors: constructing one is consent to display `data` unredacted, even in prod.
export class PublicError<T = unknown> extends Error {
  constructor(public data: T);
}

// Server-only redaction override on RenderOptions (renderToStream / renderToString):
transformError?: (error: unknown) => unknown;
<ErrorBoundary
  fallback$={(error, reset) => (
    <div role="alert">
      <p>Something broke: {error.message}</p>
      {/* wrap as `() => reset()` — a bare QRL doesn't serialize the listener on a streamed fallback */}
      <button onClick$={() => reset()}>Try again</button>
    </div>
  )}
  onError$={(error, info) => reportToSentry(error, info.phase)}
>
  <Dashboard />
</ErrorBoundary>

1. Invariant: never block streaming

A boundary may sit anywhere, including the root, at ~zero cost — it never buffers its content. Content streams live into a content-host; on a throw the SSR catch only sets store.error (raw, in-memory), fires onError$, marks the dead content inert, and returns null — a sibling fallback-host delivers the fallback. A swap, never a buffer-rollback. The closest boundary catches.

2. The swap: decided by error origin, at drain time

The fallback host picks its mechanism when it drains (SSRErrorFallbackHost) — by which point any in-place throw has already set store.error:

  • In-place errors (sync render, awaited async, rejected promise child, async signal, task) render the fallback inline and swap with a tiny qErr(id) display toggle (q:ebf host) — regardless of the streaming mode.
  • No error yet under out-of-order streaming → a q:rp late-delivery shell. A genuinely deferred throw from a child <Suspense> (the boundary's position already flushed) tears the whole boundary down and streams the fallback late as a qO segment.
  • A boundary inside a Suspense segment renders inline with a hoisted qErr emitted after the segment reveal.

So a boundary without <Suspense> involvement never uses qO: the happy path ships no swap scripts at all, and errors swap in document order via qErr. (Under out-of-order mode an error-free boundary still emits its passive q:rp shell markers.) The deferred path stays segment-shaped because its vnode-data must travel through the segment to stay resume-consistent (inline content must never sit under a q:rp host). Both hosts carry static styles; the qErr script writes the errored end state directly into the DOM — nothing subscribes, so nothing can un-hide dead content later.

3. Production redaction & the Error membrane

One display-time membrane, redactBoundaryErrorForDisplay(error, dev, transformError), projects the raw stored error at every display site (SSR fallback render and CSR re-render alike). In prod a caught error displays as a generic message + stable digest (message, stack, and attached props stripped); dev keeps full fidelity. RenderOptions.transformError projects at display time, fail-closed: a throwing transform, a non-Error, or an unreadable projection (throwing getters) falls back to the generic scrub; a readable Error projection is kept by identity. A thrown PublicError displays unredacted even in prod — construction is consent. Only framework-branded redacted errors skip re-redaction (module-private symbol set by the scrubber); an app error carrying its own digest field still redacts.

Both callbacks are typed Error, and the runtime guarantees it rather than asserting it: onError$ always receives the original error (a raw non-Error preserved on cause), and the fallback receives the membrane projection — in dev a non-Error throw keeps the raw value on cause, which is safe now that nothing error-shaped ever serializes.

4. What routes to a boundary

Render throws (sync + async), useTask$/useVisibleTask$ throws, event-handler throws (qwikloader emits qerror → nearest boundary, info.phase === 'event'), async-generator and async-signal rejections. Thrown falsy values — including undefined — reveal the fallback (normalized to a keyable Error; onError$ still gets the raw value). A fallback chunk that fails to load renders a built-in role="alert" last-resort node; a fallback that loads and then throws escalates to the ancestor boundary. Fire-and-forget rejections hit a single page-level unhandledrejection bridge (logged, not bounded). No enclosing boundary → the original error surfaces (SSR rejects the render; CSR logs and async-rethrows so window.onerror/monitoring fires).

5. Resume, not re-run

The boundary serializes nothing error-shaped — not the error, not a flag (store.error is a non-enumerable in-memory field; only boundaryId and the resetOwner node ref cross the wire). A server-errored page resumes already showing its fallback because the qErr swap wrote the static end state into the DOM and resume runs nothing. Error state is derived: the first re-execution — a reset() or an owner re-render — re-runs the children. A still-throwing child re-derives the same fallback client-side (the prod digest can differ between environments); a healed child auto-recovers to content. Task-phase SSR throws don't re-derive (pattern: catch in the task, reflect into a signal, throw during render). The swapped-out content is torn down inert (INERT vnode-data, cleared effects, cut slot refs; serialized refs into the inert region are written as undefined), so it can never resume — including nested boundaries where the outer and inner both errored on the server.

6. Routing & escalation

Layout Catches Untouched
EB-outer › Suspense › EB-inner › throw EB-inner EB-outer
EB-outer › Suspense › throw (no inner EB) EB-outer
EB-outer › Suspense-A › EB-mid › Suspense-B › throw EB-mid EB-outer

A throwing fallback escalates to the nearest ancestor (no loop). onError$ fires once per errored episode per environment (an SSR-reported error fires again if the client re-derives it — dedupe externally via digest or info.boundaryId; a second client error escalates to the ancestor instead), swallows its own throws, and never affects rendering.

7. reset()

The second fallback$ arg: clears any in-memory error and re-executes the children by re-rendering their owner, re-running their async work. Works for client-caught and SSR errors, in-order and out-of-order, on resumed pages (no serialized error state needed — the serialized resetOwner node ref resolves the owner), and for boundaries inside <Suspense>.

Architecture diagrams (under the hood)

Component structure — the boundary emits two sibling hosts with static styles; the qErr script owns the errored end state in the DOM.

flowchart TD
  EB["ErrorBoundary { fallback$, onError$ }"] --> CMP["errorBoundaryCmp (server)"]
  CMP --> H1["content-host: div q:ebc=id<br/>static style display:contents<br/>holds a Slot = your children"]
  CMP --> H2["SSRErrorFallbackHost (static display:none)<br/>internal server component<br/>picks fallback delivery at DRAIN time"]
  CMP -. writes .-> STORE["ErrorBoundaryStore<br/>error — RAW, in-memory only, never serializes<br/>boundaryId, resetOwner (serialized node ref)<br/>$fallback$, $onError$ — noSerialize mirrors"]
Loading

SSR: catching a throw and choosing delivery — the handler only marks state and returns null; the fallback host picks qErr (in-place) vs qO (deferred) at drain time and projects the error through the display membrane.

flowchart TD
  START["SSR drain reaches a node under the boundary"] --> THROW{"throws?"}
  THROW -- no --> OK["stream normally; content-host stays display:contents"]
  THROW -- yes --> CATCH["catchToErrorBoundary → renderErrorBoundaryFallback"]
  CATCH --> FIND["findErrorBoundaryNode: nearest boundary<br/>whose $fallback$ is still attached"]
  FIND -- none --> RETHROW["rethrow → abort render"]
  FIND -- found --> MARK["markBoundaryErrored: store.error = raw (in-memory);<br/>fireOnError once"]
  MARK --> INERT["markSubtreeInert: tag INERT, clearAllEffects,<br/>cut claimed-Slot ref"]
  INERT --> NULL["return null"]
  NULL --> HOST["later: SSRErrorFallbackHost drains"]
  HOST --> DEC{"deliverLate? OOOS active & not in a segment & no error yet"}
  DEC -- "no — in place" --> INLINE["host q:ebf + inline fallback<br/>(display-membrane projected) + qErr(id) swap"]
  DEC -- "yes — deferred" --> LATE["host q:rp + placeholder;<br/>fallback streamed later as a qO segment"]
Loading

Resume and re-derivation — the inline qErr script swaps hosts before resume (no flash); the result is a static end state, and resume runs nothing.

sequenceDiagram
  participant B as Browser parser
  participant S as Inline qErr script
  participant Q as Qwik resume
  B->>B: parse content-host (q:ebc, display:contents) + partial content
  B->>B: parse fallback-host (q:ebf, display:none) + fallback markup
  B->>S: qErr(id) runs, scoped by currentScript.closest(container)
  S->>S: content-host display:none, fallback-host display:contents
  Note over S: static end state — swap done pre-resume, nothing subscribes
  B->>Q: qwik/state at stream end triggers resume
  Q->>Q: no boundary error state to deserialize — nothing runs
Loading

Note over Q: first re-execution (reset / owner re-render) re-derives:
still-throwing child → same fallback; healed child → content

Client-time errors & escalation — everything funnels through handleError, which walks up ERROR_CONTEXT.

flowchart TD
  SRC["render throw / task / signal / qerror event / visible-task"] --> HE["handleError(err, host, phase)"]
  HE --> WALK["walk up ERROR_CONTEXT from host"]
  WALK --> B{"boundary?"}
  B -- "none left" --> GLOBAL["logErrorAndThrowAsync → window.onerror"]
  B -- "store.error === undefined" --> CATCH["store.error = err; fireOnError(props.onError$);<br/>markVNodeDirty → render fallback"]
  B -- "already errored/dirty" --> ESC["escalate to parent boundary"]
  ESC --> WALK
Loading

reset() — dirtying the owner in the same tick as clearing the error re-supplies and re-executes the consumed children.

sequenceDiagram
  participant U as User clicks Retry
  participant R as reset QRL (_ebR, host captured)
  participant C as resetErrorBoundary(host)
  U->>R: onClick$ = () => reset()
  R->>C: resolve boundary (ERROR_CONTEXT, else closest q:ebf/q:rp/q:ebc)
  C->>C: resolve owner (getParentHost, climb past Suspense, else serialized resetOwner)
  C->>C: markVNodeDirty(owner) + store.error = undefined, same tick
  C->>C: owner re-renders, re-distributes fresh children into Slot
  Note over C: works with NO in-memory error (resumed page) — children RE-EXECUTE
Loading

The Error / redaction membrane — two channels, two jobs: telemetry gets the truth, display gets something always-safe. One membrane, applied at display time, never throws.

flowchart TD
  RAW["raw store.error: Error | 0 | '' | undefined | object"] --> M["redactBoundaryErrorForDisplay(error, dev, transformError)"]
  M --> T{"transformError configured?"}
  T -- "readable Error projection" --> TP["kept by identity"]
  T -- "throws / non-Error / unreadable" --> G1["generic + digest"]
  T -- "no" --> PE{"PublicError?"}
  PE -- yes --> SHOW["displayed unredacted — construction is consent"]
  PE -- no --> BR{"framework REDACTED brand?"}
  BR -- "yes" --> PASS["already projected — pass by identity<br/>(an app-owned digest field does NOT count)"]
  BR -- "no" --> ENV{"dev or prod?"}
  ENV -- prod --> G2["redactToGeneric: fresh Error + digest + brand<br/>NEVER cause, NEVER fields"]
  ENV -- dev --> DEV["Error → same instance;<br/>non-Error → Error(message), cause = raw (always)"]
  TEL["onError$ / server logError"] -.-> ORIG["always the ORIGINAL raw value<br/>(cause-wrapped for onError$)"]
Loading

Also in this PR

Tests

Unit (207 specs) — behavior describe.each across CSR/SSR, a 3-arm reset table (CSR click / resumed in-order / resumed OOOS), CSR-specific (qerror routing + nearest-container isolation, falsy values incl. undefined, multi-container, last-resort, unhandledrejection bridge), SSR-specific (safety net, async-generator, non-serializable), SSR→CSR cross-phase (resume, inert drop, post-resume errors), stateless wire (nothing error-shaped serializes; an outside re-render can't un-hide dead content), client re-derivation (still-throwing re-derive, healed auto-recovery, computed-throw re-derive, onError$ refire, task-throw degrade), qErr swap mechanics (incl. in-place-beside-a-live-segment), OOOS/Suspense suite (case b/c, routing, concurrent teardown), redaction + transformError + the Error-coercion contract (identity, cause, prod no-cause/no-fields, brand-gated pass-through incl. the app-digest forgery), the PublicError membrane + serdes marker suite, and hostile-value fail-closed rows.

E2E (36 real-browser tests) — streaming swaps in-order + OOOS and interactivity after resume, deferred teardown, inert content never re-runs, client-time errors after resume, escalation incl. throwing fallbacks (both modes), reset() (7 scenarios, incl. re-derivation on an always-throwing child in dev and prod), onError$ fires once, no-boundary surfacing, nested both-error resume, and a dedicated qDev=false prod app pinning client-side redaction display, prod resume, the reset round-trip, and PublicError shown unredacted beside a redacted sibling with instanceof surviving resume.

@maiieul
maiieul requested review from a team as code owners June 18, 2026 07:26
@changeset-bot

changeset-bot Bot commented Jun 18, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 5d0b08b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
@qwik.dev/devtools Patch
@qwik.dev/core Major
@qwik.dev/router Major
eslint-plugin-qwik Major
@qwik.dev/react Major
create-qwik Major

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@maiieul maiieul self-assigned this Jun 18, 2026
@maiieul maiieul moved this to Waiting For Review in Qwik Development Jun 18, 2026
@pkg-pr-new

pkg-pr-new Bot commented Jun 18, 2026

Copy link
Copy Markdown

Open in StackBlitz

@qwik.dev/core

npm i https://pkg.pr.new/QwikDev/qwik/@qwik.dev/core@8745

@qwik.dev/router

npm i https://pkg.pr.new/QwikDev/qwik/@qwik.dev/router@8745

eslint-plugin-qwik

npm i https://pkg.pr.new/QwikDev/qwik/eslint-plugin-qwik@8745

create-qwik

npm i https://pkg.pr.new/QwikDev/qwik/create-qwik@8745

@qwik.dev/optimizer

npm i https://pkg.pr.new/QwikDev/qwik/@qwik.dev/optimizer@8745

@qwik.dev/devtools

npm i https://pkg.pr.new/QwikDev/qwik/@qwik.dev/devtools@8745

commit: 5d0b08b

@maiieul
maiieul force-pushed the claude/gracious-poitras-0f1723 branch from f211d54 to f370afb Compare June 18, 2026 07:32
@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor
built with Refined Cloudflare Pages Action

⚡ Cloudflare Pages Deployment

Name Status Preview Last Commit
qwik-docs ✅ Ready (View Log) Visit Preview 5d0b08b

@maiieul maiieul changed the title feat(core)!: export ErrorBoundary from core instead of router feat(core)!: core ErrorBoundary working in SSR and CSR Jun 18, 2026
@maiieul
maiieul force-pushed the claude/gracious-poitras-0f1723 branch 3 times, most recently from eb0023c to 0f38af0 Compare June 18, 2026 10:27
@wmertens wmertens changed the title feat(core)!: core ErrorBoundary working in SSR and CSR feat(core): core ErrorBoundary working in SSR and CSR Jun 18, 2026
@maiieul
maiieul force-pushed the claude/gracious-poitras-0f1723 branch from 16e1bf6 to 2623262 Compare June 19, 2026 10:37
@maiieul
maiieul marked this pull request as draft June 19, 2026 12:40
@maiieul maiieul moved this from Waiting For Review to In progress in Qwik Development Jun 19, 2026
maiieul added 12 commits June 19, 2026 20:42
The streaming ErrorBoundary swap reuses the out-of-order Suspense machinery, so without the `suspense` experimental feature (+ `streaming.outOfOrder` at render time) it silently degrades to the synchronous in-place fallback with no diagnostic — unlike Suspense and ssr.segment, which fail loudly. Warn once at config time (the in-place fallback still works, so warn rather than throw).
A buffering ErrorBoundary's rollback() truncated only $roots$, leaving the parallel $rootObjs$ array longer and stale $seenObjsMap$ entries pointing at discarded indices. A root added in the rolled-back subtree and re-referenced afterwards then serialized to an out-of-range/duplicated slot, corrupting resume. Add SerializationContext.$rollbackRoots$ to truncate both arrays and drop dedup entries created since the checkpoint; rollback() calls it.
Adds regression coverage for the deferred stopPropagation fix: when a child handler's QRL is still importing and calls stopPropagation() after the synchronous bubbling walk, runEventTasks' cancelBubble re-check must skip a later ancestor whose handler is also deferred (loaded via import). Both handlers go through the QRL-import path so they only run when their task group flushes — a synchronous _qDispatch ancestor would always run during the walk and could not be skipped. Verified failing against a loader without the re-check.
pnpm api.update for the _QDocument.qProcessOOOS parameter rename (content -> revealNode) from the OOOS executor naming change. Internal type, parameter-name only.
…ations

Locks in the 'who catches what' semantics across CSR, in-order SSR, and out-of-order streaming: two throws in one boundary (single fallback, first error wins), two adjacent boundaries both throwing (independent fallbacks), a nested outer boundary superseding the inner one, two boundaries inside one Suspense, and two Suspense in one boundary both async-throwing (single fallback).
Companion to the bubble-phase deferred-stop coverage: a capture-phase handler whose QRL is still importing calls stopPropagation() after the synchronous walk, and runEventTasks' cancelBubble re-check must skip a later deferred bubble handler. Both handlers use the QRL-import path. Verified failing against a loader without the re-check.
…ntainer, tasks

New coverage: a throw in Slot-projected content resolves to the boundary it is projected into (CSR + in-order SSR, documenting that in-order SSR renders the fallback in place so already-streamed siblings remain); a no-boundary SSR throw still propagates/aborts (sync, async component, rejected promise child); a qerror is isolated to its own container when multiple containers share a document; and useTask$/useVisibleTask$ throws route to the nearest boundary.
The boundary used `store.error` truthiness as its 'has error' signal, so a thrown falsy value (0, '', false, null) re-rendered to the content instead of the fallback. Gate on `store.error !== undefined` (the store inits error: undefined) in the render branch, both display fn-signals + their inlined strings, and the OOOS sync-throw check, so any thrown value shows the fallback.
build.core type-checks the test files: the streamAndResume write callback returned the array length (number) where void is expected, and the tasks spec imported an unused 'trigger'. No behavior change.
…ndary

renderErrorBoundaryFallback caught every SSR throw, including non-recoverable build/plugin errors (e.g. a dev Vite transform error, flagged by isRecoverable), hiding them in the fallback — while the client rethrows them past the boundary. In dev that meant a compile error showed a fallback on SSR but surfaced on CSR. Add the same isRecoverable gate to the SSR path (dev-only) so build errors surface consistently; recoverable runtime errors still render the fallback on both.
Removed the OOOS 'two Suspense in one boundary both async-throw -> single fallback' case from error-boundary-combinations: it duplicates (and asserts less than) the dedicated teardown-exactly-once regression test in error-boundary-concurrent.spec.tsx.
Cut the verbose multi-line comments across the ErrorBoundary/SSR/qwikloader code down to 1-2 sentences that explain the bug/constraint rather than narrate the implementation, and drop self-explanatory ones. Also finishes the OOOS swap dedup (emitErrorBoundaryFallback now reuses finalizeAndSwapOutOfOrderSegment) and regenerates the fallback$ API doc.
@maiieul
maiieul force-pushed the claude/gracious-poitras-0f1723 branch from 55dd1b0 to e858d11 Compare July 28, 2026 12:44
maiieul added 8 commits July 29, 2026 20:10
Two cooperating mechanisms, one per failure mode (#8881): expectSlot
re-schedules a moved projection's components whose output is gone (CSR),
and the inert cut records the severed projection's author so reset can
re-render it after resume (SSR).
Healthy boundaries no longer serialize their parent's state (#8883):
the inert cut retains the errored boundary's ancestor chain instead, and
reset falls back to a boundary-only re-render when no author is
renderable, which the projection re-scheduling recovers.
Four describes collided with a sibling, the six info.phase tests used three
different wordings, and the falsy `''` row printed its value as a blank. Also
drops two assertions that are strict subsets of a neighbour, and tightens the
multi-container fallback check, which passed on any caught message.
Folds the one-test `ErrorBoundary behavior` orphan and its SSR twin into the
mode matrix, moves the two qerror tests under `qerror routing` and the three
re-render tests under `client re-derivation`, and drops a duplicate fixture
and a shadowed helper name.
The redaction, coercion, transformError and PublicError display tests call
`error-handling.ts` directly and render nothing, so they move next to it like
every other module in `shared/`. The five that do render stay in the spec.
Four hand-rolled copies of the two streaming modes disagreed on the OOOS
label; they now read one const. The page-error and console-error capture
blocks become two helpers beside `assertNoBrowserErrors`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Waiting For Review

Development

Successfully merging this pull request may close these issues.

2 participants