Skip to content

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

Open
maiieul wants to merge 179 commits into
build/v2from
claude/gracious-poitras-0f1723
Open

feat(core): core ErrorBoundary in SSR and CSR#8745
maiieul wants to merge 179 commits into
build/v2from
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.

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`. */
  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`. */
  onError$?: QRL<(error: Error, info: ErrorBoundaryInfo) => void>;
}
export const ErrorBoundary: Component<ErrorBoundaryProps>;

// 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 (a redacted projection), 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).

3. Production redaction & the Error membrane

In prod, a caught error is serialized to the client as a generic message + stable digest (message, stack, and attached props stripped); dev keeps full fidelity. RenderOptions.transformError overrides the projection, fail-closed: a throwing or non-serializable transform falls back to the generic scrub, never the raw error. onError$ always receives the original error — only the client-bound payload (and what fallback$ displays) is redacted. The client-side display path applies the same redaction for parity.

Both callbacks are typed Error, and the runtime guarantees it rather than asserting it: a non-Error throw is coerced to an Error. For onError$ (telemetry) the raw thrown value is preserved on error.cause; for the prod-redacted store.error (which is serialized into the HTML) the cause is never attached — that would be a data-leak channel. So {error.message} is always safe, and the raw value survives only where it never serializes (onError$ + server logError).

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

store.error serializes, so a server-errored boundary resumes already showing its fallback without re-running component code; a later client error routes to the same boundary. 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 caught error (a new error caught on the server re-fires with it; a second client error escalates to the ancestor instead), swallows its own throws, and never affects rendering.

7. reset()

The second fallback$ arg: clears the error and re-executes the children, re-running their async work. Works for client-caught and SSR errors, in-order and out-of-order, on resumed pages, and for boundaries inside <Suspense>.

Architecture diagrams (under the hood)

Component structure — the boundary emits two sibling hosts; which one shows is a pure function of one serialized field, store.error.

