Skip to content

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

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

feat(core): core ErrorBoundary working in SSR and CSR#8745
maiieul wants to merge 80 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. Core isn't run through the optimizer, so it's hand-built with componentQrl + inlinedQrl (like Each/Suspense/Reveal).

Breaking:

  • Import ErrorBoundary from @qwik.dev/core (was @qwik.dev/router).
  • fallback$ is now required.
  • useErrorBoundary() is removed — use <ErrorBoundary>.
  • An error is handled by the closest boundary (not every boundary on the page).

SSR streaming (experimental errorBoundary feature, with suspense + out-of-order streaming): the boundary never blocks streaming. Its subtree streams inside a visible content host next to a hidden fallback host; if a descendant throws, fallback$ streams as an out-of-order segment and an inline script (the shared Suspense qO executor) hides the content and reveals the fallback — the swap fires as the error chunk is parsed, before the framework resumes, so it never waits on the client runtime. A deferred child <Suspense> throw tears the whole boundary down to fallback$. A boundary placed inside a <Suspense> instead buffers within that already-deferred segment. Client-time errors keep the reactive re-render path. Covered by unit specs and a real-browser e2e.

@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: 14d4da2

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: 14d4da2

@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 14d4da2

@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 30 commits June 21, 2026 08:57
Collapse the two errorBoundaryCmp server branches (OOOS q:rp+qO vs in-order q:ebc/q:ebf+qErr) into ONE: every boundary is the same two-host structure (content-host q:ebc + fallback-host q:ebf), and SSRErrorFallback applies one rule — swap with qErr when the fallback is known IN PLACE (sync throw, in-order, or buffered in a Suspense segment), and fall back to qO only to DELIVER a genuinely LATE fallback (a deferred child Suspense throw), where the fallback-host doubles as the q:rp delivery target. So qErr is the boundary swap; qO is purely late delivery, no longer 'the EB swap when OOOS is on'. Also fixes the wart where a sync throw under out-of-order streaming needlessly streamed a segment instead of swapping inline. Removes SSRErrorFallbackInline (merged).
closeOpenElementsTo/getCurrentElementFrame (and ssr-unwind.spec) were infrastructure for an unwind-based in-order swap that the final render-null approach never used. Dead code — removed.
A3 siblings outside the boundary stay visible; A4 an awaited-async in-order throw delivers the fallback to the sibling host (document order, not the throw site); A6 a throw deep in nested tags yields well-formed, hideable HTML. All test existing behavior and pass.
B4: EB-outer › Suspense › EB-inner › throw → inner catches, outer untouched. B6: EB-outer › Suspense-A › EB-mid › Suspense-B › throw → mid catches, outer untouched. Both confirm the settled routing table; both pass.
…lapse

D2: SSR inner error, then a client throw to the outer boundary replaces the whole subtree (distinct stores). D3(in-order): the in-order q:ebc/q:ebf two-host collapses to a single clean fallback on a client-first error (no Missing child) — the in-order analog of the existing OOOS collapse test.
…p-out

The redesign swaps the errored content OUT (content-host hidden) instead of leaving it 'in place'. Rename + tighten the SSR spec to assert the content-host is hidden and contains the partial content, and fix the stale in-order task-throw comment.
…lient (A7)

A boundary's swapped-out (hidden) content is dead, but its tasks' signal subscriptions were still serialized and resumed — so a signal change from OUTSIDE the boundary re-ran a task in the dead subtree on the client (proven by the new scenario=inert E2E, which failed before this).

On a caught throw, walk the boundary component node (the content is projected through <Slot>, so its SSR nodes hang off the boundary node, not the content-host element) and clearAllEffects on each content task so it unsubscribes and never resumes. The sibling fallback-host has not rendered yet at catch time, so only dead content tasks are cleared.
…-data INERT)

Extends the task-effect clearing (a7013ae) to FULL inertness. On a caught throw, for each dead content node: (1) tag its vnode-data INERT (new VNodeDataFlag.INERT) — emission keeps the REFERENCE marker but drops the structural/component block, so it materializes as plain non-resumable DOM; (2) clearAllEffects on its tasks; (3) cut the claimed-<Slot> projection ref into it — the boundary's and any LIVE ancestor's owner[slotName]=projectionId (consumeChildrenForSlot), found via the boundary's parentComponent chain — so client resume's ensureProjectionResolved never index-walks into the inert subtree (was: Missing child). Sets up client-side DOM deletion. core 1161 + EB 66 + EB e2e 5/5 green.
…content

Guards the decision that no dedicated content-deletion (Phase B) is needed: re-rendering an SSR-errored boundary (in-order AND out-of-order) collapses the two-host to the fallback Fragment and the vnode diff drops the inert content-host + content cleanly — no Missing child. Drives the re-render with rerenderComponent (an SSR-errored boundary has no self re-render trigger; a 2nd error escalates past it). Removal is thus subsumed by reset() (Phase 2).
Title-only: 'A7 inert' -> 'E2E-6 inert' — it's the real-browser test of the A7 inert behavior (§9 catalog E2E-6). EB streaming e2e 5/5 green.
…ry test titles

Catalog numbers (the §9 spec/progress labels) belong in the planning docs, not in real test titles/comments — strip them so titles read as plain behavior descriptions. Title/comment-only; EB spec 68 + EB streaming e2e 5/5 green.
…, case-b, D2)

Core Playwright coverage for the streaming ErrorBoundary: E2E-1 happy path (interactive content, no fallback/swap script, then a client throw is caught), E2E-2 in-order qErr swap, E2E-4 boundary inside a deferred <Suspense> (case b), E2E-9 D2 cross-phase. Adds happy/suspense/nested fixture scenarios + an id prefix on EbFallback for nested boundaries.
… by core e2e

