feat(core): core ErrorBoundary in SSR and CSR - #8745
Open
maiieul wants to merge 278 commits into
Open
Conversation
🦋 Changeset detectedLatest commit: 5d0b08b 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
The streaming ErrorBoundary swap reuses the out-of-order Suspense machinery, so without the `suspense` experimental feature (+ `streaming.outOfOrder` at render time) it silently degrades to the synchronous in-place fallback with no diagnostic — unlike Suspense and ssr.segment, which fail loudly. Warn once at config time (the in-place fallback still works, so warn rather than throw).
A buffering ErrorBoundary's rollback() truncated only $roots$, leaving the parallel $rootObjs$ array longer and stale $seenObjsMap$ entries pointing at discarded indices. A root added in the rolled-back subtree and re-referenced afterwards then serialized to an out-of-range/duplicated slot, corrupting resume. Add SerializationContext.$rollbackRoots$ to truncate both arrays and drop dedup entries created since the checkpoint; rollback() calls it.
Adds regression coverage for the deferred stopPropagation fix: when a child handler's QRL is still importing and calls stopPropagation() after the synchronous bubbling walk, runEventTasks' cancelBubble re-check must skip a later ancestor whose handler is also deferred (loaded via import). Both handlers go through the QRL-import path so they only run when their task group flushes — a synchronous _qDispatch ancestor would always run during the walk and could not be skipped. Verified failing against a loader without the re-check.
pnpm api.update for the _QDocument.qProcessOOOS parameter rename (content -> revealNode) from the OOOS executor naming change. Internal type, parameter-name only.
…ations Locks in the 'who catches what' semantics across CSR, in-order SSR, and out-of-order streaming: two throws in one boundary (single fallback, first error wins), two adjacent boundaries both throwing (independent fallbacks), a nested outer boundary superseding the inner one, two boundaries inside one Suspense, and two Suspense in one boundary both async-throwing (single fallback).
Companion to the bubble-phase deferred-stop coverage: a capture-phase handler whose QRL is still importing calls stopPropagation() after the synchronous walk, and runEventTasks' cancelBubble re-check must skip a later deferred bubble handler. Both handlers use the QRL-import path. Verified failing against a loader without the re-check.
…ntainer, tasks New coverage: a throw in Slot-projected content resolves to the boundary it is projected into (CSR + in-order SSR, documenting that in-order SSR renders the fallback in place so already-streamed siblings remain); a no-boundary SSR throw still propagates/aborts (sync, async component, rejected promise child); a qerror is isolated to its own container when multiple containers share a document; and useTask$/useVisibleTask$ throws route to the nearest boundary.
The boundary used `store.error` truthiness as its 'has error' signal, so a thrown falsy value (0, '', false, null) re-rendered to the content instead of the fallback. Gate on `store.error !== undefined` (the store inits error: undefined) in the render branch, both display fn-signals + their inlined strings, and the OOOS sync-throw check, so any thrown value shows the fallback.
build.core type-checks the test files: the streamAndResume write callback returned the array length (number) where void is expected, and the tasks spec imported an unused 'trigger'. No behavior change.
…ndary renderErrorBoundaryFallback caught every SSR throw, including non-recoverable build/plugin errors (e.g. a dev Vite transform error, flagged by isRecoverable), hiding them in the fallback — while the client rethrows them past the boundary. In dev that meant a compile error showed a fallback on SSR but surfaced on CSR. Add the same isRecoverable gate to the SSR path (dev-only) so build errors surface consistently; recoverable runtime errors still render the fallback on both.
Removed the OOOS 'two Suspense in one boundary both async-throw -> single fallback' case from error-boundary-combinations: it duplicates (and asserts less than) the dedicated teardown-exactly-once regression test in error-boundary-concurrent.spec.tsx.
Cut the verbose multi-line comments across the ErrorBoundary/SSR/qwikloader code down to 1-2 sentences that explain the bug/constraint rather than narrate the implementation, and drop self-explanatory ones. Also finishes the OOOS swap dedup (emitErrorBoundaryFallback now reuses finalizeAndSwapOutOfOrderSegment) and regenerates the fallback$ API doc.
maiieul
force-pushed
the
claude/gracious-poitras-0f1723
branch
from
July 28, 2026 12:44
55dd1b0 to
e858d11
Compare
It recovered the wrapper topology in CSR but not after a resume, because the gate it needs cannot be evaluated before the first render. Tracked in #8881.
Two cooperating mechanisms, one per failure mode (#8881): expectSlot re-schedules a moved projection's components whose output is gone (CSR), and the inert cut records the severed projection's author so reset can re-render it after resume (SSR).
Healthy boundaries no longer serialize their parent's state (#8883): the inert cut retains the errored boundary's ancestor chain instead, and reset falls back to a boundary-only re-render when no author is renderable, which the projection re-scheduling recovers.
Four describes collided with a sibling, the six info.phase tests used three different wordings, and the falsy `''` row printed its value as a blank. Also drops two assertions that are strict subsets of a neighbour, and tightens the multi-container fallback check, which passed on any caught message.
Folds the one-test `ErrorBoundary behavior` orphan and its SSR twin into the mode matrix, moves the two qerror tests under `qerror routing` and the three re-render tests under `client re-derivation`, and drops a duplicate fixture and a shadowed helper name.
The redaction, coercion, transformError and PublicError display tests call `error-handling.ts` directly and render nothing, so they move next to it like every other module in `shared/`. The five that do render stay in the spec.
Four hand-rolled copies of the two streaming modes disagreed on the OOOS label; they now read one const. The page-error and console-error capture blocks become two helpers beside `assertNoBrowserErrors`.
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 boundary is 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.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 non-Error, or an unreadable projection (throwing getters) falls back to the generic scrub; a readableErrorprojection is kept by identity. 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). 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; onlyboundaryIdand theresetOwnernode ref cross the wire). 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.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, in-order and out-of-order, on resumed pages (no serialized error state needed — the serializedresetOwnernode ref resolves the owner), and for boundaries inside<Suspense>.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, resetOwner (serialized node ref)<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).Tests
Unit (207 specs) — behavior
describe.eachacross CSR/SSR, a 3-arm reset table (CSR click / resumed in-order / resumed OOOS), CSR-specific (qerror routing + nearest-container isolation, falsy values incl.undefined, multi-container, last-resort, unhandledrejection bridge), SSR-specific (safety net, async-generator, non-serializable), SSR→CSR cross-phase (resume, inert drop, post-resume errors), stateless wire (nothing error-shaped serializes; an outside re-render can't un-hide dead content), client re-derivation (still-throwing re-derive, healed auto-recovery, computed-throw re-derive,onError$refire, task-throw degrade),qErrswap mechanics (incl. in-place-beside-a-live-segment), OOOS/Suspense suite (case b/c, routing, concurrent teardown), redaction +transformError+ theError-coercion contract (identity,cause, prod no-cause/no-fields, brand-gated pass-through incl. the app-digestforgery), the PublicError membrane + serdes marker suite, and hostile-value fail-closed rows.E2E (36 real-browser tests) — streaming swaps in-order + OOOS and interactivity after resume, deferred teardown, inert content never re-runs, client-time errors after resume, escalation incl. throwing fallbacks (both modes),
reset()(7 scenarios, incl. re-derivation on an always-throwing child in dev and prod),onError$fires once, no-boundary surfacing, nested both-error resume, and a dedicatedqDev=falseprod app pinning client-side redaction display, prod resume, the reset round-trip, andPublicErrorshown unredacted beside a redacted sibling withinstanceofsurviving resume.