Repository navigation
feat(core): core ErrorBoundary in SSR and CSR - #8745
Merged
Merged
Conversation
🦋 Changeset detectedLatest commit: b37e40e The changes in this PR will be included in the next version bump. This PR includes changesets to release 7 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
@qwik.dev/utils
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
…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.
…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
Regenerate the qwikloader snapshot from the merged source so it carries both main's CSS.escape selector fix and this branch's loader changes.
…as-0f1723 # Conflicts: # packages/qwik/src/core/index.ts # packages/qwik/src/core/ssr/ssr-render-jsx.ts # packages/qwik/src/core/tests/suspense.spec.tsx # packages/qwik/src/core/tests/use-computed.spec.tsx # packages/qwik/src/qwikloader.unit.ts # packages/qwik/src/server/scripts.ts
maiieul
enabled auto-merge
August 26, 2026 06:43
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.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, showRaw, transformError), projects the raw stored error at every display site. Redaction follows the error's origin: a server-origin error displays in prod as a generic message + stable digest (message, stack, and attached props stripped) and keeps full fidelity in dev; a client-origin error renders as thrown — its message already ships in the bundle. An SSR-caught error the client re-derives (reset, owner re-render) becomes client-origin and shows raw.RenderOptions.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. 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 — a raw-displayed non-Error keeps the raw value oncause, which is safe now that the boundary never serializes it.4. What routes to a boundary
Render throws (sync + async),
useTask$/useVisibleTask$throws, event-handler throws (qwikloader 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 stay with the browser's ownunhandledrejectionreporting — the framework installs no bridge. 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, plusprojectedContentOwnerwhen the errored author sits past a projection cut — healthy boundaries retain nothing). An errored async signal is the one deliberate exception: it serializes a redacted error, scrubbed at write time so the raw error never enters serializable state. A server-errored page resumes already showing its fallback because 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 serializedprojectedContentOwneras 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 (+projectedContentOwner 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, showRaw, transformError)"] M --> T{"transformError configured?"} T -- "readable Error projection" --> TP["kept by identity"] T -- "throws / non-Error / unreadable" --> G1["generic + digest"] T -- "no" --> BR{"framework REDACTED brand?"} BR -- "yes" --> PASS["already projected — pass by identity<br/>(an app-owned digest field does NOT count)"] BR -- "no" --> SR{"showRaw?<br/>server: only in dev · client: always"} SR -- "no — server prod" --> G2["redactToGeneric: fresh Error + digest + brand<br/>NEVER cause, NEVER fields"] SR -- yes --> DEV["Error → same instance;<br/>non-Error → Error(message), cause = raw (always)"] TEL["onError$ / server logError"] -.-> ORIG["always the ORIGINAL raw value<br/>(cause-wrapped for onError$)"]Also in this PR
<Suspense>and its suspending child no longer blocks deferral (found via EB, Suspense-generic; regression-tested with a plain stateful wrapper).Cache-Control: no-store(error documents always do), errored renders skip the SSR etag cache, and the router warns when that disables caching the app configured. Core reports the catch through the experimentalonBeforeFirstFlushrender option andRenderToStreamResult.errorBoundaryCaught./docs/labs/error-boundary/(menu entry included) covering the public API, redaction, 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). ~300 tests across three levels.Unit —
core/tests/error-boundary.spec.tsx(198, 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)transformError(render option)Unit — co-located (52, no rendering):
shared/error/error-handling.unit.ts(43 — display membrane: prod redaction + digest, dev coercion,transformErrorincl. async-transform dev warning, hostile fail-closed rows),ErrorBoundarytype pins (5),qErrexecutor incl. broadcast-handler stripping (4). Plus the qwikloader failed-import dedupe pins and asuspense.specpin keeping the Suspense fallback visible through a wrapping boundary.E2E —
error-handling.e2e.ts(43, real browser, dedicated router app +qDev=falsetwin):ErrorBoundary + fallback$(core)role="alert"when thefallback$chunk 404sqErrscoping, mid-stream click replay, deferred teardown (1 parked → #8877).errorguard vs.valuerethrow, loader 500 is NOT caught (2 parked → #8911)qerrorroutingonError$info.phase+ stableboundaryIdfrom a real qwikloader throwParked ledger: #8877 deferred-teardown resume, #8884 out-of-order segment walks/reset, #8885 late-delivery state liveness, #8962 resumed-dispatch chunk-404 routing, loader reset ×2 (#8911). Each pin carries its issue URL in-source.