ErrorBoundary moved from @qwik.dev/router to @qwik.dev/core; its router happy-path e2e (and the /error fixture route) is replaced by the core ErrorBoundary e2e (happy path + client-throw catch). The router error-PAGE tests (error.tsx / error-page.e2e.ts) are unrelated and kept.
Optional onError$ QRL on ErrorBoundary, fired once per caught error with the original error — at every catch point (SSR renderErrorBoundaryFallback, deferred case-c streamFallback, and client handleError), guarded on the undefined→set transition. Stored serialized on the boundary store (unlike noSerialize'd $fallback$) so a post-resume client throw fires it too. Pure side-effect (fire-and-forget, own failures logged); never affects rendering. Adds unit + e2e coverage.
…ent throw

A `$`-prefixed store field is not serialized, so `store.$onError$` was undefined on the client and the post-resume client-throw path never fired onError$ (CI trace: the onError$ chunk was never requested; only the prop is in the serialized state). Read the serialized `props.onError$` from the boundary host in `handleError` instead; keep `store.$onError$` for the in-memory SSR catch only. fireOnError now takes the callable directly.
…irror like $fallback$

Clarify the dual mechanism: store.$onError$ is a server-only mirror read only by the SSR catch (now noSerialize'd + a fresh closure, exactly like $fallback$, so it stays off the serialized state without tainting the prop QRL). The client (CSR and resume) fires props.onError$ in handleError. No behavior change; fixes the misleading 'client cannot read this' comment.
Collapse every multi-line comment in the PR to one short why-focused sentence (or delete it when it just restated the code), and strip internal cruft from comments and test titles — finding/catalog IDs, 'case b/c', design-doc \u00a7 references, and legacy-test pointers. Comments and test/it titles only; no code changed (tsc clean, 145 EB/suspense/qwikloader unit tests pass).
The in-order ErrorBoundary fallback host (SSRErrorFallbackInline) is an
internal server component, but the SSR walker only dispatched those when
`suspense` was enabled, so enabling `errorBoundary` alone threw at render.
Dispatch internal server components when either `suspense` or `errorBoundary`
is on; everything else in the in-order swap path is already `errorBoundary`-gated.
Removes the now-incorrect plugin warning that claimed the combo degrades to
in-place rendering.
… flag

The `errorBoundary` experimental flag is build-time replaced, so with it off
`<ErrorBoundary>` cannot work. Fail loud with an actionable message instead of
silently degrading (SSR aborts the render; CSR-dev logs it). This also removes
the broken flag-off in-place SSR path.
handleError's no-boundary terminal only console.error'd the error, so
window.onerror / Sentry / Insights never saw it. Re-throw on a fresh macrotask
via logErrorAndThrowAsync so monitoring still fires without surfacing as an
uncaught chore rejection. Adds a no-boundary e2e.
The ErrorBoundary section named a nonexistent getBufferingErrorBoundaryStore and described the reverted buffering model. Rewrite it to the current model (flag-required; works without suspense; qO/qErr two-branch swap and its resume invariant; INERT teardown; $contentHostNode$ serialized by design). Regenerated via ruler apply.
api.json/index.mdx lagged the shortened ErrorBoundaryProps TSDoc; pnpm api.update synced them.
…g boundary on SSR

A fallback that itself throws now escalates to the enclosing <ErrorBoundary> on SSR (in-order and out-of-order), matching CSR. renderErrorBoundaryFallback walks boundary nodes and skips one whose own fallback already threw (detached $fallback$); SSRErrorFallbackInline detaches $fallback$ before rendering so the signal is uniform across modes. Adds unit + e2e escalation coverage, plus onError$ resume and in-order SSR writer coverage in the same spec.
The DomContainer qerror listener re-logged import/symbol failures that qwikloader already console.error'd, duplicating the log once per container (the listener is document-level). Drop the re-log; qwikloader owns that message. Updates the C2 spec wording to match.
…broadcast invariants

Comments only: (1) $contentHostNode$ is serialized by design (roots the content host so a client re-render can drop the inert subtree) and must not be noSerialize'd; (2) markErrorBoundaryContentInert runs before the fallback host renders, so it only marks the dead content; (3) broadcast() skips the per-element cancelBubble re-check because document/window handlers are same-target, where native stopPropagation never skips siblings.
The drain had six try/catch blocks each routing a render throw to the closest ErrorBoundary. Extract catchToErrorBoundary(ssr, host, produce) so the qwik-component, inline-component and awaited-promise sites share one wrapper (and the deferred-component resolver drops its own catch). Two sites keep a local catch because their control flow can't be wrapped — the async-signal retryOnPromise+push and the async-generator for-await stream — but both still route through renderErrorBoundaryFallback. Behavior-preserving (221 unit + 28 e2e green); addresses review feedback that the scattered catches weren't maintainable.
The #prevent-default-2 anchor called ev.stopPropagation() inside an async onClick$, which Qwik doesn't support — preventDefault/stopPropagation must be synchronous (via the preventdefault:/stoppropagation: attributes or sync$), so a call inside an async handler lands after the synchronous event-path walk. Use the stoppropagation:click attribute, like the #stop-propagation button, so the stop applies during the walk.
Reverts the per-element taskGroups + runEventTasks re-check that made an async (deferred) ev.stopPropagation() skip ancestor handlers. Calling stopPropagation inside an async handler is unsupported in Qwik (it lands after the synchronous walk, and preventDefault can't work that way either), so honoring only the async stop was inconsistent and added always-shipped loader code. Restores the single task-list processElementEvent, the 2100 brotli budget (loader back to 2079b brotli), and drops the two deferred-stop behavior tests + the changeset. The one e2e that relied on it now uses the stoppropagation: attribute.
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