Skip to content

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

Draft
maiieul wants to merge 121 commits into
build/v2from
claude/gracious-poitras-0f1723
Draft

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

Tests

I. Core behaviour

  • [S1] nearest boundary catches a sync/async render throw — CSR + SSR (basics here; the in-order/OOOS
    swap proof lives in §V)
  • [C1] an async qerror is routed to the nearest boundary (CSR)
  • [S7-smoke] a throwing fallback with no ancestor escalates/terminates, no infinite loop (full S7 in §IV)

II. Routing & scope (combinations, projection, multiple containers)

  • [S2] nested → inner catches, outer intact
  • [S3] adjacent sibling boundaries each own fallback (CSR / in-order / OOOS)
  • [S4] two throwing children → single fallback (first wins)
  • [S5] projected throw caught by the projected-into boundary (CSR + in-order SSR)
  • [C3] multi-container qerror isolation

III. Error sources (task throws, async-generator + non-serializable, falsy values, recoverable vs build)

  • task throws: useTask$ / useVisibleTask$ → nearest boundary (CSR + in-order SSR)
  • async-generator child throw routed to the boundary; a non-serializable throw renders the fallback AND
    the page still serializes; a normal Error is unchanged
  • falsy thrown values reveal the fallback — keys on store.error !== undefined (CSR 0/null/''/false + OOOS)
  • recoverable vs build errors (dev): a .plugin (non-recoverable) error surfaces past the boundary
    (SSR rejects / CSR not caught); a recoverable error renders the fallback

