feat(core): core ErrorBoundary in SSR and CSR#8745
Open
maiieul wants to merge 179 commits into
Open
Conversation
🦋 Changeset detectedLatest commit: 58094a9 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: |
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).
Broaden the failed-import assertion to match WebKit's "Importing a module script failed" phrasing and pad the in-order mid-stream shell with the webkitFlush marker so the qwikloader executes while the stream is gated open.
…oitras-0f1723 # Conflicts: # e2e/qwik-e2e/dev-server.ts # packages/docs/src/routes/api/qwik-optimizer/api.json
1245c88 to
577894d
Compare
Resolve the useAsync$→useComputed$ overlap: - use-async.spec.tsx: take build/v2's minimal deprecation test; the full suite it dropped is now covered by use-computed.spec.tsx. - use-computed.spec.tsx: migrate the removed useErrorBoundary hook to useErrorBoundaryStore (semantic conflict git didn't flag).
…izable transformError projections
…nd the experimental flag
…g-off pin invariants
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.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(a redacted projection), 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).3. Production redaction & the
ErrormembraneIn prod, a caught error is serialized to the client as a generic message + stable digest (message, stack, and attached props stripped); dev keeps full fidelity.
RenderOptions.transformErroroverrides the projection, fail-closed: a throwing or non-serializable transform falls back to the generic scrub, never the raw error.onError$always receives the original error — only the client-bound payload (and whatfallback$displays) is redacted. The client-side display path applies the same redaction for parity.Both callbacks are typed
Error, and the runtime guarantees it rather than asserting it: a non-Error throw is coerced to anError. ForonError$(telemetry) the raw thrown value is preserved onerror.cause; for the prod-redactedstore.error(which is serialized into the HTML) thecauseis never attached — that would be a data-leak channel. So{error.message}is always safe, and the raw value survives only where it never serializes (onError$+ serverlogError).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
store.errorserializes, so a server-errored boundary resumes already showing its fallback without re-running component code; a later client error routes to the same boundary. 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 caught error (a new error caught on the server re-fires with it; a second client error escalates to the ancestor instead), swallows its own throws, and never affects rendering.7.
reset()The second
fallback$arg: clears the error and re-executes the children, re-running their async work. Works for client-caught and SSR errors, in-order and out-of-order, on resumed pages, and for boundaries inside<Suspense>.Architecture diagrams (under the hood)
Component structure — the boundary emits two sibling hosts; which one shows is a pure function of one serialized field,
store.error.flowchart TD EB["ErrorBoundary { fallback$, onError$ }"] --> CMP["errorBoundaryCmp (server)"] CMP --> H1["content-host: div q:ebc=id<br/>style = fnSignal(store) → display none/contents<br/>holds a Slot = your children"] CMP --> H2["SSRErrorFallbackHost<br/>internal server component<br/>picks fallback delivery at DRAIN time"] CMP -. writes .-> STORE["ErrorBoundaryStore<br/>error? — the SSR→CSR bridge, redacted<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.flowchart TD START["SSR drain reaches a node under the boundary"] --> THROW{"throws?"} THROW -- no --> OK["stream normally; content-host style stays 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 = redacted;<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 + qErr(id) swap"] DEC -- "yes — deferred" --> LATE["host q:rp + placeholder;<br/>fallback streamed later as a qO segment"]Resume and the swap — the inline
qErrscript swaps hosts before resume (no flash); the reactive style then takes over.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 (and prod-serialized state never carries the raw value).flowchart TD THROWN["thrown value: Error | 0 | '' | undefined | object"] --> SPLIT{"which channel?"} SPLIT -- "onError$ (telemetry)" --> OE["toBoundaryError(raw, cause=true):<br/>Error → same instance; non-Error → Error(String(raw)), cause=raw"] SPLIT -- "server logError" --> LOG["always the RAW original"] SPLIT -- "store.error → fallback$ (display)" --> ENV{"dev or prod?"} ENV -- dev --> K{"keepable?"} K -- "Error" --> RAW["pass untouched (enumerable fields serialize)"] K -- "serializable non-Error" --> W["Error(String(raw)), cause=raw"] K -- "non-serializable" --> WN["Error(message), no cause"] ENV -- prod --> RED["redactToGeneric: fresh Error + digest<br/>NEVER cause, NEVER fields (serialized-leak guard)"]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).Tests
Unit (146 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),qErrswap mechanics (incl. in-place-beside-a-live-segment), OOOS/Suspense suite (case b/c, routing, concurrent teardown), and redaction +transformError+ theError-coercion contract (identity,cause, prod no-cause/no-fields).E2E (35 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),onError$fires once, no-boundary surfacing, nested both-error resume, and a dedicatedqDev=falseprod app pinning client-side redaction display + prod resume + reset round-trip.