Skip to content

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

Merged
maiieul merged 345 commits into
mainfrom
claude/gracious-poitras-0f1723
Aug 26, 2026
Merged

maiieul merged 345 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 errors are stateless across the wire: no error state serializes, and errored boundaries re-derive on the client.

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
   *  server-origin errors to a generic message + `digest`; client-origin errors render as
   *  thrown. A re-derived client fallback shows the raw error, so it can differ from the
   *  server-rendered value. */
  fallback$: QRL<(error: Error & { digest?: string }, 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` + `digest` (the code a prod fallback displays,
   *  for matching a user's bug report to your logs). 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>;

// Server-only redaction override on RenderOptions (renderToStream / renderToString).
// Synchronous (an async transform redacts everything and warns in dev); returning
// undefined/null declines and falls through to the default policy.
transformError?: (error: unknown) => Error | undefined | null | void;
<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, followed by a qErr that hides the dead content and strips its document:/window: handlers.
  • 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, showRaw, transformError), projects the raw stored error at every display site. Redaction follows the error's origin: a server-origin error displays in prod as a generic message + stable digest (message, stack, and attached props stripped) and keeps full fidelity in dev; a client-origin error renders as thrown — its message already ships in the bundle. An SSR-caught error the client re-derives (reset, owner re-render) becomes client-origin and shows raw. RenderOptions.transformError projects at display time, fail-closed: a throwing transform, a junk non-Error return, or an unreadable projection (throwing getters) falls back to the generic scrub; a readable Error projection is kept by identity, and returning undefined/null declines — the default policy applies. 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 — a raw-displayed non-Error keeps the raw value on cause, which is safe now that the boundary never serializes it.

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 stay with the browser's own unhandledrejection reporting — the framework installs no bridge. 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 crosses, plus projectedContentOwner when the errored author sits past a projection cut — healthy boundaries retain nothing). An errored async signal is the one deliberate exception: it serializes a redacted error, scrubbed at write time so the raw error never enters serializable state. 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. The one gap: a late-delivered fallback (deferred segment rejecting after the root streamed) ships after the state froze, so its dead content keeps serialized effect subscriptions (#8885, pinned); the DOM half is still defused via qErr.

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, on resumed pages (no serialized error state needed — the client author walk resolves the owner, with the serialized projectedContentOwner as the projection-cut fallback), and for boundaries inside <Suspense>. One pinned exception: resetting a boundary inside a resumed out-of-order segment no-ops — resumed segment vnodes are parentless, so walks and re-renders die at the segment boundary (#8884); those tests pin in-order until it lands.

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 (+projectedContentOwner at a projection cut)<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 via ERROR_CONTEXT from the captured host
  C->>C: resolve owner (author walk past wrappers/Suspense, else serialized projectedContentOwner)
  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, showRaw, transformError)"]
  M --> T{"transformError configured?"}
  T -- "readable Error projection" --> TP["kept by identity"]
  T -- "throws / non-Error / unreadable" --> G1["generic + digest"]
  T -- "no" --> BR{"framework REDACTED brand?"}
  BR -- "yes" --> PASS["already projected — pass by identity<br/>(an app-owned digest field does NOT count)"]
  BR -- "no" --> SR{"showRaw?<br/>server: only in dev · client: always"}
  SR -- "no — server prod" --> G2["redactToGeneric: fresh Error + digest + brand<br/>NEVER cause, NEVER fields"]
  SR -- yes --> 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

All e2e authority runs on a strict pnpm build.core dist (build.core.dev proxies prod bundles to dev and falsely fails the redaction assertions). ~300 tests across three levels.

Unit — core/tests/error-boundary.spec.tsx (198, simulated resume), organised by feature:

Feature block Tests Covers
ErrorBoundary + fallback$ (CSR / SSR / SSR-in-a-deferred-segment matrix + mode-locked) 36 catch + nearest-boundary routing, nesting, safety nets, last-resort
↳ SSR delivery & teardown 38 qErr swaps, discards, out-of-order streaming (default pinned), late delivery (1 pin → #8885), inert teardown, stateless wire
↳ after resume 10 outer replaces subtree, re-derivation
↳ integration (Slot projection, tasks, visible tasks, computeds, function children, SSRStream) 30 each primitive × the boundary, as a coverage map
qerror (client event channel) 20 routing, falsy throws, multi-container isolation
onError$ (matrix + mode-locked, incl. info.phase for all five phases) 41 fire-once, identity, Error coercion, digest, resilience
ErrorBoundary reset (3-mode matrix + topologies) 19 plain, nested, wrapper components (#8881), healthy-then-client-error
transformError (render option) 4 projection kept by identity, decline falls through, unreadable projection scrubs

Unit — co-located (52, no rendering): shared/error/error-handling.unit.ts (43 — display membrane: prod redaction + digest, dev coercion, transformError incl. async-transform dev warning, hostile fail-closed rows), ErrorBoundary type pins (5), qErr executor incl. broadcast-handler stripping (4). Plus the qwikloader failed-import dedupe pins and a suspense.spec pin keeping the Suspense fallback visible through a wrapping boundary.

E2E — error-handling.e2e.ts (43, real browser, dedicated router app + qDev=false twin):

Group Tests Covers
ErrorBoundary + fallback$ (core) 9 happy path ships no swap JS, sync throw ×2 modes, client-time throw ×2 modes, nearest-boundary routing, outer supersedes, escalating fallbacks ×2 modes
↳ last-resort fallback 1 built-in role="alert" when the fallback$ chunk 404s
↳ SSR delivery & teardown 5 boundary in a deferred Suspense, swap beside a live segment, per-container qErr scoping, mid-stream click replay, deferred teardown (1 parked → #8877)
↳ after resume 2 an SSR inner error then a client throw to the outer, swapped content stays inert
↳ integration 6 visible-task routing, async-signal .error guard vs .value rethrow, loader 500 is NOT caught (2 parked → #8911)
qerror routing 2 no-boundary surfacing, chunk-404 dispatch (parked → #8962)
onError$ 2 fires once, info.phase + stable boundaryId from a real qwikloader throw
reset 10 re-executes children across modes and nesting, always-throwing re-derive, Suspense re-error + SPA-nav, Slot-wrapper (in-order pins → #8884)
production build (qDev=false) 6 redacted display after resume, raw client-origin throw, digest forgery still redacts, prod reset round-trip, an errored async signal serializes only a redacted error

Parked ledger: #8877 deferred-teardown resume, #8884 out-of-order segment walks/reset, #8885 late-delivery state liveness, #8962 resumed-dispatch chunk-404 routing, loader reset ×2 (#8911). Each pin carries its issue URL in-source.

@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: b37e40e

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

This PR includes changesets to release 7 packages
Name Type
@qwik.dev/devtools Major
@qwik.dev/core Major
@qwik.dev/router Major
eslint-plugin-qwik Major
@qwik.dev/react Major
@qwik.dev/utils 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

@qwik.dev/utils

npm i https://pkg.pr.new/QwikDev/qwik/@qwik.dev/utils@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: b37e40e

@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 b37e40e

@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

@Varixo Varixo left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks okay, but I think the approach should be different. We should reuse suspense containers and not create "similar" code for this. for every error boundary just create segment container, maybe even use Suspense inside (just change experimental flags to work with errorboundary enabled only)

Comment thread packages/qwik/src/core/ssr/ssr-types.ts Resolved
Comment thread packages/qwik/src/qwikloader.ts Resolved
@maiieul
maiieul force-pushed the claude/gracious-poitras-0f1723 branch from 0742ce8 to ff2cd9a Compare June 24, 2026 19:41
@maiieul
maiieul force-pushed the claude/gracious-poitras-0f1723 branch from fddc732 to 32d05ad Compare June 26, 2026 05:10
@maiieul maiieul changed the title feat(core): core ErrorBoundary working in SSR and CSR feat(core): core ErrorBoundar in SSR and CSR Jun 26, 2026
@maiieul maiieul changed the title feat(core): core ErrorBoundar in SSR and CSR feat(core): core ErrorBoundary in SSR and CSR Jun 26, 2026
maiieul added 5 commits June 26, 2026 15:51
…resumes

Capture the boundary host in the _ebR reset QRL. The handler was using the
reset-time $hostElement$ (the streamed fallback's host), which doesn't chain
to the boundary's ERROR_CONTEXT after an out-of-order resume, so reset() silently
no-op'd. Capturing the render-time boundary host resolves it directly.
Varixo
Varixo previously approved these changes Aug 4, 2026
maiieul added 3 commits August 5, 2026 08:49
Regenerate the qwikloader snapshot from the merged source so it carries both main's CSS.escape selector fix and this branch's loader changes.
…as-0f1723

# Conflicts:
#	packages/qwik/src/core/index.ts
#	packages/qwik/src/core/ssr/ssr-render-jsx.ts
#	packages/qwik/src/core/tests/suspense.spec.tsx
#	packages/qwik/src/core/tests/use-computed.spec.tsx
#	packages/qwik/src/qwikloader.unit.ts
#	packages/qwik/src/server/scripts.ts
@maiieul
maiieul enabled auto-merge August 26, 2026 06:43

@Varixo Varixo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@maiieul
maiieul merged commit dd97d16 into main Aug 26, 2026
48 checks passed
@maiieul
maiieul deleted the claude/gracious-poitras-0f1723 branch August 26, 2026 06:46
@github-project-automation github-project-automation Bot moved this from Waiting For Review to Done in Qwik Development Aug 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

2 participants