IV. Escalation & no-boundary edges (throwing-fallback escalation, safety net, qerror listener)

  • [S7] throwing fallback escalates to ancestor/generic, no loop — CSR escalates (spec:577);
    SSR-no-outer aborts (spec:416); SSR-WITH-outer now escalates in BOTH in-order + OOOS (Part 2 review chore: add type imports, set importsNotUsedAsValues "error" in tsconfig #5);
    onError$ fires once per boundary for its own error under escalation — CSR + in-order SSR (final test-review pass)
  • [S6] no enclosing boundary → original error surfaces (SSR rejects / CSR logs); original error identity preserved
  • [C2] qerror listener: importError only logs (no re-log per container, no fallback); a qerror with
    no enclosing boundary does not let handleError's re-throw escape document.dispatchEvent

V. SSR streaming swap mechanics (in-order swap, OOOS swap, Suspense routing, teardown, inert, cross-phase)
in-order (A):

  • [A1] happy path: no fallback content, no swap JS
  • [A2] sync throw → content-host hidden + fallback in sibling host + qErr, partial swapped out
  • [A3] a sibling OUTSIDE the boundary that streamed before the throw stays visible (in-order) 9f73845b2
  • [A4] awaited-ASYNC in-order throw (outOfOrder:false) → fallback in document order 9f73845b2
  • [A5] qErr executor independent of OOOS
  • [A6] deep-nested-tags throw → well-formed HTML (render-null natural close) 9f73845b2
    out-of-order (B):
  • [B1] EB alone + OOOS, no Suspense → behaves like in-order swap
  • [B2] Suspense PARENT of EB, EB throws → swaps within the segment (case b)
  • [B3] Suspense CHILD of EB, deferred throw → whole boundary torn down, qErr late (case c)
  • [B4] routing: EB-outer › Suspense › EB-inner › throw → EB-inner catches, outer untouched 9e161969c
  • [B5] routing: EB-outer › Suspense › throw (no inner EB) → EB-outer
  • [B6] routing: EB-outer › Suspense-A › EB-mid › Suspense-B › throw → EB-mid 9e161969c
  • [B7] two boundaries inside one Suspense → each own fallback
  • [B8] two sibling Suspense under one boundary → tear down exactly once
  • [B9] sibling boundaries in sibling Suspenses swap independently
    post-swap reconcile:
  • inert swapped-out content dropped for free on any re-render (in-order + OOOS, via rerenderComponent)
  • SSR→CSR cross-phase: the two-host collapses cleanly on a client re-render (no "Missing child"); an
    SSR inner error then a client throw to the outer replaces the whole subtree

VI. onError$ side-effects (onError$)

  • fires once: CSR; SSR writer (out-of-order + in-order); post-resume reader from serialized props.onError$
  • a throwing (sync) and an async-rejecting onError$ are swallowed by fireOnError; the render is unaffected (CSR + SSR)
  • fire-count under escalation: inner + outer each fire once for their own error (CSR + in-order SSR)
  • optional: a boundary without onError$ still catches

E2E (Playwright) — real-browser invariants (the gap)

  • E2E-1 happy path (scenario=happy, 0209351dd): content INTERACTIVE after resume, no fallback, no
    swap script (asserts the SSR HTML ships no qErr(/qInstallErrorSwap/qO(/qInstallOOOS), then a client
    throw inside the boundary is caught + fallback interactive. Replaces the legacy router test (removed
    5d470515e); the router error-PAGE tests (error.tsx/error-page.e2e.ts) are unrelated and kept.
  • E2E-2 IN-ORDER SSR sync throw (default scenario + ?outOfOrder=false, 0209351dd) → qErr swap +
    fallback INTERACTIVE (the qErr-without-OOOS path — simplest swap)
  • E2E-3 OOOS SSR sync throw → swap + fallback interactive
  • E2E-4 EB inside a deferred <Suspense> (case b, scenario=suspense, 0209351dd): EbDeferredOk
    forces a real OOO segment, the boundary throws synchronously inside it → hoisted-qErr swap within the
    segment + fallback INTERACTIVE; the non-throwing #eb-deferred-ok sibling still resolves.
  • E2E-5 async deferred throw (case c) → teardown + fallback interactive
  • E2E-6 A7 INERT (scenario=inert): a useTask$ in the swapped-out content does NOT re-run when a
    signal is bumped from OUTSIDE the boundary after resume.
  • E2E-7 client-time throw after resume (in-order) → re-render + interactive
  • E2E-8 client-time throw after resume (OOOS) → re-render + interactive
  • E2E-9 (scenario=nested, 0209351dd): SSR error in inner EB → click on an inner-EB SIBLING
    triggers the outer EB to throw → outer fallback replaces the whole subtree (incl. the inner fallback)
    and stays interactive. Distinct eb-outer/eb-inner fallback ids.
  • E2E-10 throwing inner fallback escalates to the OUTER boundary, IN-ORDER (scenario=throw-fallback&outOfOrder=false):
    #eb-outer shows caught: inner fallback boom, #eb-content hidden, #eb-outer-button interactive (review chore: add type imports, set importsNotUsedAsValues "error" in tsconfig #5)
  • E2E-11 throwing inner fallback escalates to the OUTER boundary, OUT-OF-ORDER (scenario=throw-fallback):
    same assertions; proves the escalated fallback resumes interactive after the qO swap (review chore: add type imports, set importsNotUsedAsValues "error" in tsconfig #5)

ErrorBoundary — design & current mechanism

<ErrorBoundary> in @qwik.dev/core. Experimental: gated on the errorBoundary Vite flag —
errorBoundaryCmp throws a clear error if it's used with the flag off. The streaming swap reuses
Suspense's out-of-order machinery, so out-of-order delivery additionally needs suspense +
streaming.outOfOrder; without them the boundary still works, delivering the fallback in document
order.

Public API

// @qwik.dev/core  (experimental: needs the `errorBoundary` Vite flag)
export interface ErrorBoundaryProps {
  /** REQUIRED. Lazily loaded; only fetched when the subtree errors. */
  fallback$: QRL<(error: any) => JSXOutput>;
  /** Optional side-effect for logging/telemetry; never affects rendering. */
  onError$?: QRL<(error: unknown) => void>;
}
export const ErrorBoundary: Component<ErrorBoundaryProps>;
// Usage — fallback is guaranteed, error + reset are provided
<ErrorBoundary
  fallback$={(error, reset) => (
    <div role="alert">
      <p>Something broke: {error instanceof Error ? error.message : String(error)}</p>
      <button onClick$={reset}>Try again</button>
    </div>
  )}
  onError$={(error) => reportToSentry(error)}
>
  <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 partial content is hidden and a sibling
fallback-host is revealed — a swap, never a buffer-rollback. The closest boundary catches
(nearest ERROR_CONTEXT up the component chain).

2. SSR: two display-toggled hosts + a two-branch swap

The boundary renders two sibling hosts whose display is reactive on store.error:

  • content-host (q:ebc) wraps <Slot/>display:contents, becomes none once errored.
  • fallback-host (q:ebf, or q:rp in the out-of-order branch) — none, becomes contents once errored.
<eb>
  <content-host>            <!-- streams live; hidden on throw -->
     <div>partial…</div>    <!-- already out; can't un-send -->
  </content-host>
  <fallback-host>           <!-- sibling; rendered in document order on throw -->
     <fallback/>
  </fallback-host>
</eb>

On a throw the SSR catch (renderErrorBoundaryFallback) does not render the fallback itself: it
sets store.error = toSerializableBoundaryError(err) (serializable projection so a non-serializable
throw can't abort page serialization), fires onError$ once, marks the swapped-out content inert,
and returns null. The drain's natural tag-closing leaves well-formed, hideable HTML — there is no
bespoke unwind.

The fallback is delivered by one of two branches, chosen by isOutOfOrderStreaming():

  • qErr — in-order, or the boundary is already inside a Suspense segment. The fallback renders
    inline in document order; a tiny qErr(id) inline script hides the content-host and reveals the
    fallback-host. The qErr executor installs independently of Suspense's qO (gated on
    errorBoundary, not suspense), so a plain in-order page still swaps. Inside a Suspense segment an
    inline qErr would be inert in the <template>, so the boundary registers its id
    ($registerErrorSwap$) and the segment emits qErr(id) at the root right after its qO(segmentId)
    reveal.
  • qO — out-of-order streaming active and not already in a segment. The fallback streams as an
    out-of-order segment; the shared qO executor delivers + reveals it (the fallback-host carries
    q:rp). A deferred throw from a child <Suspense> uses this: it tears the whole boundary down via
    store.$emitFallback$ and streams the fallback late.

Why two branches, not one: an out-of-order fallback's vnode-data must travel through a segment to
stay resume-consistent; rendering it inline under OOOS desyncs the refs. A single unified model was
tried and reverted for exactly this.

Inert teardown — the hidden, dead content must never resume. Three gates:

  1. the content subtree's vnode-data is tagged VNodeDataFlag.INERT, so it materializes as plain,
    non-resumable DOM;
  2. clearAllEffects drops its tasks' effects;
  3. the live owner's claimed-<Slot> ref into the dead content is removed so client resume won't
    index-walk into it.

The content-host SSR node is serialized — by design (not noSerialize'd like the other
$-fields) — so a client re-render can locate and drop the inert subtree (see §4).

Happy path ships the two hosts (display-toggled) and no swap script — no throw, no qErr/qO.

3. CSR + event handlers

The closest boundary intercepts a client throw via handleError (dom-container): it walks up to the
nearest ERROR_CONTEXT, sets store.error, fires the serialized props.onError$, and marks the
boundary dirty. errorBoundaryCmp then re-renders
store.error !== undefined ? <Fragment>{fallback}</Fragment> : <Slot/>, and the vnode-diff replaces
the SSR two-host with the fallback.

Throws in event handlers (e.g. onClick$) route here too: qwikloader catches the handler throw
(sync or awaited-async) and emits a qerror document event; the container's qerror listener
resolves the source element to its VNode and calls handleError. Not routed: fire-and-forget
non-awaited rejections, and QRL import failures (logged, not bounded).

4. store.error is the SSR→CSR bridge

store.error serializes (an Error resumes truthy), so a resumed boundary is already in the error
state without re-running the component. The content-host stays in the DOM (display:none, not
removed), keeping content-first / fallback-second order, so a later throw from inside it still
resolves up to the boundary. A resumed-error boundary that re-renders outputs the keyless fallback
Fragment, and the vnode-diff cleanly drops both SSR hosts — which the serialized content-host node
is what enables.

5. Routing & escalation

resolveContext / findErrorBoundaryNode return the closest ERROR_CONTEXT provider; the SSR
throw-site and the Suspense deferred-slot use the same closest-walk so they can't disagree. The
deferred-slot resolves from above the Suspense (parentComponentFrame), so $emitFallback$ fires
only for a throw that escaped a segment with no boundary inside it.

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

No enclosing boundary → the original error rethrows: SSR aborts the render (safety net); CSR
reaches the global handler via logErrorAndThrowAsync (so window.onerror / monitoring still fires).

A fallback that itself throws escalates. Both SSR (renderErrorBoundaryFallback) and CSR
(handleError) skip a boundary whose $fallback$ is already detached / that's already showing its
fallback, and walk to the nearest ancestor — so a throwing fallback moves up instead of looping, and
the top-level handler terminates it. An SSR inner error and a later CSR throw to an outer boundary are
independent (distinct stores; the outer replaces the inner subtree).

6. onError$ — side-effect, fires once

onError$ is logging/telemetry only: it never affects rendering, and a throwing/rejecting onError$
is swallowed (fireOnError). It fires exactly once per caught error — server-side via the
store.$onError$ mirror, client-side via the serialized props.onError$ (the $-store mirror is
server-only).

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

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

This PR includes changesets to release 5 packages
Name Type
@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

commit: a2eb40a

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

@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 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 28 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.
…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.
…uspending child

A blocking promise registered its Suspense via getNearestCursorBoundary on the
throwing node only. An intervening component (e.g. ErrorBoundary) leaves its
projected child without the boundary cached, so the lookup missed, the Suspense
was never marked pending, and client nav blocked on the async work instead of
showing the fallback. addCursorBoundary now walks up to the nearest boundary.
…e a Suspense

reset() re-renders the component that supplies the boundary's children. For a
boundary projected through a <Suspense> slot, getParentHost resolves to the
Suspense (which re-projects the boundary, not its children), so a 2nd retry or a
CSR/SPA-nav retry cleared the error but left the content empty. Climb past the
Suspense to the actual owner. First-retry-on-resume was unaffected (the resumed
tree had no Suspense frame in the parent chain).
…g wrapper

A plain <Slot>-projecting component between the route and an <ErrorBoundary> makes
reset() re-render the wrapper and re-claim (not re-execute) the children, so an async
child never re-runs. Adds the reset-wrapped fixture scenario + a test.fixme that
documents it (kept skipped so CI stays green; verified it fails at the recovery
assertion when un-skipped). Remove .fixme to drive the fix.
…me, not the physical parent

A <Slot>-projecting wrapper between the route and an <ErrorBoundary> made
$resetOwner$ resolve to the EB's physical parent (the wrapper) instead of the
component that authored the children. Re-rendering the wrapper re-claims and
throws insertBefore. Use the EB frame's projectionComponentFrame.componentNode
(the authoring/route frame) instead; falls back to host.parentComponent.
The error-boundary streaming fixture recomputed `scenario` from
useServerData('url') on every render. On the client that url has no query
string, so an owner re-render (reset() or a key bump) collapsed the branch to
the default and replaced the wrapper's async child — masking re-execution and
making the Slot-wrapper reset case look broken.

Capture `scenario` in a (serialized) signal so the branch is stable across
re-renders. With the artifact gone, the projection-frame owner fix (5b3e69c)
is shown to be correct: the previously-fixme "reset re-executes async children
through a Slot-projecting wrapper" test now passes. Also add a
`reset-wrapped-key` scenario + test proving a dev-owned `key` bump recovers the
same shape.

EB streaming e2e: 18/18.
A useAsync$ error captured into `.error` is handled inline and the enclosing
<ErrorBoundary> stays inert (the expected channel); reading `.value` re-throws
the captured error so the boundary catches it (the unexpected channel). Pins the
split loaders are being refactored onto — a ServerError is surfaced via `.error`,
an unexpected error propagates to the boundary.

Adds `async-error-inline` / `async-error-throw` scenarios to the EB e2e fixture +
error-boundary-async-channel.e2e.ts (2 tests).
…key so reset works in prod builds

$-prefixed store keys are dropped by the prod build (the server-only convention), so the $resetOwner$
node ref was lost on resume under build.core (present under build.core.dev) — a resumed wrapper reset()
found no owner and no-op'd. Rename the field $resetOwner$ -> resetOwner (non-$), keeping the node ref.
…oitras-0f1723

# Conflicts:
#	packages/docs/src/routes/api/qwik-router/api.json
#	packages/docs/src/routes/api/qwik-router/index.mdx
#	packages/qwik-router/src/runtime/src/qwik-router.runtime.api.md
Prod serializes a generic message + stable digest (onError$ and server logError still receive the original); dev keeps full fidelity.
…d fallback

The out-of-order streamFallback rendered the raw caught error; render the redacted store.error so the streamed fallback can't leak it (matches the inline path).
…ty with SSR

The client fallback render redacts store.error via redactBoundaryErrorForDisplay (no-op in dev); store.error stays raw (client-origin, never serialized) and onError$ still receives the original.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In progress

Development

Successfully merging this pull request may close these issues.

2 participants