flowchart TD
  EB["ErrorBoundary { fallback$, onError$ }"] --> CMP["errorBoundaryCmp (server)"]
  CMP --> H1["content-host: div q:ebc=id<br/>style = fnSignal(store) → display none/contents<br/>holds a Slot = your children"]
  CMP --> H2["SSRErrorFallbackHost<br/>internal server component<br/>picks fallback delivery at DRAIN time"]
  CMP -. writes .-> STORE["ErrorBoundaryStore<br/>error? — the SSR→CSR bridge, redacted<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.

flowchart TD
  START["SSR drain reaches a node under the boundary"] --> THROW{"throws?"}
  THROW -- no --> OK["stream normally; content-host style stays 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 = redacted;<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 + qErr(id) swap"]
  DEC -- "yes — deferred" --> LATE["host q:rp + placeholder;<br/>fallback streamed later as a qO segment"]
Loading

Resume and the swap — the inline qErr script swaps hosts before resume (no flash); the reactive style then takes over.

sequenceDiagram
  participant B as Browser parser
  participant S as Inline qErr script
  participant Q as Qwik resume
  B->>B: parse content-host (q:ebc) + partial content
  B->>B: parse fallback-host (q:ebf) + 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: swap done pre-resume — no flash
  B->>Q: qwik/state at stream end triggers resume
  Q->>Q: deserialize store.error (redacted Error)
  Q->>Q: reactive style fnSignal(store) now owns the toggle
  Q->>Q: fallback resumable — interactive on first click
Loading

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: error cleared, boundary renders Slot, children RE-EXECUTE
Loading

The Error / redaction membrane — two channels, two jobs: telemetry gets the truth, display gets something always-safe (and prod-serialized state never carries the raw value).

flowchart TD
  THROWN["thrown value: Error | 0 | '' | undefined | object"] --> SPLIT{"which channel?"}
  SPLIT -- "onError$ (telemetry)" --> OE["toBoundaryError(raw, cause=true):<br/>Error → same instance; non-Error → Error(String(raw)), cause=raw"]
  SPLIT -- "server logError" --> LOG["always the RAW original"]
  SPLIT -- "store.error → fallback$ (display)" --> ENV{"dev or prod?"}
  ENV -- dev --> K{"keepable?"}
  K -- "Error" --> RAW["pass untouched (enumerable fields serialize)"]
  K -- "serializable non-Error" --> W["Error(String(raw)), cause=raw"]
  K -- "non-serializable" --> WN["Error(message), no cause"]
  ENV -- prod --> RED["redactToGeneric: fresh Error + digest<br/>NEVER cause, NEVER fields (serialized-leak guard)"]
Loading

Also in this PR

Tests

Unit (146 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), qErr swap mechanics (incl. in-place-beside-a-live-segment), OOOS/Suspense suite (case b/c, routing, concurrent teardown), and redaction + transformError + the Error-coercion contract (identity, cause, prod no-cause/no-fields).

E2E (35 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), onError$ fires once, no-boundary surfacing, nested both-error resume, and a dedicated qDev=false prod app pinning client-side redaction display + prod resume + reset round-trip.

@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: 58094a9

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 Minor
@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: 58094a9

@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 58094a9

@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 added 15 commits June 19, 2026 12:31
ErrorBoundary now lives in @qwik.dev/core, built with the internal
componentQrl + inlinedQrl pattern (core isn't run through the optimizer).
Removed from @qwik.dev/router; import it from @qwik.dev/core instead.
…lback$

- The container routes errors (sync render throws + async `qerror`) to the
  CLOSEST boundary via handleError; drops the per-boundary `qerror` broadcast
  and its `_ebL` listener QRL.
- ErrorBoundary now catches render throws during SSR, rendering `fallback$` in
  place of the failed subtree (boundaries without a fallback still propagate).
- `fallback$` is now required.
<ErrorBoundary> is now the single public error-boundary surface. The
store-provider hook is kept as an internal helper, renamed
`useErrorBoundaryStore` (shared by the component and the test
boundaries) and no longer exported; the orphaned ErrorBoundaryStore type
is also made internal. Updates the devtools hook registry and docs
accordingly.
Behind the new `errorBoundary` experimental feature (with out-of-order
streaming), <ErrorBoundary> defers its subtree into an OOOS segment via a
fallback-less <Suspense>. A throw is carried (DeferredBoundaryError) to the
segment swap, which renders the boundary's fallback into the same placeholder —
so SSR matches the client's clean `boundary > fallback` instead of leaving
streamed siblings in place, without blocking the shell's stream. Flag-off
behavior is unchanged.
Adds checkpoint()/truncate() to SSRInternalStreamWriter (and the
StreamHandler stream-block buffer) so a buffered region of output can be
discarded back to a marked position. Foundation for ErrorBoundary
buffer-and-swap; purely additive, no behavior change. Unit-tested in
isolation across the string, segment, and streaming writers (incl.
nested checkpoints).
Adds checkpoint()/rollback() to the SSR container: snapshot the render
cursor (writer position, vNodeData incl. the in-place-mutated current
frame, node tree + parent children, depthFirstElementCount, component
stack, serialization roots) and restore it to discard a partially
rendered subtree. Styles already flushed to <head> and dedup-map entries
for discarded objects are left as harmless orphans. Foundation for
ErrorBoundary buffer-and-swap; not wired in yet. Verified end-to-end: a
rolled-back subtree leaves no markup and the result still resumes.
…wap)

Replaces the OOOS auto-Suspense approach. Under the experimental
`errorBoundary` feature, the SSR renderer renders a boundary's subtree in
a nested pass wrapped in checkpoint()/rollback(): a throw unwinds to the
nearest boundary, which rolls back the partially-rendered output and
renders fallback$ in its place — a clean `boundary > fallback` with no
leftover siblings, matching the client. Works in-order, inside <Suspense>
(rolls back within the segment), and nests (call-stack semantics).

ErrorBoundary itself is unaware of streaming again: drops the
auto-<Suspense>, $deferred$ marker, DeferredBoundaryError and the
suspense.tsx segment-rejection routing.

Known gap: a throw inside a dev-placed <Suspense> that sits *outside* the
boundary still aborts (the boundary already committed) — follow-up.
…llback

Closes the gap from the buffer-and-swap commit: when a boundary wraps a
<Suspense> whose deferred (async) content throws, the boundary already
committed (it only saw the Suspense placeholder), so its own buffer can't
catch it and the rejected OOOS segment aborted the render.

Now SSRDeferredSlot captures the nearest enclosing ErrorBoundary up front
and, on segment rejection, renders that boundary's fallback$ into a fresh
segment injected into the same placeholder (gated by the experimental
errorBoundary feature; with no boundary above it rethrows as before).

Limitation: the fallback replaces the failing Suspense slot, so EB
siblings that already streamed remain (can't be un-streamed) — this
diverges from the client, which re-renders the whole boundary.
…y inside Suspense

Locks the composition <Suspense fallback={skeleton}><ErrorBoundary> where
the whole subtree is deferred behind the skeleton: an async throw rolls
the entire content back within the segment (siblings included) and the
segment resolves to the boundary fallback, injected in place of the
skeleton. The user only ever sees skeleton → fallback — no broken-content
flash, no leaked siblings, and no CLS when skeleton/fallback/content
share a box. No production change; documents existing behavior.
…O (never blocks streaming)

Replaces the buffer-and-swap SSR approach: a live <ErrorBoundary> no longer
buffers (blocks) its subtree. It streams the content inside a visible content
host beside a hidden fallback host (modeled on Suspense's two-host OOOS
structure). On a throw it streams fallback$ as an out-of-order segment and the
shared qO executor hides the content host + reveals the fallback host via an
inline script — the swap fires as the error chunk parses, before resume, so it
never waits on the client runtime and never renders in place.

- A deferred child <Suspense> throw tears the WHOLE boundary down to fallback$
  (store.$emitFallback$), not into the Suspense sub-slot.
- A boundary inside a <Suspense> segment still buffers within that already-
  deferred segment (the one case buffering is allowed; it doesn't block the
  shell). getBufferingErrorBoundaryStore now gates on isOutOfOrderSegmentContainer.
- Resume consistency reuses qProcessOOOS; host display is a _fnSignal of
  store.error, so the resumed/re-rendered boundary matches the inline swap.
- Client-time errors keep the reactive re-render path.

Requires the suspense + outOfOrder streaming features.
…rdown)

Adds /e2e/error-boundary-streaming fixture + Playwright coverage proving in a
real browser that the boundary never blocks streaming (the title and footer
around it render), the inline qO swap hides the content and reveals fallback$
before resume, the fallback is interactive once resumed, and a deferred child
<Suspense> throw tears the WHOLE boundary down on release. Enables the
errorBoundary experimental feature for the e2e fixture app.
A sync throw queued the fallback segment, so the qO swap script landed at
end-of-stream (after Promise.all) — leaving the broken content visible the
whole time. SSRErrorFallback now returns the emission promise so a sync throw
awaits it inline in the drain: the qO(id) swap lands immediately after the
boundary, before trailing content. It's a plain inline script, so it runs as
the chunk parses with no dependency on the framework having resumed. A spec
assertion locks the swap position before trailing content.
…throws

An error-free streaming <ErrorBoundary> was shipping the shared qO executor:
allocating its id via nextOutOfOrderId() flipped outOfOrderUsed, so the
container emitted the executor at end-of-render regardless of whether anything
threw. The boundary now reserves its id with nextErrorBoundaryId() →
nextOutOfOrderId(false), which does not arm OOOS; the executor is armed only
when a throw creates the fallback segment() (segment() now sets outOfOrderUsed)
and emitErrorBoundaryFallback emits the executor right before the first qO(id).

Net: an error-free boundary ships zero swap JS; a throwing one ships one shared
executor + one tiny qO(id) per boundary. A spec asserts the error-free HTML has
no qO(/qInstallOOOS. Suspense is unaffected (it already armed OOOS via
nextOutOfOrderId before segment(); segment() setting the flag is idempotent).
…ing fallback

Self-review of the streaming ErrorBoundary surfaced regressions vs the old
buffer-and-swap (which caught async throws via `await renderJSX`):

- The SSR drain (`_walkJSX`) now routes rejections at all three await points —
  the Promise marker (promise children), the async-component thunk, and the
  MaybeAsyncSignal path (async signals) — to `renderErrorBoundaryFallback`.
  Previously a rejected promise child / async component / async signal that
  wasn't wrapped in a <Suspense> aborted the whole stream.
- `renderErrorBoundaryFallback` rethrows inside an out-of-order segment (when
  the boundary is outside it), so the segment rejects and SSRDeferredSlot routes
  to the boundary's `$emitFallback$` (whole-boundary teardown) instead of
  rendering in place — keeps the deferred-Suspense (case 3) teardown working
  now that the drain catches the rejection.
- A throwing `fallback$` no longer deadlocks: `streamFallback` detaches
  `store.$fallback$` while rendering it, so a re-throw propagates (aborts)
  instead of re-rendering the fallback forever.

Adds regression specs (async component / promise child / async signal /
throwing fallback / sibling-boundary isolation) and an e2e client-error
scenario.

KNOWN LIMITATION (tracked via test.fixme): a client-time error on a boundary
that streamed without erroring during SSR does not yet render the fallback (the
two-host structure can't re-render to the fallback on the client) — the SSR
error path works; client errors on non-streamed boundaries work via the normal
reactive re-render. Fix needs a client-reactive fallback host.
Investigated the client-time-error-on-streamed-boundary regression in depth and
documented the precise cascading causes in core-notes: (1) it only routes if the
throwing handler resumed the container first; (2) even when routed, filling the
fallback host on the client asserts "Missing child" because the fallback host
holds the raw qO <template> placeholder (no vnode), which any client re-render of
that host trips on. A naive client-reactive fallback host does not work. Records
the real fix direction (client-side qO injection, or a vnode-backed placeholder).
maiieul added 16 commits July 6, 2026 15:13
Broaden the failed-import assertion to match WebKit's "Importing a module
script failed" phrasing and pad the in-order mid-stream shell with the
webkitFlush marker so the qwikloader executes while the stream is gated open.
…oitras-0f1723

# Conflicts:
#	e2e/qwik-e2e/dev-server.ts
#	packages/docs/src/routes/api/qwik-optimizer/api.json
@maiieul maiieul marked this pull request as ready for review July 8, 2026 21:05
@maiieul maiieul moved this from In progress to Waiting For Review in Qwik Development Jul 8, 2026
@maiieul maiieul force-pushed the claude/gracious-poitras-0f1723 branch from 1245c88 to 577894d Compare July 13, 2026 08:34
maiieul added 7 commits July 13, 2026 10:53
Resolve the useAsync$→useComputed$ overlap:
- use-async.spec.tsx: take build/v2's minimal deprecation test; the
  full suite it dropped is now covered by use-computed.spec.tsx.
- use-computed.spec.tsx: migrate the removed useErrorBoundary hook to
  useErrorBoundaryStore (semantic conflict git didn't flag).
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