feat(core): core ErrorBoundary in SSR and CSR#8745
Draft
maiieul wants to merge 121 commits into
Draft
Conversation
🦋 Changeset detectedLatest commit: b191038 The changes in this PR will be included in the next version bump. This PR includes changesets to release 5 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
commit: |
f211d54 to
f370afb
Compare
Contributor
built with Refined Cloudflare Pages Action⚡ Cloudflare Pages Deployment
|
eb0023c to
0f38af0
Compare
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).
`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.
…oves the reset Suspense-climb)
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.
…-resort fallback, and rejection bridge
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.Tests
I. Core behaviour
swap proof lives in §V)
qerroris routed to the nearest boundary (CSR)II. Routing & scope (
combinations,projection,multiple containers)qerrorisolationIII. Error sources (
task throws,async-generator + non-serializable,falsy values,recoverable vs build)useTask$/useVisibleTask$→ nearest boundary (CSR + in-order SSR)the page still serializes; a normal
Erroris unchangedstore.error !== undefined(CSR 0/null/''/false + OOOS).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)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)
qerrorlistener:importErroronly logs (no re-log per container, no fallback); aqerrorwithno enclosing boundary does not let
handleError's re-throw escapedocument.dispatchEventV. SSR streaming swap mechanics (in-order swap, OOOS swap, Suspense routing, teardown, inert, cross-phase)
in-order (A):
qErr, partial swapped out9f73845b2outOfOrder:false) → fallback in document order9f73845b2qErrexecutor independent of OOOSnullnatural close)9f73845b2out-of-order (B):
qErrlate (case c)9e161969c9e161969cpost-swap reconcile:
rerenderComponent)SSR inner error then a client throw to the outer replaces the whole subtree
VI. onError$ side-effects (
onError$)props.onError$fireOnError; the render is unaffected (CSR + SSR)E2E (Playwright) — real-browser invariants (the gap)
scenario=happy,0209351dd): content INTERACTIVE after resume, no fallback, noswap script (asserts the SSR HTML ships no
qErr(/qInstallErrorSwap/qO(/qInstallOOOS), then a clientthrow 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.?outOfOrder=false,0209351dd) →qErrswap +fallback INTERACTIVE (the
qErr-without-OOOS path — simplest swap)<Suspense>(case b,scenario=suspense,0209351dd):EbDeferredOkforces a real OOO segment, the boundary throws synchronously inside it → hoisted-
qErrswap within thesegment + fallback INTERACTIVE; the non-throwing
#eb-deferred-oksibling still resolves.scenario=inert): auseTask$in the swapped-out content does NOT re-run when asignal is bumped from OUTSIDE the boundary after resume.
scenario=nested,0209351dd): SSR error in inner EB → click on an inner-EB SIBLINGtriggers the outer EB to throw → outer fallback replaces the whole subtree (incl. the inner fallback)
and stays interactive. Distinct
eb-outer/eb-innerfallback ids.scenario=throw-fallback&outOfOrder=false):#eb-outershowscaught: inner fallback boom,#eb-contenthidden,#eb-outer-buttoninteractive (review chore: add type imports, set importsNotUsedAsValues "error" in tsconfig #5)scenario=throw-fallback):same assertions; proves the escalated fallback resumes interactive after the
qOswap (review chore: add type imports, set importsNotUsedAsValues "error" in tsconfig #5)ErrorBoundary — design & current mechanism
<ErrorBoundary>in@qwik.dev/core. Experimental: gated on theerrorBoundaryVite flag —errorBoundaryCmpthrows a clear error if it's used with the flag off. The streaming swap reusesSuspense'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 documentorder.
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 partial content is hidden and a siblingfallback-hostis revealed — a swap, never a buffer-rollback. The closest boundary catches(nearest
ERROR_CONTEXTup the component chain).2. SSR: two display-toggled hosts + a two-branch swap
The boundary renders two sibling hosts whose
displayis reactive onstore.error:content-host(q:ebc) wraps<Slot/>—display:contents, becomesnoneonce errored.fallback-host(q:ebf, orq:rpin the out-of-order branch) —none, becomescontentsonce errored.On a throw the SSR catch (
renderErrorBoundaryFallback) does not render the fallback itself: itsets
store.error = toSerializableBoundaryError(err)(serializable projection so a non-serializablethrow 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 nobespoke 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 rendersinline in document order; a tiny
qErr(id)inline script hides the content-host and reveals thefallback-host. The
qErrexecutor installs independently of Suspense'sqO(gated onerrorBoundary, notsuspense), so a plain in-order page still swaps. Inside a Suspense segment aninline
qErrwould be inert in the<template>, so the boundary registers its id(
$registerErrorSwap$) and the segment emitsqErr(id)at the root right after itsqO(segmentId)reveal.
qO— out-of-order streaming active and not already in a segment. The fallback streams as anout-of-order segment; the shared
qOexecutor delivers + reveals it (the fallback-host carriesq:rp). A deferred throw from a child<Suspense>uses this: it tears the whole boundary down viastore.$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:
VNodeDataFlag.INERT, so it materializes as plain,non-resumable DOM;
clearAllEffectsdrops its tasks' effects;<Slot>ref into the dead content is removed so client resume won'tindex-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 thenearest
ERROR_CONTEXT, setsstore.error, fires the serializedprops.onError$, and marks theboundary dirty.
errorBoundaryCmpthen re-rendersstore.error !== undefined ? <Fragment>{fallback}</Fragment> : <Slot/>, and the vnode-diff replacesthe 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
qerrordocument event; the container'sqerrorlistenerresolves the source element to its VNode and calls
handleError. Not routed: fire-and-forgetnon-awaited rejections, and QRL import failures (logged, not bounded).
4.
store.erroris the SSR→CSR bridgestore.errorserializes (anErrorresumes truthy), so a resumed boundary is already in the errorstate without re-running the component. The content-host stays in the DOM (
display:none, notremoved), 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 nodeis what enables.
5. Routing & escalation
resolveContext/findErrorBoundaryNodereturn the closestERROR_CONTEXTprovider; the SSRthrow-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$firesonly for a throw that escaped a segment with no boundary inside it.
EB-outer › Suspense › EB-inner › throwEB-outer › Suspense › throw(no inner EB)EB-outer › Suspense-A › EB-mid › Suspense-B › throwNo enclosing boundary → the original error rethrows: SSR aborts the render (safety net); CSR
reaches the global handler via
logErrorAndThrowAsync(sowindow.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 itsfallback, 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 onceonError$is logging/telemetry only: it never affects rendering, and a throwing/rejectingonError$is swallowed (
fireOnError). It fires exactly once per caught error — server-side via thestore.$onError$mirror, client-side via the serializedprops.onError$(the$-store mirror isserver-only).