Skip to content

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

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

feat(core): core ErrorBoundary in SSR and CSR#8745
maiieul wants to merge 341 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. 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 & { 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>;

// 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).
// 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, 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 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, so a PublicError stays public under a scoped transform. 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; removed when the last container is destroyed). 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 authorId when the errored author sits past a projection cut — healthy boundaries retain nothing). 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 authorId 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 (+authorId 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 authorId)
  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

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). ~307 tests across three levels.

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

Feature block Tests Covers
ErrorBoundary + fallback$ (CSR / SSR / SSR-in-a-deferred-segment matrix + mode-locked) 34 catch + nearest-boundary routing, nesting, safety nets, last-resort
↳ SSR delivery & teardown 43 qErr swaps, discards, out-of-order streaming (default pinned), late delivery (1 pin → #8885), inert teardown, stateless wire
↳ after resume 6 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) 18 routing, falsy throws, multi-container isolation, unhandledrejection bridge
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
PublicError (rendered) + display membrane 11 unredacted-by-consent, transformError render option, hostile throws

Unit — co-located (61, no rendering): shared/error/error-handling.unit.ts (48 — display membrane: prod redaction + digest, dev coercion, transformError incl. async-transform dev warning, PublicError pass-through, hostile fail-closed rows), ErrorBoundary type pins (5), PublicError class (4), qErr executor incl. broadcast-handler stripping (4). Plus the q:pe marker suite in serdes.unit.ts, 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 (45, real browser, dedicated router app + qDev=false twin):

Group Tests Covers
streaming swap 8 happy path ships no swap JS, sync throw ×2 modes, boundary in a deferred Suspense, swap beside a live segment, inert content never re-runs, mid-stream click replay (1 parked → #8877)
client-time throws 4 post-resume throws ×2 modes, visible-task routing, no-boundary surfacing
onError$ 2 fires once, info.phase + stable boundaryId from a real qwikloader throw
nested boundaries 5 inner/outer routing, outer supersedes, escalating fallbacks ×2 modes
reset 10 re-executes children across all modes, always-throwing re-derive, Suspense + Slot-wrapper (in-order pins → #8884)
multi-container + chunk-load / rejection bridge 4 per-container qErr scoping, last-resort on a fallback-chunk 404, chunk-404 inertness, unhandledrejection reaches logError
× async-signal + × loader 5 .error guard handled inline vs .value rethrow → boundary; loader 500 is NOT caught (2 parked → Phase B revalidation ruling)
production build (qDev=false) 7 redacted display after resume, prod reset round-trip through the serializer, digest forgery still redacts, PublicError unredacted beside a redacted sibling with instanceof surviving resume

Parked ledger: #8877 deferred-teardown resume, #8884 out-of-order segment walks/reset, #8885 late-delivery state liveness, loader reset ×2 (Phase B). 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: 9ccb82a

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: 9ccb82a

@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 9ccb82a

@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-render-jsx.ts Outdated
Comment thread packages/qwik/src/core/ssr/ssr-render-jsx.ts Outdated
Comment thread packages/qwik/src/core/ssr/ssr-render-jsx.ts Outdated
Comment thread packages/qwik/src/core/ssr/ssr-types.ts Outdated
Comment thread packages/qwik/src/core/ssr/stream-writer.ts Outdated
Comment thread packages/qwik/src/core/tests/ssr-checkpoint.spec.tsx Outdated
Comment thread packages/qwik/src/core/tests/ssr-rollback-roots.spec.tsx Outdated
Comment thread packages/qwik/src/qwikloader.ts Outdated
maiieul added 2 commits June 26, 2026 08:03
The content host is already rooted by its reactive `style` binding, so the explicit
`$contentHostNode$` store field was redundant — removed it and `captureErrorBoundaryContentHost`
so every boundary serializes only `error`. The inert marker takes the boundary node directly.
…egion

The display swap adds no role/aria-live; document on `fallback$` that authors should render their
own live region (e.g. `<div role="alert">`). Regenerated the API docs blurb.
@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 3 commits June 26, 2026 14:36
`fallback$` now receives `(error, reset)`. `reset()` clears the error and re-renders the projection
owner to re-supply + re-execute the children (once, via owner-dirty + clear-error in the same tick).

Works for client-caught AND SSR render errors (in-order and out-of-order). A resumed owner isn't
reachable by a DOM walk, so the boundary serializes a `$resetOwner$` ref (which also roots it); the
boundary is re-resolved from its host element when reset fires from inside a streamed fallback
segment. Use `onClick$={() => reset()}` — a bare QRL doesn't serialize the listener on resume.

Covered by unit specs (CSR + in-order/OOOS resume) and 3 real-browser e2e tests.
Varixo
Varixo previously approved these changes Aug 4, 2026
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