feat(core): core ErrorBoundary in SSR and CSR - #8745
Open
maiieul wants to merge 341 commits into
Open
Conversation
🦋 Changeset detectedLatest commit: 9ccb82a The changes in this PR will be included in the next version bump. This PR includes changesets to release 6 packages
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 |
@qwik.dev/core
@qwik.dev/router
eslint-plugin-qwik
create-qwik
@qwik.dev/optimizer
@qwik.dev/devtools
commit: |
maiieul
force-pushed
the
claude/gracious-poitras-0f1723
branch
from
June 18, 2026 07:32
f211d54 to
f370afb
Compare
Contributor
built with Refined Cloudflare Pages Action⚡ Cloudflare Pages Deployment
|
maiieul
force-pushed
the
claude/gracious-poitras-0f1723
branch
3 times, most recently
from
June 18, 2026 10:27
eb0023c to
0f38af0
Compare
maiieul
force-pushed
the
claude/gracious-poitras-0f1723
branch
from
June 19, 2026 10:37
16e1bf6 to
2623262
Compare
maiieul
marked this pull request as draft
June 19, 2026 12:40
Varixo
requested changes
Jun 20, 2026
Member
There was a problem hiding this comment.
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)
maiieul
force-pushed
the
claude/gracious-poitras-0f1723
branch
from
June 24, 2026 19:41
0742ce8 to
ff2cd9a
Compare
maiieul
force-pushed
the
claude/gracious-poitras-0f1723
branch
from
June 26, 2026 05:10
fddc732 to
32d05ad
Compare
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.
`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.
…both apps and the dev-server
… webkit flush const
…as-0f1723 # Conflicts: # e2e/qwik-e2e/tests/qwikrouter/ssg-snapshot.e2e.ts # packages/docs/src/components/sidebar/sidebar.tsx
Varixo
previously approved these changes
Aug 4, 2026
maiieul
force-pushed
the
claude/gracious-poitras-0f1723
branch
from
August 4, 2026 19:52
0fc7472 to
8e1c728
Compare
This was referenced Aug 4, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What is it?
Description
Moves
ErrorBoundaryfrom@qwik.dev/routerto@qwik.dev/coreso 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. IncludesPublicError(folded from #8868).ErrorBoundary — design & mechanism
<ErrorBoundary>in@qwik.dev/core. Experimental: gated on theerrorBoundaryVite 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
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 setsstore.error(raw, in-memory), firesonError$, marks the dead content inert, and returnsnull— a siblingfallback-hostdelivers 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 setstore.error:qErr(id)display toggle (q:ebfhost) — regardless of the streaming mode.q:rplate-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 aqOsegment, followed by aqErrthat hides the dead content and strips itsdocument:/window:handlers.qErremitted after the segment reveal.So a boundary without
<Suspense>involvement never usesqO: the happy path ships no swap scripts at all, and errors swap in document order viaqErr. (Under out-of-order mode an error-free boundary still emits its passiveq:rpshell 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 aq:rphost). Both hosts carry static styles; theqErrscript writes the errored end state directly into the DOM — nothing subscribes, so nothing can un-hide dead content later.3. Production redaction & the
ErrormembraneOne 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.transformErrorprojects 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 readableErrorprojection is kept by identity, and returningundefined/nulldeclines — the default policy applies, so aPublicErrorstays public under a scoped transform. A thrownPublicErrordisplays 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 owndigestfield 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 oncause), and the fallback receives the membrane projection — in dev a non-Error throw keeps the raw value oncause, 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 emitsqerror→ nearest boundary,info.phase === 'event'), async-generator and async-signal rejections. Thrown falsy values — includingundefined— reveal the fallback (normalized to a keyableError;onError$still gets the raw value). A fallback chunk that fails to load renders a built-inrole="alert"last-resort node; a fallback that loads and then throws escalates to the ancestor boundary. Fire-and-forget rejections hit a single page-levelunhandledrejectionbridge (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 sowindow.onerror/monitoring fires).5. Resume, not re-run
The boundary serializes nothing error-shaped — not the error, not a flag (
store.erroris a non-enumerable in-memory field; onlyboundaryIdcrosses, plusauthorIdwhen the errored author sits past a projection cut — healthy boundaries retain nothing). A server-errored page resumes already showing its fallback because theqErrswap wrote the static end state into the DOM and resume runs nothing. Error state is derived: the first re-execution — areset()or an owner re-render — re-runs the children. A still-throwing child re-derives the same fallback client-side (the proddigestcan 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 asundefined), 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 viaqErr.6. Routing & escalation
EB-outer › Suspense › EB-inner › throwEB-outer › Suspense › throw(no inner EB)EB-outer › Suspense-A › EB-mid › Suspense-B › throwA 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 viadigestorinfo.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 serializedauthorIdas 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
qErrscript 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"]SSR: catching a throw and choosing delivery — the handler only marks state and returns
null; the fallback host picksqErr(in-place) vsqO(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"]Resume and re-derivation — the inline
qErrscript swaps hosts before resume (no flash); the result is a static end state, and resume runs nothing.Client-time errors & escalation — everything funnels through
handleError, which walks upERROR_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 --> WALKreset()— dirtying the owner in the same tick as clearing the error re-supplies and re-executes the consumed children.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$)"]Also in this PR
PublicError(folded from feat(core): PublicError — user-facing errors render unredacted through ErrorBoundary #8868):throw new PublicError(data)renders unredacted through the fallback in prod — construction is consent covering the whole instance. A reservedq:peserialization marker restores the class on resume, soinstanceof PublicErrorworks in client code on userland-captured errors; a user Error field forging the marker key is dropped, and a subclass resumes as the base class.<Suspense>and its suspending child no longer blocks deferral (found via EB, Suspense-generic; regression-tested with a plain stateful wrapper)./docs/labs/error-boundary/(menu entry included) covering the public API, redaction,PublicError, andreset().Tests
All e2e authority runs on a strict
pnpm build.coredist (build.core.devproxies 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:ErrorBoundary + fallback$(CSR / SSR / SSR-in-a-deferred-segment matrix + mode-locked)qErrswaps, discards, out-of-order streaming (default pinned), late delivery (1 pin → #8885), inert teardown, stateless wireqerror(client event channel)onError$(matrix + mode-locked, incl.info.phasefor all five phases)ErrorBoundary reset(3-mode matrix + topologies)PublicError(rendered) + display membraneUnit — co-located (61, no rendering):
shared/error/error-handling.unit.ts(48 — display membrane: prod redaction + digest, dev coercion,transformErrorincl. async-transform dev warning, PublicError pass-through, hostile fail-closed rows),ErrorBoundarytype pins (5),PublicErrorclass (4),qErrexecutor incl. broadcast-handler stripping (4). Plus theq:pemarker suite inserdes.unit.ts, the qwikloader failed-import dedupe pins, and asuspense.specpin keeping the Suspense fallback visible through a wrapping boundary.E2E —
error-handling.e2e.ts(45, real browser, dedicated router app +qDev=falsetwin):info.phase+ stableboundaryIdfrom a real qwikloader throwqErrscoping, last-resort on a fallback-chunk 404, chunk-404 inertness, unhandledrejection reacheslogError.errorguard handled inline vs.valuerethrow → boundary; loader 500 is NOT caught (2 parked → Phase B revalidation ruling)PublicErrorunredacted beside a redacted sibling withinstanceofsurviving resumeParked 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.