feat(core): core ErrorBoundary working in SSR and CSR#8745
Draft
maiieul wants to merge 80 commits into
Draft
Conversation
🦋 Changeset detectedLatest commit: 14d4da2 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).
Collapse the two errorBoundaryCmp server branches (OOOS q:rp+qO vs in-order q:ebc/q:ebf+qErr) into ONE: every boundary is the same two-host structure (content-host q:ebc + fallback-host q:ebf), and SSRErrorFallback applies one rule — swap with qErr when the fallback is known IN PLACE (sync throw, in-order, or buffered in a Suspense segment), and fall back to qO only to DELIVER a genuinely LATE fallback (a deferred child Suspense throw), where the fallback-host doubles as the q:rp delivery target. So qErr is the boundary swap; qO is purely late delivery, no longer 'the EB swap when OOOS is on'. Also fixes the wart where a sync throw under out-of-order streaming needlessly streamed a segment instead of swapping inline. Removes SSRErrorFallbackInline (merged).
closeOpenElementsTo/getCurrentElementFrame (and ssr-unwind.spec) were infrastructure for an unwind-based in-order swap that the final render-null approach never used. Dead code — removed.
This reverts commit 65f5560.
A3 siblings outside the boundary stay visible; A4 an awaited-async in-order throw delivers the fallback to the sibling host (document order, not the throw site); A6 a throw deep in nested tags yields well-formed, hideable HTML. All test existing behavior and pass.
B4: EB-outer › Suspense › EB-inner › throw → inner catches, outer untouched. B6: EB-outer › Suspense-A › EB-mid › Suspense-B › throw → mid catches, outer untouched. Both confirm the settled routing table; both pass.
…lapse D2: SSR inner error, then a client throw to the outer boundary replaces the whole subtree (distinct stores). D3(in-order): the in-order q:ebc/q:ebf two-host collapses to a single clean fallback on a client-first error (no Missing child) — the in-order analog of the existing OOOS collapse test.
…p-out The redesign swaps the errored content OUT (content-host hidden) instead of leaving it 'in place'. Rename + tighten the SSR spec to assert the content-host is hidden and contains the partial content, and fix the stale in-order task-throw comment.
…lient (A7) A boundary's swapped-out (hidden) content is dead, but its tasks' signal subscriptions were still serialized and resumed — so a signal change from OUTSIDE the boundary re-ran a task in the dead subtree on the client (proven by the new scenario=inert E2E, which failed before this). On a caught throw, walk the boundary component node (the content is projected through <Slot>, so its SSR nodes hang off the boundary node, not the content-host element) and clearAllEffects on each content task so it unsubscribes and never resumes. The sibling fallback-host has not rendered yet at catch time, so only dead content tasks are cleared.
…-data INERT) Extends the task-effect clearing (a7013ae) to FULL inertness. On a caught throw, for each dead content node: (1) tag its vnode-data INERT (new VNodeDataFlag.INERT) — emission keeps the REFERENCE marker but drops the structural/component block, so it materializes as plain non-resumable DOM; (2) clearAllEffects on its tasks; (3) cut the claimed-<Slot> projection ref into it — the boundary's and any LIVE ancestor's owner[slotName]=projectionId (consumeChildrenForSlot), found via the boundary's parentComponent chain — so client resume's ensureProjectionResolved never index-walks into the inert subtree (was: Missing child). Sets up client-side DOM deletion. core 1161 + EB 66 + EB e2e 5/5 green.
…content Guards the decision that no dedicated content-deletion (Phase B) is needed: re-rendering an SSR-errored boundary (in-order AND out-of-order) collapses the two-host to the fallback Fragment and the vnode diff drops the inert content-host + content cleanly — no Missing child. Drives the re-render with rerenderComponent (an SSR-errored boundary has no self re-render trigger; a 2nd error escalates past it). Removal is thus subsumed by reset() (Phase 2).
Title-only: 'A7 inert' -> 'E2E-6 inert' — it's the real-browser test of the A7 inert behavior (§9 catalog E2E-6). EB streaming e2e 5/5 green.
…ry test titles Catalog numbers (the §9 spec/progress labels) belong in the planning docs, not in real test titles/comments — strip them so titles read as plain behavior descriptions. Title/comment-only; EB spec 68 + EB streaming e2e 5/5 green.
…, case-b, D2) Core Playwright coverage for the streaming ErrorBoundary: E2E-1 happy path (interactive content, no fallback/swap script, then a client throw is caught), E2E-2 in-order qErr swap, E2E-4 boundary inside a deferred <Suspense> (case b), E2E-9 D2 cross-phase. Adds happy/suspense/nested fixture scenarios + an id prefix on EbFallback for nested boundaries.
… by core e2e ErrorBoundary moved from @qwik.dev/router to @qwik.dev/core; its router happy-path e2e (and the /error fixture route) is replaced by the core ErrorBoundary e2e (happy path + client-throw catch). The router error-PAGE tests (error.tsx / error-page.e2e.ts) are unrelated and kept.
Optional onError$ QRL on ErrorBoundary, fired once per caught error with the original error — at every catch point (SSR renderErrorBoundaryFallback, deferred case-c streamFallback, and client handleError), guarded on the undefined→set transition. Stored serialized on the boundary store (unlike noSerialize'd $fallback$) so a post-resume client throw fires it too. Pure side-effect (fire-and-forget, own failures logged); never affects rendering. Adds unit + e2e coverage.
This reverts commit 74a32ac.
…ent throw A `$`-prefixed store field is not serialized, so `store.$onError$` was undefined on the client and the post-resume client-throw path never fired onError$ (CI trace: the onError$ chunk was never requested; only the prop is in the serialized state). Read the serialized `props.onError$` from the boundary host in `handleError` instead; keep `store.$onError$` for the in-memory SSR catch only. fireOnError now takes the callable directly.
…irror like $fallback$ Clarify the dual mechanism: store.$onError$ is a server-only mirror read only by the SSR catch (now noSerialize'd + a fresh closure, exactly like $fallback$, so it stays off the serialized state without tainting the prop QRL). The client (CSR and resume) fires props.onError$ in handleError. No behavior change; fixes the misleading 'client cannot read this' comment.
Collapse every multi-line comment in the PR to one short why-focused sentence (or delete it when it just restated the code), and strip internal cruft from comments and test titles — finding/catalog IDs, 'case b/c', design-doc \u00a7 references, and legacy-test pointers. Comments and test/it titles only; no code changed (tsc clean, 145 EB/suspense/qwikloader unit tests pass).
The in-order ErrorBoundary fallback host (SSRErrorFallbackInline) is an internal server component, but the SSR walker only dispatched those when `suspense` was enabled, so enabling `errorBoundary` alone threw at render. Dispatch internal server components when either `suspense` or `errorBoundary` is on; everything else in the in-order swap path is already `errorBoundary`-gated. Removes the now-incorrect plugin warning that claimed the combo degrades to in-place rendering.
… flag The `errorBoundary` experimental flag is build-time replaced, so with it off `<ErrorBoundary>` cannot work. Fail loud with an actionable message instead of silently degrading (SSR aborts the render; CSR-dev logs it). This also removes the broken flag-off in-place SSR path.
handleError's no-boundary terminal only console.error'd the error, so window.onerror / Sentry / Insights never saw it. Re-throw on a fresh macrotask via logErrorAndThrowAsync so monitoring still fires without surfacing as an uncaught chore rejection. Adds a no-boundary e2e.
The ErrorBoundary section named a nonexistent getBufferingErrorBoundaryStore and described the reverted buffering model. Rewrite it to the current model (flag-required; works without suspense; qO/qErr two-branch swap and its resume invariant; INERT teardown; $contentHostNode$ serialized by design). Regenerated via ruler apply.
api.json/index.mdx lagged the shortened ErrorBoundaryProps TSDoc; pnpm api.update synced them.
…g boundary on SSR A fallback that itself throws now escalates to the enclosing <ErrorBoundary> on SSR (in-order and out-of-order), matching CSR. renderErrorBoundaryFallback walks boundary nodes and skips one whose own fallback already threw (detached $fallback$); SSRErrorFallbackInline detaches $fallback$ before rendering so the signal is uniform across modes. Adds unit + e2e escalation coverage, plus onError$ resume and in-order SSR writer coverage in the same spec.
The DomContainer qerror listener re-logged import/symbol failures that qwikloader already console.error'd, duplicating the log once per container (the listener is document-level). Drop the re-log; qwikloader owns that message. Updates the C2 spec wording to match.
…broadcast invariants Comments only: (1) $contentHostNode$ is serialized by design (roots the content host so a client re-render can drop the inert subtree) and must not be noSerialize'd; (2) markErrorBoundaryContentInert runs before the fallback host renders, so it only marks the dead content; (3) broadcast() skips the per-element cancelBubble re-check because document/window handlers are same-target, where native stopPropagation never skips siblings.
The drain had six try/catch blocks each routing a render throw to the closest ErrorBoundary. Extract catchToErrorBoundary(ssr, host, produce) so the qwik-component, inline-component and awaited-promise sites share one wrapper (and the deferred-component resolver drops its own catch). Two sites keep a local catch because their control flow can't be wrapped — the async-signal retryOnPromise+push and the async-generator for-await stream — but both still route through renderErrorBoundaryFallback. Behavior-preserving (221 unit + 28 e2e green); addresses review feedback that the scattered catches weren't maintainable.
The #prevent-default-2 anchor called ev.stopPropagation() inside an async onClick$, which Qwik doesn't support — preventDefault/stopPropagation must be synchronous (via the preventdefault:/stoppropagation: attributes or sync$), so a call inside an async handler lands after the synchronous event-path walk. Use the stoppropagation:click attribute, like the #stop-propagation button, so the stop applies during the walk.
Reverts the per-element taskGroups + runEventTasks re-check that made an async (deferred) ev.stopPropagation() skip ancestor handlers. Calling stopPropagation inside an async handler is unsupported in Qwik (it lands after the synchronous walk, and preventDefault can't work that way either), so honoring only the async stop was inconsistent and added always-shipped loader code. Restores the single task-list processElementEvent, the 2100 brotli budget (loader back to 2079b brotli), and drops the two deferred-stop behavior tests + the changeset. The one e2e that relied on it now uses the stoppropagation: attribute.
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. Core isn't run through the optimizer, so it's hand-built withcomponentQrl+inlinedQrl(likeEach/Suspense/Reveal).Breaking:
ErrorBoundaryfrom@qwik.dev/core(was@qwik.dev/router).fallback$is now required.useErrorBoundary()is removed — use<ErrorBoundary>.SSR streaming (experimental
errorBoundaryfeature, withsuspense+ out-of-order streaming): the boundary never blocks streaming. Its subtree streams inside a visible content host next to a hidden fallback host; if a descendant throws,fallback$streams as an out-of-order segment and an inline script (the shared SuspenseqOexecutor) hides the content and reveals the fallback — the swap fires as the error chunk is parsed, before the framework resumes, so it never waits on the client runtime. A deferred child<Suspense>throw tears the whole boundary down tofallback$. A boundary placed inside a<Suspense>instead buffers within that already-deferred segment. Client-time errors keep the reactive re-render path. Covered by unit specs and a real-browser e2e.