+ ,
+ { debug }
+ );
+
+ // Without a fallback the error is recorded but children keep rendering.
+ await trigger(container.element, null, 'w:qerror', {
+ detail: { error: new Error('Boom!') },
+ });
+
+ expect(document.body.textContent).toContain('All good');
+ });
+});
From 210b2e411a5f2853824bb15de69386b037f1e1db Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Thu, 18 Jun 2026 11:11:32 +0200
Subject: [PATCH 002/331] feat(qwik): ErrorBoundary closest-parent + SSR
catching, required fallback$
- 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.
---
.changeset/errorboundary-to-core.md | 6 +-
packages/docs/src/routes/api/qwik/api.json | 4 +-
packages/docs/src/routes/api/qwik/index.mdx | 19 +++-
packages/qwik-vite/src/manifest.ts | 1 -
packages/qwik/handlers.mjs | 1 -
.../qwik/src/core/client/dom-container.ts | 19 ++++
packages/qwik/src/core/index.ts | 6 +-
packages/qwik/src/core/qwik.core.api.md | 7 +-
.../src/core/shared/error/error-boundary.ts | 41 +++----
.../src/core/shared/error/error-handling.ts | 6 ++
packages/qwik/src/core/ssr/ssr-render-jsx.ts | 41 +++++--
.../src/core/tests/error-boundary.spec.tsx | 101 ++++++++++++------
12 files changed, 169 insertions(+), 83 deletions(-)
diff --git a/.changeset/errorboundary-to-core.md b/.changeset/errorboundary-to-core.md
index 5298ecb6fe8..20eb765b842 100644
--- a/.changeset/errorboundary-to-core.md
+++ b/.changeset/errorboundary-to-core.md
@@ -3,4 +3,8 @@
'@qwik.dev/router': major
---
-BREAKING: `ErrorBoundary` now ships from `@qwik.dev/core` instead of `@qwik.dev/router`. Update your imports from `@qwik.dev/router` to `@qwik.dev/core`.
+`ErrorBoundary` now ships from `@qwik.dev/core` instead of `@qwik.dev/router` — update your imports. It also changes behavior:
+
+- **`fallback$` is now required.**
+- It catches errors only from **its own subtree**: the **closest** boundary handles an error, instead of every boundary on the page reacting to the global `qerror` event.
+- It now catches render throws during **SSR** (rendering `fallback$` in place), not just on the client.
diff --git a/packages/docs/src/routes/api/qwik/api.json b/packages/docs/src/routes/api/qwik/api.json
index 3718707c3ce..199f8055157 100644
--- a/packages/docs/src/routes/api/qwik/api.json
+++ b/packages/docs/src/routes/api/qwik/api.json
@@ -668,7 +668,7 @@
}
],
"kind": "Interface",
- "content": "```typescript\nexport interface ErrorBoundaryProps \n```\n\n\n
\n\nRendered in place of the subtree when a descendant throws during render. Lazily loaded — only fetched once the subtree errors — and receives the caught error.\n\n\n
\n\n_(Optional)_ The boundary's fallback renderer. Set by `` so SSR can render the fallback in place of a subtree that throws during server render (on the client the boundary re-renders instead). Internal — not meant to be set by application code.\n\n\n
-_(Optional)_
+Rendered in place of the subtree when a descendant throws during render. Lazily loaded — only fetched once the subtree errors — and receives the caught error.
@@ -1695,6 +1695,21 @@ Description
+$fallback$?
+
+
+
+
+
+(error: any) => unknown
+
+
+
+_(Optional)_ The boundary's fallback renderer. Set by `` so SSR can render the fallback in place of a subtree that throws during server render (on the client the boundary re-renders instead). Internal — not meant to be set by application code.
+
+
+
+
error
diff --git a/packages/qwik-vite/src/manifest.ts b/packages/qwik-vite/src/manifest.ts
index 0e2328c62c8..74f8d18e128 100644
--- a/packages/qwik-vite/src/manifest.ts
+++ b/packages/qwik-vite/src/manifest.ts
@@ -26,7 +26,6 @@ const extraSymbols = new Set([
'_reT',
// ErrorBoundary
'_ebC',
- '_ebL',
]);
// This is just the initial prioritization of the symbols and entries
diff --git a/packages/qwik/handlers.mjs b/packages/qwik/handlers.mjs
index de84e285781..963ff5d8122 100644
--- a/packages/qwik/handlers.mjs
+++ b/packages/qwik/handlers.mjs
@@ -28,5 +28,4 @@ export {
_reT,
// ErrorBoundary
_ebC,
- _ebL,
} from '@qwik.dev/core/internal';
diff --git a/packages/qwik/src/core/client/dom-container.ts b/packages/qwik/src/core/client/dom-container.ts
index 45df8aadb54..310b1f0dd8c 100644
--- a/packages/qwik/src/core/client/dom-container.ts
+++ b/packages/qwik/src/core/client/dom-container.ts
@@ -114,6 +114,7 @@ export class DomContainer extends _SharedContainer implements IClientContainer {
private $stateData$: unknown[];
private $rootForwardRefs$: Array | null = null;
private $styleIds$: Set | null = null;
+ private $qErrorHandler$: ((e: Event) => void) | null = null;
constructor(element: ContainerElement) {
super({}, element.getAttribute(QLocaleAttr)!);
@@ -146,6 +147,20 @@ export class DomContainer extends _SharedContainer implements IClientContainer {
processSegmentStateScripts(this);
}
this.$hoistStyles$();
+ // Route async errors (a failed QRL import/handler emits a `qerror` event) to the CLOSEST
+ // ErrorBoundary, matching the synchronous render-throw path. Previously each ErrorBoundary
+ // listened to the global event and every one of them caught every error regardless of source.
+ this.$qErrorHandler$ = (e: Event) => {
+ const detail = (e as CustomEvent<{ error: unknown; element?: Element }>).detail;
+ const source = detail?.element;
+ if (source && this.element.contains(source)) {
+ const host = this.vNodeLocate(source);
+ if (host) {
+ this.handleError(detail.error, host);
+ }
+ }
+ };
+ document.addEventListener?.('qerror', this.$qErrorHandler$);
if (!qTest && element.isConnected) {
element.dispatchEvent(new CustomEvent('qresume', { bubbles: true }));
}
@@ -153,6 +168,10 @@ export class DomContainer extends _SharedContainer implements IClientContainer {
/** Tear down this container so stale references fail gracefully. */
$destroy$(): void {
+ if (this.$qErrorHandler$) {
+ this.document.removeEventListener?.('qerror', this.$qErrorHandler$);
+ this.$qErrorHandler$ = null;
+ }
this.vNodeLocate = () => null as any;
this.$rawStateData$.length = 0;
this.$stateData$.length = 0;
diff --git a/packages/qwik/src/core/index.ts b/packages/qwik/src/core/index.ts
index 58b1206d21d..e8d6a8766fc 100644
--- a/packages/qwik/src/core/index.ts
+++ b/packages/qwik/src/core/index.ts
@@ -160,11 +160,7 @@ export type { AsyncFn } from './use/use-async';
export { useAsyncQrl, useAsync$ } from './use/use-async';
export { useErrorBoundary } from './use/use-error-boundary';
export type { ErrorBoundaryStore } from './shared/error/error-handling';
-export {
- ErrorBoundary,
- errorBoundaryCmp as _ebC,
- errorBoundaryQrl as _ebL,
-} from './shared/error/error-boundary';
+export { ErrorBoundary, errorBoundaryCmp as _ebC } from './shared/error/error-boundary';
export type { ErrorBoundaryProps } from './shared/error/error-boundary';
export {
type ReadonlySignal,
diff --git a/packages/qwik/src/core/qwik.core.api.md b/packages/qwik/src/core/qwik.core.api.md
index 64910090cc9..1ce746b83c2 100644
--- a/packages/qwik/src/core/qwik.core.api.md
+++ b/packages/qwik/src/core/qwik.core.api.md
@@ -466,9 +466,6 @@ export const _eaT: (input: TaskCtx) => Promise;
// @internal (undocumented)
export const _ebC: (props: ErrorBoundaryProps) => JSXOutput;
-// @internal (undocumented)
-export const _ebL: (e: CustomEvent) => void;
-
// @internal (undocumented)
export const _EFFECT_BACK_REF: unique symbol;
@@ -504,12 +501,12 @@ export const ErrorBoundary: Component;
// @public (undocumented)
export interface ErrorBoundaryProps {
- // (undocumented)
- fallback$?: QRL<(error: any) => any>;
+ fallback$: QRL<(error: any) => any>;
}
// @public (undocumented)
export interface ErrorBoundaryStore {
+ $fallback$?: (error: any) => unknown;
// (undocumented)
error: any | undefined;
}
diff --git a/packages/qwik/src/core/shared/error/error-boundary.ts b/packages/qwik/src/core/shared/error/error-boundary.ts
index cf1f2a00732..9c150f57fc7 100644
--- a/packages/qwik/src/core/shared/error/error-boundary.ts
+++ b/packages/qwik/src/core/shared/error/error-boundary.ts
@@ -1,49 +1,42 @@
-import { _run } from '../../client/run-qrl';
import { useErrorBoundary } from '../../use/use-error-boundary';
-import { useOnWindow } from '../../use/use-on';
import { componentQrl, type Component } from '../component.public';
import { _jsxSorted } from '../jsx/jsx-internal';
import { Fragment } from '../jsx/jsx-runtime';
import { Slot } from '../jsx/slot.public';
import type { JSXOutput } from '../jsx/types/jsx-node';
import { inlinedQrl } from '../qrl/qrl';
-import { _captures, createQRL } from '../qrl/qrl-class';
import type { QRL } from '../qrl/qrl.public';
-import type { ErrorBoundaryStore } from './error-handling';
+import { noSerialize } from '../serdes/verify';
/** @public */
export interface ErrorBoundaryProps {
- fallback$?: QRL<(error: any) => any>;
+ /**
+ * Rendered in place of the subtree when a descendant throws during render. Lazily loaded — only
+ * fetched once the subtree errors — and receives the caught error.
+ */
+ fallback$: QRL<(error: any) => any>;
}
/*
* ErrorBoundary lives in core (not the router) so it ships with the framework. Core is not run
* through the Qwik optimizer, so we can't use `component$`/`$` here — the `$()` marker would survive
- * into the runtime and throw. Instead we build the QRLs by hand with `inlinedQrl` and export their
- * symbols from the core bundle (see `handlers.mjs` and qwik-vite's `manifest.ts`).
+ * into the runtime and throw. Instead we build the component QRL by hand with `inlinedQrl` and export
+ * its symbol from the core bundle (see `handlers.mjs` and qwik-vite's `manifest.ts`).
+ *
+ * Errors are routed to the CLOSEST boundary by the container's `handleError` (it resolves
+ * `ERROR_CONTEXT` and sets this store's `.error`); both synchronous render throws and async `qerror`
+ * events go through it. During SSR the fallback is rendered in place via `store.$fallback$`. So this
+ * component only reads its store and renders — there is no per-boundary `qerror` listener.
*/
-/** @internal */
-export const errorBoundaryQrl = (e: CustomEvent): void => {
- const store = _captures![0] as ErrorBoundaryStore;
- store.error = e.detail.error;
-};
-
/** @internal */
export const errorBoundaryCmp = (props: ErrorBoundaryProps): JSXOutput => {
const store = useErrorBoundary();
+ // Expose the fallback so SSR can render it in place when a child throws (the client re-renders this
+ // component instead, which reads `store.error` below). Server-render-only, hence noSerialize.
+ store.$fallback$ = noSerialize(props.fallback$);
- // The optimizer normally wraps event handlers that have captures in `_run` (see `setEvent`), but
- // it skips `_`-prefixed core symbols, so we wrap our listener ourselves to get the same capture
- // and invoke-context handling as a regular handler.
- useOnWindow(
- 'qerror',
- createQRL(null, '_run', _run, null, [
- /*#__PURE__*/ inlinedQrl(errorBoundaryQrl, '_ebL', [store]),
- ]) as unknown as QRL<(e: CustomEvent) => void>
- );
-
- if (store.error && props.fallback$) {
+ if (store.error) {
return /*#__PURE__*/ _jsxSorted(Fragment, null, null, props.fallback$(store.error), 0, null);
}
diff --git a/packages/qwik/src/core/shared/error/error-handling.ts b/packages/qwik/src/core/shared/error/error-handling.ts
index cb2701fa25b..9a31389749a 100644
--- a/packages/qwik/src/core/shared/error/error-handling.ts
+++ b/packages/qwik/src/core/shared/error/error-handling.ts
@@ -3,6 +3,12 @@ import { createContextId } from '../../use/use-context';
/** @public */
export interface ErrorBoundaryStore {
error: any | undefined;
+ /**
+ * The boundary's fallback renderer. Set by `` so SSR can render the fallback in
+ * place of a subtree that throws during server render (on the client the boundary re-renders
+ * instead). Internal — not meant to be set by application code.
+ */
+ $fallback$?: (error: any) => unknown;
}
export const ERROR_CONTEXT = /*#__PURE__*/ createContextId('qk-error');
diff --git a/packages/qwik/src/core/ssr/ssr-render-jsx.ts b/packages/qwik/src/core/ssr/ssr-render-jsx.ts
index 85553b54772..d155f455c72 100644
--- a/packages/qwik/src/core/ssr/ssr-render-jsx.ts
+++ b/packages/qwik/src/core/ssr/ssr-render-jsx.ts
@@ -43,6 +43,7 @@ import {
isInternalServerComponent,
} from './internal-server-component';
import { applyInlineComponent, applyQwikComponentBody } from './ssr-render-component';
+import { ERROR_CONTEXT } from '../shared/error/error-handling';
import type { ISsrComponentFrame, SSRContainer, SSRRenderJSXOptions } from './ssr-types';
import { resolveSlotName } from '../shared/utils/prop';
@@ -110,6 +111,27 @@ export async function _walkJSX(
await drain();
}
+/**
+ * A component threw during SSR render. Route it to the CLOSEST ErrorBoundary (matching the client's
+ * handleError): set the boundary's error and render its fallback in place of the failed subtree.
+ * With no boundary above, rethrow so the request aborts to the error page.
+ */
+function renderErrorBoundaryFallback(
+ ssr: SSRContainer,
+ host: ReturnType,
+ err: unknown
+): ValueOrPromise {
+ const errorStore = ssr.resolveContext(host, ERROR_CONTEXT);
+ // Only catch when the closest boundary exposes a fallback to render in place (SSR can't re-render
+ // the boundary). Boundaries without one — or no boundary at all — propagate, aborting to the error
+ // page, exactly as before.
+ if (!errorStore || !errorStore.$fallback$) {
+ throw err;
+ }
+ errorStore.error = err;
+ return errorStore.$fallback$(err) as ValueOrPromise;
+}
+
function processJSXNode(
ssr: SSRContainer,
enqueue: (value: StackValue) => void,
@@ -306,7 +328,12 @@ function processJSXNode(
options.parentComponentFrame
);
- const jsxOutput = applyQwikComponentBody(ssr, jsx, type);
+ let jsxOutput: ValueOrPromise;
+ try {
+ jsxOutput = applyQwikComponentBody(ssr, jsx, type);
+ } catch (err) {
+ jsxOutput = renderErrorBoundaryFallback(ssr, host, err);
+ }
enqueue(
setParentOptions(options, options.currentStyleScoped, options.parentComponentFrame)
);
@@ -333,12 +360,12 @@ function processJSXNode(
ssr.openFragment(inlineComponentProps);
enqueue(ssr.closeFragment);
const component = ssr.getParentComponentFrame();
- const jsxOutput = applyInlineComponent(
- ssr,
- component && component.componentNode,
- type,
- jsx
- );
+ let jsxOutput: ValueOrPromise;
+ try {
+ jsxOutput = applyInlineComponent(ssr, component && component.componentNode, type, jsx);
+ } catch (err) {
+ jsxOutput = renderErrorBoundaryFallback(ssr, ssr.getOrCreateLastNode(), err);
+ }
enqueue(jsxOutput);
isPromise(jsxOutput) && enqueue(Promise);
}
diff --git a/packages/qwik/src/core/tests/error-boundary.spec.tsx b/packages/qwik/src/core/tests/error-boundary.spec.tsx
index 21ff3a4843b..dc5ed1c175b 100644
--- a/packages/qwik/src/core/tests/error-boundary.spec.tsx
+++ b/packages/qwik/src/core/tests/error-boundary.spec.tsx
@@ -1,62 +1,93 @@
-import { domRender, ssrRenderToDom, trigger } from '@qwik.dev/core/testing';
+import { $, component$, ErrorBoundary } from '@qwik.dev/core';
+import { domRender, ssrRenderToDom, waitForDrain } from '@qwik.dev/core/testing';
import { describe, expect, it } from 'vitest';
-import { $, ErrorBoundary } from '@qwik.dev/core';
const debug = false;
-describe.each([
- { render: ssrRenderToDom }, //
- { render: domRender }, //
-])('$render.name: ErrorBoundary', ({ render }) => {
- it('should project children when there is no error', async () => {
- const { document } = await render(
+const Thrower = component$(() => {
+ throw new Error('boom');
+});
+
+describe('ErrorBoundary', () => {
+ it('projects children when there is no error', async () => {
+ const { container } = await domRender(
(
-
Caught: {error.message}
+ fallback$={$((e: any) => (
+
caught: {e.message}
))}
>
-
All good
+
All good
,
{ debug }
);
-
- expect(document.body.textContent).toContain('All good');
- expect(document.body.textContent).not.toContain('Caught');
+ expect(container.element.querySelector('#content')).toBeTruthy();
+ expect(container.element.querySelector('#fb')).toBeFalsy();
});
- it('should render the fallback when a qerror window event fires', async () => {
- const { container, document } = await render(
+ it('SSR: renders the fallback in place when a child throws during render', async () => {
+ const { container } = await ssrRenderToDom(
(
-
\n\n_(Optional)_ The boundary's fallback renderer. Set by `` so SSR can render the fallback in place of a subtree that throws during server render (on the client the boundary re-renders instead). Internal — not meant to be set by application code.\n\n\n
-
-_(Optional)_ The boundary's fallback renderer. Set by `` so SSR can render the fallback in place of a subtree that throws during server render (on the client the boundary re-renders instead). Internal — not meant to be set by application code.
-
-
-
-
-error
-
-
-
-
-
-any \| undefined
-
-
-
-
-
-
-[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/shared/error/error-handling.ts)
-
event$
```typescript
@@ -9803,18 +9748,6 @@ void
[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-context.ts)
-
```typescript
diff --git a/packages/docs/src/routes/docs/(qwik)/core/overview/index.mdx b/packages/docs/src/routes/docs/(qwik)/core/overview/index.mdx
index b6a16b61caf..f9807466e0d 100644
--- a/packages/docs/src/routes/docs/(qwik)/core/overview/index.mdx
+++ b/packages/docs/src/routes/docs/(qwik)/core/overview/index.mdx
@@ -550,13 +550,13 @@ Note the `string extends C`, this is only true when TypeScript cannot infer the
- [`$()`](../../advanced/qrl/index.mdx) - creates a QRL
- [`noSerialize()`](/docs/core/state/#noserialize)
-- `useErrorBoundary()`
- [`qwikVite()`](../../advanced/vite/index.mdx#qwikvite) - entry point for qwik with vite
### Components
- [``](../slots/index.mdx) - declares a content projection slot
- [``](/docs/labs/suspense/index.mdx) - shows fallback UI while part of the page waits
+- [``](/api/qwik/#errorboundary) - renders a fallback when a descendant throws
- `` - declares a stream block
- `` - declares a stream
- `` - declares a JSX fragment
diff --git a/packages/docs/src/routes/docs/labs/suspense/index.mdx b/packages/docs/src/routes/docs/labs/suspense/index.mdx
index e78af0acfe2..a80a6b2af9b 100644
--- a/packages/docs/src/routes/docs/labs/suspense/index.mdx
+++ b/packages/docs/src/routes/docs/labs/suspense/index.mdx
@@ -587,7 +587,7 @@ Props:
`` only handles content that is still loading. It does not catch errors thrown by child components.
-For errors thrown by descendants, reach for [`useErrorBoundary()`](/api/qwik/#useerrorboundary).
+For errors thrown by descendants, reach for [``](/api/qwik/#errorboundary).
## Choosing the Right Primitive
diff --git a/packages/qwik/public.d.ts b/packages/qwik/public.d.ts
index 1f6788df306..37fd3c81428 100644
--- a/packages/qwik/public.d.ts
+++ b/packages/qwik/public.d.ts
@@ -77,7 +77,6 @@ export {
useConstant,
useContext,
useContextProvider,
- useErrorBoundary,
useId,
useOn,
useOnDocument,
diff --git a/packages/qwik/src/core/index.ts b/packages/qwik/src/core/index.ts
index e8d6a8766fc..9e89597f128 100644
--- a/packages/qwik/src/core/index.ts
+++ b/packages/qwik/src/core/index.ts
@@ -158,8 +158,6 @@ export { useVisibleTask$ } from './use/use-visible-task-dollar';
export { useComputed$ } from './use/use-computed';
export type { AsyncFn } from './use/use-async';
export { useAsyncQrl, useAsync$ } from './use/use-async';
-export { useErrorBoundary } from './use/use-error-boundary';
-export type { ErrorBoundaryStore } from './shared/error/error-handling';
export { ErrorBoundary, errorBoundaryCmp as _ebC } from './shared/error/error-boundary';
export type { ErrorBoundaryProps } from './shared/error/error-boundary';
export {
diff --git a/packages/qwik/src/core/qwik.core.api.md b/packages/qwik/src/core/qwik.core.api.md
index 1ce746b83c2..97ff0597718 100644
--- a/packages/qwik/src/core/qwik.core.api.md
+++ b/packages/qwik/src/core/qwik.core.api.md
@@ -504,13 +504,6 @@ export interface ErrorBoundaryProps {
fallback$: QRL<(error: any) => any>;
}
-// @public (undocumented)
-export interface ErrorBoundaryStore {
- $fallback$?: (error: any) => unknown;
- // (undocumented)
- error: any | undefined;
-}
-
// @public (undocumented)
export const event$: (qrl: T) => QRL;
@@ -2109,9 +2102,6 @@ export const useContext: UseContext;
// @public
export const useContextProvider: (context: ContextId, newValue: STATE) => void;
-// @public (undocumented)
-export const useErrorBoundary: () => ErrorBoundaryStore;
-
// @internal
export function _useHmr(devPath: string): void;
diff --git a/packages/qwik/src/core/shared/error/error-boundary.ts b/packages/qwik/src/core/shared/error/error-boundary.ts
index 9c150f57fc7..53ddc5f77e5 100644
--- a/packages/qwik/src/core/shared/error/error-boundary.ts
+++ b/packages/qwik/src/core/shared/error/error-boundary.ts
@@ -1,4 +1,4 @@
-import { useErrorBoundary } from '../../use/use-error-boundary';
+import { useErrorBoundaryStore } from '../../use/use-error-boundary-store';
import { componentQrl, type Component } from '../component.public';
import { _jsxSorted } from '../jsx/jsx-internal';
import { Fragment } from '../jsx/jsx-runtime';
@@ -23,7 +23,9 @@ export interface ErrorBoundaryProps {
* into the runtime and throw. Instead we build the component QRL by hand with `inlinedQrl` and export
* its symbol from the core bundle (see `handlers.mjs` and qwik-vite's `manifest.ts`).
*
- * Errors are routed to the CLOSEST boundary by the container's `handleError` (it resolves
+ * The store is created and provided on `ERROR_CONTEXT` by the internal `useErrorBoundaryStore` hook
+ * (the old public `useErrorBoundary` was removed). Errors are routed to the CLOSEST boundary by the
+ * container's `handleError` (it resolves
* `ERROR_CONTEXT` and sets this store's `.error`); both synchronous render throws and async `qerror`
* events go through it. During SSR the fallback is rendered in place via `store.$fallback$`. So this
* component only reads its store and renders — there is no per-boundary `qerror` listener.
@@ -31,7 +33,7 @@ export interface ErrorBoundaryProps {
/** @internal */
export const errorBoundaryCmp = (props: ErrorBoundaryProps): JSXOutput => {
- const store = useErrorBoundary();
+ const store = useErrorBoundaryStore();
// Expose the fallback so SSR can render it in place when a child throws (the client re-renders this
// component instead, which reads `store.error` below). Server-render-only, hence noSerialize.
store.$fallback$ = noSerialize(props.fallback$);
diff --git a/packages/qwik/src/core/shared/error/error-handling.ts b/packages/qwik/src/core/shared/error/error-handling.ts
index 9a31389749a..8d7272b637b 100644
--- a/packages/qwik/src/core/shared/error/error-handling.ts
+++ b/packages/qwik/src/core/shared/error/error-handling.ts
@@ -1,6 +1,6 @@
import { createContextId } from '../../use/use-context';
-/** @public */
+/** @internal */
export interface ErrorBoundaryStore {
error: any | undefined;
/**
diff --git a/packages/qwik/src/core/tests/suspense.spec.tsx b/packages/qwik/src/core/tests/suspense.spec.tsx
index 0f9871be231..7f48bc914b6 100644
--- a/packages/qwik/src/core/tests/suspense.spec.tsx
+++ b/packages/qwik/src/core/tests/suspense.spec.tsx
@@ -18,7 +18,6 @@ import {
getDomContainer,
type JSXOutput,
useAsync$,
- useErrorBoundary,
Slot,
useTask$,
useSignal,
@@ -27,6 +26,7 @@ import {
Fragment as Signal,
} from '@qwik.dev/core';
import { ErrorProvider, emulateExecutionOfBackpatch } from '../../testing/rendering.unit-util';
+import { useErrorBoundaryStore } from '../use/use-error-boundary-store';
import { delay } from '../shared/utils/promises';
import { getScopedStyles } from '../shared/utils/scoped-stylesheet';
import { TypeIds } from '../shared/serdes/constants';
@@ -279,7 +279,7 @@ describe.each([
it('should bubble descendant throws to the nearest regular error boundary on the client', async () => {
const ErrorBoundary = component$(() => {
- const boundary = useErrorBoundary();
+ const boundary = useErrorBoundaryStore();
return boundary.error ?
Error: {(boundary.error as Error).message}
: ;
});
const BadChild = component$(() => {
diff --git a/packages/qwik/src/core/tests/use-async.spec.tsx b/packages/qwik/src/core/tests/use-async.spec.tsx
index 60c9ecc335f..d615bcf7327 100644
--- a/packages/qwik/src/core/tests/use-async.spec.tsx
+++ b/packages/qwik/src/core/tests/use-async.spec.tsx
@@ -7,7 +7,6 @@ import {
component$,
useAsync$,
useConstant,
- useErrorBoundary,
useSignal,
useTask$,
useVisibleTask$,
@@ -18,6 +17,7 @@ import { describe, expect, it } from 'vitest';
import type { AsyncSignalImpl } from '../reactive-primitives/impl/async-signal-impl';
import { NEEDS_COMPUTATION } from '../reactive-primitives/types';
import { delay } from '../shared/utils/promises';
+import { useErrorBoundaryStore } from '../use/use-error-boundary-store';
const debug = false; //true;
Error.stackTraceLimit = 100;
@@ -132,7 +132,7 @@ describe.each([
it('should throw error on value if promise is rejected', async () => {
(globalThis as any).log = [];
const ErrorBoundary = component$(() => {
- const store = useErrorBoundary();
+ const store = useErrorBoundaryStore();
(globalThis as any).log.push(`rendering error boundary, ${store.error || 'no error'}`);
return store.error ?
{JSON.stringify(store.error)}
: ;
});
diff --git a/packages/qwik/src/core/use/use-error-boundary.ts b/packages/qwik/src/core/use/use-error-boundary-store.ts
similarity index 83%
rename from packages/qwik/src/core/use/use-error-boundary.ts
rename to packages/qwik/src/core/use/use-error-boundary-store.ts
index dc5398c27d5..c0a8bb0dc31 100644
--- a/packages/qwik/src/core/use/use-error-boundary.ts
+++ b/packages/qwik/src/core/use/use-error-boundary-store.ts
@@ -2,8 +2,8 @@ import { ERROR_CONTEXT, type ErrorBoundaryStore } from '../shared/error/error-ha
import { useContextProvider } from './use-context';
import { useStore } from './use-store.public';
-/** @public */
-export const useErrorBoundary = () => {
+/** @internal */
+export const useErrorBoundaryStore = () => {
const error = useStore({ error: undefined });
useContextProvider(ERROR_CONTEXT, error);
From 9d18c103c44e7fd252cdb682d24cba1cb4bedaa6 Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Thu, 18 Jun 2026 13:24:17 +0200
Subject: [PATCH 004/331] feat(qwik): experimental out-of-order ErrorBoundary
SSR swap
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Behind the new `errorBoundary` experimental feature (with out-of-order
streaming), defers its subtree into an OOOS segment via a
fallback-less . 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.
---
.changeset/errorboundary-to-core.md | 1 +
.../src/routes/api/qwik-optimizer/api.json | 19 ++++-
.../src/routes/api/qwik-optimizer/index.mdx | 15 ++++
packages/qwik-vite/src/plugins/plugin.ts | 5 ++
packages/qwik-vite/src/qwik.optimizer.api.md | 1 +
.../qwik/src/core/control-flow/suspense.tsx | 76 ++++++++++++++++---
packages/qwik/src/core/qwik.core.api.md | 4 +-
.../src/core/shared/error/error-boundary.ts | 24 ++++++
.../src/core/shared/error/error-handling.ts | 26 +++++++
packages/qwik/src/core/ssr/ssr-render-jsx.ts | 8 +-
.../src/core/tests/error-boundary.spec.tsx | 69 ++++++++++++++++-
vitest.config.ts | 2 +-
12 files changed, 233 insertions(+), 17 deletions(-)
diff --git a/.changeset/errorboundary-to-core.md b/.changeset/errorboundary-to-core.md
index c15b92919a1..3da8752ce22 100644
--- a/.changeset/errorboundary-to-core.md
+++ b/.changeset/errorboundary-to-core.md
@@ -9,3 +9,4 @@
- The `useErrorBoundary()` hook is **removed** — `` is the single error-boundary surface.
- It catches errors only from **its own subtree**: the **closest** boundary handles an error, instead of every boundary on the page reacting to the global `qerror` event.
- It now catches render throws during **SSR** (rendering `fallback$` in place), not just on the client.
+- **Experimental** (opt-in `errorBoundary` feature + out-of-order streaming): on SSR the boundary defers its subtree into an out-of-order segment and swaps the fallback in on error, so the result matches the client's clean `boundary > fallback` without leftover streamed siblings.
diff --git a/packages/docs/src/routes/api/qwik-optimizer/api.json b/packages/docs/src/routes/api/qwik-optimizer/api.json
index 866bde9efc0..b7070ec460d 100644
--- a/packages/docs/src/routes/api/qwik-optimizer/api.json
+++ b/packages/docs/src/routes/api/qwik-optimizer/api.json
@@ -33,6 +33,23 @@
"content": "",
"mdFile": "qwik-vite.experimentalfeatures.each.md"
},
+ {
+ "name": "errorBoundary",
+ "id": "experimentalfeatures-errorboundary",
+ "hierarchy": [
+ {
+ "name": "ExperimentalFeatures",
+ "id": "experimentalfeatures-errorboundary"
+ },
+ {
+ "name": "errorBoundary",
+ "id": "experimentalfeatures-errorboundary"
+ }
+ ],
+ "kind": "EnumMember",
+ "content": "",
+ "mdFile": "qwik-vite.experimentalfeatures.errorboundary.md"
+ },
{
"name": "ExperimentalFeatures",
"id": "experimentalfeatures",
@@ -43,7 +60,7 @@
}
],
"kind": "Enum",
- "content": "Use `__EXPERIMENTAL__.x` to check if feature `x` is enabled. It will be replaced with `true` or `false` via an exact string replacement.\n\nAdd experimental features to this enum definition.\n\n\n```typescript\nexport declare enum ExperimentalFeatures \n```\n\n\n
\n\nMember\n\n\n
\n\nValue\n\n\n
\n\nDescription\n\n\n
\n
\n\neach\n\n\n
\n\n`\"each\"`\n\n\n
\n\nEnable the Each keyed-list primitive\n\n\n
\n
\n\ninsights\n\n\n
\n\n`\"insights\"`\n\n\n
\n\nEnable the ability to use the Qwik Insights vite plugin and `` component\n\n\n
\n
\n\nnoSPA\n\n\n
\n\n`\"noSPA\"`\n\n\n
\n\nDisable SPA navigation handler in Qwik Router\n\n\n
\n
\n\nshow\n\n\n
\n\n`\"show\"`\n\n\n
\n\nEnable the Show conditional primitive\n\n\n
\n
\n\nsuspense\n\n\n
\n\n`\"suspense\"`\n\n\n
\n\nEnable the Suspense fallback primitive\n\n\n
\n
\n\nvalibot\n\n\n
\n\n`\"valibot\"`\n\n\n
\n\nEnable the Valibot form validation\n\n\n
\n
",
+ "content": "Use `__EXPERIMENTAL__.x` to check if feature `x` is enabled. It will be replaced with `true` or `false` via an exact string replacement.\n\nAdd experimental features to this enum definition.\n\n\n```typescript\nexport declare enum ExperimentalFeatures \n```\n\n\n
\n\nMember\n\n\n
\n\nValue\n\n\n
\n\nDescription\n\n\n
\n
\n\neach\n\n\n
\n\n`\"each\"`\n\n\n
\n\nEnable the Each keyed-list primitive\n\n\n
\n
\n\nerrorBoundary\n\n\n
\n\n`\"errorBoundary\"`\n\n\n
\n\nEnable out-of-order streaming swap for `` (SSR renders a clean `boundary > fallback`)\n\n\n
\n
\n\ninsights\n\n\n
\n\n`\"insights\"`\n\n\n
\n\nEnable the ability to use the Qwik Insights vite plugin and `` component\n\n\n
\n
\n\nnoSPA\n\n\n
\n\n`\"noSPA\"`\n\n\n
\n\nDisable SPA navigation handler in Qwik Router\n\n\n
Use `__EXPERIMENTAL__.x` to check if feature `x` is enabled. It will be replaced with `true` or `false` via an exact string replacement.
@@ -62,6 +64,19 @@ Enable the Each keyed-list primitive
+errorBoundary
+
+
+
+`"errorBoundary"`
+
+
+
+Enable out-of-order streaming swap for `` (SSR renders a clean `boundary > fallback`)
+
+
+
+
insights
diff --git a/packages/qwik-vite/src/plugins/plugin.ts b/packages/qwik-vite/src/plugins/plugin.ts
index b7149b472cf..3f196ccf0fd 100644
--- a/packages/qwik-vite/src/plugins/plugin.ts
+++ b/packages/qwik-vite/src/plugins/plugin.ts
@@ -84,6 +84,11 @@ export enum ExperimentalFeatures {
show = 'show',
/** Enable the Suspense fallback primitive */
suspense = 'suspense',
+ /**
+ * Enable out-of-order streaming swap for `` (SSR renders a clean `boundary >
+ * fallback`)
+ */
+ errorBoundary = 'errorBoundary',
/** Enable the Valibot form validation */
valibot = 'valibot',
/** Disable SPA navigation handler in Qwik Router */
diff --git a/packages/qwik-vite/src/qwik.optimizer.api.md b/packages/qwik-vite/src/qwik.optimizer.api.md
index ee4d86a3cc3..bdbf982ab98 100644
--- a/packages/qwik-vite/src/qwik.optimizer.api.md
+++ b/packages/qwik-vite/src/qwik.optimizer.api.md
@@ -21,6 +21,7 @@ export type BundleGraphAdder = (manifest: QwikManifest) => Record
- emitRenderedOutOfOrderSegment(
- ssr,
- boundaryId,
- contentSegment,
- rendered,
- contentStyle,
- revealBoundary,
- boundaryState
+ content
+ .then((rendered) =>
+ emitRenderedOutOfOrderSegment(
+ ssr,
+ boundaryId,
+ contentSegment,
+ rendered,
+ contentStyle,
+ revealBoundary,
+ boundaryState
+ )
+ )
+ // A throw from an enclosing that deferred its subtree arrives here as a
+ // DeferredBoundaryError; swap that boundary's fallback into this same placeholder.
+ .catch((error) =>
+ emitOutOfOrderErrorFallback(
+ ssr,
+ boundaryId,
+ error,
+ options,
+ contentStyle,
+ revealBoundary,
+ boundaryState
+ )
)
- )
);
})
: null!;
@@ -333,6 +348,47 @@ async function emitRenderedOutOfOrderSegment(
});
}
+/**
+ * A deferred (out-of-order) segment rejected. If the throw came from an `` that
+ * deferred its subtree (a DeferredBoundaryError carrying the boundary store), render that
+ * boundary's fallback into a fresh segment and inject it into the same placeholder — the partial
+ * (failed) segment was already discarded by `ssr.segment`. Anything else rethrows, aborting as
+ * before.
+ */
+async function emitOutOfOrderErrorFallback(
+ ssr: SSRContainer,
+ boundaryId: number,
+ thrown: unknown,
+ options: SSRRenderJSXOptions,
+ contentStyle: Signal<{ display: string }>,
+ revealBoundary: OutOfOrderRevealBoundary | null,
+ boundaryState: SSROutOfOrderBoundaryState | null
+): Promise {
+ if (!__EXPERIMENTAL__.errorBoundary || !isDeferredBoundaryError(thrown)) {
+ throw thrown;
+ }
+ const { store, error } = thrown;
+ if (!store.$fallback$) {
+ throw error;
+ }
+ store.error = error;
+ const fallbackSegment = `${ssr.nextOutOfOrderId()}`;
+ const rendered = await ssr.segment(
+ fallbackSegment,
+ store.$fallback$(error) as JSXOutput,
+ options
+ );
+ return emitRenderedOutOfOrderSegment(
+ ssr,
+ boundaryId,
+ fallbackSegment,
+ rendered,
+ contentStyle,
+ revealBoundary,
+ boundaryState
+ );
+}
+
function markOutOfOrderContentResolved(boundaryState: SSROutOfOrderBoundaryState | null): void {
if (!boundaryState) {
return;
diff --git a/packages/qwik/src/core/qwik.core.api.md b/packages/qwik/src/core/qwik.core.api.md
index 97ff0597718..3538f92baca 100644
--- a/packages/qwik/src/core/qwik.core.api.md
+++ b/packages/qwik/src/core/qwik.core.api.md
@@ -1059,10 +1059,10 @@ export interface ReadonlySignal {
// Warning: (ae-forgotten-export) The symbol "SSRRevealSlotProps" needs to be exported by the entry point index.d.ts
//
// @internal (undocumented)
-export const _reC: (props: RevealProps) => JSXNodeInternal JSXNodeInternal> | JSXNodeInternal> | JSXNodeInternal>;
+}>>;
// @internal
export const _regSymbol: (symbol: any, hash: string) => any;
diff --git a/packages/qwik/src/core/shared/error/error-boundary.ts b/packages/qwik/src/core/shared/error/error-boundary.ts
index 53ddc5f77e5..b210441849b 100644
--- a/packages/qwik/src/core/shared/error/error-boundary.ts
+++ b/packages/qwik/src/core/shared/error/error-boundary.ts
@@ -1,3 +1,5 @@
+import { Suspense } from '../../control-flow/suspense';
+import { isOutOfOrderStreaming } from '../../control-flow/suspense-utils';
import { useErrorBoundaryStore } from '../../use/use-error-boundary-store';
import { componentQrl, type Component } from '../component.public';
import { _jsxSorted } from '../jsx/jsx-internal';
@@ -29,6 +31,12 @@ export interface ErrorBoundaryProps {
* `ERROR_CONTEXT` and sets this store's `.error`); both synchronous render throws and async `qerror`
* events go through it. During SSR the fallback is rendered in place via `store.$fallback$`. So this
* component only reads its store and renders — there is no per-boundary `qerror` listener.
+ *
+ * Experimental (`errorBoundary` feature, server + out-of-order streaming): instead of streaming the
+ * subtree in place, defer it through a fallback-less `` so it renders into an out-of-order
+ * segment. A throw there swaps the boundary's fallback into the same slot (see
+ * `emitOutOfOrderErrorFallback`), so SSR matches the client's clean `boundary > fallback` without
+ * blocking the shell's stream.
*/
/** @internal */
@@ -42,6 +50,22 @@ export const errorBoundaryCmp = (props: ErrorBoundaryProps): JSXOutput => {
return /*#__PURE__*/ _jsxSorted(Fragment, null, null, props.fallback$(store.error), 0, null);
}
+ // `isOutOfOrderStreaming()` is only true during an SSR out-of-order render (and false inside a
+ // segment), so this branch never runs on the client or re-nests.
+ if (__EXPERIMENTAL__.errorBoundary && isOutOfOrderStreaming()) {
+ // Mark this boundary as OOOS-deferred so the in-place SSR catch lets throws propagate to the
+ // segment swap (see renderErrorBoundaryFallback). noSerialize keeps the marker off the wire.
+ store.$deferred$ = noSerialize({});
+ return /*#__PURE__*/ _jsxSorted(
+ Suspense,
+ null,
+ null,
+ /*#__PURE__*/ _jsxSorted(Slot, null, null, null, 0, null),
+ 0,
+ null
+ );
+ }
+
return /*#__PURE__*/ _jsxSorted(Slot, null, null, null, 0, null);
};
diff --git a/packages/qwik/src/core/shared/error/error-handling.ts b/packages/qwik/src/core/shared/error/error-handling.ts
index 8d7272b637b..71933526523 100644
--- a/packages/qwik/src/core/shared/error/error-handling.ts
+++ b/packages/qwik/src/core/shared/error/error-handling.ts
@@ -9,10 +9,36 @@ export interface ErrorBoundaryStore {
* instead). Internal — not meant to be set by application code.
*/
$fallback$?: (error: any) => unknown;
+ /**
+ * Server-only marker (a `noSerialize` object): set when the boundary defers its subtree into an
+ * out-of-order segment (experimental `errorBoundary` feature). Tells the in-place SSR catch to
+ * let the throw propagate so the segment swap can render the fallback cleanly.
+ */
+ $deferred$?: object;
}
export const ERROR_CONTEXT = /*#__PURE__*/ createContextId('qk-error');
+const DEFERRED_ERROR = /*#__PURE__*/ Symbol('qDeferredBoundaryError');
+
+/**
+ * Wraps a throw from an out-of-order-deferred boundary's subtree, carrying the resolved boundary
+ * store so the segment swap can render its fallback without re-resolving the context.
+ */
+export interface DeferredBoundaryError {
+ [DEFERRED_ERROR]: true;
+ store: ErrorBoundaryStore;
+ error: unknown;
+}
+
+export const deferredBoundaryError = (
+ store: ErrorBoundaryStore,
+ error: unknown
+): DeferredBoundaryError => ({ [DEFERRED_ERROR]: true, store, error });
+
+export const isDeferredBoundaryError = (value: unknown): value is DeferredBoundaryError =>
+ !!value && typeof value === 'object' && (value as DeferredBoundaryError)[DEFERRED_ERROR] === true;
+
export const isRecoverable = (err: any) => {
if (err && err instanceof Error) {
if ('plugin' in err) {
diff --git a/packages/qwik/src/core/ssr/ssr-render-jsx.ts b/packages/qwik/src/core/ssr/ssr-render-jsx.ts
index d155f455c72..d24aea08dac 100644
--- a/packages/qwik/src/core/ssr/ssr-render-jsx.ts
+++ b/packages/qwik/src/core/ssr/ssr-render-jsx.ts
@@ -43,7 +43,7 @@ import {
isInternalServerComponent,
} from './internal-server-component';
import { applyInlineComponent, applyQwikComponentBody } from './ssr-render-component';
-import { ERROR_CONTEXT } from '../shared/error/error-handling';
+import { deferredBoundaryError, ERROR_CONTEXT } from '../shared/error/error-handling';
import type { ISsrComponentFrame, SSRContainer, SSRRenderJSXOptions } from './ssr-types';
import { resolveSlotName } from '../shared/utils/prop';
@@ -128,6 +128,12 @@ function renderErrorBoundaryFallback(
if (!errorStore || !errorStore.$fallback$) {
throw err;
}
+ // Experimental: a boundary that deferred its subtree into an out-of-order segment handles the
+ // throw via the segment swap (emitOutOfOrderErrorFallback), not in place. Carry the resolved store
+ // on the throw so the segment rejects and the swap can render a clean `boundary > fallback`.
+ if (__EXPERIMENTAL__.errorBoundary && errorStore.$deferred$) {
+ throw deferredBoundaryError(errorStore, err);
+ }
errorStore.error = err;
return errorStore.$fallback$(err) as ValueOrPromise;
}
diff --git a/packages/qwik/src/core/tests/error-boundary.spec.tsx b/packages/qwik/src/core/tests/error-boundary.spec.tsx
index dc5ed1c175b..ec323f8c27c 100644
--- a/packages/qwik/src/core/tests/error-boundary.spec.tsx
+++ b/packages/qwik/src/core/tests/error-boundary.spec.tsx
@@ -1,6 +1,7 @@
-import { $, component$, ErrorBoundary } from '@qwik.dev/core';
-import { domRender, ssrRenderToDom, waitForDrain } from '@qwik.dev/core/testing';
+import { $, component$, ErrorBoundary, type JSXOutput } from '@qwik.dev/core';
+import { createDocument, domRender, ssrRenderToDom, waitForDrain } from '@qwik.dev/core/testing';
import { describe, expect, it } from 'vitest';
+import { processOutOfOrderSegmentVNodeData } from '../client/process-vnode-data';
const debug = false;
@@ -91,3 +92,67 @@ describe('ErrorBoundary', () => {
expect(el.querySelector('#fb-outer')).toBeFalsy();
});
});
+
+describe('ErrorBoundary out-of-order streaming (experimental)', () => {
+ // Render with OOOS streaming, then run the emitted scripts to simulate the client injecting each
+ // resolved segment into its placeholder (mirrors the Suspense OOOS specs).
+ const renderOOOS = async (jsx: JSXOutput) => {
+ const chunks: string[] = [];
+ await ssrRenderToDom(jsx, {
+ stream: {
+ write: (c: string) => {
+ chunks.push(c);
+ },
+ },
+ streaming: { inOrder: { strategy: 'disabled' }, outOfOrder: true },
+ debug,
+ });
+ const document = createDocument({ html: chunks.join('') });
+ (document as any).qProcessOOOS = (boundaryId: number, content: Element | null) =>
+ processOutOfOrderSegmentVNodeData(document, String(boundaryId), content);
+ const scripts = Array.from(
+ document.querySelectorAll('script[type="text/javascript"]'),
+ (s) => s.textContent || ''
+ );
+ // eslint-disable-next-line no-new-func
+ new Function('document', scripts.join('\n'))(document);
+ return document;
+ };
+
+ it('swaps in the fallback with no leftover siblings when a descendant throws', async () => {
+ const document = await renderOOOS(
+
+ (
+
caught: {e.message}
+ ))}
+ >
+
before
+
+
after
+
+
+ );
+ // Unlike the in-place SSR path, the streamed `before`/`after` siblings are gone — the boundary
+ // rendered as a clean `boundary > fallback`, matching the client.
+ expect(document.querySelector('#fb')?.textContent).toContain('caught: boom');
+ expect(document.querySelector('#before')).toBeFalsy();
+ expect(document.querySelector('#after')).toBeFalsy();
+ });
+
+ it('streams the content (no fallback) when nothing throws', async () => {
+ const document = await renderOOOS(
+
+ (
+
fallback
+ ))}
+ >
+
all good
+
+
+ );
+ expect(document.querySelector('#content')?.textContent).toBe('all good');
+ expect(document.querySelector('#fb')).toBeFalsy();
+ });
+});
diff --git a/vitest.config.ts b/vitest.config.ts
index fdf9e280c7a..29bfad36036 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -17,7 +17,7 @@ export default defineConfig({
debug: !true,
srcDir: fromRoot('./packages/qwik/src'),
devTools: { hmr: false },
- experimental: ['each', 'show', 'suspense'],
+ experimental: ['each', 'show', 'suspense', 'errorBoundary'],
}),
tsconfigPaths({ ignoreConfigErrors: true }),
],
From a8399fa91596b1836a5700d02a6042fdccc79fff Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Thu, 18 Jun 2026 16:06:21 +0200
Subject: [PATCH 005/331] feat(qwik): SSR writer checkpoint/truncate primitive
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).
---
packages/qwik/src/core/ssr/ssr-types.ts | 7 ++
packages/qwik/src/core/ssr/stream-writer.ts | 19 ++++-
.../src/server/ssr-buffer-checkpoint.unit.ts | 69 +++++++++++++++++++
.../qwik/src/server/ssr-stream-handler.ts | 27 +++++++-
packages/qwik/src/server/ssr-stream-writer.ts | 12 ++++
5 files changed, 130 insertions(+), 4 deletions(-)
create mode 100644 packages/qwik/src/server/ssr-buffer-checkpoint.unit.ts
diff --git a/packages/qwik/src/core/ssr/ssr-types.ts b/packages/qwik/src/core/ssr/ssr-types.ts
index 9b360550023..10d789949c0 100644
--- a/packages/qwik/src/core/ssr/ssr-types.ts
+++ b/packages/qwik/src/core/ssr/ssr-types.ts
@@ -43,6 +43,13 @@ export interface SSRInternalStreamWriter extends StreamWriter {
writeRootRef(id: number): ValueOrPromise;
writeRootRefPath(path: number[]): ValueOrPromise;
toString(remap?: number[]): string;
+ /** Mark the current write position so a later `truncate` can discard everything written after it. */
+ checkpoint(): number;
+ /**
+ * Discard everything written since `checkpoint`. Only valid while the output is still buffered
+ * (no flush has happened in between) — e.g. inside a stream block or a segment writer.
+ */
+ truncate(checkpoint: number): void;
}
export interface ISsrNode {
diff --git a/packages/qwik/src/core/ssr/stream-writer.ts b/packages/qwik/src/core/ssr/stream-writer.ts
index b18e693c9c4..1e0df9d9021 100644
--- a/packages/qwik/src/core/ssr/stream-writer.ts
+++ b/packages/qwik/src/core/ssr/stream-writer.ts
@@ -23,7 +23,12 @@ export const writeStringRootRefPath = (
/** @internal */
export const createStringStreamWriter = (
- write: StreamWriter['write']
+ write: StreamWriter['write'],
+ /**
+ * Buffer checkpoint/truncate, supplied by a buffering owner (e.g. the stream handler operating on
+ * its stream-block buffer). When omitted, the writer streams straight through and cannot rewind.
+ */
+ ops?: { checkpoint(): number; truncate(checkpoint: number): void }
): SSRInternalStreamWriter => ({
write,
writeRootRef(id) {
@@ -32,4 +37,16 @@ export const createStringStreamWriter = (
writeRootRefPath(path) {
return writeStringRootRefPath(this, path);
},
+ checkpoint() {
+ if (!ops) {
+ throw new Error('This stream writer does not support checkpoint/truncate.');
+ }
+ return ops.checkpoint();
+ },
+ truncate(checkpoint) {
+ if (!ops) {
+ throw new Error('This stream writer does not support checkpoint/truncate.');
+ }
+ ops.truncate(checkpoint);
+ },
});
diff --git a/packages/qwik/src/server/ssr-buffer-checkpoint.unit.ts b/packages/qwik/src/server/ssr-buffer-checkpoint.unit.ts
new file mode 100644
index 00000000000..9fc1a300a39
--- /dev/null
+++ b/packages/qwik/src/server/ssr-buffer-checkpoint.unit.ts
@@ -0,0 +1,69 @@
+import { describe, expect, it } from 'vitest';
+import { StreamHandler } from './ssr-stream-handler';
+import { StringBufferSegmentWriter, StringSSRWriter } from './ssr-stream-writer';
+import type { RenderToStreamOptions } from './types';
+
+describe('SSR buffer checkpoint/truncate', () => {
+ it('StringSSRWriter discards back to a checkpoint', () => {
+ const w = new StringSSRWriter();
+ w.write('keep');
+ const cp = w.checkpoint();
+ w.write('DISCARD');
+ w.truncate(cp);
+ w.write('-more');
+ expect(w.toString()).toBe('keep-more');
+ });
+
+ it('StringBufferSegmentWriter discards back to a checkpoint', () => {
+ const w = new StringBufferSegmentWriter();
+ w.write('keep');
+ const cp = w.checkpoint();
+ w.write('DISCARD');
+ w.truncate(cp);
+ w.write('-more');
+ expect(w.toString()).toBe('keep-more');
+ });
+
+ const makeHandler = () => {
+ const out: string[] = [];
+ const opts = {
+ stream: {
+ write: (chunk: string) => {
+ out.push(chunk);
+ },
+ },
+ streaming: { inOrder: { strategy: 'disabled' } },
+ } as unknown as RenderToStreamOptions;
+ const handler = new StreamHandler(opts, { firstFlush: 0, render: 0, snapshot: 0 });
+ return { handler, out };
+ };
+
+ it('StreamHandler discards stream-block buffer back to a checkpoint', async () => {
+ const { handler, out } = makeHandler();
+ handler.streamBlockStart();
+ handler.stream.write('keep');
+ const cp = handler.stream.checkpoint();
+ handler.stream.write('DISCARD');
+ handler.stream.truncate(cp);
+ handler.stream.write('-more');
+ await handler.streamBlockEnd();
+ await handler.flush();
+ expect(out.join('')).toBe('keep-more');
+ });
+
+ it('StreamHandler supports nested checkpoints (inner truncate keeps outer)', async () => {
+ const { handler, out } = makeHandler();
+ handler.streamBlockStart();
+ handler.stream.write('outer-');
+ const outerCp = handler.stream.checkpoint();
+ handler.stream.write('inner-');
+ const innerCp = handler.stream.checkpoint();
+ handler.stream.write('DISCARD');
+ handler.stream.truncate(innerCp); // drop only the innermost write
+ handler.stream.write('end');
+ expect(handler.stream.checkpoint()).toBeGreaterThan(outerCp);
+ await handler.streamBlockEnd();
+ await handler.flush();
+ expect(out.join('')).toBe('outer-inner-end');
+ });
+});
diff --git a/packages/qwik/src/server/ssr-stream-handler.ts b/packages/qwik/src/server/ssr-stream-handler.ts
index 2a66f9789c8..dc1d62463e3 100644
--- a/packages/qwik/src/server/ssr-stream-handler.ts
+++ b/packages/qwik/src/server/ssr-stream-handler.ts
@@ -38,6 +38,12 @@ export class StreamHandler implements IStreamHandler {
private setupStreamWriter(): SSRInternalStreamWriter {
const handler = this;
+ // Checkpoint/truncate operate on the stream-block buffer, so they only rewind output that is
+ // still buffered (i.e. written while a stream block is open and not yet flushed).
+ const bufferOps = {
+ checkpoint: () => handler.streamCheckpoint(),
+ truncate: (checkpoint: number) => handler.streamTruncate(checkpoint),
+ };
let stream: SSRInternalStreamWriter;
switch (this.inOrderStreaming.strategy) {
case 'disabled':
@@ -46,7 +52,7 @@ export class StreamHandler implements IStreamHandler {
return;
}
handler.enqueue(chunk);
- });
+ }, bufferOps);
break;
case 'direct': {
const originalStream = this.nativeStream;
@@ -59,7 +65,7 @@ export class StreamHandler implements IStreamHandler {
return handler.trackPendingFlush(queued);
}
return handler.trackPendingFlush(originalStream.write(chunk));
- });
+ }, bufferOps);
break;
}
default:
@@ -81,7 +87,7 @@ export class StreamHandler implements IStreamHandler {
return handler.flush();
}
}
- });
+ }, bufferOps);
break;
}
}
@@ -156,6 +162,21 @@ export class StreamHandler implements IStreamHandler {
return this.pendingFlush;
}
+ /**
+ * Mark the current stream-block buffer position. Only meaningful while a stream block is open
+ * (`streamBlockDepth > 0`); the caller is responsible for suppressing flushes over the region it
+ * intends to be able to discard.
+ */
+ streamCheckpoint(): number {
+ return this.streamBlockBuffer.length;
+ }
+
+ /** Discard stream-block buffer content written since `checkpoint`. */
+ streamTruncate(checkpoint: number): void {
+ this.streamBlockBuffer = this.streamBlockBuffer.slice(0, checkpoint);
+ this.streamBlockBufferSize = this.streamBlockBuffer.length;
+ }
+
streamBlockStart() {
this.streamBlockDepth++;
}
diff --git a/packages/qwik/src/server/ssr-stream-writer.ts b/packages/qwik/src/server/ssr-stream-writer.ts
index 1dbfb096d4b..57210601d62 100644
--- a/packages/qwik/src/server/ssr-stream-writer.ts
+++ b/packages/qwik/src/server/ssr-stream-writer.ts
@@ -39,6 +39,12 @@ export class StringSSRWriter implements SSRInternalStreamWriter {
writeRootRefPath(path: number[]): void {
writeStringRootRefPath(this, path);
}
+ checkpoint(): number {
+ return this.buffer.length;
+ }
+ truncate(checkpoint: number): void {
+ this.buffer.length = checkpoint;
+ }
clear() {
this.buffer.length = 0;
}
@@ -58,6 +64,12 @@ export class StringBufferSegmentWriter implements SSRInternalStreamWriter {
writeRootRefPath(path: number[]): void {
this.chunks.push({ type: 'root-ref-path', localPath: path });
}
+ checkpoint(): number {
+ return this.chunks.length;
+ }
+ truncate(checkpoint: number): void {
+ this.chunks.length = checkpoint;
+ }
clear() {
this.chunks.length = 0;
}
From 91ec57d221e156c994ed59be413152d124be7ac0 Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Thu, 18 Jun 2026 16:22:55 +0200
Subject: [PATCH 006/331] feat(qwik): SSR container checkpoint/rollback
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 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.
---
packages/qwik/src/core/ssr/ssr-types.ts | 13 +++++
.../src/core/tests/ssr-checkpoint.spec.tsx | 37 ++++++++++++
packages/qwik/src/server/ssr-container.ts | 58 +++++++++++++++++++
3 files changed, 108 insertions(+)
create mode 100644 packages/qwik/src/core/tests/ssr-checkpoint.spec.tsx
diff --git a/packages/qwik/src/core/ssr/ssr-types.ts b/packages/qwik/src/core/ssr/ssr-types.ts
index 10d789949c0..5eb8535147f 100644
--- a/packages/qwik/src/core/ssr/ssr-types.ts
+++ b/packages/qwik/src/core/ssr/ssr-types.ts
@@ -87,6 +87,15 @@ export interface ISsrComponentFrame {
export type SymbolToChunkResolver = (symbol: string) => string;
+/**
+ * Opaque snapshot of the container's render state, taken by `checkpoint()` and restored by
+ * `rollback()`. Used to discard a partially-rendered subtree (e.g. an `` whose child
+ * threw) so the fallback can be rendered in its place. The shape is internal to the container.
+ */
+export interface SSRBufferCheckpoint {
+ readonly __brand: 'SSRBufferCheckpoint';
+}
+
export interface SSRRenderJSXOptions {
currentStyleScoped: string | null;
parentComponentFrame: ISsrComponentFrame | null;
@@ -153,6 +162,10 @@ export interface SSRContainer extends Container {
commentNode(text: string): void;
addRoot(obj: any): number | string | undefined;
getOrCreateLastNode(): ISsrNode;
+ /** Snapshot render state so a later `rollback` can discard everything rendered since. */
+ checkpoint(): SSRBufferCheckpoint;
+ /** Restore render state to a `checkpoint`, discarding HTML, vnode-data, nodes and roots since. */
+ rollback(checkpoint: SSRBufferCheckpoint): void;
addUnclaimedProjection(frame: ISsrComponentFrame, name: string, children: JSXChildren): void;
isStatic(): boolean;
render(jsx: JSXOutput): Promise;
diff --git a/packages/qwik/src/core/tests/ssr-checkpoint.spec.tsx b/packages/qwik/src/core/tests/ssr-checkpoint.spec.tsx
new file mode 100644
index 00000000000..9f1dc9ae506
--- /dev/null
+++ b/packages/qwik/src/core/tests/ssr-checkpoint.spec.tsx
@@ -0,0 +1,37 @@
+import { ssrRenderToDom } from '@qwik.dev/core/testing';
+import { describe, expect, it } from 'vitest';
+import { createInternalServerComponent } from '../ssr/internal-server-component';
+
+// Drives the container checkpoint/rollback through a real SSR render: render some content into a
+// stream block, snapshot, render a subtree, discard it, then render a replacement. The replacement
+// must end up where the discarded subtree was, and the result must still resume on the client.
+const Buffered = createInternalServerComponent(async (ssr, _jsx, options) => {
+ ssr.streamHandler.streamBlockStart();
+ await ssr.renderJSX(
, options);
+ await ssr.streamHandler.streamBlockEnd();
+});
+
+describe('SSR checkpoint/rollback', () => {
+ it('discards a rolled-back subtree, keeps prior output, renders the replacement, and resumes', async () => {
+ const { container } = await ssrRenderToDom(
+
+
+ ,
+ { debug: false }
+ );
+ const el = container.element;
+ expect(el.querySelector('#keep')?.textContent).toBe('keep');
+ expect(el.querySelector('#fb')?.textContent).toBe('fallback');
+ expect(el.querySelector('#discard')).toBeFalsy();
+ expect(el.querySelector('span')).toBeFalsy();
+ });
+});
diff --git a/packages/qwik/src/server/ssr-container.ts b/packages/qwik/src/server/ssr-container.ts
index b73556f6657..5a74d266fcd 100644
--- a/packages/qwik/src/server/ssr-container.ts
+++ b/packages/qwik/src/server/ssr-container.ts
@@ -82,6 +82,7 @@ import {
type ContextId,
type HostElement,
type InnerContainer,
+ type SSRBufferCheckpoint,
type SSRContainer as ISSRContainer,
type ISsrComponentFrame,
type ISsrNode,
@@ -212,6 +213,20 @@ interface ElementFrame {
refBase: number | string | null;
}
+/** Concrete shape behind the opaque {@link SSRBufferCheckpoint}. */
+interface ContainerCheckpoint {
+ writer: number;
+ vNodeDatasLength: number;
+ frame: ElementFrame | null;
+ frameVNodeData: VNodeData | null;
+ lastNode: ISsrNode | null;
+ currentComponentNode: ISsrNode | null;
+ componentNodeChildrenLength: number;
+ depthFirstElementCount: number;
+ componentStackLength: number;
+ rootsLength: number;
+}
+
const noopStreamHandler: IStreamHandler = {
flush() {},
waitForPendingFlush() {},
@@ -954,6 +969,49 @@ class SSRContainer extends _SharedContainer implements ISSRContainer {
return this.lastNode!;
}
+ checkpoint(): SSRBufferCheckpoint {
+ const frame = this.currentElementFrame;
+ const cp: ContainerCheckpoint = {
+ writer: this.writer.checkpoint(),
+ vNodeDatasLength: this.vNodeDatas.length,
+ frame,
+ frameVNodeData: frame ? (frame.vNodeData.slice() as VNodeData) : null,
+ lastNode: this.lastNode,
+ currentComponentNode: this.currentComponentNode,
+ componentNodeChildrenLength: this.currentComponentNode?.children?.length ?? 0,
+ depthFirstElementCount: this.depthFirstElementCount,
+ componentStackLength: this.componentStack.length,
+ rootsLength: this.serializationCtx.$roots$.length,
+ };
+ return cp as unknown as SSRBufferCheckpoint;
+ }
+
+ rollback(checkpoint: SSRBufferCheckpoint): void {
+ const cp = checkpoint as unknown as ContainerCheckpoint;
+ // Discard buffered HTML, vnode-data, nodes and roots produced since the checkpoint, then restore
+ // the cursor state so rendering can continue (e.g. with a fallback) as if the subtree never ran.
+ // Styles already emitted to and entries in the root dedup map are left as harmless
+ // orphans (the discarded objects are never referenced again).
+ this.writer.truncate(cp.writer);
+ this.vNodeDatas.length = cp.vNodeDatasLength;
+ this.currentElementFrame = cp.frame;
+ if (cp.frame && cp.frameVNodeData) {
+ // `vNodeDatas` holds this same array by reference, so restore its contents in place.
+ cp.frame.vNodeData.length = 0;
+ for (let i = 0; i < cp.frameVNodeData.length; i++) {
+ cp.frame.vNodeData.push(cp.frameVNodeData[i]);
+ }
+ }
+ this.lastNode = cp.lastNode;
+ this.currentComponentNode = cp.currentComponentNode;
+ if (cp.currentComponentNode?.children) {
+ cp.currentComponentNode.children.length = cp.componentNodeChildrenLength;
+ }
+ this.depthFirstElementCount = cp.depthFirstElementCount;
+ this.componentStack.length = cp.componentStackLength;
+ this.serializationCtx.$roots$.length = cp.rootsLength;
+ }
+
addUnclaimedProjection(frame: ISsrComponentFrame, name: string, children: JSXChildren): void {
// With inline emission, just add the children back to the frame's slots
// They will be emitted when the component closes
From eeadc58dcf2b799ec0df74505a5f2f79f856e3eb Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Thu, 18 Jun 2026 16:46:10 +0200
Subject: [PATCH 007/331] feat(qwik)!: ErrorBoundary self-buffers its SSR
subtree (buffer-and-swap)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
(rolls back within the segment), and nests (call-stack semantics).
ErrorBoundary itself is unaware of streaming again: drops the
auto-, $deferred$ marker, DeferredBoundaryError and the
suspense.tsx segment-rejection routing.
Known gap: a throw inside a dev-placed that sits *outside* the
boundary still aborts (the boundary already committed) — follow-up.
---
.changeset/errorboundary-to-core.md | 2 +-
.../qwik/src/core/control-flow/suspense.tsx | 76 +++----------------
packages/qwik/src/core/qwik.core.api.md | 4 +-
.../src/core/shared/error/error-boundary.ts | 28 ++-----
.../src/core/shared/error/error-handling.ts | 30 ++------
packages/qwik/src/core/ssr/ssr-render-jsx.ts | 63 +++++++++++++--
.../src/core/tests/error-boundary.spec.tsx | 75 +++++++++++++++---
packages/qwik/src/server/qwik-types.ts | 1 +
8 files changed, 145 insertions(+), 134 deletions(-)
diff --git a/.changeset/errorboundary-to-core.md b/.changeset/errorboundary-to-core.md
index 3da8752ce22..89d72d6406d 100644
--- a/.changeset/errorboundary-to-core.md
+++ b/.changeset/errorboundary-to-core.md
@@ -9,4 +9,4 @@
- The `useErrorBoundary()` hook is **removed** — `` is the single error-boundary surface.
- It catches errors only from **its own subtree**: the **closest** boundary handles an error, instead of every boundary on the page reacting to the global `qerror` event.
- It now catches render throws during **SSR** (rendering `fallback$` in place), not just on the client.
-- **Experimental** (opt-in `errorBoundary` feature + out-of-order streaming): on SSR the boundary defers its subtree into an out-of-order segment and swaps the fallback in on error, so the result matches the client's clean `boundary > fallback` without leftover streamed siblings.
+- **Experimental** (opt-in `errorBoundary` feature): on SSR the boundary buffers its subtree and, if a descendant throws, rolls it back and renders `fallback$` in its place — so SSR matches the client's clean `boundary > fallback` instead of leaving the partially-streamed subtree behind. Works with in-order streaming and inside ``, and nests.
diff --git a/packages/qwik/src/core/control-flow/suspense.tsx b/packages/qwik/src/core/control-flow/suspense.tsx
index 6ea6c6857fc..edae794583b 100644
--- a/packages/qwik/src/core/control-flow/suspense.tsx
+++ b/packages/qwik/src/core/control-flow/suspense.tsx
@@ -20,7 +20,6 @@ import {
import { resolveSlotName } from '../shared/utils/prop';
import { createInternalServerComponent } from '../ssr/internal-server-component';
import type { SSRContainer, SSROutOfOrderSegment, SSRRenderJSXOptions } from '../ssr/ssr-types';
-import { isDeferredBoundaryError } from '../shared/error/error-handling';
import { useComputedQrl } from '../use/use-computed';
import { useCursorBoundary, type CursorBoundary } from '../use/use-cursor-boundary';
import { useSignal } from '../use/use-signal';
@@ -262,31 +261,17 @@ const SSRDeferredSlot = __EXPERIMENTAL__.suspense
writeOutOfOrderPlaceholder(ssr, boundaryId);
ssr.emitOutOfOrderExecutorIfNeeded();
ssr.queueOutOfOrderSegment(
- content
- .then((rendered) =>
- emitRenderedOutOfOrderSegment(
- ssr,
- boundaryId,
- contentSegment,
- rendered,
- contentStyle,
- revealBoundary,
- boundaryState
- )
- )
- // A throw from an enclosing that deferred its subtree arrives here as a
- // DeferredBoundaryError; swap that boundary's fallback into this same placeholder.
- .catch((error) =>
- emitOutOfOrderErrorFallback(
- ssr,
- boundaryId,
- error,
- options,
- contentStyle,
- revealBoundary,
- boundaryState
- )
+ content.then((rendered) =>
+ emitRenderedOutOfOrderSegment(
+ ssr,
+ boundaryId,
+ contentSegment,
+ rendered,
+ contentStyle,
+ revealBoundary,
+ boundaryState
)
+ )
);
})
: null!;
@@ -348,47 +333,6 @@ async function emitRenderedOutOfOrderSegment(
});
}
-/**
- * A deferred (out-of-order) segment rejected. If the throw came from an `` that
- * deferred its subtree (a DeferredBoundaryError carrying the boundary store), render that
- * boundary's fallback into a fresh segment and inject it into the same placeholder — the partial
- * (failed) segment was already discarded by `ssr.segment`. Anything else rethrows, aborting as
- * before.
- */
-async function emitOutOfOrderErrorFallback(
- ssr: SSRContainer,
- boundaryId: number,
- thrown: unknown,
- options: SSRRenderJSXOptions,
- contentStyle: Signal<{ display: string }>,
- revealBoundary: OutOfOrderRevealBoundary | null,
- boundaryState: SSROutOfOrderBoundaryState | null
-): Promise {
- if (!__EXPERIMENTAL__.errorBoundary || !isDeferredBoundaryError(thrown)) {
- throw thrown;
- }
- const { store, error } = thrown;
- if (!store.$fallback$) {
- throw error;
- }
- store.error = error;
- const fallbackSegment = `${ssr.nextOutOfOrderId()}`;
- const rendered = await ssr.segment(
- fallbackSegment,
- store.$fallback$(error) as JSXOutput,
- options
- );
- return emitRenderedOutOfOrderSegment(
- ssr,
- boundaryId,
- fallbackSegment,
- rendered,
- contentStyle,
- revealBoundary,
- boundaryState
- );
-}
-
function markOutOfOrderContentResolved(boundaryState: SSROutOfOrderBoundaryState | null): void {
if (!boundaryState) {
return;
diff --git a/packages/qwik/src/core/qwik.core.api.md b/packages/qwik/src/core/qwik.core.api.md
index 3538f92baca..97ff0597718 100644
--- a/packages/qwik/src/core/qwik.core.api.md
+++ b/packages/qwik/src/core/qwik.core.api.md
@@ -1059,10 +1059,10 @@ export interface ReadonlySignal {
// Warning: (ae-forgotten-export) The symbol "SSRRevealSlotProps" needs to be exported by the entry point index.d.ts
//
// @internal (undocumented)
-export const _reC: (props: RevealProps) => JSXNodeInternal> | JSXNodeInternal JSXNodeInternal>;
+}>> | JSXNodeInternal>;
// @internal
export const _regSymbol: (symbol: any, hash: string) => any;
diff --git a/packages/qwik/src/core/shared/error/error-boundary.ts b/packages/qwik/src/core/shared/error/error-boundary.ts
index b210441849b..830f0896bb9 100644
--- a/packages/qwik/src/core/shared/error/error-boundary.ts
+++ b/packages/qwik/src/core/shared/error/error-boundary.ts
@@ -1,5 +1,3 @@
-import { Suspense } from '../../control-flow/suspense';
-import { isOutOfOrderStreaming } from '../../control-flow/suspense-utils';
import { useErrorBoundaryStore } from '../../use/use-error-boundary-store';
import { componentQrl, type Component } from '../component.public';
import { _jsxSorted } from '../jsx/jsx-internal';
@@ -32,11 +30,11 @@ export interface ErrorBoundaryProps {
* events go through it. During SSR the fallback is rendered in place via `store.$fallback$`. So this
* component only reads its store and renders — there is no per-boundary `qerror` listener.
*
- * Experimental (`errorBoundary` feature, server + out-of-order streaming): instead of streaming the
- * subtree in place, defer it through a fallback-less `` so it renders into an out-of-order
- * segment. A throw there swaps the boundary's fallback into the same slot (see
- * `emitOutOfOrderErrorFallback`), so SSR matches the client's clean `boundary > fallback` without
- * blocking the shell's stream.
+ * Experimental (`errorBoundary` feature): during SSR the container renders this boundary's subtree
+ * into a discardable buffer (see the buffering ErrorBoundary path in `ssr-render-jsx`). On a throw it
+ * rolls back the buffer and renders `fallback$` in place, so SSR matches the client's clean
+ * `boundary > fallback` instead of leaving the partially-streamed subtree behind. This component
+ * itself stays unaware of streaming — it only provides the store and renders.
*/
/** @internal */
@@ -50,22 +48,6 @@ export const errorBoundaryCmp = (props: ErrorBoundaryProps): JSXOutput => {
return /*#__PURE__*/ _jsxSorted(Fragment, null, null, props.fallback$(store.error), 0, null);
}
- // `isOutOfOrderStreaming()` is only true during an SSR out-of-order render (and false inside a
- // segment), so this branch never runs on the client or re-nests.
- if (__EXPERIMENTAL__.errorBoundary && isOutOfOrderStreaming()) {
- // Mark this boundary as OOOS-deferred so the in-place SSR catch lets throws propagate to the
- // segment swap (see renderErrorBoundaryFallback). noSerialize keeps the marker off the wire.
- store.$deferred$ = noSerialize({});
- return /*#__PURE__*/ _jsxSorted(
- Suspense,
- null,
- null,
- /*#__PURE__*/ _jsxSorted(Slot, null, null, null, 0, null),
- 0,
- null
- );
- }
-
return /*#__PURE__*/ _jsxSorted(Slot, null, null, null, 0, null);
};
diff --git a/packages/qwik/src/core/shared/error/error-handling.ts b/packages/qwik/src/core/shared/error/error-handling.ts
index 71933526523..0b694de5ad9 100644
--- a/packages/qwik/src/core/shared/error/error-handling.ts
+++ b/packages/qwik/src/core/shared/error/error-handling.ts
@@ -1,3 +1,4 @@
+import type { SSRBufferCheckpoint } from '../../ssr/ssr-types';
import { createContextId } from '../../use/use-context';
/** @internal */
@@ -10,35 +11,16 @@ export interface ErrorBoundaryStore {
*/
$fallback$?: (error: any) => unknown;
/**
- * Server-only marker (a `noSerialize` object): set when the boundary defers its subtree into an
- * out-of-order segment (experimental `errorBoundary` feature). Tells the in-place SSR catch to
- * let the throw propagate so the segment swap can render the fallback cleanly.
+ * Server-only. When the boundary buffers its subtree (experimental `errorBoundary` feature), this
+ * holds the render checkpoint taken at the start of its content. Its presence tells the in-place
+ * SSR catch to let the throw propagate to the boundary's nested render, which rolls back to this
+ * checkpoint and renders the fallback in place.
*/
- $deferred$?: object;
+ $checkpoint$?: SSRBufferCheckpoint;
}
export const ERROR_CONTEXT = /*#__PURE__*/ createContextId('qk-error');
-const DEFERRED_ERROR = /*#__PURE__*/ Symbol('qDeferredBoundaryError');
-
-/**
- * Wraps a throw from an out-of-order-deferred boundary's subtree, carrying the resolved boundary
- * store so the segment swap can render its fallback without re-resolving the context.
- */
-export interface DeferredBoundaryError {
- [DEFERRED_ERROR]: true;
- store: ErrorBoundaryStore;
- error: unknown;
-}
-
-export const deferredBoundaryError = (
- store: ErrorBoundaryStore,
- error: unknown
-): DeferredBoundaryError => ({ [DEFERRED_ERROR]: true, store, error });
-
-export const isDeferredBoundaryError = (value: unknown): value is DeferredBoundaryError =>
- !!value && typeof value === 'object' && (value as DeferredBoundaryError)[DEFERRED_ERROR] === true;
-
export const isRecoverable = (err: any) => {
if (err && err instanceof Error) {
if ('plugin' in err) {
diff --git a/packages/qwik/src/core/ssr/ssr-render-jsx.ts b/packages/qwik/src/core/ssr/ssr-render-jsx.ts
index d24aea08dac..144898cc25f 100644
--- a/packages/qwik/src/core/ssr/ssr-render-jsx.ts
+++ b/packages/qwik/src/core/ssr/ssr-render-jsx.ts
@@ -24,6 +24,7 @@ import { EMPTY_OBJ } from '../shared/utils/flyweight';
import { getFileLocationFromJsx } from '../shared/utils/jsx-filename';
import {
ELEMENT_KEY,
+ QCtxAttr,
QCursorBoundary,
QDefaultSlot,
QScopedStyle,
@@ -31,6 +32,7 @@ import {
QSlotParent,
qwikInspectorAttr,
} from '../shared/utils/markers';
+import { mapArray_get, mapArray_has } from '../client/util-mapArray';
import { isPromise, retryOnPromise } from '../shared/utils/promises';
import { qInspector } from '../shared/utils/qdev';
import { addComponentStylePrefix } from '../shared/utils/scoped-styles';
@@ -43,7 +45,7 @@ import {
isInternalServerComponent,
} from './internal-server-component';
import { applyInlineComponent, applyQwikComponentBody } from './ssr-render-component';
-import { deferredBoundaryError, ERROR_CONTEXT } from '../shared/error/error-handling';
+import { ERROR_CONTEXT, type ErrorBoundaryStore } from '../shared/error/error-handling';
import type { ISsrComponentFrame, SSRContainer, SSRRenderJSXOptions } from './ssr-types';
import { resolveSlotName } from '../shared/utils/prop';
@@ -128,16 +130,35 @@ function renderErrorBoundaryFallback(
if (!errorStore || !errorStore.$fallback$) {
throw err;
}
- // Experimental: a boundary that deferred its subtree into an out-of-order segment handles the
- // throw via the segment swap (emitOutOfOrderErrorFallback), not in place. Carry the resolved store
- // on the throw so the segment rejects and the swap can render a clean `boundary > fallback`.
- if (__EXPERIMENTAL__.errorBoundary && errorStore.$deferred$) {
- throw deferredBoundaryError(errorStore, err);
+ // Experimental: a buffering boundary handles the throw in its own nested render — it rolls back to
+ // its checkpoint and renders the fallback there. Propagate so the throw unwinds to that boundary
+ // instead of rendering in place here (which would leave the partially-streamed subtree behind).
+ if (__EXPERIMENTAL__.errorBoundary && errorStore.$checkpoint$) {
+ throw err;
}
errorStore.error = err;
return errorStore.$fallback$(err) as ValueOrPromise;
}
+/**
+ * Experimental: if `host` is itself an `` (it provided `ERROR_CONTEXT` with a
+ * fallback), return its store so the renderer can buffer the boundary's subtree and swap in the
+ * fallback on a throw. Reads the host's OWN context (not an ancestor's) so children don't match.
+ */
+function getBufferingErrorBoundaryStore(
+ host: ReturnType
+): ErrorBoundaryStore | null {
+ if (!__EXPERIMENTAL__.errorBoundary) {
+ return null;
+ }
+ const ctx = host.getProp(QCtxAttr) as Array | null;
+ if (!ctx || !mapArray_has(ctx, ERROR_CONTEXT.id, 0)) {
+ return null;
+ }
+ const store = mapArray_get(ctx, ERROR_CONTEXT.id, 0) as ErrorBoundaryStore | null;
+ return store && store.$fallback$ ? store : null;
+}
+
function processJSXNode(
ssr: SSRContainer,
enqueue: (value: StackValue) => void,
@@ -340,11 +361,39 @@ function processJSXNode(
} catch (err) {
jsxOutput = renderErrorBoundaryFallback(ssr, host, err);
}
+ const bufferingErrorStore = getBufferingErrorBoundaryStore(host);
enqueue(
setParentOptions(options, options.currentStyleScoped, options.parentComponentFrame)
);
enqueue(() => ssr.closeComponent());
- if (isPromise(jsxOutput)) {
+ if (bufferingErrorStore && !isPromise(jsxOutput)) {
+ // Experimental buffering ErrorBoundary: render the subtree in a nested pass so a throw
+ // unwinds here, where we roll back the partially-rendered output and render the fallback
+ // in its place — yielding a clean `boundary > fallback` instead of a half-streamed
+ // subtree. The stream block keeps the buffered HTML discardable (a no-op inside a
+ // segment, which is already buffered).
+ const content = jsxOutput as JSXOutput;
+ enqueue(async () => {
+ ssr.streamHandler.streamBlockStart();
+ const checkpoint = (bufferingErrorStore.$checkpoint$ = ssr.checkpoint());
+ const savedStyle = options.currentStyleScoped;
+ const savedFrame = options.parentComponentFrame;
+ try {
+ await ssr.renderJSX(content, options);
+ } catch (err) {
+ ssr.rollback(checkpoint);
+ options.currentStyleScoped = savedStyle;
+ options.parentComponentFrame = savedFrame;
+ bufferingErrorStore.error = err;
+ await ssr.renderJSX(bufferingErrorStore.$fallback$!(err) as JSXOutput, options);
+ } finally {
+ bufferingErrorStore.$checkpoint$ = undefined;
+ await ssr.streamHandler.streamBlockEnd();
+ }
+ });
+ const compStyleComponentId = addComponentStylePrefix(host.getProp(QScopedStyle));
+ enqueue(setParentOptions(options, compStyleComponentId, componentFrame));
+ } else if (isPromise(jsxOutput)) {
// Defer reading QScopedStyle until after the promise resolves
enqueue(async () => {
const resolvedOutput = await jsxOutput;
diff --git a/packages/qwik/src/core/tests/error-boundary.spec.tsx b/packages/qwik/src/core/tests/error-boundary.spec.tsx
index ec323f8c27c..d7bf046deb7 100644
--- a/packages/qwik/src/core/tests/error-boundary.spec.tsx
+++ b/packages/qwik/src/core/tests/error-boundary.spec.tsx
@@ -1,4 +1,4 @@
-import { $, component$, ErrorBoundary, type JSXOutput } from '@qwik.dev/core';
+import { $, component$, ErrorBoundary, Suspense, type JSXOutput } from '@qwik.dev/core';
import { createDocument, domRender, ssrRenderToDom, waitForDrain } from '@qwik.dev/core/testing';
import { describe, expect, it } from 'vitest';
import { processOutOfOrderSegmentVNodeData } from '../client/process-vnode-data';
@@ -93,10 +93,10 @@ describe('ErrorBoundary', () => {
});
});
-describe('ErrorBoundary out-of-order streaming (experimental)', () => {
- // Render with OOOS streaming, then run the emitted scripts to simulate the client injecting each
- // resolved segment into its placeholder (mirrors the Suspense OOOS specs).
- const renderOOOS = async (jsx: JSXOutput) => {
+describe('ErrorBoundary buffer-and-swap (experimental)', () => {
+ // Render with streaming, then run the emitted scripts to resume on the client (and inject any
+ // out-of-order Suspense segments into their placeholders, mirroring the Suspense OOOS specs).
+ const ssrRenderResumed = async (jsx: JSXOutput) => {
const chunks: string[] = [];
await ssrRenderToDom(jsx, {
stream: {
@@ -119,8 +119,8 @@ describe('ErrorBoundary out-of-order streaming (experimental)', () => {
return document;
};
- it('swaps in the fallback with no leftover siblings when a descendant throws', async () => {
- const document = await renderOOOS(
+ it('discards leftover siblings and swaps in the fallback when a descendant throws', async () => {
+ const document = await ssrRenderResumed(
(
@@ -133,26 +133,79 @@ describe('ErrorBoundary out-of-order streaming (experimental)', () => {
);
- // Unlike the in-place SSR path, the streamed `before`/`after` siblings are gone — the boundary
- // rendered as a clean `boundary > fallback`, matching the client.
+ // The partially-streamed `before`/`after` siblings are rolled back — the boundary renders as a
+ // clean `boundary > fallback`, matching the client (instead of the in-place divergence).
expect(document.querySelector('#fb')?.textContent).toContain('caught: boom');
expect(document.querySelector('#before')).toBeFalsy();
expect(document.querySelector('#after')).toBeFalsy();
});
- it('streams the content (no fallback) when nothing throws', async () => {
- const document = await renderOOOS(
+ it('renders the content unchanged when nothing throws', async () => {
+ const document = await ssrRenderResumed(
(
fallback
))}
>
+
before
all good
+
after
);
expect(document.querySelector('#content')?.textContent).toBe('all good');
+ expect(document.querySelector('#before')).toBeTruthy();
+ expect(document.querySelector('#after')).toBeTruthy();
expect(document.querySelector('#fb')).toBeFalsy();
});
+
+ it('works when the boundary is inside a (swaps within the segment)', async () => {
+ const document = await ssrRenderResumed(
+
+ loading}>
+ (
+
+
+
+
+ );
+ expect(document.querySelector('#fb-inner')).toBeTruthy();
+ expect(document.querySelector('#outer-sibling')).toBeTruthy();
+ expect(document.querySelector('#fb-outer')).toBeFalsy();
+ expect(document.querySelector('#before')).toBeFalsy();
+ expect(document.querySelector('#after')).toBeFalsy();
+ });
});
diff --git a/packages/qwik/src/server/qwik-types.ts b/packages/qwik/src/server/qwik-types.ts
index fc6ab51d538..e4a40e39045 100644
--- a/packages/qwik/src/server/qwik-types.ts
+++ b/packages/qwik/src/server/qwik-types.ts
@@ -26,6 +26,7 @@ export type {
ISsrNode,
SSRRootRefPathChunk,
SSRSegmentWriteChunk,
+ SSRBufferCheckpoint,
SSRContainer,
SSRSegmentContainer,
SSRInternalStreamWriter,
From 12bb5b2b8d36aa713d3619b05a1878ad84a51c48 Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Thu, 18 Jun 2026 17:31:05 +0200
Subject: [PATCH 008/331] feat(qwik): route async child-Suspense throws to the
ErrorBoundary fallback
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Closes the gap from the buffer-and-swap commit: when a boundary wraps a
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.
---
.changeset/errorboundary-to-core.md | 2 +-
.../qwik/src/core/control-flow/suspense.tsx | 81 ++++++++++++++++---
.../src/core/tests/error-boundary.spec.tsx | 27 +++++++
3 files changed, 99 insertions(+), 11 deletions(-)
diff --git a/.changeset/errorboundary-to-core.md b/.changeset/errorboundary-to-core.md
index 89d72d6406d..6c156f1381c 100644
--- a/.changeset/errorboundary-to-core.md
+++ b/.changeset/errorboundary-to-core.md
@@ -9,4 +9,4 @@
- The `useErrorBoundary()` hook is **removed** — `` is the single error-boundary surface.
- It catches errors only from **its own subtree**: the **closest** boundary handles an error, instead of every boundary on the page reacting to the global `qerror` event.
- It now catches render throws during **SSR** (rendering `fallback$` in place), not just on the client.
-- **Experimental** (opt-in `errorBoundary` feature): on SSR the boundary buffers its subtree and, if a descendant throws, rolls it back and renders `fallback$` in its place — so SSR matches the client's clean `boundary > fallback` instead of leaving the partially-streamed subtree behind. Works with in-order streaming and inside ``, and nests.
+- **Experimental** (opt-in `errorBoundary` feature): on SSR the boundary buffers its subtree and, if a descendant throws, rolls it back and renders `fallback$` in its place — so SSR matches the client's clean `boundary > fallback` instead of leaving the partially-streamed subtree behind. Works with in-order streaming, inside ``, and nested; an async throw from a child `` is routed to the boundary's fallback (injected into the Suspense slot) instead of aborting the render.
diff --git a/packages/qwik/src/core/control-flow/suspense.tsx b/packages/qwik/src/core/control-flow/suspense.tsx
index edae794583b..a13c318abb1 100644
--- a/packages/qwik/src/core/control-flow/suspense.tsx
+++ b/packages/qwik/src/core/control-flow/suspense.tsx
@@ -20,6 +20,7 @@ import {
import { resolveSlotName } from '../shared/utils/prop';
import { createInternalServerComponent } from '../ssr/internal-server-component';
import type { SSRContainer, SSROutOfOrderSegment, SSRRenderJSXOptions } from '../ssr/ssr-types';
+import { ERROR_CONTEXT, type ErrorBoundaryStore } from '../shared/error/error-handling';
import { useComputedQrl } from '../use/use-computed';
import { useCursorBoundary, type CursorBoundary } from '../use/use-cursor-boundary';
import { useSignal } from '../use/use-signal';
@@ -252,6 +253,17 @@ const SSRDeferredSlot = __EXPERIMENTAL__.suspense
const boundaryState = jsx.varProps.boundary as SSROutOfOrderBoundaryState | null;
const contentStyle = jsx.varProps.contentStyle as Signal<{ display: string }>;
const revealBoundary = jsx.varProps.reveal as OutOfOrderRevealBoundary | null;
+ // Capture the nearest enclosing up front. If this deferred segment later
+ // rejects (e.g. an async child threw after the placeholder was already streamed), route that
+ // boundary's fallback into this same placeholder instead of aborting the whole render. The
+ // boundary's own buffer-and-swap can't catch this because it already committed once the
+ // Suspense placeholder rendered. Gated by the experimental errorBoundary feature.
+ const errorBoundaryStore =
+ __EXPERIMENTAL__.errorBoundary && options.parentComponentFrame
+ ? (ssr.resolveContext(options.parentComponentFrame.componentNode, ERROR_CONTEXT) as
+ | ErrorBoundaryStore
+ | undefined)
+ : undefined;
const content = ssr.segment(
contentSegment,
createClaimedDeferredSlot(ssr, jsx, options),
@@ -261,17 +273,33 @@ const SSRDeferredSlot = __EXPERIMENTAL__.suspense
writeOutOfOrderPlaceholder(ssr, boundaryId);
ssr.emitOutOfOrderExecutorIfNeeded();
ssr.queueOutOfOrderSegment(
- content.then((rendered) =>
- emitRenderedOutOfOrderSegment(
- ssr,
- boundaryId,
- contentSegment,
- rendered,
- contentStyle,
- revealBoundary,
- boundaryState
+ content
+ .then((rendered) =>
+ emitRenderedOutOfOrderSegment(
+ ssr,
+ boundaryId,
+ contentSegment,
+ rendered,
+ contentStyle,
+ revealBoundary,
+ boundaryState
+ )
)
- )
+ .catch((error) => {
+ if (errorBoundaryStore && errorBoundaryStore.$fallback$) {
+ return emitOutOfOrderErrorFallback(
+ ssr,
+ boundaryId,
+ errorBoundaryStore,
+ error,
+ options,
+ contentStyle,
+ revealBoundary,
+ boundaryState
+ );
+ }
+ throw error;
+ })
);
})
: null!;
@@ -333,6 +361,39 @@ async function emitRenderedOutOfOrderSegment(
});
}
+/**
+ * A deferred segment rejected inside an enclosing . Render that boundary's fallback
+ * into a fresh segment and inject it into the same placeholder — the failed content segment was
+ * already discarded by `ssr.segment`, so this is a clean swap of content for fallback.
+ */
+async function emitOutOfOrderErrorFallback(
+ ssr: SSRContainer,
+ boundaryId: number,
+ store: ErrorBoundaryStore,
+ error: unknown,
+ options: SSRRenderJSXOptions,
+ contentStyle: Signal<{ display: string }>,
+ revealBoundary: OutOfOrderRevealBoundary | null,
+ boundaryState: SSROutOfOrderBoundaryState | null
+): Promise {
+ store.error = error;
+ const fallbackSegment = `${ssr.nextOutOfOrderId()}`;
+ const rendered = await ssr.segment(
+ fallbackSegment,
+ store.$fallback$!(error) as JSXOutput,
+ options
+ );
+ return emitRenderedOutOfOrderSegment(
+ ssr,
+ boundaryId,
+ fallbackSegment,
+ rendered,
+ contentStyle,
+ revealBoundary,
+ boundaryState
+ );
+}
+
function markOutOfOrderContentResolved(boundaryState: SSROutOfOrderBoundaryState | null): void {
if (!boundaryState) {
return;
diff --git a/packages/qwik/src/core/tests/error-boundary.spec.tsx b/packages/qwik/src/core/tests/error-boundary.spec.tsx
index d7bf046deb7..a6349207d85 100644
--- a/packages/qwik/src/core/tests/error-boundary.spec.tsx
+++ b/packages/qwik/src/core/tests/error-boundary.spec.tsx
@@ -9,6 +9,13 @@ const Thrower = component$(() => {
throw new Error('boom');
});
+// Throws *asynchronously*: a rejected promise child resolves after the Suspense placeholder has
+// already streamed, so the throw surfaces as the deferred segment's rejection.
+const AsyncThrower = component$(() => {
+ const pending = new Promise((_resolve, reject) => reject(new Error('async boom')));
+ return <>{pending}>;
+});
+
describe('ErrorBoundary', () => {
it('projects children when there is no error', async () => {
const { container } = await domRender(
@@ -208,4 +215,24 @@ describe('ErrorBoundary buffer-and-swap (experimental)', () => {
expect(document.querySelector('#before')).toBeFalsy();
expect(document.querySelector('#after')).toBeFalsy();
});
+
+ it('routes a deferred (async) throw inside a child to the boundary fallback', async () => {
+ // The boundary commits once the Suspense placeholder streams, so its own buffer can't catch the
+ // later async throw; the rejected segment is instead routed to the boundary's fallback, injected
+ // into the Suspense slot — no aborted render.
+ const document = await ssrRenderResumed(
+
+ (
+
caught: {String(e?.message ?? e)}
+ ))}
+ >
+ loading}>
+
+
+
+
+ );
+ expect(document.querySelector('#fb')?.textContent).toContain('caught: async boom');
+ });
});
From 63314c81ed65e33705da2e529f5e74a120c774cf Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Thu, 18 Jun 2026 18:44:31 +0200
Subject: [PATCH 009/331] =?UTF-8?q?test(qwik):=20clean=20skeleton=E2=86=92?=
=?UTF-8?q?fallback=20swap=20for=20async=20throw=20with=20boundary=20insid?=
=?UTF-8?q?e=20Suspense?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Locks the composition 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.
---
.../src/core/tests/error-boundary.spec.tsx | 26 +++++++++++++++++++
1 file changed, 26 insertions(+)
diff --git a/packages/qwik/src/core/tests/error-boundary.spec.tsx b/packages/qwik/src/core/tests/error-boundary.spec.tsx
index a6349207d85..3d1c058c658 100644
--- a/packages/qwik/src/core/tests/error-boundary.spec.tsx
+++ b/packages/qwik/src/core/tests/error-boundary.spec.tsx
@@ -235,4 +235,30 @@ describe('ErrorBoundary buffer-and-swap (experimental)', () => {
);
expect(document.querySelector('#fb')?.textContent).toContain('caught: async boom');
});
+
+ it('boundary inside a : an async throw rolls back the WHOLE content (skeleton → fallback)', async () => {
+ // Here the entire boundary subtree is deferred behind the Suspense skeleton, so the user only
+ // ever sees the skeleton — never the broken/partial content. The async throw rolls the whole
+ // subtree back within the segment (siblings included), and the segment resolves to the fallback,
+ // which is injected in place of the skeleton: a clean skeleton → fallback swap, no leaked
+ // siblings, no broken-content flash (and no CLS if skeleton/fallback/content share a box).
+ const document = await ssrRenderResumed(
+
+ loading}>
+ (
+
caught: {String(e?.message ?? e)}
+ ))}
+ >
+
before
+
+
after
+
+
+
+ );
+ expect(document.querySelector('#fb')?.textContent).toContain('caught: async boom');
+ expect(document.querySelector('#before')).toBeFalsy();
+ expect(document.querySelector('#after')).toBeFalsy();
+ });
});
From 083d4c445daa3861430e508b0bd4f375cb3751da Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Thu, 18 Jun 2026 23:22:42 +0200
Subject: [PATCH 010/331] feat(qwik)!: ErrorBoundary streams and swaps to
fallback via inline qO (never blocks streaming)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replaces the buffer-and-swap SSR approach: a live 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 throw tears the WHOLE boundary down to fallback$
(store.$emitFallback$), not into the Suspense sub-slot.
- A boundary inside a 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.
---
.changeset/errorboundary-to-core.md | 2 +-
.../references/core-notes.md | 33 +++++
.../qwik/src/core/control-flow/suspense.tsx | 93 +++++++-----
packages/qwik/src/core/qwik.core.api.md | 4 +-
.../src/core/shared/error/error-boundary.ts | 63 +++++++--
.../src/core/shared/error/error-handling.ts | 17 ++-
packages/qwik/src/core/ssr/ssr-render-jsx.ts | 27 +++-
.../src/core/tests/error-boundary.spec.tsx | 133 ++++++++++--------
8 files changed, 251 insertions(+), 121 deletions(-)
diff --git a/.changeset/errorboundary-to-core.md b/.changeset/errorboundary-to-core.md
index 6c156f1381c..7d092abe103 100644
--- a/.changeset/errorboundary-to-core.md
+++ b/.changeset/errorboundary-to-core.md
@@ -9,4 +9,4 @@
- The `useErrorBoundary()` hook is **removed** — `` is the single error-boundary surface.
- It catches errors only from **its own subtree**: the **closest** boundary handles an error, instead of every boundary on the page reacting to the global `qerror` event.
- It now catches render throws during **SSR** (rendering `fallback$` in place), not just on the client.
-- **Experimental** (opt-in `errorBoundary` feature): on SSR the boundary buffers its subtree and, if a descendant throws, rolls it back and renders `fallback$` in its place — so SSR matches the client's clean `boundary > fallback` instead of leaving the partially-streamed subtree behind. Works with in-order streaming, inside ``, and nested; an async throw from a child `` is routed to the boundary's fallback (injected into the Suspense slot) instead of aborting the render.
+- **Experimental** (opt-in `errorBoundary` feature, with `suspense` + out-of-order streaming): on SSR the boundary **never blocks streaming**. Its subtree streams inside a visible content host next to a hidden fallback host; if a descendant throws, `fallback$` is streamed as an out-of-order segment and an inline script (the shared Suspense `qO` executor) hides the content host and reveals the fallback — the swap happens as the error chunk is parsed, before the framework resumes, so it never waits on the client runtime. A deferred throw from a child `` tears the **whole** boundary down to `fallback$` (the fallback replaces the boundary, not a sub-slot of it). Size the fallback to the content box for zero CLS. A boundary placed *inside* a `` instead buffers within that already-deferred segment (clean skeleton → fallback).
diff --git a/.ruler/skills/qwik-core-development/references/core-notes.md b/.ruler/skills/qwik-core-development/references/core-notes.md
index 3a285440f60..35bf23b7d26 100644
--- a/.ruler/skills/qwik-core-development/references/core-notes.md
+++ b/.ruler/skills/qwik-core-development/references/core-notes.md
@@ -168,6 +168,39 @@ streaming, navigation, or integration with fixture apps. For Qwik e2e, load
Never use `pnpm test.unit` for agent verification in this repo.
+## ErrorBoundary Streaming (experimental `errorBoundary`)
+
+`` must NEVER block streaming — people nest it freely, so a buffering boundary would
+silently kill a page's streaming. On SSR with out-of-order streaming the boundary (`errorBoundaryCmp`
+in `shared/error/error-boundary.ts`) renders two display-toggled hosts modeled on Suspense:
+
+- a visible content host (`
` wrapping ``), and
+- a hidden fallback host (`
` containing `SSRErrorFallback`).
+
+On a throw, `renderErrorBoundaryFallback` (in `ssr/ssr-render-jsx.ts`) just sets `store.error` and
+renders nothing in place. The fallback host (rendered after the content) streams `fallback$(error)`
+as an out-of-order **segment** and reuses the Suspense pipeline verbatim:
+`writeOutOfOrderResolvedTemplate` + `emitInlineScript('qO(id)')`. The shared `qO` executor
+(`out-of-order-executor-shared.ts`) reveals the `q:rp` host and hides its previous sibling — which is
+exactly content-host-hidden + fallback-host-shown, no executor change. Resume consistency comes for
+free via `qProcessOOOS` / `processOutOfOrderSegmentVNodeData`. Display is driven by `_fnSignal`
+reading `store.error`, so the resumed/re-rendered boundary stays consistent with the inline swap.
+
+Key invariants:
+
+- The swap is an inline script (fires as its chunk parses) — it does NOT depend on the client
+ re-rendering, so it works before the framework resumes. Client-time errors still use the reactive
+ re-render path (`handleError` → `store.error`).
+- A deferred child `` that throws routes to the enclosing boundary's `store.$emitFallback$`
+ (set by `SSRErrorFallback`), tearing the whole boundary down — not into the Suspense sub-slot.
+- A boundary *inside* a `` segment is the one case that still buffers (the segment is
+ already buffered, so it doesn't block the shell): `getBufferingErrorBoundaryStore` gates buffering
+ on `isOutOfOrderSegmentContainer`.
+- Tests must assert BOTH the streamed HTML (content was not blocked) and the post-`qO` DOM
+ (content host `display:none`, fallback host `display:contents`). Resume helpers must filter the
+ emitted scripts to `qO`/`qInstallOOOS` (running every `text/javascript` script hits debug-build
+ refs). See `core/tests/error-boundary.spec.tsx`.
+
## Keep This Reference Fresh
Before finishing a core task, ask:
diff --git a/packages/qwik/src/core/control-flow/suspense.tsx b/packages/qwik/src/core/control-flow/suspense.tsx
index a13c318abb1..5ce37841d6a 100644
--- a/packages/qwik/src/core/control-flow/suspense.tsx
+++ b/packages/qwik/src/core/control-flow/suspense.tsx
@@ -18,6 +18,7 @@ import {
QSuspenseResultParent,
} from '../shared/utils/markers';
import { resolveSlotName } from '../shared/utils/prop';
+import { noSerialize } from '../shared/serdes/verify';
import { createInternalServerComponent } from '../ssr/internal-server-component';
import type { SSRContainer, SSROutOfOrderSegment, SSRRenderJSXOptions } from '../ssr/ssr-types';
import { ERROR_CONTEXT, type ErrorBoundaryStore } from '../shared/error/error-handling';
@@ -286,17 +287,12 @@ const SSRDeferredSlot = __EXPERIMENTAL__.suspense
)
)
.catch((error) => {
- if (errorBoundaryStore && errorBoundaryStore.$fallback$) {
- return emitOutOfOrderErrorFallback(
- ssr,
- boundaryId,
- errorBoundaryStore,
- error,
- options,
- contentStyle,
- revealBoundary,
- boundaryState
- );
+ // A deferred child threw after its placeholder streamed, so the enclosing boundary
+ // already committed and can't catch it in place. Route it to that boundary's fallback
+ // host instead: the whole boundary (this Suspense included) is torn down to `fallback$`,
+ // matching the boundary's intent rather than wedging the fallback into this slot.
+ if (errorBoundaryStore && errorBoundaryStore.$emitFallback$) {
+ return errorBoundaryStore.$emitFallback$(error);
}
throw error;
})
@@ -362,36 +358,55 @@ async function emitRenderedOutOfOrderSegment(
}
/**
- * A deferred segment rejected inside an enclosing . Render that boundary's fallback
- * into a fresh segment and inject it into the same placeholder — the failed content segment was
- * already discarded by `ssr.segment`, so this is a clean swap of content for fallback.
+ * SSR host for a streaming ``'s fallback. Lives inside the boundary's hidden
+ * fallback host (`q:rp={boundaryId}`) and writes the out-of-order placeholder. When the boundary's
+ * subtree throws — synchronously during the content render, or later from a deferred child
+ * `` — `store.$emitFallback$` streams `fallback$(error)` as an out-of-order segment and
+ * the shared `qO` executor injects it here while hiding the content host. The boundary never blocks
+ * streaming: its content streams first, then the fallback swaps in if (and only if) it errors.
*/
-async function emitOutOfOrderErrorFallback(
+export const SSRErrorFallback = __EXPERIMENTAL__.errorBoundary
+ ? /*#__PURE__*/ createInternalServerComponent<{ boundaryId: number; store: ErrorBoundaryStore }>(
+ (ssr, jsx, options) => {
+ const boundaryId = jsx.varProps.boundaryId as number;
+ const store = jsx.varProps.store as ErrorBoundaryStore;
+ const segmentId = `${boundaryId}`;
+ const emitFallback = (error: unknown): Promise => {
+ store.error = error;
+ ssr.emitOutOfOrderExecutorIfNeeded();
+ const rendered = ssr.segment(segmentId, store.$fallback$!(error) as JSXOutput, options);
+ const emission = rendered.then((segment) =>
+ emitErrorBoundaryFallback(ssr, boundaryId, segmentId, segment)
+ );
+ ssr.queueOutOfOrderSegment(emission);
+ return emission;
+ };
+ store.$emitFallback$ = noSerialize(emitFallback);
+ writeOutOfOrderPlaceholder(ssr, boundaryId);
+ // A throw during the content render already set `store.error` before this host renders
+ // (the content host is our previous sibling), so stream the fallback now.
+ if (store.error != null && store.$fallback$) {
+ emitFallback(store.error);
+ }
+ }
+ )
+ : null!;
+
+/** Inject a streamed `` fallback segment into its fallback host via `qO`. */
+async function emitErrorBoundaryFallback(
ssr: SSRContainer,
boundaryId: number,
- store: ErrorBoundaryStore,
- error: unknown,
- options: SSRRenderJSXOptions,
- contentStyle: Signal<{ display: string }>,
- revealBoundary: OutOfOrderRevealBoundary | null,
- boundaryState: SSROutOfOrderBoundaryState | null
+ segmentId: string,
+ rendered: SSROutOfOrderSegment
): Promise {
- store.error = error;
- const fallbackSegment = `${ssr.nextOutOfOrderId()}`;
- const rendered = await ssr.segment(
- fallbackSegment,
- store.$fallback$!(error) as JSXOutput,
- options
- );
- return emitRenderedOutOfOrderSegment(
- ssr,
- boundaryId,
- fallbackSegment,
- rendered,
- contentStyle,
- revealBoundary,
- boundaryState
- );
+ await ssr.$runQueuedRender$(async () => {
+ const result = await rendered.container.$finalizeOutOfOrderSegment$(segmentId, rendered);
+ writeOutOfOrderResolvedTemplate(ssr, boundaryId, result.html, null);
+ ssr.emitOutOfOrderSegmentScripts(result.scripts);
+ ssr.emitInlineScript(`qO(${boundaryId})`);
+ // qO() is the browser-visible swap (hide content host, reveal fallback host), so flush now.
+ await ssr.streamHandler.flush();
+ });
}
function markOutOfOrderContentResolved(boundaryState: SSROutOfOrderBoundaryState | null): void {
@@ -439,11 +454,11 @@ function shouldRenderFallback(
);
}
-function writeOutOfOrderPlaceholder(ssr: SSRContainer, boundaryId: number): void {
+export function writeOutOfOrderPlaceholder(ssr: SSRContainer, boundaryId: number): void {
ssr.write(``);
}
-function writeOutOfOrderResolvedTemplate(
+export function writeOutOfOrderResolvedTemplate(
ssr: SSRContainer,
boundaryId: number,
html: string,
diff --git a/packages/qwik/src/core/qwik.core.api.md b/packages/qwik/src/core/qwik.core.api.md
index 97ff0597718..3538f92baca 100644
--- a/packages/qwik/src/core/qwik.core.api.md
+++ b/packages/qwik/src/core/qwik.core.api.md
@@ -1059,10 +1059,10 @@ export interface ReadonlySignal {
// Warning: (ae-forgotten-export) The symbol "SSRRevealSlotProps" needs to be exported by the entry point index.d.ts
//
// @internal (undocumented)
-export const _reC: (props: RevealProps) => JSXNodeInternal JSXNodeInternal> | JSXNodeInternal> | JSXNodeInternal>;
+}>>;
// @internal
export const _regSymbol: (symbol: any, hash: string) => any;
diff --git a/packages/qwik/src/core/shared/error/error-boundary.ts b/packages/qwik/src/core/shared/error/error-boundary.ts
index 830f0896bb9..819749e630d 100644
--- a/packages/qwik/src/core/shared/error/error-boundary.ts
+++ b/packages/qwik/src/core/shared/error/error-boundary.ts
@@ -1,12 +1,20 @@
+import { isBrowser } from '@qwik.dev/core/build';
+import { isOutOfOrderStreaming, nextOutOfOrderSuspenseId } from '../../control-flow/suspense-utils';
+import { SSRErrorFallback } from '../../control-flow/suspense';
import { useErrorBoundaryStore } from '../../use/use-error-boundary-store';
import { componentQrl, type Component } from '../component.public';
import { _jsxSorted } from '../jsx/jsx-internal';
import { Fragment } from '../jsx/jsx-runtime';
import { Slot } from '../jsx/slot.public';
import type { JSXOutput } from '../jsx/types/jsx-node';
+import { isServerPlatform } from '../platform/platform';
+import { _fnSignal } from '../qrl/inlined-fn';
import { inlinedQrl } from '../qrl/qrl';
import type { QRL } from '../qrl/qrl.public';
import { noSerialize } from '../serdes/verify';
+import { QSuspenseResultParent } from '../utils/markers';
+import { qTest } from '../utils/qdev';
+import type { ErrorBoundaryStore } from './error-handling';
/** @public */
export interface ErrorBoundaryProps {
@@ -25,25 +33,60 @@ export interface ErrorBoundaryProps {
*
* The store is created and provided on `ERROR_CONTEXT` by the internal `useErrorBoundaryStore` hook
* (the old public `useErrorBoundary` was removed). Errors are routed to the CLOSEST boundary by the
- * container's `handleError` (it resolves
- * `ERROR_CONTEXT` and sets this store's `.error`); both synchronous render throws and async `qerror`
- * events go through it. During SSR the fallback is rendered in place via `store.$fallback$`. So this
- * component only reads its store and renders — there is no per-boundary `qerror` listener.
+ * container's `handleError` (it resolves `ERROR_CONTEXT` and sets this store's `.error`); both
+ * synchronous render throws and async `qerror` events go through it.
*
- * Experimental (`errorBoundary` feature): during SSR the container renders this boundary's subtree
- * into a discardable buffer (see the buffering ErrorBoundary path in `ssr-render-jsx`). On a throw it
- * rolls back the buffer and renders `fallback$` in place, so SSR matches the client's clean
- * `boundary > fallback` instead of leaving the partially-streamed subtree behind. This component
- * itself stays unaware of streaming — it only provides the store and renders.
+ * Streaming (experimental `errorBoundary` feature): the boundary NEVER blocks streaming. On SSR with
+ * out-of-order streaming it renders its subtree inside a visible content host plus a hidden fallback
+ * host. The content streams as usual; if the subtree throws, the boundary's `fallback$` is streamed
+ * as an out-of-order segment and the shared `qO` executor hides the content host and reveals the
+ * fallback host (an inline script, so the swap happens before the framework resumes). On the client
+ * — a fresh render, or a re-render after a post-resume error — the boundary just swaps `` for
+ * the fallback reactively (see `store.error` below).
*/
+const _ebContentStyle = (store: ErrorBoundaryStore) => ({
+ display: store.error ? 'none' : 'contents',
+});
+const _ebContentStyle_str = '{display:p0.error?"none":"contents"}';
+const _ebFallbackStyle = (store: ErrorBoundaryStore) => ({
+ display: store.error ? 'contents' : 'none',
+});
+const _ebFallbackStyle_str = '{display:p0.error?"contents":"none"}';
+
/** @internal */
export const errorBoundaryCmp = (props: ErrorBoundaryProps): JSXOutput => {
const store = useErrorBoundaryStore();
- // Expose the fallback so SSR can render it in place when a child throws (the client re-renders this
+ // Expose the fallback so SSR can stream it when a child throws (the client re-renders this
// component instead, which reads `store.error` below). Server-render-only, hence noSerialize.
store.$fallback$ = noSerialize(props.fallback$);
+ const isServerEnv = qTest ? isServerPlatform() : !isBrowser;
+ if (__EXPERIMENTAL__.errorBoundary && isServerEnv && isOutOfOrderStreaming()) {
+ const boundaryId = nextOutOfOrderSuspenseId();
+ return [
+ /*#__PURE__*/ _jsxSorted(
+ 'div',
+ { style: /*#__PURE__*/ _fnSignal(_ebContentStyle, [store], _ebContentStyle_str) },
+ null,
+ /*#__PURE__*/ _jsxSorted(Slot, null, null, null, 0, null),
+ 1,
+ null
+ ),
+ /*#__PURE__*/ _jsxSorted(
+ 'div',
+ {
+ [QSuspenseResultParent]: String(boundaryId),
+ style: /*#__PURE__*/ _fnSignal(_ebFallbackStyle, [store], _ebFallbackStyle_str),
+ },
+ null,
+ /*#__PURE__*/ _jsxSorted(SSRErrorFallback, { boundaryId, store }, null, null, 1, null),
+ 1,
+ null
+ ),
+ ] as unknown as JSXOutput;
+ }
+
if (store.error) {
return /*#__PURE__*/ _jsxSorted(Fragment, null, null, props.fallback$(store.error), 0, null);
}
diff --git a/packages/qwik/src/core/shared/error/error-handling.ts b/packages/qwik/src/core/shared/error/error-handling.ts
index 0b694de5ad9..13314684485 100644
--- a/packages/qwik/src/core/shared/error/error-handling.ts
+++ b/packages/qwik/src/core/shared/error/error-handling.ts
@@ -11,12 +11,21 @@ export interface ErrorBoundaryStore {
*/
$fallback$?: (error: any) => unknown;
/**
- * Server-only. When the boundary buffers its subtree (experimental `errorBoundary` feature), this
- * holds the render checkpoint taken at the start of its content. Its presence tells the in-place
- * SSR catch to let the throw propagate to the boundary's nested render, which rolls back to this
- * checkpoint and renders the fallback in place.
+ * Server-only. When the boundary buffers its subtree (experimental `errorBoundary` feature) —
+ * only inside a `` segment, which is already buffered — this holds the render
+ * checkpoint taken at the start of its content. Its presence tells the in-place SSR catch to let
+ * the throw propagate to the boundary's nested render, which rolls back to this checkpoint and
+ * renders the fallback.
*/
$checkpoint$?: SSRBufferCheckpoint;
+ /**
+ * Server-only, streaming ``. Set by the boundary's SSR fallback host: streams the
+ * boundary's `fallback$(error)` as an out-of-order segment and injects it (via the shared `qO`
+ * executor) into the fallback host, hiding the content host. Called either synchronously — when a
+ * descendant threw during the content render — or later, when a deferred child ``
+ * throws.
+ */
+ $emitFallback$?: (error: unknown) => void | Promise;
}
export const ERROR_CONTEXT = /*#__PURE__*/ createContextId('qk-error');
diff --git a/packages/qwik/src/core/ssr/ssr-render-jsx.ts b/packages/qwik/src/core/ssr/ssr-render-jsx.ts
index 144898cc25f..483cccae677 100644
--- a/packages/qwik/src/core/ssr/ssr-render-jsx.ts
+++ b/packages/qwik/src/core/ssr/ssr-render-jsx.ts
@@ -36,7 +36,7 @@ import { mapArray_get, mapArray_has } from '../client/util-mapArray';
import { isPromise, retryOnPromise } from '../shared/utils/promises';
import { qInspector } from '../shared/utils/qdev';
import { addComponentStylePrefix } from '../shared/utils/scoped-styles';
-import type { InnerContainer } from '../shared/utils/container';
+import { isOutOfOrderSegmentContainer, type InnerContainer } from '../shared/utils/container';
import { isFunction, type ValueOrPromise } from '../shared/utils/types';
import { trackSignalAndAssignHost } from '../use/use-core';
import type { CursorBoundary } from '../use/use-cursor-boundary';
@@ -130,12 +130,24 @@ function renderErrorBoundaryFallback(
if (!errorStore || !errorStore.$fallback$) {
throw err;
}
- // Experimental: a buffering boundary handles the throw in its own nested render — it rolls back to
- // its checkpoint and renders the fallback there. Propagate so the throw unwinds to that boundary
- // instead of rendering in place here (which would leave the partially-streamed subtree behind).
+ // Experimental: a buffering boundary (only inside a `` segment, which is already
+ // buffered) handles the throw in its own nested render — it rolls back to its checkpoint and
+ // renders the fallback there. Propagate so the throw unwinds to that boundary.
if (__EXPERIMENTAL__.errorBoundary && errorStore.$checkpoint$) {
throw err;
}
+ // Experimental: a live streaming boundary never blocks streaming and never renders in place. Just
+ // mark the error — the boundary's fallback host (rendered after the content) streams `fallback$`
+ // as an out-of-order segment and the shared `qO` executor hides the content and reveals it. The
+ // throwing subtree renders nothing here.
+ if (
+ __EXPERIMENTAL__.errorBoundary &&
+ ssr.outOfOrderStreaming &&
+ !isOutOfOrderSegmentContainer(ssr)
+ ) {
+ errorStore.error = err;
+ return null;
+ }
errorStore.error = err;
return errorStore.$fallback$(err) as ValueOrPromise;
}
@@ -146,9 +158,12 @@ function renderErrorBoundaryFallback(
* fallback on a throw. Reads the host's OWN context (not an ancestor's) so children don't match.
*/
function getBufferingErrorBoundaryStore(
+ ssr: SSRContainer,
host: ReturnType
): ErrorBoundaryStore | null {
- if (!__EXPERIMENTAL__.errorBoundary) {
+ // Only buffer inside a `` segment, which is already buffered — a live boundary must
+ // never block streaming, so it streams its subtree and swaps in the fallback via `qO` instead.
+ if (!__EXPERIMENTAL__.errorBoundary || !isOutOfOrderSegmentContainer(ssr)) {
return null;
}
const ctx = host.getProp(QCtxAttr) as Array | null;
@@ -361,7 +376,7 @@ function processJSXNode(
} catch (err) {
jsxOutput = renderErrorBoundaryFallback(ssr, host, err);
}
- const bufferingErrorStore = getBufferingErrorBoundaryStore(host);
+ const bufferingErrorStore = getBufferingErrorBoundaryStore(ssr, host);
enqueue(
setParentOptions(options, options.currentStyleScoped, options.parentComponentFrame)
);
diff --git a/packages/qwik/src/core/tests/error-boundary.spec.tsx b/packages/qwik/src/core/tests/error-boundary.spec.tsx
index 3d1c058c658..f6afd1902db 100644
--- a/packages/qwik/src/core/tests/error-boundary.spec.tsx
+++ b/packages/qwik/src/core/tests/error-boundary.spec.tsx
@@ -100,10 +100,12 @@ describe('ErrorBoundary', () => {
});
});
-describe('ErrorBoundary buffer-and-swap (experimental)', () => {
- // Render with streaming, then run the emitted scripts to resume on the client (and inject any
- // out-of-order Suspense segments into their placeholders, mirroring the Suspense OOOS specs).
- const ssrRenderResumed = async (jsx: JSXOutput) => {
+describe('ErrorBoundary streaming swap (experimental)', () => {
+ // Render with out-of-order streaming, then run the emitted `qO` scripts to perform the inline swap
+ // (hide the content host, reveal the fallback host) and wire `qProcessOOOS` for resume, mirroring
+ // the Suspense OOOS specs. Returns the streamed HTML (to assert the boundary did NOT block) plus
+ // the resumed document (to assert the post-swap DOM).
+ const streamAndResume = async (jsx: JSXOutput) => {
const chunks: string[] = [];
await ssrRenderToDom(jsx, {
stream: {
@@ -114,20 +116,24 @@ describe('ErrorBoundary buffer-and-swap (experimental)', () => {
streaming: { inOrder: { strategy: 'disabled' }, outOfOrder: true },
debug,
});
- const document = createDocument({ html: chunks.join('') });
+ const html = chunks.join('');
+ const document = createDocument({ html });
(document as any).qProcessOOOS = (boundaryId: number, content: Element | null) =>
processOutOfOrderSegmentVNodeData(document, String(boundaryId), content);
const scripts = Array.from(
document.querySelectorAll('script[type="text/javascript"]'),
(s) => s.textContent || ''
- );
+ ).filter((code) => code.includes('qO') || code.includes('qInstallOOOS'));
// eslint-disable-next-line no-new-func
new Function('document', scripts.join('\n'))(document);
- return document;
+ return { html, document };
};
- it('discards leftover siblings and swaps in the fallback when a descendant throws', async () => {
- const document = await ssrRenderResumed(
+ const displayOf = (el: Element | null | undefined) =>
+ (el as HTMLElement | null | undefined)?.style?.display;
+
+ it('streams the content, then hides it and reveals the fallback when a descendant throws', async () => {
+ const { html, document } = await streamAndResume(
(
@@ -140,15 +146,18 @@ describe('ErrorBoundary buffer-and-swap (experimental)', () => {
);
- // The partially-streamed `before`/`after` siblings are rolled back — the boundary renders as a
- // clean `boundary > fallback`, matching the client (instead of the in-place divergence).
+ // The boundary never blocks: its content streams as usual.
+ expect(html).toContain('id="before"');
+ // On the throw the content host is hidden and the fallback host revealed (inline `qO` swap). The
+ // content stays in the DOM (hidden), so no-JS clients still see something and the swap has no
+ // re-render dependency.
expect(document.querySelector('#fb')?.textContent).toContain('caught: boom');
- expect(document.querySelector('#before')).toBeFalsy();
- expect(document.querySelector('#after')).toBeFalsy();
+ expect(displayOf(document.querySelector('#fb')?.closest('[q\\:rp]'))).toBe('contents');
+ expect(displayOf(document.querySelector('#before')?.closest('div[style]'))).toBe('none');
});
it('renders the content unchanged when nothing throws', async () => {
- const document = await ssrRenderResumed(
+ const { document } = await streamAndResume(
(
@@ -162,34 +171,37 @@ describe('ErrorBoundary buffer-and-swap (experimental)', () => {
);
expect(document.querySelector('#content')?.textContent).toBe('all good');
- expect(document.querySelector('#before')).toBeTruthy();
- expect(document.querySelector('#after')).toBeTruthy();
+ expect(displayOf(document.querySelector('#content')?.closest('div[style]'))).toBe('contents');
expect(document.querySelector('#fb')).toBeFalsy();
});
- it('works when the boundary is inside a (swaps within the segment)', async () => {
- const document = await ssrRenderResumed(
+ it('a deferred (async) throw inside a child tears down the WHOLE boundary', async () => {
+ // The boundary committed once the Suspense placeholder streamed, so it can't catch the later
+ // async throw in place. Instead the whole boundary — the streamed sibling and the Suspense — is
+ // hidden and the boundary fallback revealed (the dev's `fallback$` replaces the boundary, not a
+ // sub-slot of it).
+ const { html, document } = await streamAndResume(
- loading}>
- (
-
caught: {e.message}
- ))}
- >
-
before
-
-
after
-
-
+ (
+
caught: {String(e?.message ?? e)}
+ ))}
+ >
+
sibling
+ loading}>
+
+
+
);
- expect(document.querySelector('#fb')?.textContent).toContain('caught: boom');
- expect(document.querySelector('#before')).toBeFalsy();
- expect(document.querySelector('#after')).toBeFalsy();
+ expect(html).toContain('id="sibling"');
+ expect(document.querySelector('#fb')?.textContent).toContain('caught: async boom');
+ expect(displayOf(document.querySelector('#fb')?.closest('[q\\:rp]'))).toBe('contents');
+ expect(displayOf(document.querySelector('#sibling')?.closest('div[style]'))).toBe('none');
});
- it('nested boundaries: the inner one catches, the outer subtree is untouched', async () => {
- const document = await ssrRenderResumed(
+ it('nested boundaries: the inner one tears down, the outer subtree stays visible', async () => {
+ const { document } = await streamAndResume(
(
@@ -209,40 +221,43 @@ describe('ErrorBoundary buffer-and-swap (experimental)', () => {
);
+ // The inner boundary catches: its content host hides, its fallback shows.
expect(document.querySelector('#fb-inner')).toBeTruthy();
- expect(document.querySelector('#outer-sibling')).toBeTruthy();
expect(document.querySelector('#fb-outer')).toBeFalsy();
- expect(document.querySelector('#before')).toBeFalsy();
- expect(document.querySelector('#after')).toBeFalsy();
+ expect(displayOf(document.querySelector('#before')?.closest('div[style]'))).toBe('none');
+ // The outer subtree is untouched and stays visible.
+ expect(document.querySelector('#outer-sibling')).toBeTruthy();
+ expect(displayOf(document.querySelector('#outer-sibling')?.closest('div[style]'))).toBe(
+ 'contents'
+ );
});
- it('routes a deferred (async) throw inside a child to the boundary fallback', async () => {
- // The boundary commits once the Suspense placeholder streams, so its own buffer can't catch the
- // later async throw; the rejected segment is instead routed to the boundary's fallback, injected
- // into the Suspense slot — no aborted render.
- const document = await ssrRenderResumed(
+ it('boundary inside a buffers within the segment (skeleton → fallback)', async () => {
+ // When the boundary sits inside a its subtree is already deferred into a buffered
+ // segment, so the boundary buffers there and discards the partial content — a clean
+ // skeleton → fallback swap rather than the stream-and-hide path used by a live boundary.
+ const { document } = await streamAndResume(
- (
-
caught: {String(e?.message ?? e)}
- ))}
- >
- loading}>
-
-
-
+ loading}>
+ (
+
caught: {e.message}
+ ))}
+ >
+
before
+
+
after
+
+
);
- expect(document.querySelector('#fb')?.textContent).toContain('caught: async boom');
+ expect(document.querySelector('#fb')?.textContent).toContain('caught: boom');
+ expect(document.querySelector('#before')).toBeFalsy();
+ expect(document.querySelector('#after')).toBeFalsy();
});
- it('boundary inside a : an async throw rolls back the WHOLE content (skeleton → fallback)', async () => {
- // Here the entire boundary subtree is deferred behind the Suspense skeleton, so the user only
- // ever sees the skeleton — never the broken/partial content. The async throw rolls the whole
- // subtree back within the segment (siblings included), and the segment resolves to the fallback,
- // which is injected in place of the skeleton: a clean skeleton → fallback swap, no leaked
- // siblings, no broken-content flash (and no CLS if skeleton/fallback/content share a box).
- const document = await ssrRenderResumed(
+ it('boundary inside a : an async throw rolls back the WHOLE content', async () => {
+ const { document } = await streamAndResume(
loading}>
Date: Thu, 18 Jun 2026 23:29:12 +0200
Subject: [PATCH 011/331] test(qwik): e2e for streaming ErrorBoundary swap
(sync + deferred teardown)
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
throw tears the WHOLE boundary down on release. Enables the
errorBoundary experimental feature for the e2e fixture app.
---
.../error-boundary/error-boundary.tsx | 120 ++++++++++++++++++
e2e/qwik-e2e/apps/e2e/src/entry.ssr.tsx | 6 +-
e2e/qwik-e2e/apps/e2e/src/root.tsx | 2 +
e2e/qwik-e2e/dev-server.ts | 4 +-
.../tests/error-boundary-streaming.e2e.ts | 68 ++++++++++
5 files changed, 197 insertions(+), 3 deletions(-)
create mode 100644 e2e/qwik-e2e/apps/e2e/src/components/error-boundary/error-boundary.tsx
create mode 100644 e2e/qwik-e2e/tests/error-boundary-streaming.e2e.ts
diff --git a/e2e/qwik-e2e/apps/e2e/src/components/error-boundary/error-boundary.tsx b/e2e/qwik-e2e/apps/e2e/src/components/error-boundary/error-boundary.tsx
new file mode 100644
index 00000000000..5ad9ea9d917
--- /dev/null
+++ b/e2e/qwik-e2e/apps/e2e/src/components/error-boundary/error-boundary.tsx
@@ -0,0 +1,120 @@
+import {
+ component$,
+ ErrorBoundary,
+ isServer,
+ Suspense,
+ useServerData,
+ useSignal,
+ type JSXOutput,
+} from '@qwik.dev/core';
+import { ManualOutOfOrderReleaseButton } from '../suspense/ooos';
+
+const getSearchParam = (url: string | undefined, name: string): string | null =>
+ url ? new URL(url).searchParams.get(name) : null;
+
+// Reuse the shared out-of-order release store (same one ooos.tsx and the dev-server release endpoint
+// use) so a test can deterministically trigger a deferred throw.
+type ReleaseStore = { resolved: Set; resolvers: Map void>> };
+const getReleaseStore = (): ReleaseStore =>
+ ((globalThis as any).__qwikOOOSReleaseStore ||= {
+ resolved: new Set(),
+ resolvers: new Map void>>(),
+ });
+const waitForRelease = (requestId: string, releaseId: string): Promise =>
+ new Promise((resolve) => {
+ const store = getReleaseStore();
+ const key = `${requestId}:${releaseId}`;
+ if (store.resolved.has(key)) {
+ resolve();
+ return;
+ }
+ let resolvers = store.resolvers.get(key);
+ if (!resolvers) {
+ store.resolvers.set(key, (resolvers = new Set()));
+ }
+ resolvers.add(resolve);
+ });
+
+const EbFallback = component$((props: { msg: string }) => {
+ const count = useSignal(0);
+ return (
+
+
+
+ {count.value}
+
+ );
+});
+
+// Throws during SSR (only on the server) — the boundary streams its content first, then swaps.
+const EbSyncThrower = component$(() => {
+ if (isServer) {
+ throw new Error('eb sync boom');
+ }
+ return ;
+});
+
+// A deferred child whose async work rejects after its Suspense placeholder has streamed.
+const EbAsyncThrower = component$(() => {
+ const url = useServerData('url');
+ const requestId = useServerData('ooosRequestId');
+ if (isServer) {
+ const releaseId = getSearchParam(url, 'release');
+ if (releaseId && requestId) {
+ return waitForRelease(requestId, releaseId).then(() => {
+ throw new Error('eb async boom');
+ }) as unknown as JSXOutput;
+ }
+ return new Promise((_resolve, reject) => {
+ setTimeout(() => reject(new Error('eb async boom')), 1000);
+ }) as unknown as JSXOutput;
+ }
+ return ;
+});
+
+export const ErrorBoundaryStreamingRoot = component$(() => {
+ const url = useServerData('url');
+ const scenario = getSearchParam(url, 'scenario');
+
+ return (
+
+
EB Streaming
+ {scenario === 'async' ? (
+ <>
+ }>
+
sibling
+ loading}>
+
+
+
+
+ >
+ ) : (
+ }>
+
+
+
+ )}
+
+
+ );
+});
diff --git a/e2e/qwik-e2e/apps/e2e/src/entry.ssr.tsx b/e2e/qwik-e2e/apps/e2e/src/entry.ssr.tsx
index bd2a092dd88..c9db3312375 100644
--- a/e2e/qwik-e2e/apps/e2e/src/entry.ssr.tsx
+++ b/e2e/qwik-e2e/apps/e2e/src/entry.ssr.tsx
@@ -11,7 +11,11 @@ export default function (opts: RenderToStreamOptions) {
const url = new URL(opts.serverData!.url);
const outOfOrderParam = url.searchParams.get('outOfOrder');
const outOfOrderStreaming =
- outOfOrderParam === 'false' ? false : url.pathname === '/e2e/suspense-ooos' ? true : undefined;
+ outOfOrderParam === 'false'
+ ? false
+ : url.pathname === '/e2e/suspense-ooos' || url.pathname === '/e2e/error-boundary-streaming'
+ ? true
+ : undefined;
const renderOpts: RenderToStreamOptions = {
debug: true,
...opts,
diff --git a/e2e/qwik-e2e/apps/e2e/src/root.tsx b/e2e/qwik-e2e/apps/e2e/src/root.tsx
index 9d5a8b62c3b..2d0d8ac24d7 100644
--- a/e2e/qwik-e2e/apps/e2e/src/root.tsx
+++ b/e2e/qwik-e2e/apps/e2e/src/root.tsx
@@ -43,6 +43,7 @@ import { EachRoot } from './components/each/each';
import { ShowRoot } from './components/show/show';
import { SuspenseRoot } from './components/suspense/suspense';
import { OutOfOrderSuspenseRoot } from './components/suspense/ooos';
+import { ErrorBoundaryStreamingRoot } from './components/error-boundary/error-boundary';
const tests: Record = {
'/e2e/two-listeners': () => ,
@@ -86,6 +87,7 @@ const tests: Record = {
'/e2e/show': () => ,
'/e2e/suspense': () => ,
'/e2e/suspense-ooos': () => ,
+ '/e2e/error-boundary-streaming': () => ,
'/e2e/worker': () => ,
};
diff --git a/e2e/qwik-e2e/dev-server.ts b/e2e/qwik-e2e/dev-server.ts
index 68c35f2ee9f..e23f5342faa 100644
--- a/e2e/qwik-e2e/dev-server.ts
+++ b/e2e/qwik-e2e/dev-server.ts
@@ -242,7 +242,7 @@ export { router }
clientManifest = manifest;
},
},
- experimental: ['each', 'show', 'suspense'],
+ experimental: ['each', 'show', 'suspense', 'errorBoundary'],
}),
],
})
@@ -258,7 +258,7 @@ export { router }
plugins: [
...plugins,
optimizer.qwikVite({
- experimental: ['each', 'show', 'suspense'],
+ experimental: ['each', 'show', 'suspense', 'errorBoundary'],
ssr: {
manifestInput: clientManifest,
},
diff --git a/e2e/qwik-e2e/tests/error-boundary-streaming.e2e.ts b/e2e/qwik-e2e/tests/error-boundary-streaming.e2e.ts
new file mode 100644
index 00000000000..2f000e46f05
--- /dev/null
+++ b/e2e/qwik-e2e/tests/error-boundary-streaming.e2e.ts
@@ -0,0 +1,68 @@
+import { test, expect, type Page } from '@playwright/test';
+
+const assertNoBrowserErrors = (page: Page) => {
+ page.on('pageerror', (err) => expect(err).toEqual(undefined));
+ page.on('console', (msg) => {
+ if (msg.type() === 'error') {
+ expect(msg.text()).toEqual(undefined);
+ }
+ });
+};
+
+const releaseDeferred = async (page: Page, selector: string) => {
+ const releaseButton = page.locator(selector);
+ await expect(releaseButton).toBeVisible();
+ const releaseUrl = await releaseButton.getAttribute('data-release-url');
+ expect(releaseUrl).not.toBeNull();
+ const response = await page.request.post(new URL(releaseUrl!, page.url()).toString());
+ expect(response.ok()).toBeTruthy();
+};
+
+test.describe('ErrorBoundary streaming swap', () => {
+ test('sync throw: streams the shell, swaps to the fallback, keeps it interactive', async ({
+ page,
+ }) => {
+ assertNoBrowserErrors(page);
+ await page.goto('/e2e/error-boundary-streaming', { waitUntil: 'commit' });
+
+ // The boundary never blocks streaming: the title and the footer AFTER the boundary both render.
+ await expect(page.locator('#eb-title')).toHaveText('EB Streaming', { timeout: 10000 });
+ await expect(page.locator('#eb-footer')).toHaveText('Footer shell', { timeout: 10000 });
+
+ // The descendant threw during SSR, so the inline swap hides the content and reveals the fallback.
+ await expect(page.locator('#eb-fallback')).toBeVisible({ timeout: 10000 });
+ await expect(page.locator('#eb-fallback-msg')).toHaveText('caught: eb sync boom');
+ await expect(page.locator('#eb-content')).toBeHidden();
+
+ // The swapped-in fallback is interactive once the framework resumes.
+ await page.locator('#eb-fallback-button').click();
+ await expect(page.locator('#eb-fallback-count')).toHaveText('1');
+ });
+
+ test('async deferred throw: streams siblings + skeleton, then tears down the whole boundary', async ({
+ page,
+ }) => {
+ assertNoBrowserErrors(page);
+ await page.goto('/e2e/error-boundary-streaming?scenario=async&release=eb', {
+ waitUntil: 'commit',
+ });
+
+ // Everything streams immediately — the boundary does not wait on the deferred child.
+ await expect(page.locator('#eb-title')).toHaveText('EB Streaming', { timeout: 10000 });
+ await expect(page.locator('#eb-sibling')).toBeVisible({ timeout: 10000 });
+ await expect(page.locator('#eb-skel')).toBeVisible({ timeout: 10000 });
+ await expect(page.locator('#eb-footer')).toHaveText('Footer shell', { timeout: 10000 });
+ await expect(page.locator('#eb-fallback')).toHaveCount(0);
+
+ // Release the deferred child so it rejects.
+ await releaseDeferred(page, '#eb-release');
+
+ // The whole boundary — the streamed sibling and the Suspense — is torn down to the fallback.
+ await expect(page.locator('#eb-fallback')).toBeVisible({ timeout: 10000 });
+ await expect(page.locator('#eb-fallback-msg')).toHaveText('caught: eb async boom');
+ await expect(page.locator('#eb-sibling')).toBeHidden();
+
+ await page.locator('#eb-fallback-button').click();
+ await expect(page.locator('#eb-fallback-count')).toHaveText('1');
+ });
+});
From b21bf5a2c1fbe05574e31e1803c3124ed8cb2534 Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Thu, 18 Jun 2026 23:42:11 +0200
Subject: [PATCH 012/331] fix(qwik): emit ErrorBoundary fallback swap inline,
not at end-of-stream
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
.../references/core-notes.md | 4 +++
.../qwik/src/core/control-flow/suspense.tsx | 27 ++++++++++---------
.../src/core/tests/error-boundary.spec.tsx | 11 +++++---
3 files changed, 27 insertions(+), 15 deletions(-)
diff --git a/.ruler/skills/qwik-core-development/references/core-notes.md b/.ruler/skills/qwik-core-development/references/core-notes.md
index 35bf23b7d26..1aa3c94d4e0 100644
--- a/.ruler/skills/qwik-core-development/references/core-notes.md
+++ b/.ruler/skills/qwik-core-development/references/core-notes.md
@@ -191,6 +191,10 @@ Key invariants:
- The swap is an inline script (fires as its chunk parses) — it does NOT depend on the client
re-rendering, so it works before the framework resumes. Client-time errors still use the reactive
re-render path (`handleError` → `store.error`).
+- A SYNC throw emits the fallback segment + `qO(id)` **inline** (`SSRErrorFallback` returns the
+ emission promise, awaited in the drain), so the swap lands right after the boundary — NOT via
+ `queueOutOfOrderSegment`, which would defer it to end-of-stream and leave the broken content
+ visible the whole time. A test asserts the `qO(` position precedes trailing content; keep it.
- A deferred child `` that throws routes to the enclosing boundary's `store.$emitFallback$`
(set by `SSRErrorFallback`), tearing the whole boundary down — not into the Suspense sub-slot.
- A boundary *inside* a `` segment is the one case that still buffers (the segment is
diff --git a/packages/qwik/src/core/control-flow/suspense.tsx b/packages/qwik/src/core/control-flow/suspense.tsx
index 5ce37841d6a..e3d484f41d9 100644
--- a/packages/qwik/src/core/control-flow/suspense.tsx
+++ b/packages/qwik/src/core/control-flow/suspense.tsx
@@ -371,22 +371,25 @@ export const SSRErrorFallback = __EXPERIMENTAL__.errorBoundary
const boundaryId = jsx.varProps.boundaryId as number;
const store = jsx.varProps.store as ErrorBoundaryStore;
const segmentId = `${boundaryId}`;
- const emitFallback = (error: unknown): Promise => {
+ // Render `fallback$` as an out-of-order segment and inject it (template + inline `qO(id)`)
+ // into this fallback host. Returns the emission promise so the caller can await it in stream
+ // order — a synchronous throw awaits it inline (below) so the swap script follows the content
+ // immediately instead of landing at end-of-stream; a later async throw awaits it through the
+ // rejected Suspense segment's promise chain.
+ const streamFallback = (error: unknown): Promise => {
store.error = error;
- ssr.emitOutOfOrderExecutorIfNeeded();
- const rendered = ssr.segment(segmentId, store.$fallback$!(error) as JSXOutput, options);
- const emission = rendered.then((segment) =>
- emitErrorBoundaryFallback(ssr, boundaryId, segmentId, segment)
- );
- ssr.queueOutOfOrderSegment(emission);
- return emission;
+ return ssr
+ .segment(segmentId, store.$fallback$!(error) as JSXOutput, options)
+ .then((segment) => emitErrorBoundaryFallback(ssr, boundaryId, segmentId, segment));
};
- store.$emitFallback$ = noSerialize(emitFallback);
+ store.$emitFallback$ = noSerialize(streamFallback);
+ // Install the shared `qO` executor up front (before any swap call) and write the placeholder.
+ ssr.emitOutOfOrderExecutorIfNeeded();
writeOutOfOrderPlaceholder(ssr, boundaryId);
- // A throw during the content render already set `store.error` before this host renders
- // (the content host is our previous sibling), so stream the fallback now.
+ // A throw during the content render already set `store.error` before this host renders (the
+ // content host is our previous sibling). Returning the promise awaits the swap inline.
if (store.error != null && store.$fallback$) {
- emitFallback(store.error);
+ return streamFallback(store.error);
}
}
)
diff --git a/packages/qwik/src/core/tests/error-boundary.spec.tsx b/packages/qwik/src/core/tests/error-boundary.spec.tsx
index f6afd1902db..7cb58dc087e 100644
--- a/packages/qwik/src/core/tests/error-boundary.spec.tsx
+++ b/packages/qwik/src/core/tests/error-boundary.spec.tsx
@@ -144,13 +144,18 @@ describe('ErrorBoundary streaming swap (experimental)', () => {
after
+
);
// The boundary never blocks: its content streams as usual.
expect(html).toContain('id="before"');
- // On the throw the content host is hidden and the fallback host revealed (inline `qO` swap). The
- // content stays in the DOM (hidden), so no-JS clients still see something and the swap has no
- // re-render dependency.
+ // The inline `qO` swap script lands right after the boundary — BEFORE trailing content — so the
+ // broken content is not left visible until end-of-stream. The swap is a plain inline script, so
+ // it runs as the chunk parses, with no dependency on the framework having resumed.
+ const swapPos = html.search(/qO\(\d/);
+ expect(swapPos).toBeGreaterThan(html.indexOf('id="before"'));
+ expect(swapPos).toBeLessThan(html.indexOf('id="eb-tail"'));
+ // After the swap the content host is hidden and the fallback host revealed.
expect(document.querySelector('#fb')?.textContent).toContain('caught: boom');
expect(displayOf(document.querySelector('#fb')?.closest('[q\\:rp]'))).toBe('contents');
expect(displayOf(document.querySelector('#before')?.closest('div[style]'))).toBe('none');
From 5f3c235b4497bbada934ddb859326b32b53a5572 Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Fri, 19 Jun 2026 00:08:57 +0200
Subject: [PATCH 013/331] perf(qwik): only ship ErrorBoundary swap JS when a
boundary actually throws
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
An error-free streaming 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).
---
.../references/core-notes.md | 6 ++++++
.../src/core/control-flow/suspense-utils.ts | 19 +++++++++++++++++++
.../qwik/src/core/control-flow/suspense.tsx | 8 ++++++--
.../src/core/shared/error/error-boundary.ts | 4 ++--
packages/qwik/src/core/ssr/ssr-types.ts | 7 ++++++-
.../src/core/tests/error-boundary.spec.tsx | 6 ++++--
packages/qwik/src/server/ssr-container.ts | 15 +++++++++++----
7 files changed, 54 insertions(+), 11 deletions(-)
diff --git a/.ruler/skills/qwik-core-development/references/core-notes.md b/.ruler/skills/qwik-core-development/references/core-notes.md
index 1aa3c94d4e0..8b6027bf4c8 100644
--- a/.ruler/skills/qwik-core-development/references/core-notes.md
+++ b/.ruler/skills/qwik-core-development/references/core-notes.md
@@ -195,6 +195,12 @@ Key invariants:
emission promise, awaited in the drain), so the swap lands right after the boundary — NOT via
`queueOutOfOrderSegment`, which would defer it to end-of-stream and leave the broken content
visible the whole time. A test asserts the `qO(` position precedes trailing content; keep it.
+- An **error-free** boundary ships NO swap JS (no `qO` executor, no `qO(id)` call). The boundary
+ reserves its id with `nextErrorBoundaryId()` → `container.nextOutOfOrderId(false)`, which does NOT
+ set `outOfOrderUsed`; the executor is armed only when a throw creates the fallback `segment()`
+ (which sets `outOfOrderUsed`) and `emitErrorBoundaryFallback` emits the executor right before the
+ first `qO(id)`. Per throwing boundary it's one shared executor + one tiny `qO(id)` call (same unit
+ cost as a resolved Suspense segment). A test asserts the error-free HTML has no `qO(`/`qInstallOOOS`.
- A deferred child `` that throws routes to the enclosing boundary's `store.$emitFallback$`
(set by `SSRErrorFallback`), tearing the whole boundary down — not into the Suspense sub-slot.
- A boundary *inside* a `` segment is the one case that still buffers (the segment is
diff --git a/packages/qwik/src/core/control-flow/suspense-utils.ts b/packages/qwik/src/core/control-flow/suspense-utils.ts
index 3dbc5c6cb43..19d0f651683 100644
--- a/packages/qwik/src/core/control-flow/suspense-utils.ts
+++ b/packages/qwik/src/core/control-flow/suspense-utils.ts
@@ -116,6 +116,25 @@ export const nextOutOfOrderSuspenseId = (): number => {
return container?.nextOutOfOrderId?.() ?? 0;
};
+/**
+ * Reserve an out-of-order id for a streaming `` WITHOUT arming the OOOS executor.
+ * The boundary needs an id up front (for its `q:rp` fallback host), but it only needs the executor
+ * if it actually throws — at which point streaming the fallback `segment()` arms it. So an
+ * error-free boundary ships neither the executor nor any `qO(id)` call.
+ *
+ * @internal
+ */
+export const nextErrorBoundaryId = (): number => {
+ if (!__EXPERIMENTAL__.errorBoundary) {
+ return 0;
+ }
+ const container = tryGetInvokeContext()?.$container$ as SSRContainer | undefined;
+ if (container?.outOfOrderStreaming !== true) {
+ return 0;
+ }
+ return container?.nextOutOfOrderId?.(false) ?? 0;
+};
+
/** @internal */
export const applySubscriptionPatches = (
container: Container,
diff --git a/packages/qwik/src/core/control-flow/suspense.tsx b/packages/qwik/src/core/control-flow/suspense.tsx
index e3d484f41d9..68a305c722e 100644
--- a/packages/qwik/src/core/control-flow/suspense.tsx
+++ b/packages/qwik/src/core/control-flow/suspense.tsx
@@ -383,8 +383,9 @@ export const SSRErrorFallback = __EXPERIMENTAL__.errorBoundary
.then((segment) => emitErrorBoundaryFallback(ssr, boundaryId, segmentId, segment));
};
store.$emitFallback$ = noSerialize(streamFallback);
- // Install the shared `qO` executor up front (before any swap call) and write the placeholder.
- ssr.emitOutOfOrderExecutorIfNeeded();
+ // Just reserve the injection point. The shared `qO` executor is emitted lazily (in
+ // `emitErrorBoundaryFallback`, only when a boundary actually throws), so an error-free page
+ // ships neither the executor nor any `qO(id)` call.
writeOutOfOrderPlaceholder(ssr, boundaryId);
// A throw during the content render already set `store.error` before this host renders (the
// content host is our previous sibling). Returning the promise awaits the swap inline.
@@ -406,6 +407,9 @@ async function emitErrorBoundaryFallback(
const result = await rendered.container.$finalizeOutOfOrderSegment$(segmentId, rendered);
writeOutOfOrderResolvedTemplate(ssr, boundaryId, result.html, null);
ssr.emitOutOfOrderSegmentScripts(result.scripts);
+ // Install the shared executor (idempotent) right before its first call, so error-free pages
+ // never ship it.
+ ssr.emitOutOfOrderExecutorIfNeeded();
ssr.emitInlineScript(`qO(${boundaryId})`);
// qO() is the browser-visible swap (hide content host, reveal fallback host), so flush now.
await ssr.streamHandler.flush();
diff --git a/packages/qwik/src/core/shared/error/error-boundary.ts b/packages/qwik/src/core/shared/error/error-boundary.ts
index 819749e630d..ca1fb1fccff 100644
--- a/packages/qwik/src/core/shared/error/error-boundary.ts
+++ b/packages/qwik/src/core/shared/error/error-boundary.ts
@@ -1,5 +1,5 @@
import { isBrowser } from '@qwik.dev/core/build';
-import { isOutOfOrderStreaming, nextOutOfOrderSuspenseId } from '../../control-flow/suspense-utils';
+import { isOutOfOrderStreaming, nextErrorBoundaryId } from '../../control-flow/suspense-utils';
import { SSRErrorFallback } from '../../control-flow/suspense';
import { useErrorBoundaryStore } from '../../use/use-error-boundary-store';
import { componentQrl, type Component } from '../component.public';
@@ -63,7 +63,7 @@ export const errorBoundaryCmp = (props: ErrorBoundaryProps): JSXOutput => {
const isServerEnv = qTest ? isServerPlatform() : !isBrowser;
if (__EXPERIMENTAL__.errorBoundary && isServerEnv && isOutOfOrderStreaming()) {
- const boundaryId = nextOutOfOrderSuspenseId();
+ const boundaryId = nextErrorBoundaryId();
return [
/*#__PURE__*/ _jsxSorted(
'div',
diff --git a/packages/qwik/src/core/ssr/ssr-types.ts b/packages/qwik/src/core/ssr/ssr-types.ts
index 5eb8535147f..72a5291348f 100644
--- a/packages/qwik/src/core/ssr/ssr-types.ts
+++ b/packages/qwik/src/core/ssr/ssr-types.ts
@@ -171,7 +171,12 @@ export interface SSRContainer extends Container {
render(jsx: JSXOutput): Promise;
renderJSX(jsx: JSXOutput, options: SSRRenderJSXOptions): Promise;
$runQueuedRender$(render: () => ValueOrPromise): ValueOrPromise;
- nextOutOfOrderId(): number;
+ /**
+ * Allocate the next out-of-order boundary id. Pass `markUsed: false` to reserve an id without
+ * arming the OOOS executor — used by ``, which only needs the executor if it
+ * actually throws (creating the fallback `segment()` then marks OOOS used).
+ */
+ nextOutOfOrderId(markUsed?: boolean): number;
emitOutOfOrderSegmentScripts(scripts: string): void;
segment(
segmentId: string,
diff --git a/packages/qwik/src/core/tests/error-boundary.spec.tsx b/packages/qwik/src/core/tests/error-boundary.spec.tsx
index 7cb58dc087e..e049b9cbe4d 100644
--- a/packages/qwik/src/core/tests/error-boundary.spec.tsx
+++ b/packages/qwik/src/core/tests/error-boundary.spec.tsx
@@ -161,8 +161,8 @@ describe('ErrorBoundary streaming swap (experimental)', () => {
expect(displayOf(document.querySelector('#before')?.closest('div[style]'))).toBe('none');
});
- it('renders the content unchanged when nothing throws', async () => {
- const { document } = await streamAndResume(
+ it('renders the content unchanged when nothing throws (ships no swap JS)', async () => {
+ const { html, document } = await streamAndResume(
(
@@ -178,6 +178,8 @@ describe('ErrorBoundary streaming swap (experimental)', () => {
expect(document.querySelector('#content')?.textContent).toBe('all good');
expect(displayOf(document.querySelector('#content')?.closest('div[style]'))).toBe('contents');
expect(document.querySelector('#fb')).toBeFalsy();
+ // An error-free boundary arms nothing: neither the shared qO executor nor any qO(id) call.
+ expect(html).not.toMatch(/qO\(|qInstallOOOS/);
});
it('a deferred (async) throw inside a child tears down the WHOLE boundary', async () => {
diff --git a/packages/qwik/src/server/ssr-container.ts b/packages/qwik/src/server/ssr-container.ts
index 5a74d266fcd..d4f0182b7d7 100644
--- a/packages/qwik/src/server/ssr-container.ts
+++ b/packages/qwik/src/server/ssr-container.ts
@@ -469,11 +469,15 @@ class SSRContainer extends _SharedContainer implements ISSRContainer {
this.rootContainerReadyPromise = null;
}
- nextOutOfOrderId(): number {
+ nextOutOfOrderId(markUsed = true): number {
if (!__EXPERIMENTAL__.suspense || !this.outOfOrderStreaming) {
return 0;
}
- this.outOfOrderUsed = true;
+ // `` reserves an id without arming the executor (markUsed=false): it only needs
+ // OOOS if it actually throws, at which point creating the fallback `segment()` marks it used.
+ if (markUsed) {
+ this.outOfOrderUsed = true;
+ }
return ++this.outOfOrderId;
}
@@ -499,6 +503,9 @@ class SSRContainer extends _SharedContainer implements ISSRContainer {
'Out-of-order Suspense streaming requires `streaming.outOfOrder` to be `true`.'
);
}
+ // Creating a segment means OOOS is genuinely used, so arm the executor. (For Suspense this is
+ // already set by `nextOutOfOrderId`; for a throwing `` this is what arms it.)
+ this.outOfOrderUsed = true;
this.markVNodeRefForSerialization(options.parentComponentFrame?.componentNode);
const writer = new StringBufferSegmentWriter();
const segmentContainer = this.createSegmentContainer(segmentId, writer);
@@ -1886,8 +1893,8 @@ export class SSRSegmentContainer extends SSRContainer implements ISSRSegmentCont
super(opts);
}
- override nextOutOfOrderId(): number {
- return this.$rootContainer$.nextOutOfOrderId();
+ override nextOutOfOrderId(markUsed = true): number {
+ return this.$rootContainer$.nextOutOfOrderId(markUsed);
}
override $runQueuedRender$(render: () => ValueOrPromise): ValueOrPromise {
From 1d60f429c4aae72c0f9e8d473bc61e45d290348c Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Fri, 19 Jun 2026 00:46:17 +0200
Subject: [PATCH 014/331] fix(qwik): route async throws in streaming
ErrorBoundary; guard throwing fallback
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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 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.
---
.../references/core-notes.md | 21 +++
.../error-boundary/error-boundary.tsx | 13 ++
.../tests/error-boundary-streaming.e2e.ts | 22 +++
.../qwik/src/core/control-flow/suspense.tsx | 16 ++-
.../src/core/shared/error/error-boundary.ts | 4 +
packages/qwik/src/core/ssr/ssr-render-jsx.ts | 51 +++++--
.../src/core/tests/error-boundary.spec.tsx | 133 +++++++++++++++++-
7 files changed, 247 insertions(+), 13 deletions(-)
diff --git a/.ruler/skills/qwik-core-development/references/core-notes.md b/.ruler/skills/qwik-core-development/references/core-notes.md
index 8b6027bf4c8..35f6672a7e4 100644
--- a/.ruler/skills/qwik-core-development/references/core-notes.md
+++ b/.ruler/skills/qwik-core-development/references/core-notes.md
@@ -201,6 +201,27 @@ Key invariants:
(which sets `outOfOrderUsed`) and `emitErrorBoundaryFallback` emits the executor right before the
first `qO(id)`. Per throwing boundary it's one shared executor + one tiny `qO(id)` call (same unit
cost as a resolved Suspense segment). A test asserts the error-free HTML has no `qO(`/`qInstallOOOS`.
+- **Async throws must be routed in the drain.** The SSR drain (`_walkJSX`) awaits children at three
+ points — the `Promise` marker (promise children), the async-component thunk (`await jsxOutput`),
+ and `MaybeAsyncSignal` (`retryOnPromise`). All three now catch and call `renderErrorBoundaryFallback`;
+ without that, a rejected promise child / async component / async signal aborts the whole stream
+ instead of hitting the boundary (the old buffer-and-swap caught these via `await renderJSX`). Keep
+ all three wrapped. Covered by `catches an async … (no )` specs.
+- **`renderErrorBoundaryFallback` rethrows inside an OOOS segment** (when the boundary has no
+ `$checkpoint$`, i.e. it is OUTSIDE the segment): this propagates so the segment rejects and
+ `SSRDeferredSlot`'s `.catch` routes to the boundary's `$emitFallback$` (whole-boundary teardown).
+ Do not render the fallback in place inside a segment — it breaks the case-3 teardown.
+- **A throwing `fallback$` must not loop.** `streamFallback` detaches `store.$fallback$` while
+ rendering the fallback, so a re-throw that resolves back to the same boundary takes the
+ "no fallback → propagate" path (aborts) instead of re-rendering the fallback forever (deadlock).
+- **KNOWN LIMITATION (regression, tracked):** a CLIENT-time error on a boundary that streamed
+ *without* erroring during SSR does not render the fallback — the content host hides (display
+ signal) but the empty fallback host can't be filled, because the boundary can't cleanly re-render
+ its two-host structure to `[fallback]` on the client. The SSR error path (sync + async) works. The
+ fix is a client-reactive fallback host (render `fallback$` reactively in the fallback host when
+ `store.error` is set, instead of relying on the component re-render). Tracked by the `test.fixme`
+ `scenario=client` e2e in `error-boundary-streaming.e2e.ts`. Client errors on NON-streamed
+ boundaries (no OOOS) still work via the normal reactive re-render.
- A deferred child `` that throws routes to the enclosing boundary's `store.$emitFallback$`
(set by `SSRErrorFallback`), tearing the whole boundary down — not into the Suspense sub-slot.
- A boundary *inside* a `` segment is the one case that still buffers (the segment is
diff --git a/e2e/qwik-e2e/apps/e2e/src/components/error-boundary/error-boundary.tsx b/e2e/qwik-e2e/apps/e2e/src/components/error-boundary/error-boundary.tsx
index 5ad9ea9d917..20c384cd249 100644
--- a/e2e/qwik-e2e/apps/e2e/src/components/error-boundary/error-boundary.tsx
+++ b/e2e/qwik-e2e/apps/e2e/src/components/error-boundary/error-boundary.tsx
@@ -108,6 +108,19 @@ export const ErrorBoundaryStreamingRoot = component$(() => {
releaseParam="release"
/>
>
+ ) : scenario === 'client' ? (
+ // Streams fine (no SSR error); a later client-time throw must re-render to the fallback.
+ }>
+
+
content ok
+
) : (
}>
diff --git a/e2e/qwik-e2e/tests/error-boundary-streaming.e2e.ts b/e2e/qwik-e2e/tests/error-boundary-streaming.e2e.ts
index 2f000e46f05..d8ed9d337d6 100644
--- a/e2e/qwik-e2e/tests/error-boundary-streaming.e2e.ts
+++ b/e2e/qwik-e2e/tests/error-boundary-streaming.e2e.ts
@@ -65,4 +65,26 @@ test.describe('ErrorBoundary streaming swap', () => {
await page.locator('#eb-fallback-button').click();
await expect(page.locator('#eb-fallback-count')).toHaveText('1');
});
+
+ // KNOWN LIMITATION (tracked): 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. Fix needs a client-reactive fallback host.
+ test.fixme('client-time throw after resume re-renders the streamed boundary to its fallback', async ({
+ page,
+ }) => {
+ assertNoBrowserErrors(page);
+ await page.goto('/e2e/error-boundary-streaming?scenario=client', { waitUntil: 'commit' });
+
+ // The boundary streamed fine — content is shown, no fallback yet.
+ await expect(page.locator('#eb-content')).toHaveText('content ok', { timeout: 10000 });
+ await expect(page.locator('#eb-fallback')).toHaveCount(0);
+
+ // A click handler that throws routes to the boundary, which re-renders to its fallback.
+ await page.locator('#eb-client-throw').click();
+ await expect(page.locator('#eb-fallback')).toBeVisible({ timeout: 10000 });
+ await expect(page.locator('#eb-content')).toBeHidden();
+
+ await page.locator('#eb-fallback-button').click();
+ await expect(page.locator('#eb-fallback-count')).toHaveText('1');
+ });
});
diff --git a/packages/qwik/src/core/control-flow/suspense.tsx b/packages/qwik/src/core/control-flow/suspense.tsx
index 68a305c722e..ca1192bd880 100644
--- a/packages/qwik/src/core/control-flow/suspense.tsx
+++ b/packages/qwik/src/core/control-flow/suspense.tsx
@@ -376,11 +376,19 @@ export const SSRErrorFallback = __EXPERIMENTAL__.errorBoundary
// order — a synchronous throw awaits it inline (below) so the swap script follows the content
// immediately instead of landing at end-of-stream; a later async throw awaits it through the
// rejected Suspense segment's promise chain.
- const streamFallback = (error: unknown): Promise => {
+ const streamFallback = async (error: unknown): Promise => {
store.error = error;
- return ssr
- .segment(segmentId, store.$fallback$!(error) as JSXOutput, options)
- .then((segment) => emitErrorBoundaryFallback(ssr, boundaryId, segmentId, segment));
+ // Detach the fallback while it renders: if the fallback itself throws and resolves back to
+ // THIS boundary, it must not re-render the fallback (infinite loop / deadlock). With no
+ // `$fallback$`, that throw takes the "no boundary handles it" path and propagates up.
+ const fallback = store.$fallback$!;
+ store.$fallback$ = undefined;
+ try {
+ const segment = await ssr.segment(segmentId, fallback(error) as JSXOutput, options);
+ await emitErrorBoundaryFallback(ssr, boundaryId, segmentId, segment);
+ } finally {
+ store.$fallback$ = fallback;
+ }
};
store.$emitFallback$ = noSerialize(streamFallback);
// Just reserve the injection point. The shared `qO` executor is emitted lazily (in
diff --git a/packages/qwik/src/core/shared/error/error-boundary.ts b/packages/qwik/src/core/shared/error/error-boundary.ts
index ca1fb1fccff..12189e172b9 100644
--- a/packages/qwik/src/core/shared/error/error-boundary.ts
+++ b/packages/qwik/src/core/shared/error/error-boundary.ts
@@ -64,6 +64,10 @@ export const errorBoundaryCmp = (props: ErrorBoundaryProps): JSXOutput => {
const isServerEnv = qTest ? isServerPlatform() : !isBrowser;
if (__EXPERIMENTAL__.errorBoundary && isServerEnv && isOutOfOrderStreaming()) {
const boundaryId = nextErrorBoundaryId();
+ // KNOWN LIMITATION: a CLIENT-time error on a boundary that streamed without erroring during SSR
+ // does not yet render the fallback — the content host hides (display signal) but the fallback
+ // host stays empty. The SSR error path (sync/async) works; the client-error path needs the
+ // fallback host to render `fallback$` client-reactively (see core-notes). SSR errors only here.
return [
/*#__PURE__*/ _jsxSorted(
'div',
diff --git a/packages/qwik/src/core/ssr/ssr-render-jsx.ts b/packages/qwik/src/core/ssr/ssr-render-jsx.ts
index 483cccae677..07da6afc35c 100644
--- a/packages/qwik/src/core/ssr/ssr-render-jsx.ts
+++ b/packages/qwik/src/core/ssr/ssr-render-jsx.ts
@@ -87,12 +87,34 @@ export async function _walkJSX(
// Reference equality first (no prototype walk), then typeof
if (value === MaybeAsyncSignal) {
const trackFn = stack.pop() as () => StackValue;
- await retryOnPromise(() => stack.push(trackFn()));
+ try {
+ await retryOnPromise(() => stack.push(trackFn()));
+ } catch (err) {
+ // An async signal (e.g. a rejecting computed) threw on read — route it to the closest
+ // ErrorBoundary like a render throw instead of aborting the stream.
+ let fallback = renderErrorBoundaryFallback(ssr, ssr.getOrCreateLastNode(), err);
+ if (isPromise(fallback)) {
+ fallback = await fallback;
+ }
+ stack.push(fallback);
+ }
continue;
}
if (typeof value === 'function') {
if (value === Promise) {
- stack.push(await (stack.pop() as Promise));
+ const pending = stack.pop() as Promise;
+ try {
+ stack.push(await pending);
+ } catch (err) {
+ // An awaited child (async component, promise child, resource) rejected. Route it to
+ // the closest ErrorBoundary just like a synchronous render throw — otherwise the
+ // rejection would abort the whole stream. With no boundary above, this rethrows.
+ let fallback = renderErrorBoundaryFallback(ssr, ssr.getOrCreateLastNode(), err);
+ if (isPromise(fallback)) {
+ fallback = await fallback;
+ }
+ stack.push(fallback);
+ }
} else {
const result = (value as StackFn).apply(ssr);
if (isPromise(result)) {
@@ -136,15 +158,18 @@ function renderErrorBoundaryFallback(
if (__EXPERIMENTAL__.errorBoundary && errorStore.$checkpoint$) {
throw err;
}
+ // Experimental: inside an out-of-order segment, the resolved boundary is OUTSIDE this segment (a
+ // boundary inside it would be buffering, handled above). Propagate so the segment rejects and the
+ // boundary's `$emitFallback$` tears the whole boundary down — rather than rendering the fallback
+ // in place inside this segment's slot.
+ if (__EXPERIMENTAL__.errorBoundary && isOutOfOrderSegmentContainer(ssr)) {
+ throw err;
+ }
// Experimental: a live streaming boundary never blocks streaming and never renders in place. Just
// mark the error — the boundary's fallback host (rendered after the content) streams `fallback$`
// as an out-of-order segment and the shared `qO` executor hides the content and reveals it. The
// throwing subtree renders nothing here.
- if (
- __EXPERIMENTAL__.errorBoundary &&
- ssr.outOfOrderStreaming &&
- !isOutOfOrderSegmentContainer(ssr)
- ) {
+ if (__EXPERIMENTAL__.errorBoundary && ssr.outOfOrderStreaming) {
errorStore.error = err;
return null;
}
@@ -411,7 +436,17 @@ function processJSXNode(
} else if (isPromise(jsxOutput)) {
// Defer reading QScopedStyle until after the promise resolves
enqueue(async () => {
- const resolvedOutput = await jsxOutput;
+ let resolvedOutput: JSXOutput;
+ try {
+ resolvedOutput = await jsxOutput;
+ } catch (err) {
+ // An async component's render rejected — route it to the closest ErrorBoundary
+ // (matching the sync throw path) instead of aborting the stream.
+ resolvedOutput = renderErrorBoundaryFallback(ssr, host, err) as JSXOutput;
+ if (isPromise(resolvedOutput)) {
+ resolvedOutput = await resolvedOutput;
+ }
+ }
const compStyleComponentId = addComponentStylePrefix(host.getProp(QScopedStyle));
enqueue(resolvedOutput);
diff --git a/packages/qwik/src/core/tests/error-boundary.spec.tsx b/packages/qwik/src/core/tests/error-boundary.spec.tsx
index e049b9cbe4d..b67fc23e12e 100644
--- a/packages/qwik/src/core/tests/error-boundary.spec.tsx
+++ b/packages/qwik/src/core/tests/error-boundary.spec.tsx
@@ -1,4 +1,11 @@
-import { $, component$, ErrorBoundary, Suspense, type JSXOutput } from '@qwik.dev/core';
+import {
+ $,
+ component$,
+ createAsync$,
+ ErrorBoundary,
+ Suspense,
+ type JSXOutput,
+} from '@qwik.dev/core';
import { createDocument, domRender, ssrRenderToDom, waitForDrain } from '@qwik.dev/core/testing';
import { describe, expect, it } from 'vitest';
import { processOutOfOrderSegmentVNodeData } from '../client/process-vnode-data';
@@ -16,6 +23,24 @@ const AsyncThrower = component$(() => {
return <>{pending}>;
});
+// An async component whose render itself rejects (returns a rejecting promise, not a promise child) —
+// exercises the async-component drain path rather than the promise-child path.
+const AsyncRejector = component$(
+ () => new Promise((_resolve, reject) => reject(new Error('async boom'))) as any
+);
+
+// A fallback whose own render throws — must not deadlock the stream.
+const FallbackBoomer = component$(() => {
+ throw new Error('fallback boom');
+});
+
+// Reads an async signal (the signal object, not its value) whose computation rejects — exercises the
+// async-signal drain path.
+const AsyncSignalThrower = component$(() => {
+ const sig = createAsync$(() => Promise.reject(new Error('async signal boom')));
+ return <>{sig}>;
+});
+
describe('ErrorBoundary', () => {
it('projects children when there is no error', async () => {
const { container } = await domRender(
@@ -283,4 +308,110 @@ describe('ErrorBoundary streaming swap (experimental)', () => {
expect(document.querySelector('#before')).toBeFalsy();
expect(document.querySelector('#after')).toBeFalsy();
});
+
+ it('catches an async component whose render rejects (no )', async () => {
+ // The async component is not wrapped in a ; its rejection is routed to the boundary
+ // (async-component drain path) instead of aborting the stream.
+ const { document } = await streamAndResume(
+
+ (
+
caught: {String(e?.message ?? e)}
+ ))}
+ >
+
before
+
+
+
+ );
+ expect(document.querySelector('#fb')?.textContent).toContain('caught: async boom');
+ expect(displayOf(document.querySelector('#fb')?.closest('[q\\:rp]'))).toBe('contents');
+ expect(displayOf(document.querySelector('#before')?.closest('div[style]'))).toBe('none');
+ });
+
+ it('catches a rejected promise child (no )', async () => {
+ // A rejected promise child (not wrapped in a ) is routed to the boundary
+ // (promise-child drain path) instead of aborting the stream.
+ const { document } = await streamAndResume(
+
+ (
+
caught: {String(e?.message ?? e)}
+ ))}
+ >
+
+
+
+ );
+ expect(document.querySelector('#fb')?.textContent).toContain('caught: async boom');
+ });
+
+ it('a fallback whose own render throws aborts the stream instead of deadlocking', async () => {
+ // A throwing fallback must not re-render itself forever. With its own `$fallback$` detached
+ // while rendering, the throw propagates (here: aborts to the error page) rather than hanging.
+ await expect(
+ streamAndResume(
+
+ (
+
+ ))}
+ >
+
+
+
+ )
+ ).rejects.toThrow('fallback boom');
+ });
+
+ it('catches an async signal that rejects (no )', async () => {
+ const { document } = await streamAndResume(
+
+ (
+
+
+
+ );
+ // The first boundary errored → its fallback shows; the second is fine → its content shows. The
+ // boundaries use distinct ids, so the swaps don't interfere.
+ expect(document.querySelector('#fb-a')).toBeTruthy();
+ expect(document.querySelector('#ok-b')?.textContent).toBe('b ok');
+ expect(document.querySelector('#fb-b')).toBeFalsy();
+ expect(displayOf(document.querySelector('#fb-a')?.closest('[q\\:rp]'))).toBe('contents');
+ expect(displayOf(document.querySelector('#ok-b')?.closest('div[style]'))).toBe('contents');
+ });
+
+ // NOTE: a client-time error after a streamed (out-of-order) resume is covered by the e2e
+ // (`scenario=client` in error-boundary-streaming.e2e.ts) rather than here: re-rendering a resumed
+ // streamed boundary to its fallback needs the `fallback$` QRL to resolve, which the unit resume
+ // harness (createDocument + emulated scripts) cannot do, so it would hang. The reactive re-render
+ // path itself is unit-tested via `domRender` in the first describe block above.
});
From e6d5ebc47454d424615433c8aa02d17ecfbd440b Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Fri, 19 Jun 2026 08:22:03 +0200
Subject: [PATCH 015/331] docs(qwik): refine ErrorBoundary client-error
limitation with root cause
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 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).
---
.../references/core-notes.md | 25 +++++++++++++------
1 file changed, 18 insertions(+), 7 deletions(-)
diff --git a/.ruler/skills/qwik-core-development/references/core-notes.md b/.ruler/skills/qwik-core-development/references/core-notes.md
index 35f6672a7e4..a189004f52c 100644
--- a/.ruler/skills/qwik-core-development/references/core-notes.md
+++ b/.ruler/skills/qwik-core-development/references/core-notes.md
@@ -215,13 +215,24 @@ Key invariants:
rendering the fallback, so a re-throw that resolves back to the same boundary takes the
"no fallback → propagate" path (aborts) instead of re-rendering the fallback forever (deadlock).
- **KNOWN LIMITATION (regression, tracked):** a CLIENT-time error on a boundary that streamed
- *without* erroring during SSR does not render the fallback — the content host hides (display
- signal) but the empty fallback host can't be filled, because the boundary can't cleanly re-render
- its two-host structure to `[fallback]` on the client. The SSR error path (sync + async) works. The
- fix is a client-reactive fallback host (render `fallback$` reactively in the fallback host when
- `store.error` is set, instead of relying on the component re-render). Tracked by the `test.fixme`
- `scenario=client` e2e in `error-boundary-streaming.e2e.ts`. Client errors on NON-streamed
- boundaries (no OOOS) still work via the normal reactive re-render.
+ *without* erroring during SSR does not render the fallback. The SSR error path (sync + async)
+ works; client errors on NON-streamed boundaries (no OOOS) work via the normal reactive re-render.
+ Investigated in depth — two cascading causes:
+ 1. The error only ROUTES to the boundary if the throwing handler resumed the container first (a
+ handler that touches a signal does; a pure `() => { throw }` doesn't, so `$qErrorHandler$`
+ isn't attached yet and the `qerror` is dropped). This is a general qerror/resume-timing point,
+ not boundary-specific.
+ 2. Even once it routes (`handleError` sets `store.error`), filling the fallback host on the client
+ asserts `"Missing child"`: the fallback host contains the raw `qO` `` placeholder
+ (written via `ssr.write`, no vnode), and any client-side re-render of that host's contents
+ (whether the boundary re-renders to `[fallback]`, or a sibling client-reactive component
+ renders into the host) trips the vnode diff on the vnode-less template. The `qO` placeholder
+ mechanism (great for the instant SSR swap) and client-side vnode re-rendering don't compose.
+ Fix direction (a real change, not done): inject the fallback on the client the SAME way the server
+ does — render `fallback$` to a `` and run the `qO` injection client-side (imperative DOM
+ swap, bypassing the vnode diff) — or make `writeOutOfOrderPlaceholder` emit a vnode-backed
+ placeholder (touches Suspense too). A naive "client-reactive fallback host" does NOT work (hits
+ cause 2). Tracked by the `test.fixme` `scenario=client` e2e in `error-boundary-streaming.e2e.ts`.
- A deferred child `` that throws routes to the enclosing boundary's `store.$emitFallback$`
(set by `SSRErrorFallback`), tearing the whole boundary down — not into the Suspense sub-slot.
- A boundary *inside* a `` segment is the one case that still buffers (the segment is
From 2ec84899ad1fbc2d32727649813c08242ee71f33 Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Fri, 19 Jun 2026 09:19:22 +0200
Subject: [PATCH 016/331] fix(qwik): ErrorBoundary client-time errors
(noSerialize taint + handleError loop)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A client-time error on ANY SSR'd (in-order streaming included)
infinite-looped handleError (~50k calls) and never showed the fallback.
Root cause: `store.$fallback$ = noSerialize(props.fallback$)`. noSerialize marks
the value's IDENTITY, and props.fallback$ is the SAME QRL object, so the
serialized prop was dropped and resumed as `undefined`; the client re-render's
`props.fallback$(error)` threw "undefined is not a function", which re-entered
handleError with no guard.
Fixes:
- noSerialize a fresh closure (`(e) => fallbackQrl(e)`) so props.fallback$ stays
serializable and is defined on the client.
- handleError re-entrancy guard: if the closest boundary already holds an error
(`!= null` — covers ErrorBoundary's `undefined` init and the generic
ERROR_CONTEXT `null` init), propagate instead of re-triggering — so a throwing
fallback surfaces/propagates rather than hanging the tab.
Tests: the in-order `scenario=client` e2e (un-fixme'd; the handler touches a
signal then throws so the container resumes and qerror routes) + a domRender
throwing-fallback loop-guard unit test. The unit resume harness inlines QRLs so
it can't catch the taint — the e2e does.
Still open (lower priority, OOOS two-host only): a client error on an
OOOS-streamed boundary can't fill the qO `` placeholder host on the
client (vnode diff "Missing child"); documented in core-notes.
---
.../references/core-notes.md | 42 ++++++++++---------
.../error-boundary/error-boundary.tsx | 4 ++
.../tests/error-boundary-streaming.e2e.ts | 21 ++++++----
.../qwik/src/core/client/dom-container.ts | 11 ++++-
.../src/core/shared/error/error-boundary.ts | 18 +++++---
.../src/core/tests/error-boundary.spec.tsx | 27 ++++++++++++
6 files changed, 88 insertions(+), 35 deletions(-)
diff --git a/.ruler/skills/qwik-core-development/references/core-notes.md b/.ruler/skills/qwik-core-development/references/core-notes.md
index a189004f52c..18c89187b38 100644
--- a/.ruler/skills/qwik-core-development/references/core-notes.md
+++ b/.ruler/skills/qwik-core-development/references/core-notes.md
@@ -214,25 +214,29 @@ Key invariants:
- **A throwing `fallback$` must not loop.** `streamFallback` detaches `store.$fallback$` while
rendering the fallback, so a re-throw that resolves back to the same boundary takes the
"no fallback → propagate" path (aborts) instead of re-rendering the fallback forever (deadlock).
-- **KNOWN LIMITATION (regression, tracked):** a CLIENT-time error on a boundary that streamed
- *without* erroring during SSR does not render the fallback. The SSR error path (sync + async)
- works; client errors on NON-streamed boundaries (no OOOS) work via the normal reactive re-render.
- Investigated in depth — two cascading causes:
- 1. The error only ROUTES to the boundary if the throwing handler resumed the container first (a
- handler that touches a signal does; a pure `() => { throw }` doesn't, so `$qErrorHandler$`
- isn't attached yet and the `qerror` is dropped). This is a general qerror/resume-timing point,
- not boundary-specific.
- 2. Even once it routes (`handleError` sets `store.error`), filling the fallback host on the client
- asserts `"Missing child"`: the fallback host contains the raw `qO` `` placeholder
- (written via `ssr.write`, no vnode), and any client-side re-render of that host's contents
- (whether the boundary re-renders to `[fallback]`, or a sibling client-reactive component
- renders into the host) trips the vnode diff on the vnode-less template. The `qO` placeholder
- mechanism (great for the instant SSR swap) and client-side vnode re-rendering don't compose.
- Fix direction (a real change, not done): inject the fallback on the client the SAME way the server
- does — render `fallback$` to a `` and run the `qO` injection client-side (imperative DOM
- swap, bypassing the vnode diff) — or make `writeOutOfOrderPlaceholder` emit a vnode-backed
- placeholder (touches Suspense too). A naive "client-reactive fallback host" does NOT work (hits
- cause 2). Tracked by the `test.fixme` `scenario=client` e2e in `error-boundary-streaming.e2e.ts`.
+- **FIXED — the critical client-error loop.** A client-time error on ANY SSR'd ``
+ (in-order or OOOS) used to infinite-loop `handleError` (~50k calls) and never show the fallback.
+ Root cause was NOT a lazy QRL — it was `noSerialize`: `store.$fallback$ = noSerialize(props.fallback$)`
+ taints the value's IDENTITY, and `props.fallback$` is the SAME QRL object, so the serialized prop
+ was dropped and resumed as `undefined`; the client re-render's `props.fallback$(error)` threw
+ `undefined is not a function`, which re-entered `handleError` with no guard. Fixed by (a)
+ `noSerialize`-ing a FRESH closure (`(e) => fallbackQrl(e)`) so the prop stays serializable, and
+ (b) a re-entrancy guard in `handleError` (`dom-container.ts`) that propagates when the boundary
+ already holds an error (`errorStore.error != null` — covers the `undefined` ErrorBoundary init AND
+ the `null` generic-ERROR_CONTEXT/`ErrorProvider` init) instead of looping. Covered by the in-order
+ `scenario=client` e2e + a `domRender` throwing-fallback unit test (the unit harness inlines QRLs,
+ so it can't catch the taint — only the loop guard; the e2e catches the taint).
+ GENERAL GOTCHA: never `noSerialize` a value you also need serialized elsewhere — `noSerialize`
+ marks the object identity, so wrap a fresh object/closure.
+- **OPEN (secondary, OOOS two-host only):** a client error on a boundary that streamed via OOOS
+ can't fill the fallback host — it holds the raw `qO` `` placeholder (written via
+ `ssr.write`, no vnode), so any client-side re-render of its contents trips the vnode diff
+ (`"Missing child"`). The `qO` placeholder (great for the instant SSR swap) and client-side vnode
+ re-render don't compose. Fix direction: inject the client fallback the SAME way the server does
+ (render `fallback$` to a `` + imperative `qO` swap, bypassing the diff), or make
+ `writeOutOfOrderPlaceholder` emit a vnode-backed placeholder. Also note a qerror only ROUTES if the
+ throwing handler resumed the container first (touching a signal does; a bare `() => { throw }`
+ doesn't, so `$qErrorHandler$` isn't attached yet).
- A deferred child `` that throws routes to the enclosing boundary's `store.$emitFallback$`
(set by `SSRErrorFallback`), tearing the whole boundary down — not into the Suspense sub-slot.
- A boundary *inside* a `` segment is the one case that still buffers (the segment is
diff --git a/e2e/qwik-e2e/apps/e2e/src/components/error-boundary/error-boundary.tsx b/e2e/qwik-e2e/apps/e2e/src/components/error-boundary/error-boundary.tsx
index 20c384cd249..0460bdfbae6 100644
--- a/e2e/qwik-e2e/apps/e2e/src/components/error-boundary/error-boundary.tsx
+++ b/e2e/qwik-e2e/apps/e2e/src/components/error-boundary/error-boundary.tsx
@@ -90,6 +90,7 @@ const EbAsyncThrower = component$(() => {
export const ErrorBoundaryStreamingRoot = component$(() => {
const url = useServerData('url');
const scenario = getSearchParam(url, 'scenario');
+ const touched = useSignal(0);
return (
@@ -110,15 +111,18 @@ export const ErrorBoundaryStreamingRoot = component$(() => {
>
) : scenario === 'client' ? (
// Streams fine (no SSR error); a later client-time throw must re-render to the fallback.
+ // The handler touches a signal first so the container resumes and the qerror actually routes.
}>
+ {touched.value}
content ok
) : (
diff --git a/e2e/qwik-e2e/tests/error-boundary-streaming.e2e.ts b/e2e/qwik-e2e/tests/error-boundary-streaming.e2e.ts
index d8ed9d337d6..56bf3ea4145 100644
--- a/e2e/qwik-e2e/tests/error-boundary-streaming.e2e.ts
+++ b/e2e/qwik-e2e/tests/error-boundary-streaming.e2e.ts
@@ -66,24 +66,27 @@ test.describe('ErrorBoundary streaming swap', () => {
await expect(page.locator('#eb-fallback-count')).toHaveText('1');
});
- // KNOWN LIMITATION (tracked): 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. Fix needs a client-reactive fallback host.
- test.fixme('client-time throw after resume re-renders the streamed boundary to its fallback', async ({
+ // A client-time error on an SSR'd boundary (here in-order streaming, `outOfOrder=false`) must
+ // re-render to the fallback — `fallback$` is a lazy QRL after resume, so the client render must
+ // resolve it instead of calling it synchronously, and a throwing fallback render must not loop.
+ test('client-time throw after resume re-renders the boundary to its fallback (in-order)', async ({
page,
}) => {
- assertNoBrowserErrors(page);
- await page.goto('/e2e/error-boundary-streaming?scenario=client', { waitUntil: 'commit' });
+ // The intentional throw may log; we only care that the boundary recovers (no infinite loop).
+ await page.goto('/e2e/error-boundary-streaming?scenario=client&outOfOrder=false', {
+ waitUntil: 'commit',
+ });
- // The boundary streamed fine — content is shown, no fallback yet.
+ // The boundary rendered fine — content is shown, no fallback yet.
await expect(page.locator('#eb-content')).toHaveText('content ok', { timeout: 10000 });
await expect(page.locator('#eb-fallback')).toHaveCount(0);
- // A click handler that throws routes to the boundary, which re-renders to its fallback.
+ // A click handler that touches state then throws routes to the boundary, which re-renders to the
+ // fallback (and must not infinite-loop handleError).
await page.locator('#eb-client-throw').click();
await expect(page.locator('#eb-fallback')).toBeVisible({ timeout: 10000 });
- await expect(page.locator('#eb-content')).toBeHidden();
+ // The recovered fallback is interactive.
await page.locator('#eb-fallback-button').click();
await expect(page.locator('#eb-fallback-count')).toHaveText('1');
});
diff --git a/packages/qwik/src/core/client/dom-container.ts b/packages/qwik/src/core/client/dom-container.ts
index 310b1f0dd8c..a3eb786872a 100644
--- a/packages/qwik/src/core/client/dom-container.ts
+++ b/packages/qwik/src/core/client/dom-container.ts
@@ -246,6 +246,16 @@ export class DomContainer extends _SharedContainer implements IClientContainer {
}
handleError(err: any, host: VNode | null): void {
+ const errorStore = host && this.resolveContext(host, ERROR_CONTEXT);
+ // Re-entrancy guard: if the closest boundary already holds an error, a further throw — e.g. the
+ // boundary's own fallback render failing — must NOT re-trigger it. Otherwise handleError loops
+ // forever (set error → re-render → fallback throws → handleError → ...). Propagate instead, so a
+ // throwing fallback surfaces (or reaches a parent boundary) rather than hanging the tab.
+ // `!= null` covers both store init sentinels: `` uses `undefined`, the generic
+ // ERROR_CONTEXT path uses `null` — neither counts as "already errored".
+ if (errorStore && errorStore.error != null) {
+ throw err;
+ }
if (qDev && host) {
if (typeof document !== 'undefined') {
setErrorPayload(host, err);
@@ -261,7 +271,6 @@ export class DomContainer extends _SharedContainer implements IClientContainer {
throw err;
}
}
- const errorStore = host && this.resolveContext(host, ERROR_CONTEXT);
if (!errorStore) {
throw err;
}
diff --git a/packages/qwik/src/core/shared/error/error-boundary.ts b/packages/qwik/src/core/shared/error/error-boundary.ts
index 12189e172b9..a8eb762c61a 100644
--- a/packages/qwik/src/core/shared/error/error-boundary.ts
+++ b/packages/qwik/src/core/shared/error/error-boundary.ts
@@ -58,16 +58,20 @@ const _ebFallbackStyle_str = '{display:p0.error?"contents":"none"}';
export const errorBoundaryCmp = (props: ErrorBoundaryProps): JSXOutput => {
const store = useErrorBoundaryStore();
// Expose the fallback so SSR can stream it when a child throws (the client re-renders this
- // component instead, which reads `store.error` below). Server-render-only, hence noSerialize.
- store.$fallback$ = noSerialize(props.fallback$);
+ // component instead, which reads `store.error` below). Wrap in a fresh closure before
+ // `noSerialize` — `noSerialize` taints the value's identity, and `props.fallback$` is the SAME QRL
+ // object, so noSerializing it directly would also drop the serialized prop (it would resume as
+ // `undefined`, and the client re-render would call `undefined(error)`).
+ const fallbackQrl = props.fallback$;
+ store.$fallback$ = noSerialize((error: any) => fallbackQrl(error));
const isServerEnv = qTest ? isServerPlatform() : !isBrowser;
if (__EXPERIMENTAL__.errorBoundary && isServerEnv && isOutOfOrderStreaming()) {
const boundaryId = nextErrorBoundaryId();
- // KNOWN LIMITATION: a CLIENT-time error on a boundary that streamed without erroring during SSR
- // does not yet render the fallback — the content host hides (display signal) but the fallback
- // host stays empty. The SSR error path (sync/async) works; the client-error path needs the
- // fallback host to render `fallback$` client-reactively (see core-notes). SSR errors only here.
+ // OPEN (OOOS-only): a CLIENT-time error on a boundary that streamed via OOOS can't fill this
+ // fallback host on the client — it holds the `qO` `` placeholder (no vnode), so a
+ // client re-render of its contents trips the vnode diff ("Missing child"). The SSR error path
+ // and IN-ORDER client errors work (see core-notes for the fix direction). SSR errors only here.
return [
/*#__PURE__*/ _jsxSorted(
'div',
@@ -92,6 +96,8 @@ export const errorBoundaryCmp = (props: ErrorBoundaryProps): JSXOutput => {
}
if (store.error) {
+ // `fallback$` is a lazy QRL after resume; invoking it returns the JSX (or a promise the renderer
+ // awaits). It is now serialized (see the closure note above), so it is defined here.
return /*#__PURE__*/ _jsxSorted(Fragment, null, null, props.fallback$(store.error), 0, null);
}
diff --git a/packages/qwik/src/core/tests/error-boundary.spec.tsx b/packages/qwik/src/core/tests/error-boundary.spec.tsx
index b67fc23e12e..23e521fe396 100644
--- a/packages/qwik/src/core/tests/error-boundary.spec.tsx
+++ b/packages/qwik/src/core/tests/error-boundary.spec.tsx
@@ -123,6 +123,33 @@ describe('ErrorBoundary', () => {
expect(el.querySelector('#fb-inner')).toBeTruthy();
expect(el.querySelector('#fb-outer')).toBeFalsy();
});
+
+ it('client: a throwing fallback does not infinite-loop handleError', async () => {
+ const { container } = await domRender(
+ {
+ throw new Error('fallback boom');
+ })}
+ >
+
+ ,
+ { debug }
+ );
+ const el = container.element;
+ const target = el.querySelector('#target')!;
+ const ev = el.ownerDocument.createEvent('Event');
+ ev.initEvent('qerror', false, false);
+ (ev as any).detail = { error: new Error('client boom'), element: target };
+ el.ownerDocument.dispatchEvent(ev);
+ // The re-entrancy guard breaks the loop; without it this hangs (~infinite handleError calls).
+ // A throwing fallback may surface as a drain rejection — that's fine; the point is it terminates.
+ try {
+ await waitForDrain(container);
+ } catch {
+ /* throwing fallback propagated — acceptable, as long as we didn't loop */
+ }
+ expect(el.querySelector('#fb')).toBeFalsy();
+ });
});
describe('ErrorBoundary streaming swap (experimental)', () => {
From 262326269951566e81240c159ceea6a708ec53de Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Fri, 19 Jun 2026 12:03:55 +0200
Subject: [PATCH 017/331] fix(qwik): render OOOS-streamed ErrorBoundary
fallback on client error
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A client-time error on an streamed via out-of-order streaming never showed its fallback: the OOOS branch of errorBoundaryCmp returns its two-host structure early without reading store.error, so the boundary never subscribed and never re-rendered — the inline style swap just revealed the (empty) streamed fallback host.
handleError now explicitly re-renders the owning boundary after setting store.error, scoped to real boundaries via the error===undefined init sentinel (generic ERROR_CONTEXT consumers use null and only capture). Subscribing the OOOS component to store.error instead would schedule a re-render chore on an already-streamed host on a deferred SSR throw. Adds an OOOS scenario=client e2e and a ssrRenderToDom+outOfOrder+qerror unit test.
---
.../references/core-notes.md | 33 +++++++++-----
.../tests/error-boundary-streaming.e2e.ts | 25 +++++++++++
.../qwik/src/core/client/dom-container.ts | 28 ++++++++++++
.../src/core/shared/error/error-boundary.ts | 14 ++++--
.../src/core/tests/error-boundary.spec.tsx | 44 ++++++++++++++++---
5 files changed, 124 insertions(+), 20 deletions(-)
diff --git a/.ruler/skills/qwik-core-development/references/core-notes.md b/.ruler/skills/qwik-core-development/references/core-notes.md
index 18c89187b38..1c03913f7fe 100644
--- a/.ruler/skills/qwik-core-development/references/core-notes.md
+++ b/.ruler/skills/qwik-core-development/references/core-notes.md
@@ -189,8 +189,9 @@ reading `store.error`, so the resumed/re-rendered boundary stays consistent with
Key invariants:
- The swap is an inline script (fires as its chunk parses) — it does NOT depend on the client
- re-rendering, so it works before the framework resumes. Client-time errors still use the reactive
- re-render path (`handleError` → `store.error`).
+ re-rendering, so it works before the framework resumes. Client-time errors instead go through
+ `handleError`, which sets `store.error` and explicitly re-renders the boundary (see the OOOS
+ client-error note below).
- A SYNC throw emits the fallback segment + `qO(id)` **inline** (`SSRErrorFallback` returns the
emission promise, awaited in the drain), so the swap lands right after the boundary — NOT via
`queueOutOfOrderSegment`, which would defer it to end-of-stream and leave the broken content
@@ -228,15 +229,25 @@ Key invariants:
so it can't catch the taint — only the loop guard; the e2e catches the taint).
GENERAL GOTCHA: never `noSerialize` a value you also need serialized elsewhere — `noSerialize`
marks the object identity, so wrap a fresh object/closure.
-- **OPEN (secondary, OOOS two-host only):** a client error on a boundary that streamed via OOOS
- can't fill the fallback host — it holds the raw `qO` `` placeholder (written via
- `ssr.write`, no vnode), so any client-side re-render of its contents trips the vnode diff
- (`"Missing child"`). The `qO` placeholder (great for the instant SSR swap) and client-side vnode
- re-render don't compose. Fix direction: inject the client fallback the SAME way the server does
- (render `fallback$` to a `` + imperative `qO` swap, bypassing the diff), or make
- `writeOutOfOrderPlaceholder` emit a vnode-backed placeholder. Also note a qerror only ROUTES if the
- throwing handler resumed the container first (touching a signal does; a bare `() => { throw }`
- doesn't, so `$qErrorHandler$` isn't attached yet).
+- **FIXED (OOOS two-host + client error).** A client-time error on a boundary that streamed via OOOS
+ used to never show its fallback. The real cause was a SUBSCRIPTION GAP, not a vnode diff: the OOOS
+ branch of `errorBoundaryCmp` returns the two-host structure early WITHOUT reading `store.error`, so
+ the boundary never subscribed to it. A client error then only flipped the inline `_fnSignal` style
+ swap — revealing the still-empty `q:rp` fallback host (it holds just the `qO` ``
+ placeholder) — and the component never re-rendered to `fallback$`. It was NOT a `"Missing child"`
+ diff: the existing `hasOnlySuspensePlaceholder` guard in `vnode_getFirstChild` already removes the
+ placeholder host cleanly once the boundary re-renders. Fix: `DomContainer.handleError` (client-only)
+ now explicitly re-renders the boundary — `markVNodeDirty(boundaryHost, ChoreBits.COMPONENT)` after
+ setting `store.error` — so an OOOS boundary (never subscribed) swaps to its fallback like an in-order
+ one (still re-renders via its `store.error` subscription; the mark is a no-op when that already
+ scheduled it). Scoped to real boundaries via the `store.error === undefined` init sentinel — a
+ generic ERROR_CONTEXT/`ErrorProvider` consumer uses `null` and only captures, never re-renders.
+ Deliberately NOT fixed by subscribing the OOOS component to `store.error`: a deferred (async) throw
+ sets `store.error` mid-stream, which would schedule a re-render chore on the already-streamed host
+ (the dev `"chore scheduled on a host that was already streamed"` warning). Covered by the OOOS
+ `scenario=client` e2e + a `ssrRenderToDom` + `outOfOrder` + `qerror` unit test. Note a qerror only
+ ROUTES if the throwing handler resumed the container first (touching a signal does; a bare
+ `() => { throw }` doesn't, so `$qErrorHandler$` isn't attached yet).
- A deferred child `` that throws routes to the enclosing boundary's `store.$emitFallback$`
(set by `SSRErrorFallback`), tearing the whole boundary down — not into the Suspense sub-slot.
- A boundary *inside* a `` segment is the one case that still buffers (the segment is
diff --git a/e2e/qwik-e2e/tests/error-boundary-streaming.e2e.ts b/e2e/qwik-e2e/tests/error-boundary-streaming.e2e.ts
index 56bf3ea4145..5d73875c871 100644
--- a/e2e/qwik-e2e/tests/error-boundary-streaming.e2e.ts
+++ b/e2e/qwik-e2e/tests/error-boundary-streaming.e2e.ts
@@ -90,4 +90,29 @@ test.describe('ErrorBoundary streaming swap', () => {
await page.locator('#eb-fallback-button').click();
await expect(page.locator('#eb-fallback-count')).toHaveText('1');
});
+
+ // Same client-time throw, but on a boundary that streamed via out-of-order streaming (OOOS). The
+ // fallback host holds the `qO` `` placeholder (no vnode), so the client re-render
+ // must still tear the two-host structure down and render the fallback fresh.
+ test('client-time throw after resume re-renders the boundary to its fallback (out-of-order)', async ({
+ page,
+ }) => {
+ await page.goto('/e2e/error-boundary-streaming?scenario=client', {
+ waitUntil: 'commit',
+ });
+
+ // The boundary streamed fine (no SSR error) — content is shown, no fallback yet.
+ await expect(page.locator('#eb-content')).toHaveText('content ok', { timeout: 10000 });
+ await expect(page.locator('#eb-fallback')).toHaveCount(0);
+
+ // A click handler that touches state then throws routes to the boundary. The boundary streamed
+ // via OOOS (its fallback host holds only the empty `qO` placeholder), so it must re-render to the
+ // fallback rather than just revealing the empty host.
+ await page.locator('#eb-client-throw').click();
+ await expect(page.locator('#eb-fallback')).toBeVisible({ timeout: 10000 });
+
+ // The recovered fallback is interactive.
+ await page.locator('#eb-fallback-button').click();
+ await expect(page.locator('#eb-fallback-count')).toHaveText('1');
+ });
});
diff --git a/packages/qwik/src/core/client/dom-container.ts b/packages/qwik/src/core/client/dom-container.ts
index a3eb786872a..3d5144da9ae 100644
--- a/packages/qwik/src/core/client/dom-container.ts
+++ b/packages/qwik/src/core/client/dom-container.ts
@@ -274,7 +274,23 @@ export class DomContainer extends _SharedContainer implements IClientContainer {
if (!errorStore) {
throw err;
}
+ // Distinguish a re-rendering `` from a generic ERROR_CONTEXT consumer that only
+ // captures the error: a boundary's store starts `error: undefined`, the generic path uses `null`
+ // (the same init sentinel the re-entrancy guard above relies on). Only the boundary re-renders.
+ const isErrorBoundary = errorStore.error === undefined;
errorStore.error = err;
+ // Re-render the boundary so it swaps to its fallback. An in-order boundary already re-renders from
+ // the reactive write above (it read `store.error` during render, so it subscribed), but a boundary
+ // streamed via out-of-order streaming returned its two-host structure early without reading
+ // `store.error`, so the write alone only flips the inline style swap — revealing the still-empty
+ // streamed fallback host. Marking the boundary host re-renders it to `fallback$` (a no-op when the
+ // reactive write already marked it).
+ if (isErrorBoundary && host) {
+ const boundaryHost = this.resolveContextHost(host, ERROR_CONTEXT);
+ if (boundaryHost) {
+ markVNodeDirty(this, boundaryHost, ChoreBits.COMPONENT);
+ }
+ }
}
setContext(host: VNode, context: ContextId, value: T): void {
@@ -296,6 +312,18 @@ export class DomContainer extends _SharedContainer implements IClientContainer {
return undefined;
}
+ /** Like {@link resolveContext}, but returns the host that provides the context, not its value. */
+ resolveContextHost(host: VNode, contextId: ContextId): VNode | null {
+ while (host) {
+ const ctx = this.getHostProp>(host, QCtxAttr);
+ if (ctx != null && mapArray_has(ctx, contextId.id, 0)) {
+ return host;
+ }
+ host = this.getParentHost(host)!;
+ }
+ return null;
+ }
+
getParentHost(host: VNode): VNode | null {
let vNode: VNode | null = host.parent;
while (vNode) {
diff --git a/packages/qwik/src/core/shared/error/error-boundary.ts b/packages/qwik/src/core/shared/error/error-boundary.ts
index a8eb762c61a..2b3f7c160b0 100644
--- a/packages/qwik/src/core/shared/error/error-boundary.ts
+++ b/packages/qwik/src/core/shared/error/error-boundary.ts
@@ -68,10 +68,16 @@ export const errorBoundaryCmp = (props: ErrorBoundaryProps): JSXOutput => {
const isServerEnv = qTest ? isServerPlatform() : !isBrowser;
if (__EXPERIMENTAL__.errorBoundary && isServerEnv && isOutOfOrderStreaming()) {
const boundaryId = nextErrorBoundaryId();
- // OPEN (OOOS-only): a CLIENT-time error on a boundary that streamed via OOOS can't fill this
- // fallback host on the client — it holds the `qO` `` placeholder (no vnode), so a
- // client re-render of its contents trips the vnode diff ("Missing child"). The SSR error path
- // and IN-ORDER client errors work (see core-notes for the fix direction). SSR errors only here.
+ // SSR with out-of-order streaming: render the subtree inside a visible content host plus a hidden
+ // fallback host. The content streams as usual; an SSR throw streams `fallback$` into the fallback
+ // host and the `qO` executor swaps the two via inline style. This branch deliberately does NOT
+ // read `store.error`, so the component is not subscribed to it on the server (a late deferred
+ // throw sets `store.error` mid-stream, and re-rendering an already-streamed host is unsupported).
+ // A CLIENT-time error instead re-renders this component into the `if (store.error)` branch below —
+ // the container's `handleError` marks this host dirty (see DomContainer.handleError) since the
+ // OOOS boundary never subscribed to `store.error`. The diff then drops the two-host structure (the
+ // empty `q:r` placeholder host removes cleanly, see `hasOnlySuspensePlaceholder`) and renders
+ // `fallback$` fresh.
return [
/*#__PURE__*/ _jsxSorted(
'div',
diff --git a/packages/qwik/src/core/tests/error-boundary.spec.tsx b/packages/qwik/src/core/tests/error-boundary.spec.tsx
index 23e521fe396..a2654f53f1b 100644
--- a/packages/qwik/src/core/tests/error-boundary.spec.tsx
+++ b/packages/qwik/src/core/tests/error-boundary.spec.tsx
@@ -436,9 +436,43 @@ describe('ErrorBoundary streaming swap (experimental)', () => {
expect(displayOf(document.querySelector('#ok-b')?.closest('div[style]'))).toBe('contents');
});
- // NOTE: a client-time error after a streamed (out-of-order) resume is covered by the e2e
- // (`scenario=client` in error-boundary-streaming.e2e.ts) rather than here: re-rendering a resumed
- // streamed boundary to its fallback needs the `fallback$` QRL to resolve, which the unit resume
- // harness (createDocument + emulated scripts) cannot do, so it would hang. The reactive re-render
- // path itself is unit-tested via `domRender` in the first describe block above.
+ // A client-time error after an out-of-order streamed resume must re-render the boundary to its
+ // fallback. The fallback host only holds the empty `qO` `` placeholder (no fallback
+ // was streamed, since there was no SSR error), so revealing it reactively shows nothing — the
+ // boundary has to re-render to `fallback$` and drop the two-host structure. Regression for the
+ // subscription gap: the OOOS branch returned early without reading `store.error`, so the component
+ // never re-rendered on a client error. (The full browser round-trip is also covered by
+ // `scenario=client` in error-boundary-streaming.e2e.ts.)
+ it('client: a post-resume error on an out-of-order streamed boundary re-renders to its fallback', async () => {
+ const { container } = await ssrRenderToDom(
+
+ (
+
caught: {e.message}
+ ))}
+ >
+
+
content ok
+
+ ,
+ { streaming: { inOrder: { strategy: 'disabled' }, outOfOrder: true }, debug }
+ );
+ const el = container.element;
+ // Streamed fine: the content is present and no fallback was streamed.
+ expect(el.querySelector('#content')?.textContent).toBe('content ok');
+ expect(el.querySelector('#fb')).toBeFalsy();
+
+ // Route a client-time error to the boundary, mirroring a resumed `qerror`.
+ const target = el.querySelector('#target')!;
+ const ev = el.ownerDocument.createEvent('Event');
+ ev.initEvent('qerror', false, false);
+ (ev as any).detail = { error: new Error('client boom'), element: target };
+ el.ownerDocument.dispatchEvent(ev);
+ await waitForDrain(container);
+
+ // The boundary re-rendered to its fallback; the two-host structure (incl. the placeholder host)
+ // is gone.
+ expect(el.querySelector('#fb')?.textContent).toContain('caught: client boom');
+ expect(el.querySelector('#content')).toBeFalsy();
+ });
});
From 6c86603705f11b196b91ae734e31da3319033c6d Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Fri, 19 Jun 2026 12:59:27 +0200
Subject: [PATCH 018/331] fix(qwik): make build.core pass (types, tsdoc,
regenerated api)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
build.core.dev (fast) missed these; build.core (strict tsc + api-extractor) catches them.
- ssr-render-jsx: keep the async-component fallback typed as ValueOrPromise and
narrow it in a ternary. The `as JSXOutput` cast discarded the Promise type, so
the later `await` resolved to `unknown` (TS2322).
- dom-container: `resolveContextHost`'s JSDoc used `{@link resolveContext}`, an
unresolvable api-extractor link (it's a method, not a package export) — use a
code span instead.
- qwik-vite: the `errorBoundary` ExperimentalFeatures doc comment wrapped a code
span across a line break, which TSDoc rejects (code-span-missing-delimiter +
unescaped `>`) — shortened to one line (the old text was also stale).
- regenerated qwik.core.api.md (resolveContextHost) + docs api.json/index.mdx.
---
packages/docs/src/routes/api/qwik-optimizer/api.json | 2 +-
packages/docs/src/routes/api/qwik-optimizer/index.mdx | 2 +-
packages/qwik-vite/src/plugins/plugin.ts | 5 +----
packages/qwik/src/core/client/dom-container.ts | 2 +-
packages/qwik/src/core/qwik.core.api.md | 1 +
packages/qwik/src/core/ssr/ssr-render-jsx.ts | 6 ++----
6 files changed, 7 insertions(+), 11 deletions(-)
diff --git a/packages/docs/src/routes/api/qwik-optimizer/api.json b/packages/docs/src/routes/api/qwik-optimizer/api.json
index b7070ec460d..1cf3cc34a1b 100644
--- a/packages/docs/src/routes/api/qwik-optimizer/api.json
+++ b/packages/docs/src/routes/api/qwik-optimizer/api.json
@@ -60,7 +60,7 @@
}
],
"kind": "Enum",
- "content": "Use `__EXPERIMENTAL__.x` to check if feature `x` is enabled. It will be replaced with `true` or `false` via an exact string replacement.\n\nAdd experimental features to this enum definition.\n\n\n```typescript\nexport declare enum ExperimentalFeatures \n```\n\n\n
\n\nMember\n\n\n
\n\nValue\n\n\n
\n\nDescription\n\n\n
\n
\n\neach\n\n\n
\n\n`\"each\"`\n\n\n
\n\nEnable the Each keyed-list primitive\n\n\n
\n
\n\nerrorBoundary\n\n\n
\n\n`\"errorBoundary\"`\n\n\n
\n\nEnable out-of-order streaming swap for `` (SSR renders a clean `boundary > fallback`)\n\n\n
\n
\n\ninsights\n\n\n
\n\n`\"insights\"`\n\n\n
\n\nEnable the ability to use the Qwik Insights vite plugin and `` component\n\n\n
\n
\n\nnoSPA\n\n\n
\n\n`\"noSPA\"`\n\n\n
\n\nDisable SPA navigation handler in Qwik Router\n\n\n
\n
\n\nshow\n\n\n
\n\n`\"show\"`\n\n\n
\n\nEnable the Show conditional primitive\n\n\n
\n
\n\nsuspense\n\n\n
\n\n`\"suspense\"`\n\n\n
\n\nEnable the Suspense fallback primitive\n\n\n
\n
\n\nvalibot\n\n\n
\n\n`\"valibot\"`\n\n\n
\n\nEnable the Valibot form validation\n\n\n
\n
",
+ "content": "Use `__EXPERIMENTAL__.x` to check if feature `x` is enabled. It will be replaced with `true` or `false` via an exact string replacement.\n\nAdd experimental features to this enum definition.\n\n\n```typescript\nexport declare enum ExperimentalFeatures \n```\n\n\n
\n\nMember\n\n\n
\n\nValue\n\n\n
\n\nDescription\n\n\n
\n
\n\neach\n\n\n
\n\n`\"each\"`\n\n\n
\n\nEnable the Each keyed-list primitive\n\n\n
\n
\n\nerrorBoundary\n\n\n
\n\n`\"errorBoundary\"`\n\n\n
\n\nEnable out-of-order streaming swap for the ErrorBoundary fallback\n\n\n
\n
\n\ninsights\n\n\n
\n\n`\"insights\"`\n\n\n
\n\nEnable the ability to use the Qwik Insights vite plugin and `` component\n\n\n
\n
\n\nnoSPA\n\n\n
\n\n`\"noSPA\"`\n\n\n
\n\nDisable SPA navigation handler in Qwik Router\n\n\n
-Enable out-of-order streaming swap for `` (SSR renders a clean `boundary > fallback`)
+Enable out-of-order streaming swap for the ErrorBoundary fallback
diff --git a/packages/qwik-vite/src/plugins/plugin.ts b/packages/qwik-vite/src/plugins/plugin.ts
index 3f196ccf0fd..09fcba7da0a 100644
--- a/packages/qwik-vite/src/plugins/plugin.ts
+++ b/packages/qwik-vite/src/plugins/plugin.ts
@@ -84,10 +84,7 @@ export enum ExperimentalFeatures {
show = 'show',
/** Enable the Suspense fallback primitive */
suspense = 'suspense',
- /**
- * Enable out-of-order streaming swap for `` (SSR renders a clean `boundary >
- * fallback`)
- */
+ /** Enable out-of-order streaming swap for the ErrorBoundary fallback */
errorBoundary = 'errorBoundary',
/** Enable the Valibot form validation */
valibot = 'valibot',
diff --git a/packages/qwik/src/core/client/dom-container.ts b/packages/qwik/src/core/client/dom-container.ts
index 3d5144da9ae..4d8d85209a3 100644
--- a/packages/qwik/src/core/client/dom-container.ts
+++ b/packages/qwik/src/core/client/dom-container.ts
@@ -312,7 +312,7 @@ export class DomContainer extends _SharedContainer implements IClientContainer {
return undefined;
}
- /** Like {@link resolveContext}, but returns the host that provides the context, not its value. */
+ /** Like `resolveContext`, but returns the host that provides the context, not its value. */
resolveContextHost(host: VNode, contextId: ContextId): VNode | null {
while (host) {
const ctx = this.getHostProp>(host, QCtxAttr);
diff --git a/packages/qwik/src/core/qwik.core.api.md b/packages/qwik/src/core/qwik.core.api.md
index 3538f92baca..f4c3ecc3261 100644
--- a/packages/qwik/src/core/qwik.core.api.md
+++ b/packages/qwik/src/core/qwik.core.api.md
@@ -433,6 +433,7 @@ class DomContainer extends _SharedContainer implements ClientContainer {
qManifestHash: string;
// (undocumented)
resolveContext(host: _VNode, contextId: ContextId): T | undefined;
+ resolveContextHost(host: _VNode, contextId: ContextId): _VNode | null;
// (undocumented)
rootVNode: _ElementVNode;
// (undocumented)
diff --git a/packages/qwik/src/core/ssr/ssr-render-jsx.ts b/packages/qwik/src/core/ssr/ssr-render-jsx.ts
index 07da6afc35c..7303783a811 100644
--- a/packages/qwik/src/core/ssr/ssr-render-jsx.ts
+++ b/packages/qwik/src/core/ssr/ssr-render-jsx.ts
@@ -442,10 +442,8 @@ function processJSXNode(
} catch (err) {
// An async component's render rejected — route it to the closest ErrorBoundary
// (matching the sync throw path) instead of aborting the stream.
- resolvedOutput = renderErrorBoundaryFallback(ssr, host, err) as JSXOutput;
- if (isPromise(resolvedOutput)) {
- resolvedOutput = await resolvedOutput;
- }
+ const fallback = renderErrorBoundaryFallback(ssr, host, err);
+ resolvedOutput = isPromise(fallback) ? await fallback : fallback;
}
const compStyleComponentId = addComponentStylePrefix(host.getProp(QScopedStyle));
From c65bbf63981c0932f55c8067f4eace3fb7931850 Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Fri, 19 Jun 2026 14:00:34 +0200
Subject: [PATCH 019/331] fix(qwik): report-and-stop on a re-entrant
ErrorBoundary error (no unhandled rejection)
handleError's re-entrancy guard re-threw a second error (e.g. the boundary's own
fallback render failing) to break the infinite loop. But that guard runs inside
the client re-render chore's rejectFn, so re-throwing escaped as an UNHANDLED
rejection: the throwing-fallback unit test passed yet left a dangling
"fallback boom" rejection that made vitest exit 1 (CI red).
Now it logs the error (a broken fallback still surfaces in the console) and
returns, breaking the loop without throwing into the async void. Full core suite
exits 0 (1113 pass); the throwing-fallback loop-guard and both client-error e2e
scenarios still pass.
---
packages/qwik/src/core/client/dom-container.ts | 14 +++++++++-----
1 file changed, 9 insertions(+), 5 deletions(-)
diff --git a/packages/qwik/src/core/client/dom-container.ts b/packages/qwik/src/core/client/dom-container.ts
index 4d8d85209a3..1b51258a6cb 100644
--- a/packages/qwik/src/core/client/dom-container.ts
+++ b/packages/qwik/src/core/client/dom-container.ts
@@ -36,6 +36,7 @@ import {
} from '../shared/utils/markers';
import { isSlotProp } from '../shared/utils/prop';
import { qDev, qTest } from '../shared/utils/qdev';
+import { logError } from '../shared/utils/log';
import {
convertScopedStyleIdsToArray,
convertStyleIdsToString,
@@ -249,12 +250,15 @@ export class DomContainer extends _SharedContainer implements IClientContainer {
const errorStore = host && this.resolveContext(host, ERROR_CONTEXT);
// Re-entrancy guard: if the closest boundary already holds an error, a further throw — e.g. the
// boundary's own fallback render failing — must NOT re-trigger it. Otherwise handleError loops
- // forever (set error → re-render → fallback throws → handleError → ...). Propagate instead, so a
- // throwing fallback surfaces (or reaches a parent boundary) rather than hanging the tab.
- // `!= null` covers both store init sentinels: `` uses `undefined`, the generic
- // ERROR_CONTEXT path uses `null` — neither counts as "already errored".
+ // forever (set error → re-render → fallback throws → handleError → ...). Report it and stop:
+ // re-throwing here would escape the re-render chore as an unhandled rejection (the chore's
+ // `rejectFn` calls handleError), so a throwing fallback surfaces in the console instead of
+ // hanging the tab or failing the test runner. `!= null` covers both store init sentinels:
+ // `` uses `undefined`, the generic ERROR_CONTEXT path uses `null` — neither
+ // counts as "already errored".
if (errorStore && errorStore.error != null) {
- throw err;
+ logError(err);
+ return;
}
if (qDev && host) {
if (typeof document !== 'undefined') {
From 446c9dbd0ec8df97c95c14483573a7d4ae6770eb Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Fri, 19 Jun 2026 16:08:41 +0200
Subject: [PATCH 020/331] fix(qwik): honor stopPropagation() from a deferred
event handler
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
qwik-loader walks the event path synchronously and only checked
ev.cancelBubble during that walk. A handler whose QRL was still importing
calls stopPropagation() too late — ancestor handlers were already queued
and ran anyway. Collect deferred handlers per element and re-check
ev.cancelBubble between elements as they run, so an async stop still
prevents handlers further along the path (sync stops already truncate the
walk and are unaffected).
---
.../qwikloader-stoppropagation-deferred.md | 5 ++
packages/qwik/src/qwikloader.ts | 60 +++++++++++++++----
packages/qwik/src/qwikloader.unit.ts | 2 +-
3 files changed, 55 insertions(+), 12 deletions(-)
create mode 100644 .changeset/qwikloader-stoppropagation-deferred.md
diff --git a/.changeset/qwikloader-stoppropagation-deferred.md b/.changeset/qwikloader-stoppropagation-deferred.md
new file mode 100644
index 00000000000..460f58aa2c6
--- /dev/null
+++ b/.changeset/qwikloader-stoppropagation-deferred.md
@@ -0,0 +1,5 @@
+---
+'@qwik.dev/core': patch
+---
+
+fix(qwik): honor `stopPropagation()` from a deferred event handler so ancestor handlers on the bubbling path no longer run after a descendant stops propagation
diff --git a/packages/qwik/src/qwikloader.ts b/packages/qwik/src/qwikloader.ts
index f3518d26c29..14443d41596 100644
--- a/packages/qwik/src/qwikloader.ts
+++ b/packages/qwik/src/qwikloader.ts
@@ -100,6 +100,26 @@ const queueTasks = (tasks: Task[]) => {
}
};
+/**
+ * Run the deferred element handlers grouped per element, in propagation order. Once a handler calls
+ * `stopPropagation()` — including asynchronously, after its QRL finished importing — handlers on
+ * elements further along the path are skipped. A synchronous `stopPropagation()` already truncated
+ * the walk (so `stoppedSynchronously` is set), and every group it collected must still run.
+ */
+const runEventTasks = (ev: Event, taskGroups: Task[][], stoppedSynchronously: boolean) => {
+ if (taskGroups.length) {
+ const run = async () => {
+ for (let i = 0; i < taskGroups.length; i++) {
+ if (!stoppedSynchronously && ev.cancelBubble) {
+ break;
+ }
+ await runTasks(taskGroups[i]);
+ }
+ };
+ queuedTasks = queuedTasks ? queuedTasks.then(run, run) : run();
+ }
+};
+
const resolveContainer = (containerEl: QContainerElement) => {
if (containerEl._qwikjson_ === undefined) {
const parentJSON = containerEl === doc.documentElement ? doc.body : containerEl;
@@ -390,7 +410,11 @@ const processElementEvent = (
const captureAttribute = capturePrefix + kebabName;
const elements: Element[] = [];
const captureHandlers: boolean[] = [];
- const tasks: Task[] = [];
+ // One task list per element, kept in propagation order. Deferred handlers (QRLs still importing)
+ // can call `stopPropagation()` only after this synchronous walk has moved on, so the walk can't
+ // truncate itself; `runEventTasks` re-checks `ev.cancelBubble` between elements instead.
+ const taskGroups: Task[][] = [];
+ let stoppedSynchronously = false;
let current = ev.target as Node | null;
while (current) {
@@ -403,26 +427,40 @@ const processElementEvent = (
}
}
+ const dispatchElement = (i: number) => {
+ const tasks: Task[] = [];
+ dispatch(elements[i], ev, scopedKebabName, tasks, kebabName, allowPreventDefault);
+ if (tasks.length) {
+ taskGroups.push(tasks);
+ }
+ };
+
for (let i = elements.length - 1; i >= 0; i--) {
if (captureHandlers[i]) {
- dispatch(elements[i], ev, scopedKebabName, tasks, kebabName, allowPreventDefault);
+ dispatchElement(i);
if (ev.cancelBubble) {
- queueTasks(tasks);
- return;
+ stoppedSynchronously = true;
+ break;
}
}
}
- for (let i = 0; i < elements.length; i++) {
- if (!captureHandlers[i]) {
- dispatch(elements[i], ev, scopedKebabName, tasks, kebabName, allowPreventDefault);
- if (!ev.bubbles || ev.cancelBubble) {
- queueTasks(tasks);
- return;
+ if (!stoppedSynchronously) {
+ for (let i = 0; i < elements.length; i++) {
+ if (!captureHandlers[i]) {
+ dispatchElement(i);
+ if (ev.cancelBubble) {
+ stoppedSynchronously = true;
+ break;
+ }
+ if (!ev.bubbles) {
+ break;
+ }
}
}
}
- queueTasks(tasks);
+
+ runEventTasks(ev, taskGroups, stoppedSynchronously);
};
const processPassiveElementEvent = (ev: Event) =>
diff --git a/packages/qwik/src/qwikloader.unit.ts b/packages/qwik/src/qwikloader.unit.ts
index 0eb5068ee5e..070c45082a8 100644
--- a/packages/qwik/src/qwikloader.unit.ts
+++ b/packages/qwik/src/qwikloader.unit.ts
@@ -14,7 +14,7 @@ test('qwikloader script', () => {
// ignore, we didn't build yet
}
expect(qwikLoader).toMatchInlineSnapshot(
- `"const e=document,t=window,n="w",r="wp",o="d",s="dp",i="e",a="ep",c="capture:",l="readystatechange",p=0,u=new Set,d=new Set([e]),f=new Map,h={};let q,b,g;const m=(e,t)=>Array.from(e.querySelectorAll(t)),v=e=>{const t=[];return d.forEach(n=>t.push(...m(n,e))),t},w=(e,t,n,r=!1,o=!1)=>e.addEventListener(t,n,{capture:r,passive:o}),y=e=>{H(e);const t=m(e,"[q\\\\:shadowroot]");for(let e=0;ee&&"function"==typeof e.then,E=async e=>{for(let t=0;t{if(e.length){const t=()=>E(e);g=g?g.then(t,t):t()}},S=t=>{if(void 0===t._qwikjson_){let n=(t===e.documentElement?e.body:t).lastElementChild;for(;n;){if("SCRIPT"===n.tagName&&"qwik/json"===n.getAttribute("type")){t._qwikjson_=JSON.parse(n.textContent.replace(/\\\\x3C(\\/?script)/gi,"<$1"));break}n=n.previousElementSibling}}},_=e=>{h[e]=1,I(l)},k=t=>{const n=t.getAttribute("q:instance");return"paused"===t.getAttribute("q:container")&&"loading"===e.readyState&&!h[n]&&new Promise(t=>{w(e,l,()=>{("loading"!==e.readyState||h[n])&&t()})})},$=(e,t)=>new CustomEvent(e,{detail:t}),I=(t,n)=>{e.dispatchEvent($(t,n))},N=e=>e.replace(/([A-Z-])/g,e=>"-"+e.toLowerCase()),R=e=>e.replace(/-./g,e=>e[1].toUpperCase()),T=e=>{const t=e.indexOf(":");return{scope:e.slice(0,t),eventName:R(e.slice(t+1))}},x=e=>2===e.length,B=e=>e.charAt(0),L=e=>!!e&&1===e.nodeType,U=(e,t,n)=>e.hasAttribute(n)&&(!!e._qDispatch?.[t]||e.hasAttribute("q-"+t)),j=(t,n,r,o,s,i,a,c=!0)=>{const l={qBase:r,symbol:i,element:n,reqTime:a};if(!s){const n=(e["qFuncs_"+t.getAttribute("q:instance")]||[])[+i];if(!n&&c){const e=Error("sym:"+i);I("qerror",{importError:"sync",error:e,...l}),console.error(e)}return n}const p=\`\${i}|\${r}|\${s}\`,u=f.get(p);if(u)return u;const d=new URL(s,o).href,h=import(d);return S(t),h.then(e=>{const t=e[i];if(t)f.set(p,t),I("qsymbol",l);else{const e=Error(\`\${i} not in \${d}\`);I("qerror",{importError:"no-symbol",error:e,...l}),console.error(e)}return t},e=>{I("qerror",{importError:"async",error:e,...l}),console.error(e)})},D=(t,n,r,o,s,i=!0)=>{let a=!1;s&&(i&&t.hasAttribute("preventdefault:"+s)&&n.preventDefault(),t.hasAttribute("stoppropagation:"+s)&&n.stopPropagation());const c=t._qDispatch?.[r];if(c){if("function"==typeof c){const e=()=>c(n,t);if(a)o.push(async()=>{const t=e();A(t)&&await t});else{const t=e();A(t)&&(a=!0,o.push(()=>t))}}else if(c.length)for(let e=0;er(n,t);if(a)o.push(async()=>{const t=e();A(t)&&await t});else{const t=e();A(t)&&(a=!0,o.push(()=>t))}}}return}const l=t.getAttribute("q-"+r);if(l){const r=t.closest("[q\\\\:container]:not([q\\\\:container=html]):not([q\\\\:container=text])"),s=r.getAttribute("q:base"),i=new URL(s,e.baseURI),c=l.split("|"),p=k(r);for(let e=0;e{if(e&&t.isConnected){const r=e=>{I("qerror",{error:e,qBase:s,symbol:f,element:t,reqTime:u})};try{const o=e.call(h,n,t);if(A(o))return o.catch(r)}catch(e){return r(e)}}},b=(e=!0)=>j(r,t,s,i,d,f,u,e),g=p?void 0:b();if(p)a=!0,o.push(async()=>{await p,await q(await b(!1)||await b())});else if(A(g))a=!0,o.push(()=>g.then(q));else if(a)a=!0,o.push(async()=>{await q(g||await b())});else{const e=q(g);A(e)&&(a=!0,o.push(()=>e))}}}},O=(e,t=i,n=!0)=>{const r=N(e.type),o=t+":"+r,s=c+r,a=[],l=[],p=[];let u=e.target;for(;u;)L(u)?(a.push(u),l.push(U(u,o,s)),u=u.parentElement):u=u.parentElement;for(let t=a.length-1;t>=0;t--)if(l[t]&&(D(a[t],e,o,p,r,n),e.cancelBubble))return void C(p);for(let t=0;tO(e,a,!1),F=(e,t,n=!0)=>{const r=N(t.type),o=e+":"+r,s=v("[q-"+e+"\\\\:"+r+"]"),i=[];for(let e=0;e{F(o,e)},M=e=>{F(s,e,!1)},Z=e=>{F(n,e)},z=e=>{F(r,e,!1)},G=()=>{const n=e.readyState;if("interactive"==n||"complete"==n){if(b=1,d.forEach(y),u.has("d:qinit")){u.delete("d:qinit");const e=$("qinit"),t=v("[q-d\\\\:qinit]"),n=[];for(let r=0;r{const e=$("qidle"),t=v("[q-d\\\\:qidle]"),n=[];for(let r=0;r{const t=[];for(let n=0;n{for(let r=0;rw(e,r,a===o?i?M:J:i?P:O,!0,i)),1!==b||"e:qvisible"!==s&&"d:qinit"!==s&&"d:qidle"!==s||G()}}else d.has(s)||(u.forEach(e=>{const{scope:t,eventName:r}=T(e),i=x(t),a=B(t);a!==n&&w(s,r,a===o?i?M:J:i?P:O,!0,i)}),d.add(s))}},K=t._qwikEv;K?.roots||(Array.isArray(K)?H(...K):H("e:click","e:input"),t._qwikEv={events:u,roots:d,push:H},w(e,l,G),G());"`
+ `"const e=document,t=window,n="w",r="wp",o="d",s="dp",i="e",a="ep",c="capture:",l="readystatechange",f=0,h=new Set,p=new Set([e]),u=new Map,q={};let d,b,g;const m=(e,t)=>Array.from(e.querySelectorAll(t)),w=e=>{const t=[];return p.forEach(n=>t.push(...m(n,e))),t},y=(e,t,n,r=!1,o=!1)=>e.addEventListener(t,n,{capture:r,passive:o}),v=e=>{K(e);const t=m(e,"[q\\\\:shadowroot]");for(let e=0;ee&&"function"==typeof e.then,E=async e=>{for(let t=0;t{if(e.length){const t=()=>E(e);g=g?g.then(t,t):t()}},C=(e,t,n)=>{if(t.length){const r=async()=>{for(let r=0;r{if(void 0===t._qwikjson_){let n=(t===e.documentElement?e.body:t).lastElementChild;for(;n;){if("SCRIPT"===n.tagName&&"qwik/json"===n.getAttribute("type")){t._qwikjson_=JSON.parse(n.textContent.replace(/\\\\x3C(\\/?script)/gi,"<$1"));break}n=n.previousElementSibling}}},_=e=>{q[e]=1,I(l)},$=t=>{const n=t.getAttribute("q:instance");return"paused"===t.getAttribute("q:container")&&"loading"===e.readyState&&!q[n]&&new Promise(t=>{y(e,l,()=>{("loading"!==e.readyState||q[n])&&t()})})},B=(e,t)=>new CustomEvent(e,{detail:t}),I=(t,n)=>{e.dispatchEvent(B(t,n))},N=e=>e.replace(/([A-Z-])/g,e=>"-"+e.toLowerCase()),R=e=>e.replace(/-./g,e=>e[1].toUpperCase()),T=e=>{const t=e.indexOf(":");return{scope:e.slice(0,t),eventName:R(e.slice(t+1))}},x=e=>2===e.length,L=e=>e.charAt(0),U=e=>!!e&&1===e.nodeType,j=(e,t,n)=>e.hasAttribute(n)&&(!!e._qDispatch?.[t]||e.hasAttribute("q-"+t)),D=(t,n,r,o,s,i,a,c=!0)=>{const l={qBase:r,symbol:i,element:n,reqTime:a};if(!s){const n=(e["qFuncs_"+t.getAttribute("q:instance")]||[])[+i];if(!n&&c){const e=Error("sym:"+i);I("qerror",{importError:"sync",error:e,...l}),console.error(e)}return n}const f=\`\${i}|\${r}|\${s}\`,h=u.get(f);if(h)return h;const p=new URL(s,o).href,q=import(p);return S(t),q.then(e=>{const t=e[i];if(t)u.set(f,t),I("qsymbol",l);else{const e=Error(\`\${i} not in \${p}\`);I("qerror",{importError:"no-symbol",error:e,...l}),console.error(e)}return t},e=>{I("qerror",{importError:"async",error:e,...l}),console.error(e)})},O=(t,n,r,o,s,i=!0)=>{let a=!1;s&&(i&&t.hasAttribute("preventdefault:"+s)&&n.preventDefault(),t.hasAttribute("stoppropagation:"+s)&&n.stopPropagation());const c=t._qDispatch?.[r];if(c){if("function"==typeof c){const e=()=>c(n,t);if(a)o.push(async()=>{const t=e();A(t)&&await t});else{const t=e();A(t)&&(a=!0,o.push(()=>t))}}else if(c.length)for(let e=0;er(n,t);if(a)o.push(async()=>{const t=e();A(t)&&await t});else{const t=e();A(t)&&(a=!0,o.push(()=>t))}}}return}const l=t.getAttribute("q-"+r);if(l){const r=t.closest("[q\\\\:container]:not([q\\\\:container=html]):not([q\\\\:container=text])"),s=r.getAttribute("q:base"),i=new URL(s,e.baseURI),c=l.split("|"),f=$(r);for(let e=0;e{if(e&&t.isConnected){const r=e=>{I("qerror",{error:e,qBase:s,symbol:u,element:t,reqTime:h})};try{const o=e.call(q,n,t);if(A(o))return o.catch(r)}catch(e){return r(e)}}},b=(e=!0)=>D(r,t,s,i,p,u,h,e),g=f?void 0:b();if(f)a=!0,o.push(async()=>{await f,await d(await b(!1)||await b())});else if(A(g))a=!0,o.push(()=>g.then(d));else if(a)a=!0,o.push(async()=>{await d(g||await b())});else{const e=d(g);A(e)&&(a=!0,o.push(()=>e))}}}},P=(e,t=i,n=!0)=>{const r=N(e.type),o=t+":"+r,s=c+r,a=[],l=[],f=[];let h=!1,p=e.target;for(;p;)U(p)?(a.push(p),l.push(j(p,o,s)),p=p.parentElement):p=p.parentElement;const u=t=>{const s=[];O(a[t],e,o,s,r,n),s.length&&f.push(s)};for(let t=a.length-1;t>=0;t--)if(l[t]&&(u(t),e.cancelBubble)){h=!0;break}if(!h)for(let t=0;tP(e,a,!1),J=(e,t,n=!0)=>{const r=N(t.type),o=e+":"+r,s=w("[q-"+e+"\\\\:"+r+"]"),i=[];for(let e=0;e{J(o,e)},Z=e=>{J(s,e,!1)},z=e=>{J(n,e)},G=e=>{J(r,e,!1)},H=()=>{const n=e.readyState;if("interactive"==n||"complete"==n){if(b=1,p.forEach(v),h.has("d:qinit")){h.delete("d:qinit");const e=B("qinit"),t=w("[q-d\\\\:qinit]"),n=[];for(let r=0;r{const e=B("qidle"),t=w("[q-d\\\\:qidle]"),n=[];for(let r=0;r{const t=[];for(let n=0;n{for(let r=0;ry(e,r,a===o?i?Z:M:i?F:P,!0,i)),1!==b||"e:qvisible"!==s&&"d:qinit"!==s&&"d:qidle"!==s||H()}}else p.has(s)||(h.forEach(e=>{const{scope:t,eventName:r}=T(e),i=x(t),a=L(t);a!==n&&y(s,r,a===o?i?Z:M:i?F:P,!0,i)}),p.add(s))}},Q=t._qwikEv;Q?.roots||(Array.isArray(Q)?K(...Q):K("e:click","e:input"),t._qwikEv={events:h,roots:p,push:K},y(e,l,H),H());"`
);
});
From c99dc9f5b2fd897c306727a11cf90693ea283d4a Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Fri, 19 Jun 2026 16:29:37 +0200
Subject: [PATCH 021/331] test(qwik): bump QWIKLOADER_BROTLI_BUDGET to 2200
The per-element stopPropagation gating added ~45 bytes brotli to
qwik-loader (2145), just over the old 2100 ceiling. Bump intentionally.
---
e2e/qwik-e2e/tests/qwikrouter/ssg-snapshot.e2e.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/e2e/qwik-e2e/tests/qwikrouter/ssg-snapshot.e2e.ts b/e2e/qwik-e2e/tests/qwikrouter/ssg-snapshot.e2e.ts
index b4f2047a972..c40635be953 100644
--- a/e2e/qwik-e2e/tests/qwikrouter/ssg-snapshot.e2e.ts
+++ b/e2e/qwik-e2e/tests/qwikrouter/ssg-snapshot.e2e.ts
@@ -15,7 +15,7 @@ import tsconfigPaths from 'vite-tsconfig-paths';
// the growth.
const PRELOADER_BROTLI_BUDGET = 1800; // We currently group the vite preload helper with the preloader, adding ~500bytes brotli.
const CORE_BROTLI_BUDGET = 35000;
-const QWIKLOADER_BROTLI_BUDGET = 2100;
+const QWIKLOADER_BROTLI_BUDGET = 2200;
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const repoRoot = resolve(__dirname, '../../../../');
From ed8e0a7f6808c276572218afec44e93a4447949f Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Fri, 19 Jun 2026 18:03:29 +0200
Subject: [PATCH 022/331] docs(qwik): tighten comments, changesets, and
core-notes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Trim comments to crucial-only (≤2 sentences, no implementation detail) and
drop ones the code already explains; reduce the changesets to one sentence per
change and the core-notes ErrorBoundary section to its core mechanism.
---
.changeset/errorboundary-to-core.md | 10 +-
.../qwikloader-stoppropagation-deferred.md | 2 +-
.../references/core-notes.md | 98 +++----------------
.../qwik/src/core/client/dom-container.ts | 26 ++---
.../src/core/control-flow/suspense-utils.ts | 7 +-
.../qwik/src/core/control-flow/suspense.tsx | 43 +++-----
.../src/core/shared/error/error-boundary.ts | 42 ++------
.../src/core/shared/error/error-handling.ts | 20 +---
packages/qwik/src/core/ssr/ssr-render-jsx.ts | 46 +++------
packages/qwik/src/core/ssr/ssr-types.ts | 3 +-
.../src/core/tests/error-boundary.spec.tsx | 30 ++----
packages/qwik/src/qwikloader.ts | 12 +--
packages/qwik/src/server/ssr-container.ts | 13 +--
.../qwik/src/server/ssr-stream-handler.ts | 6 +-
14 files changed, 87 insertions(+), 271 deletions(-)
diff --git a/.changeset/errorboundary-to-core.md b/.changeset/errorboundary-to-core.md
index 7d092abe103..debf1bc96ed 100644
--- a/.changeset/errorboundary-to-core.md
+++ b/.changeset/errorboundary-to-core.md
@@ -5,8 +5,8 @@
`ErrorBoundary` now ships from `@qwik.dev/core` instead of `@qwik.dev/router` — update your imports. It also changes behavior:
-- **`fallback$` is now required.**
-- The `useErrorBoundary()` hook is **removed** — `` is the single error-boundary surface.
-- It catches errors only from **its own subtree**: the **closest** boundary handles an error, instead of every boundary on the page reacting to the global `qerror` event.
-- It now catches render throws during **SSR** (rendering `fallback$` in place), not just on the client.
-- **Experimental** (opt-in `errorBoundary` feature, with `suspense` + out-of-order streaming): on SSR the boundary **never blocks streaming**. Its subtree streams inside a visible content host next to a hidden fallback host; if a descendant throws, `fallback$` is streamed as an out-of-order segment and an inline script (the shared Suspense `qO` executor) hides the content host and reveals the fallback — the swap happens as the error chunk is parsed, before the framework resumes, so it never waits on the client runtime. A deferred throw from a child `` tears the **whole** boundary down to `fallback$` (the fallback replaces the boundary, not a sub-slot of it). Size the fallback to the content box for zero CLS. A boundary placed *inside* a `` instead buffers within that already-deferred segment (clean skeleton → fallback).
+- `fallback$` is now required.
+- The `useErrorBoundary()` hook is removed — `` is the single error-boundary surface.
+- An error is handled by the closest boundary (its own subtree), instead of every boundary reacting to the global `qerror` event.
+- Render throws are now caught during SSR, not just on the client.
+- Experimental (opt-in `errorBoundary` feature): the boundary never blocks SSR streaming — its content streams, and if a descendant throws an inline script swaps it for `fallback$`.
diff --git a/.changeset/qwikloader-stoppropagation-deferred.md b/.changeset/qwikloader-stoppropagation-deferred.md
index 460f58aa2c6..94c50e74155 100644
--- a/.changeset/qwikloader-stoppropagation-deferred.md
+++ b/.changeset/qwikloader-stoppropagation-deferred.md
@@ -2,4 +2,4 @@
'@qwik.dev/core': patch
---
-fix(qwik): honor `stopPropagation()` from a deferred event handler so ancestor handlers on the bubbling path no longer run after a descendant stops propagation
+Honor `stopPropagation()` from a deferred event handler so ancestor handlers on the bubbling path no longer run after a descendant stops propagation.
diff --git a/.ruler/skills/qwik-core-development/references/core-notes.md b/.ruler/skills/qwik-core-development/references/core-notes.md
index 1c03913f7fe..1d0611aa9ea 100644
--- a/.ruler/skills/qwik-core-development/references/core-notes.md
+++ b/.ruler/skills/qwik-core-development/references/core-notes.md
@@ -170,93 +170,17 @@ Never use `pnpm test.unit` for agent verification in this repo.
## ErrorBoundary Streaming (experimental `errorBoundary`)
-`` must NEVER block streaming — people nest it freely, so a buffering boundary would
-silently kill a page's streaming. On SSR with out-of-order streaming the boundary (`errorBoundaryCmp`
-in `shared/error/error-boundary.ts`) renders two display-toggled hosts modeled on Suspense:
-
-- a visible content host (`
` wrapping ``), and
-- a hidden fallback host (`
` containing `SSRErrorFallback`).
-
-On a throw, `renderErrorBoundaryFallback` (in `ssr/ssr-render-jsx.ts`) just sets `store.error` and
-renders nothing in place. The fallback host (rendered after the content) streams `fallback$(error)`
-as an out-of-order **segment** and reuses the Suspense pipeline verbatim:
-`writeOutOfOrderResolvedTemplate` + `emitInlineScript('qO(id)')`. The shared `qO` executor
-(`out-of-order-executor-shared.ts`) reveals the `q:rp` host and hides its previous sibling — which is
-exactly content-host-hidden + fallback-host-shown, no executor change. Resume consistency comes for
-free via `qProcessOOOS` / `processOutOfOrderSegmentVNodeData`. Display is driven by `_fnSignal`
-reading `store.error`, so the resumed/re-rendered boundary stays consistent with the inline swap.
-
-Key invariants:
-
-- The swap is an inline script (fires as its chunk parses) — it does NOT depend on the client
- re-rendering, so it works before the framework resumes. Client-time errors instead go through
- `handleError`, which sets `store.error` and explicitly re-renders the boundary (see the OOOS
- client-error note below).
-- A SYNC throw emits the fallback segment + `qO(id)` **inline** (`SSRErrorFallback` returns the
- emission promise, awaited in the drain), so the swap lands right after the boundary — NOT via
- `queueOutOfOrderSegment`, which would defer it to end-of-stream and leave the broken content
- visible the whole time. A test asserts the `qO(` position precedes trailing content; keep it.
-- An **error-free** boundary ships NO swap JS (no `qO` executor, no `qO(id)` call). The boundary
- reserves its id with `nextErrorBoundaryId()` → `container.nextOutOfOrderId(false)`, which does NOT
- set `outOfOrderUsed`; the executor is armed only when a throw creates the fallback `segment()`
- (which sets `outOfOrderUsed`) and `emitErrorBoundaryFallback` emits the executor right before the
- first `qO(id)`. Per throwing boundary it's one shared executor + one tiny `qO(id)` call (same unit
- cost as a resolved Suspense segment). A test asserts the error-free HTML has no `qO(`/`qInstallOOOS`.
-- **Async throws must be routed in the drain.** The SSR drain (`_walkJSX`) awaits children at three
- points — the `Promise` marker (promise children), the async-component thunk (`await jsxOutput`),
- and `MaybeAsyncSignal` (`retryOnPromise`). All three now catch and call `renderErrorBoundaryFallback`;
- without that, a rejected promise child / async component / async signal aborts the whole stream
- instead of hitting the boundary (the old buffer-and-swap caught these via `await renderJSX`). Keep
- all three wrapped. Covered by `catches an async … (no )` specs.
-- **`renderErrorBoundaryFallback` rethrows inside an OOOS segment** (when the boundary has no
- `$checkpoint$`, i.e. it is OUTSIDE the segment): this propagates so the segment rejects and
- `SSRDeferredSlot`'s `.catch` routes to the boundary's `$emitFallback$` (whole-boundary teardown).
- Do not render the fallback in place inside a segment — it breaks the case-3 teardown.
-- **A throwing `fallback$` must not loop.** `streamFallback` detaches `store.$fallback$` while
- rendering the fallback, so a re-throw that resolves back to the same boundary takes the
- "no fallback → propagate" path (aborts) instead of re-rendering the fallback forever (deadlock).
-- **FIXED — the critical client-error loop.** A client-time error on ANY SSR'd ``
- (in-order or OOOS) used to infinite-loop `handleError` (~50k calls) and never show the fallback.
- Root cause was NOT a lazy QRL — it was `noSerialize`: `store.$fallback$ = noSerialize(props.fallback$)`
- taints the value's IDENTITY, and `props.fallback$` is the SAME QRL object, so the serialized prop
- was dropped and resumed as `undefined`; the client re-render's `props.fallback$(error)` threw
- `undefined is not a function`, which re-entered `handleError` with no guard. Fixed by (a)
- `noSerialize`-ing a FRESH closure (`(e) => fallbackQrl(e)`) so the prop stays serializable, and
- (b) a re-entrancy guard in `handleError` (`dom-container.ts`) that propagates when the boundary
- already holds an error (`errorStore.error != null` — covers the `undefined` ErrorBoundary init AND
- the `null` generic-ERROR_CONTEXT/`ErrorProvider` init) instead of looping. Covered by the in-order
- `scenario=client` e2e + a `domRender` throwing-fallback unit test (the unit harness inlines QRLs,
- so it can't catch the taint — only the loop guard; the e2e catches the taint).
- GENERAL GOTCHA: never `noSerialize` a value you also need serialized elsewhere — `noSerialize`
- marks the object identity, so wrap a fresh object/closure.
-- **FIXED (OOOS two-host + client error).** A client-time error on a boundary that streamed via OOOS
- used to never show its fallback. The real cause was a SUBSCRIPTION GAP, not a vnode diff: the OOOS
- branch of `errorBoundaryCmp` returns the two-host structure early WITHOUT reading `store.error`, so
- the boundary never subscribed to it. A client error then only flipped the inline `_fnSignal` style
- swap — revealing the still-empty `q:rp` fallback host (it holds just the `qO` ``
- placeholder) — and the component never re-rendered to `fallback$`. It was NOT a `"Missing child"`
- diff: the existing `hasOnlySuspensePlaceholder` guard in `vnode_getFirstChild` already removes the
- placeholder host cleanly once the boundary re-renders. Fix: `DomContainer.handleError` (client-only)
- now explicitly re-renders the boundary — `markVNodeDirty(boundaryHost, ChoreBits.COMPONENT)` after
- setting `store.error` — so an OOOS boundary (never subscribed) swaps to its fallback like an in-order
- one (still re-renders via its `store.error` subscription; the mark is a no-op when that already
- scheduled it). Scoped to real boundaries via the `store.error === undefined` init sentinel — a
- generic ERROR_CONTEXT/`ErrorProvider` consumer uses `null` and only captures, never re-renders.
- Deliberately NOT fixed by subscribing the OOOS component to `store.error`: a deferred (async) throw
- sets `store.error` mid-stream, which would schedule a re-render chore on the already-streamed host
- (the dev `"chore scheduled on a host that was already streamed"` warning). Covered by the OOOS
- `scenario=client` e2e + a `ssrRenderToDom` + `outOfOrder` + `qerror` unit test. Note a qerror only
- ROUTES if the throwing handler resumed the container first (touching a signal does; a bare
- `() => { throw }` doesn't, so `$qErrorHandler$` isn't attached yet).
-- A deferred child `` that throws routes to the enclosing boundary's `store.$emitFallback$`
- (set by `SSRErrorFallback`), tearing the whole boundary down — not into the Suspense sub-slot.
-- A boundary *inside* a `` segment is the one case that still buffers (the segment is
- already buffered, so it doesn't block the shell): `getBufferingErrorBoundaryStore` gates buffering
- on `isOutOfOrderSegmentContainer`.
-- Tests must assert BOTH the streamed HTML (content was not blocked) and the post-`qO` DOM
- (content host `display:none`, fallback host `display:contents`). Resume helpers must filter the
- emitted scripts to `qO`/`qInstallOOOS` (running every `text/javascript` script hits debug-build
- refs). See `core/tests/error-boundary.spec.tsx`.
+`` must never block streaming. On SSR with out-of-order streaming the boundary
+(`errorBoundaryCmp`, `shared/error/error-boundary.ts`) renders two display-toggled hosts modeled on
+Suspense — a visible content host wrapping ``, and a hidden fallback host (`q:rp={id}`). On a
+throw, the fallback host streams `fallback$(error)` as an out-of-order segment and the shared `qO`
+executor reveals it while hiding the content host via an inline script, so the swap happens as the
+chunk parses, before the framework resumes; resume consistency comes via `qProcessOOOS`.
+
+A deferred child `` that throws tears the whole boundary down via `store.$emitFallback$`. A
+boundary placed *inside* a `` segment instead buffers within that already-deferred segment
+(`getBufferingErrorBoundaryStore` gates on `isOutOfOrderSegmentContainer`). Client-time errors
+re-render the boundary through the container's `handleError`.
## Keep This Reference Fresh
diff --git a/packages/qwik/src/core/client/dom-container.ts b/packages/qwik/src/core/client/dom-container.ts
index 1b51258a6cb..534e6a65390 100644
--- a/packages/qwik/src/core/client/dom-container.ts
+++ b/packages/qwik/src/core/client/dom-container.ts
@@ -248,14 +248,10 @@ export class DomContainer extends _SharedContainer implements IClientContainer {
handleError(err: any, host: VNode | null): void {
const errorStore = host && this.resolveContext(host, ERROR_CONTEXT);
- // Re-entrancy guard: if the closest boundary already holds an error, a further throw — e.g. the
- // boundary's own fallback render failing — must NOT re-trigger it. Otherwise handleError loops
- // forever (set error → re-render → fallback throws → handleError → ...). Report it and stop:
- // re-throwing here would escape the re-render chore as an unhandled rejection (the chore's
- // `rejectFn` calls handleError), so a throwing fallback surfaces in the console instead of
- // hanging the tab or failing the test runner. `!= null` covers both store init sentinels:
- // `` uses `undefined`, the generic ERROR_CONTEXT path uses `null` — neither
- // counts as "already errored".
+ // Re-entrancy guard: if the closest boundary already errored (e.g. its own fallback throws),
+ // report and stop instead of re-triggering — re-throwing would loop and escape the re-render
+ // chore as an unhandled rejection. `!= null` covers both store inits: boundary `undefined`,
+ // generic ERROR_CONTEXT `null`.
if (errorStore && errorStore.error != null) {
logError(err);
return;
@@ -278,17 +274,13 @@ export class DomContainer extends _SharedContainer implements IClientContainer {
if (!errorStore) {
throw err;
}
- // Distinguish a re-rendering `` from a generic ERROR_CONTEXT consumer that only
- // captures the error: a boundary's store starts `error: undefined`, the generic path uses `null`
- // (the same init sentinel the re-entrancy guard above relies on). Only the boundary re-renders.
+ // Only a real boundary re-renders (store inits `undefined`); a generic ERROR_CONTEXT consumer
+ // inits `null` and only captures.
const isErrorBoundary = errorStore.error === undefined;
errorStore.error = err;
- // Re-render the boundary so it swaps to its fallback. An in-order boundary already re-renders from
- // the reactive write above (it read `store.error` during render, so it subscribed), but a boundary
- // streamed via out-of-order streaming returned its two-host structure early without reading
- // `store.error`, so the write alone only flips the inline style swap — revealing the still-empty
- // streamed fallback host. Marking the boundary host re-renders it to `fallback$` (a no-op when the
- // reactive write already marked it).
+ // An OOOS boundary never read `store.error` (its two-host branch returns early), so the reactive
+ // write alone won't re-render it to the fallback — mark it explicitly (a no-op when an in-order
+ // boundary's write already scheduled it).
if (isErrorBoundary && host) {
const boundaryHost = this.resolveContextHost(host, ERROR_CONTEXT);
if (boundaryHost) {
diff --git a/packages/qwik/src/core/control-flow/suspense-utils.ts b/packages/qwik/src/core/control-flow/suspense-utils.ts
index 19d0f651683..0131c563b25 100644
--- a/packages/qwik/src/core/control-flow/suspense-utils.ts
+++ b/packages/qwik/src/core/control-flow/suspense-utils.ts
@@ -117,10 +117,9 @@ export const nextOutOfOrderSuspenseId = (): number => {
};
/**
- * Reserve an out-of-order id for a streaming `` WITHOUT arming the OOOS executor.
- * The boundary needs an id up front (for its `q:rp` fallback host), but it only needs the executor
- * if it actually throws — at which point streaming the fallback `segment()` arms it. So an
- * error-free boundary ships neither the executor nor any `qO(id)` call.
+ * Reserve an out-of-order id for a streaming `` without arming the OOOS executor —
+ * it only needs the executor if it actually throws (streaming the fallback `segment()` arms it
+ * then).
*
* @internal
*/
diff --git a/packages/qwik/src/core/control-flow/suspense.tsx b/packages/qwik/src/core/control-flow/suspense.tsx
index ca1192bd880..2b1589cf032 100644
--- a/packages/qwik/src/core/control-flow/suspense.tsx
+++ b/packages/qwik/src/core/control-flow/suspense.tsx
@@ -254,11 +254,9 @@ const SSRDeferredSlot = __EXPERIMENTAL__.suspense
const boundaryState = jsx.varProps.boundary as SSROutOfOrderBoundaryState | null;
const contentStyle = jsx.varProps.contentStyle as Signal<{ display: string }>;
const revealBoundary = jsx.varProps.reveal as OutOfOrderRevealBoundary | null;
- // Capture the nearest enclosing up front. If this deferred segment later
- // rejects (e.g. an async child threw after the placeholder was already streamed), route that
- // boundary's fallback into this same placeholder instead of aborting the whole render. The
- // boundary's own buffer-and-swap can't catch this because it already committed once the
- // Suspense placeholder rendered. Gated by the experimental errorBoundary feature.
+ // Capture the enclosing `` up front: if this deferred segment later rejects, the
+ // boundary already committed (when the placeholder streamed) and can't catch it in place, so we
+ // route to its `$emitFallback$` below.
const errorBoundaryStore =
__EXPERIMENTAL__.errorBoundary && options.parentComponentFrame
? (ssr.resolveContext(options.parentComponentFrame.componentNode, ERROR_CONTEXT) as
@@ -287,10 +285,7 @@ const SSRDeferredSlot = __EXPERIMENTAL__.suspense
)
)
.catch((error) => {
- // A deferred child threw after its placeholder streamed, so the enclosing boundary
- // already committed and can't catch it in place. Route it to that boundary's fallback
- // host instead: the whole boundary (this Suspense included) is torn down to `fallback$`,
- // matching the boundary's intent rather than wedging the fallback into this slot.
+ // Tear the whole enclosing boundary down to its fallback, rather than wedging it here.
if (errorBoundaryStore && errorBoundaryStore.$emitFallback$) {
return errorBoundaryStore.$emitFallback$(error);
}
@@ -358,12 +353,9 @@ async function emitRenderedOutOfOrderSegment(
}
/**
- * SSR host for a streaming ``'s fallback. Lives inside the boundary's hidden
- * fallback host (`q:rp={boundaryId}`) and writes the out-of-order placeholder. When the boundary's
- * subtree throws — synchronously during the content render, or later from a deferred child
- * `` — `store.$emitFallback$` streams `fallback$(error)` as an out-of-order segment and
- * the shared `qO` executor injects it here while hiding the content host. The boundary never blocks
- * streaming: its content streams first, then the fallback swaps in if (and only if) it errors.
+ * SSR host for a streaming ``'s fallback. On a throw, `store.$emitFallback$` streams
+ * `fallback$(error)` as an out-of-order segment and the shared `qO` executor swaps it in here while
+ * hiding the content host.
*/
export const SSRErrorFallback = __EXPERIMENTAL__.errorBoundary
? /*#__PURE__*/ createInternalServerComponent<{ boundaryId: number; store: ErrorBoundaryStore }>(
@@ -371,16 +363,10 @@ export const SSRErrorFallback = __EXPERIMENTAL__.errorBoundary
const boundaryId = jsx.varProps.boundaryId as number;
const store = jsx.varProps.store as ErrorBoundaryStore;
const segmentId = `${boundaryId}`;
- // Render `fallback$` as an out-of-order segment and inject it (template + inline `qO(id)`)
- // into this fallback host. Returns the emission promise so the caller can await it in stream
- // order — a synchronous throw awaits it inline (below) so the swap script follows the content
- // immediately instead of landing at end-of-stream; a later async throw awaits it through the
- // rejected Suspense segment's promise chain.
const streamFallback = async (error: unknown): Promise => {
store.error = error;
- // Detach the fallback while it renders: if the fallback itself throws and resolves back to
- // THIS boundary, it must not re-render the fallback (infinite loop / deadlock). With no
- // `$fallback$`, that throw takes the "no boundary handles it" path and propagates up.
+ // Detach `$fallback$` while it renders: if the fallback itself throws back to this boundary,
+ // it must not re-render the fallback (infinite loop) — with none set, that throw propagates.
const fallback = store.$fallback$!;
store.$fallback$ = undefined;
try {
@@ -391,12 +377,9 @@ export const SSRErrorFallback = __EXPERIMENTAL__.errorBoundary
}
};
store.$emitFallback$ = noSerialize(streamFallback);
- // Just reserve the injection point. The shared `qO` executor is emitted lazily (in
- // `emitErrorBoundaryFallback`, only when a boundary actually throws), so an error-free page
- // ships neither the executor nor any `qO(id)` call.
writeOutOfOrderPlaceholder(ssr, boundaryId);
- // A throw during the content render already set `store.error` before this host renders (the
- // content host is our previous sibling). Returning the promise awaits the swap inline.
+ // A sync throw during the content render already set `store.error` (the content host renders
+ // before this); returning the promise awaits the swap inline, right after the content.
if (store.error != null && store.$fallback$) {
return streamFallback(store.error);
}
@@ -415,11 +398,9 @@ async function emitErrorBoundaryFallback(
const result = await rendered.container.$finalizeOutOfOrderSegment$(segmentId, rendered);
writeOutOfOrderResolvedTemplate(ssr, boundaryId, result.html, null);
ssr.emitOutOfOrderSegmentScripts(result.scripts);
- // Install the shared executor (idempotent) right before its first call, so error-free pages
- // never ship it.
+ // Emit the shared executor right before its first call (idempotent), so error-free pages ship none.
ssr.emitOutOfOrderExecutorIfNeeded();
ssr.emitInlineScript(`qO(${boundaryId})`);
- // qO() is the browser-visible swap (hide content host, reveal fallback host), so flush now.
await ssr.streamHandler.flush();
});
}
diff --git a/packages/qwik/src/core/shared/error/error-boundary.ts b/packages/qwik/src/core/shared/error/error-boundary.ts
index 2b3f7c160b0..501bff215b2 100644
--- a/packages/qwik/src/core/shared/error/error-boundary.ts
+++ b/packages/qwik/src/core/shared/error/error-boundary.ts
@@ -26,23 +26,9 @@ export interface ErrorBoundaryProps {
}
/*
- * ErrorBoundary lives in core (not the router) so it ships with the framework. Core is not run
- * through the Qwik optimizer, so we can't use `component$`/`$` here — the `$()` marker would survive
- * into the runtime and throw. Instead we build the component QRL by hand with `inlinedQrl` and export
- * its symbol from the core bundle (see `handlers.mjs` and qwik-vite's `manifest.ts`).
- *
- * The store is created and provided on `ERROR_CONTEXT` by the internal `useErrorBoundaryStore` hook
- * (the old public `useErrorBoundary` was removed). Errors are routed to the CLOSEST boundary by the
- * container's `handleError` (it resolves `ERROR_CONTEXT` and sets this store's `.error`); both
- * synchronous render throws and async `qerror` events go through it.
- *
- * Streaming (experimental `errorBoundary` feature): the boundary NEVER blocks streaming. On SSR with
- * out-of-order streaming it renders its subtree inside a visible content host plus a hidden fallback
- * host. The content streams as usual; if the subtree throws, the boundary's `fallback$` is streamed
- * as an out-of-order segment and the shared `qO` executor hides the content host and reveals the
- * fallback host (an inline script, so the swap happens before the framework resumes). On the client
- * — a fresh render, or a re-render after a post-resume error — the boundary just swaps `` for
- * the fallback reactively (see `store.error` below).
+ * ErrorBoundary lives in core (ships with the framework) but core isn't run through the optimizer, so
+ * it's hand-built with `inlinedQrl` (symbol `_ebC`) rather than `component$`. Errors route to the
+ * closest boundary via the container's `handleError`.
*/
const _ebContentStyle = (store: ErrorBoundaryStore) => ({
@@ -57,27 +43,17 @@ const _ebFallbackStyle_str = '{display:p0.error?"contents":"none"}';
/** @internal */
export const errorBoundaryCmp = (props: ErrorBoundaryProps): JSXOutput => {
const store = useErrorBoundaryStore();
- // Expose the fallback so SSR can stream it when a child throws (the client re-renders this
- // component instead, which reads `store.error` below). Wrap in a fresh closure before
- // `noSerialize` — `noSerialize` taints the value's identity, and `props.fallback$` is the SAME QRL
- // object, so noSerializing it directly would also drop the serialized prop (it would resume as
- // `undefined`, and the client re-render would call `undefined(error)`).
+ // Wrap in a fresh closure before `noSerialize`: it taints the value's identity, and `props.fallback$`
+ // is the same QRL object, so noSerializing it directly would drop the serialized prop too.
const fallbackQrl = props.fallback$;
store.$fallback$ = noSerialize((error: any) => fallbackQrl(error));
const isServerEnv = qTest ? isServerPlatform() : !isBrowser;
if (__EXPERIMENTAL__.errorBoundary && isServerEnv && isOutOfOrderStreaming()) {
const boundaryId = nextErrorBoundaryId();
- // SSR with out-of-order streaming: render the subtree inside a visible content host plus a hidden
- // fallback host. The content streams as usual; an SSR throw streams `fallback$` into the fallback
- // host and the `qO` executor swaps the two via inline style. This branch deliberately does NOT
- // read `store.error`, so the component is not subscribed to it on the server (a late deferred
- // throw sets `store.error` mid-stream, and re-rendering an already-streamed host is unsupported).
- // A CLIENT-time error instead re-renders this component into the `if (store.error)` branch below —
- // the container's `handleError` marks this host dirty (see DomContainer.handleError) since the
- // OOOS boundary never subscribed to `store.error`. The diff then drops the two-host structure (the
- // empty `q:r` placeholder host removes cleanly, see `hasOnlySuspensePlaceholder`) and renders
- // `fallback$` fresh.
+ // A visible content host + a hidden fallback host that streams `fallback$` on a throw. This branch
+ // deliberately does NOT read `store.error`: subscribing would schedule a re-render on an
+ // already-streamed host for a late deferred throw. A client error re-renders it via `handleError`.
return [
/*#__PURE__*/ _jsxSorted(
'div',
@@ -102,8 +78,6 @@ export const errorBoundaryCmp = (props: ErrorBoundaryProps): JSXOutput => {
}
if (store.error) {
- // `fallback$` is a lazy QRL after resume; invoking it returns the JSX (or a promise the renderer
- // awaits). It is now serialized (see the closure note above), so it is defined here.
return /*#__PURE__*/ _jsxSorted(Fragment, null, null, props.fallback$(store.error), 0, null);
}
diff --git a/packages/qwik/src/core/shared/error/error-handling.ts b/packages/qwik/src/core/shared/error/error-handling.ts
index 13314684485..97720dfcda4 100644
--- a/packages/qwik/src/core/shared/error/error-handling.ts
+++ b/packages/qwik/src/core/shared/error/error-handling.ts
@@ -4,26 +4,16 @@ import { createContextId } from '../../use/use-context';
/** @internal */
export interface ErrorBoundaryStore {
error: any | undefined;
- /**
- * The boundary's fallback renderer. Set by `` so SSR can render the fallback in
- * place of a subtree that throws during server render (on the client the boundary re-renders
- * instead). Internal — not meant to be set by application code.
- */
+ /** Server-only fallback renderer; the client re-renders with `props.fallback$` instead. Internal. */
$fallback$?: (error: any) => unknown;
/**
- * Server-only. When the boundary buffers its subtree (experimental `errorBoundary` feature) —
- * only inside a `` segment, which is already buffered — this holds the render
- * checkpoint taken at the start of its content. Its presence tells the in-place SSR catch to let
- * the throw propagate to the boundary's nested render, which rolls back to this checkpoint and
- * renders the fallback.
+ * Server-only render checkpoint, present only when a boundary buffers inside a ``
+ * segment.
*/
$checkpoint$?: SSRBufferCheckpoint;
/**
- * Server-only, streaming ``. Set by the boundary's SSR fallback host: streams the
- * boundary's `fallback$(error)` as an out-of-order segment and injects it (via the shared `qO`
- * executor) into the fallback host, hiding the content host. Called either synchronously — when a
- * descendant threw during the content render — or later, when a deferred child ``
- * throws.
+ * Server-only; streams `fallback$` as an out-of-order segment and swaps it into the fallback
+ * host.
*/
$emitFallback$?: (error: unknown) => void | Promise;
}
diff --git a/packages/qwik/src/core/ssr/ssr-render-jsx.ts b/packages/qwik/src/core/ssr/ssr-render-jsx.ts
index 7303783a811..ab0429d81a6 100644
--- a/packages/qwik/src/core/ssr/ssr-render-jsx.ts
+++ b/packages/qwik/src/core/ssr/ssr-render-jsx.ts
@@ -90,8 +90,6 @@ export async function _walkJSX(
try {
await retryOnPromise(() => stack.push(trackFn()));
} catch (err) {
- // An async signal (e.g. a rejecting computed) threw on read — route it to the closest
- // ErrorBoundary like a render throw instead of aborting the stream.
let fallback = renderErrorBoundaryFallback(ssr, ssr.getOrCreateLastNode(), err);
if (isPromise(fallback)) {
fallback = await fallback;
@@ -106,9 +104,7 @@ export async function _walkJSX(
try {
stack.push(await pending);
} catch (err) {
- // An awaited child (async component, promise child, resource) rejected. Route it to
- // the closest ErrorBoundary just like a synchronous render throw — otherwise the
- // rejection would abort the whole stream. With no boundary above, this rethrows.
+ // Route an awaited child's rejection to the closest boundary, else it aborts the stream.
let fallback = renderErrorBoundaryFallback(ssr, ssr.getOrCreateLastNode(), err);
if (isPromise(fallback)) {
fallback = await fallback;
@@ -136,9 +132,8 @@ export async function _walkJSX(
}
/**
- * A component threw during SSR render. Route it to the CLOSEST ErrorBoundary (matching the client's
- * handleError): set the boundary's error and render its fallback in place of the failed subtree.
- * With no boundary above, rethrow so the request aborts to the error page.
+ * Route an SSR render throw to the closest ``; rethrow (aborting to the error page)
+ * when there's no boundary above.
*/
function renderErrorBoundaryFallback(
ssr: SSRContainer,
@@ -146,29 +141,20 @@ function renderErrorBoundaryFallback(
err: unknown
): ValueOrPromise {
const errorStore = ssr.resolveContext(host, ERROR_CONTEXT);
- // Only catch when the closest boundary exposes a fallback to render in place (SSR can't re-render
- // the boundary). Boundaries without one — or no boundary at all — propagate, aborting to the error
- // page, exactly as before.
if (!errorStore || !errorStore.$fallback$) {
throw err;
}
- // Experimental: a buffering boundary (only inside a `` segment, which is already
- // buffered) handles the throw in its own nested render — it rolls back to its checkpoint and
- // renders the fallback there. Propagate so the throw unwinds to that boundary.
+ // A buffering boundary (inside a `` segment) handles the throw in its own nested render.
if (__EXPERIMENTAL__.errorBoundary && errorStore.$checkpoint$) {
throw err;
}
- // Experimental: inside an out-of-order segment, the resolved boundary is OUTSIDE this segment (a
- // boundary inside it would be buffering, handled above). Propagate so the segment rejects and the
- // boundary's `$emitFallback$` tears the whole boundary down — rather than rendering the fallback
- // in place inside this segment's slot.
+ // Inside an out-of-order segment the boundary is outside it: propagate so the segment rejects and
+ // `$emitFallback$` tears the whole boundary down, rather than rendering the fallback in this slot.
if (__EXPERIMENTAL__.errorBoundary && isOutOfOrderSegmentContainer(ssr)) {
throw err;
}
- // Experimental: a live streaming boundary never blocks streaming and never renders in place. Just
- // mark the error — the boundary's fallback host (rendered after the content) streams `fallback$`
- // as an out-of-order segment and the shared `qO` executor hides the content and reveals it. The
- // throwing subtree renders nothing here.
+ // A live streaming boundary just marks the error; its fallback host streams `fallback$` and `qO`
+ // swaps it in (no in-place render, so streaming is never blocked).
if (__EXPERIMENTAL__.errorBoundary && ssr.outOfOrderStreaming) {
errorStore.error = err;
return null;
@@ -178,16 +164,13 @@ function renderErrorBoundaryFallback(
}
/**
- * Experimental: if `host` is itself an `` (it provided `ERROR_CONTEXT` with a
- * fallback), return its store so the renderer can buffer the boundary's subtree and swap in the
- * fallback on a throw. Reads the host's OWN context (not an ancestor's) so children don't match.
+ * If `host` is itself a buffering `` — inside a `` segment — return its
+ * store. Reads the host's own context (not an ancestor's) so children don't match.
*/
function getBufferingErrorBoundaryStore(
ssr: SSRContainer,
host: ReturnType
): ErrorBoundaryStore | null {
- // Only buffer inside a `` segment, which is already buffered — a live boundary must
- // never block streaming, so it streams its subtree and swaps in the fallback via `qO` instead.
if (!__EXPERIMENTAL__.errorBoundary || !isOutOfOrderSegmentContainer(ssr)) {
return null;
}
@@ -407,11 +390,8 @@ function processJSXNode(
);
enqueue(() => ssr.closeComponent());
if (bufferingErrorStore && !isPromise(jsxOutput)) {
- // Experimental buffering ErrorBoundary: render the subtree in a nested pass so a throw
- // unwinds here, where we roll back the partially-rendered output and render the fallback
- // in its place — yielding a clean `boundary > fallback` instead of a half-streamed
- // subtree. The stream block keeps the buffered HTML discardable (a no-op inside a
- // segment, which is already buffered).
+ // Buffering ErrorBoundary: render the subtree in a nested pass so a throw rolls back the
+ // partial output and renders the fallback in its place (a clean `boundary > fallback`).
const content = jsxOutput as JSXOutput;
enqueue(async () => {
ssr.streamHandler.streamBlockStart();
@@ -440,8 +420,6 @@ function processJSXNode(
try {
resolvedOutput = await jsxOutput;
} catch (err) {
- // An async component's render rejected — route it to the closest ErrorBoundary
- // (matching the sync throw path) instead of aborting the stream.
const fallback = renderErrorBoundaryFallback(ssr, host, err);
resolvedOutput = isPromise(fallback) ? await fallback : fallback;
}
diff --git a/packages/qwik/src/core/ssr/ssr-types.ts b/packages/qwik/src/core/ssr/ssr-types.ts
index 72a5291348f..24270b46067 100644
--- a/packages/qwik/src/core/ssr/ssr-types.ts
+++ b/packages/qwik/src/core/ssr/ssr-types.ts
@@ -173,8 +173,7 @@ export interface SSRContainer extends Container {
$runQueuedRender$(render: () => ValueOrPromise): ValueOrPromise;
/**
* Allocate the next out-of-order boundary id. Pass `markUsed: false` to reserve an id without
- * arming the OOOS executor — used by ``, which only needs the executor if it
- * actually throws (creating the fallback `segment()` then marks OOOS used).
+ * arming the OOOS executor (used by ``, which only needs it if it throws).
*/
nextOutOfOrderId(markUsed?: boolean): number;
emitOutOfOrderSegmentScripts(scripts: string): void;
diff --git a/packages/qwik/src/core/tests/error-boundary.spec.tsx b/packages/qwik/src/core/tests/error-boundary.spec.tsx
index a2654f53f1b..a71b33b6edf 100644
--- a/packages/qwik/src/core/tests/error-boundary.spec.tsx
+++ b/packages/qwik/src/core/tests/error-boundary.spec.tsx
@@ -153,10 +153,8 @@ describe('ErrorBoundary', () => {
});
describe('ErrorBoundary streaming swap (experimental)', () => {
- // Render with out-of-order streaming, then run the emitted `qO` scripts to perform the inline swap
- // (hide the content host, reveal the fallback host) and wire `qProcessOOOS` for resume, mirroring
- // the Suspense OOOS specs. Returns the streamed HTML (to assert the boundary did NOT block) plus
- // the resumed document (to assert the post-swap DOM).
+ // Render with out-of-order streaming, then run the emitted `qO` scripts to perform the inline swap.
+ // Returns the streamed HTML (to assert the boundary didn't block) and the resumed document.
const streamAndResume = async (jsx: JSXOutput) => {
const chunks: string[] = [];
await ssrRenderToDom(jsx, {
@@ -201,9 +199,7 @@ describe('ErrorBoundary streaming swap (experimental)', () => {
);
// The boundary never blocks: its content streams as usual.
expect(html).toContain('id="before"');
- // The inline `qO` swap script lands right after the boundary — BEFORE trailing content — so the
- // broken content is not left visible until end-of-stream. The swap is a plain inline script, so
- // it runs as the chunk parses, with no dependency on the framework having resumed.
+ // The swap script lands right after the boundary, before trailing content (not at end-of-stream).
const swapPos = html.search(/qO\(\d/);
expect(swapPos).toBeGreaterThan(html.indexOf('id="before"'));
expect(swapPos).toBeLessThan(html.indexOf('id="eb-tail"'));
@@ -235,10 +231,8 @@ describe('ErrorBoundary streaming swap (experimental)', () => {
});
it('a deferred (async) throw inside a child tears down the WHOLE boundary', async () => {
- // The boundary committed once the Suspense placeholder streamed, so it can't catch the later
- // async throw in place. Instead the whole boundary — the streamed sibling and the Suspense — is
- // hidden and the boundary fallback revealed (the dev's `fallback$` replaces the boundary, not a
- // sub-slot of it).
+ // The whole boundary (the streamed sibling and the Suspense) is torn down to the fallback, not
+ // wedged into the Suspense slot.
const { html, document } = await streamAndResume(
{
});
it('boundary inside a buffers within the segment (skeleton → fallback)', async () => {
- // When the boundary sits inside a its subtree is already deferred into a buffered
- // segment, so the boundary buffers there and discards the partial content — a clean
- // skeleton → fallback swap rather than the stream-and-hide path used by a live boundary.
+ // Inside a the subtree is already buffered, so the boundary buffers there and discards
+ // the partial content rather than using the live stream-and-hide path.
const { document } = await streamAndResume(
loading}>
@@ -436,13 +429,8 @@ describe('ErrorBoundary streaming swap (experimental)', () => {
expect(displayOf(document.querySelector('#ok-b')?.closest('div[style]'))).toBe('contents');
});
- // A client-time error after an out-of-order streamed resume must re-render the boundary to its
- // fallback. The fallback host only holds the empty `qO` `` placeholder (no fallback
- // was streamed, since there was no SSR error), so revealing it reactively shows nothing — the
- // boundary has to re-render to `fallback$` and drop the two-host structure. Regression for the
- // subscription gap: the OOOS branch returned early without reading `store.error`, so the component
- // never re-rendered on a client error. (The full browser round-trip is also covered by
- // `scenario=client` in error-boundary-streaming.e2e.ts.)
+ // The OOOS branch never read `store.error`, so a client-time error relies on `handleError`'s
+ // explicit re-render to swap in the fallback (also covered by the `scenario=client` e2e).
it('client: a post-resume error on an out-of-order streamed boundary re-renders to its fallback', async () => {
const { container } = await ssrRenderToDom(
diff --git a/packages/qwik/src/qwikloader.ts b/packages/qwik/src/qwikloader.ts
index 14443d41596..0770912ff98 100644
--- a/packages/qwik/src/qwikloader.ts
+++ b/packages/qwik/src/qwikloader.ts
@@ -101,10 +101,9 @@ const queueTasks = (tasks: Task[]) => {
};
/**
- * Run the deferred element handlers grouped per element, in propagation order. Once a handler calls
- * `stopPropagation()` — including asynchronously, after its QRL finished importing — handlers on
- * elements further along the path are skipped. A synchronous `stopPropagation()` already truncated
- * the walk (so `stoppedSynchronously` is set), and every group it collected must still run.
+ * Run deferred handlers grouped per element, re-checking `ev.cancelBubble` between groups so an
+ * async `stopPropagation()` (from a handler whose QRL was still importing) still skips later
+ * elements. A synchronous stop already truncated the walk, so its collected groups all run.
*/
const runEventTasks = (ev: Event, taskGroups: Task[][], stoppedSynchronously: boolean) => {
if (taskGroups.length) {
@@ -410,9 +409,8 @@ const processElementEvent = (
const captureAttribute = capturePrefix + kebabName;
const elements: Element[] = [];
const captureHandlers: boolean[] = [];
- // One task list per element, kept in propagation order. Deferred handlers (QRLs still importing)
- // can call `stopPropagation()` only after this synchronous walk has moved on, so the walk can't
- // truncate itself; `runEventTasks` re-checks `ev.cancelBubble` between elements instead.
+ // One task list per element so `runEventTasks` can re-check `cancelBubble` between them — a deferred
+ // handler can only `stopPropagation()` after this synchronous walk has already moved on.
const taskGroups: Task[][] = [];
let stoppedSynchronously = false;
let current = ev.target as Node | null;
diff --git a/packages/qwik/src/server/ssr-container.ts b/packages/qwik/src/server/ssr-container.ts
index d4f0182b7d7..776dd6e451e 100644
--- a/packages/qwik/src/server/ssr-container.ts
+++ b/packages/qwik/src/server/ssr-container.ts
@@ -473,8 +473,6 @@ class SSRContainer extends _SharedContainer implements ISSRContainer {
if (!__EXPERIMENTAL__.suspense || !this.outOfOrderStreaming) {
return 0;
}
- // `` reserves an id without arming the executor (markUsed=false): it only needs
- // OOOS if it actually throws, at which point creating the fallback `segment()` marks it used.
if (markUsed) {
this.outOfOrderUsed = true;
}
@@ -503,8 +501,8 @@ class SSRContainer extends _SharedContainer implements ISSRContainer {
'Out-of-order Suspense streaming requires `streaming.outOfOrder` to be `true`.'
);
}
- // Creating a segment means OOOS is genuinely used, so arm the executor. (For Suspense this is
- // already set by `nextOutOfOrderId`; for a throwing `` this is what arms it.)
+ // Creating a segment means OOOS is used — arm the executor (for a throwing ``,
+ // this is what arms it).
this.outOfOrderUsed = true;
this.markVNodeRefForSerialization(options.parentComponentFrame?.componentNode);
const writer = new StringBufferSegmentWriter();
@@ -995,10 +993,9 @@ class SSRContainer extends _SharedContainer implements ISSRContainer {
rollback(checkpoint: SSRBufferCheckpoint): void {
const cp = checkpoint as unknown as ContainerCheckpoint;
- // Discard buffered HTML, vnode-data, nodes and roots produced since the checkpoint, then restore
- // the cursor state so rendering can continue (e.g. with a fallback) as if the subtree never ran.
- // Styles already emitted to and entries in the root dedup map are left as harmless
- // orphans (the discarded objects are never referenced again).
+ // Discard buffered HTML, vnode-data, nodes and roots since the checkpoint and restore the cursor,
+ // so rendering continues as if the subtree never ran. Styles already in and root dedup-map
+ // entries are left as harmless orphans.
this.writer.truncate(cp.writer);
this.vNodeDatas.length = cp.vNodeDatasLength;
this.currentElementFrame = cp.frame;
diff --git a/packages/qwik/src/server/ssr-stream-handler.ts b/packages/qwik/src/server/ssr-stream-handler.ts
index dc1d62463e3..2d4f2c5d2a4 100644
--- a/packages/qwik/src/server/ssr-stream-handler.ts
+++ b/packages/qwik/src/server/ssr-stream-handler.ts
@@ -162,11 +162,7 @@ export class StreamHandler implements IStreamHandler {
return this.pendingFlush;
}
- /**
- * Mark the current stream-block buffer position. Only meaningful while a stream block is open
- * (`streamBlockDepth > 0`); the caller is responsible for suppressing flushes over the region it
- * intends to be able to discard.
- */
+ /** Mark the current stream-block buffer position (only meaningful while a stream block is open). */
streamCheckpoint(): number {
return this.streamBlockBuffer.length;
}
From b823e16abafa4247a628f63676521a302f36b71c Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Fri, 19 Jun 2026 18:57:47 +0200
Subject: [PATCH 023/331] chore(qwik): regenerate ruler skill outputs for
core-notes
Run `ruler apply` so the committed .claude/.codex copies of core-notes.md pick up the ErrorBoundary Streaming section added to the .ruler source. The ruler-check CI gate diffs these copies and would otherwise fail on drift.
---
.../qwik-core-development/references/core-notes.md | 14 ++++++++++++++
.../qwik-core-development/references/core-notes.md | 14 ++++++++++++++
2 files changed, 28 insertions(+)
diff --git a/.claude/skills/qwik-core-development/references/core-notes.md b/.claude/skills/qwik-core-development/references/core-notes.md
index 3a285440f60..1d0611aa9ea 100644
--- a/.claude/skills/qwik-core-development/references/core-notes.md
+++ b/.claude/skills/qwik-core-development/references/core-notes.md
@@ -168,6 +168,20 @@ streaming, navigation, or integration with fixture apps. For Qwik e2e, load
Never use `pnpm test.unit` for agent verification in this repo.
+## ErrorBoundary Streaming (experimental `errorBoundary`)
+
+`` must never block streaming. On SSR with out-of-order streaming the boundary
+(`errorBoundaryCmp`, `shared/error/error-boundary.ts`) renders two display-toggled hosts modeled on
+Suspense — a visible content host wrapping ``, and a hidden fallback host (`q:rp={id}`). On a
+throw, the fallback host streams `fallback$(error)` as an out-of-order segment and the shared `qO`
+executor reveals it while hiding the content host via an inline script, so the swap happens as the
+chunk parses, before the framework resumes; resume consistency comes via `qProcessOOOS`.
+
+A deferred child `` that throws tears the whole boundary down via `store.$emitFallback$`. A
+boundary placed *inside* a `` segment instead buffers within that already-deferred segment
+(`getBufferingErrorBoundaryStore` gates on `isOutOfOrderSegmentContainer`). Client-time errors
+re-render the boundary through the container's `handleError`.
+
## Keep This Reference Fresh
Before finishing a core task, ask:
diff --git a/.codex/skills/qwik-core-development/references/core-notes.md b/.codex/skills/qwik-core-development/references/core-notes.md
index 3a285440f60..1d0611aa9ea 100644
--- a/.codex/skills/qwik-core-development/references/core-notes.md
+++ b/.codex/skills/qwik-core-development/references/core-notes.md
@@ -168,6 +168,20 @@ streaming, navigation, or integration with fixture apps. For Qwik e2e, load
Never use `pnpm test.unit` for agent verification in this repo.
+## ErrorBoundary Streaming (experimental `errorBoundary`)
+
+`` must never block streaming. On SSR with out-of-order streaming the boundary
+(`errorBoundaryCmp`, `shared/error/error-boundary.ts`) renders two display-toggled hosts modeled on
+Suspense — a visible content host wrapping ``, and a hidden fallback host (`q:rp={id}`). On a
+throw, the fallback host streams `fallback$(error)` as an out-of-order segment and the shared `qO`
+executor reveals it while hiding the content host via an inline script, so the swap happens as the
+chunk parses, before the framework resumes; resume consistency comes via `qProcessOOOS`.
+
+A deferred child `` that throws tears the whole boundary down via `store.$emitFallback$`. A
+boundary placed *inside* a `` segment instead buffers within that already-deferred segment
+(`getBufferingErrorBoundaryStore` gates on `isOutOfOrderSegmentContainer`). Client-time errors
+re-render the boundary through the container's `handleError`.
+
## Keep This Reference Fresh
Before finishing a core task, ask:
From f4cece4eb85ba86142c232156d9b175e82e837f1 Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Fri, 19 Jun 2026 18:57:53 +0200
Subject: [PATCH 024/331] refactor(qwik): dedupe OOOS segment-swap and EB
fallback-await helpers
Extract finalizeAndSwapOutOfOrderSegment (shared by the Suspense deferred-slot reveal and the ErrorBoundary fallback stream) and resolveErrorBoundaryFallback (the render-then-await-if-promise idiom repeated at the three async SSR drain catch sites). No behavior change; core suite and build.core stay green.
---
.../qwik/src/core/control-flow/suspense.tsx | 41 ++++++++++++++++---
packages/qwik/src/core/ssr/ssr-render-jsx.ts | 28 +++++++------
2 files changed, 51 insertions(+), 18 deletions(-)
diff --git a/packages/qwik/src/core/control-flow/suspense.tsx b/packages/qwik/src/core/control-flow/suspense.tsx
index 2b1589cf032..01bbd96bfaf 100644
--- a/packages/qwik/src/core/control-flow/suspense.tsx
+++ b/packages/qwik/src/core/control-flow/suspense.tsx
@@ -330,6 +330,33 @@ function createClaimedDeferredSlot(
);
}
+/**
+ * Finalize a streamed out-of-order segment and swap it into its placeholder host: write the
+ * resolved template, emit the segment's scripts, then call `qO(id)` to perform the inline swap and
+ * flush. Shared by Suspense's deferred-slot reveal and the ErrorBoundary fallback stream.
+ * `emitExecutor` emits the shared `qO` executor here (the ErrorBoundary path); Suspense emits it up
+ * front instead.
+ */
+async function finalizeAndSwapOutOfOrderSegment(
+ ssr: SSRContainer,
+ boundaryId: number,
+ segmentId: string,
+ rendered: SSROutOfOrderSegment,
+ revealBoundary: OutOfOrderRevealBoundary | null,
+ emitExecutor: boolean
+): Promise {
+ const result = await rendered.container.$finalizeOutOfOrderSegment$(segmentId, rendered);
+ writeOutOfOrderResolvedTemplate(ssr, boundaryId, result.html, revealBoundary);
+ ssr.emitOutOfOrderSegmentScripts(result.scripts);
+ if (emitExecutor) {
+ // Emit the shared executor right before its first call (idempotent), so error-free pages ship none.
+ ssr.emitOutOfOrderExecutorIfNeeded();
+ }
+ ssr.emitInlineScript(`qO(${boundaryId})`);
+ // qO() is the browser-visible handoff for this segment, so flush it immediately.
+ await ssr.streamHandler.flush();
+}
+
async function emitRenderedOutOfOrderSegment(
ssr: SSRContainer,
boundaryId: number,
@@ -343,12 +370,14 @@ async function emitRenderedOutOfOrderSegment(
revealBoundary?.resolve();
await ssr.$runQueuedRender$(async () => {
ssr.addRoot(contentStyle);
- const result = await rendered.container.$finalizeOutOfOrderSegment$(segmentId, rendered);
- writeOutOfOrderResolvedTemplate(ssr, boundaryId, result.html, revealBoundary);
- ssr.emitOutOfOrderSegmentScripts(result.scripts);
- ssr.emitInlineScript(`qO(${boundaryId})`);
- // qO() is the browser-visible handoff for this segment, so flush it immediately.
- await ssr.streamHandler.flush();
+ await finalizeAndSwapOutOfOrderSegment(
+ ssr,
+ boundaryId,
+ segmentId,
+ rendered,
+ revealBoundary,
+ false
+ );
});
}
diff --git a/packages/qwik/src/core/ssr/ssr-render-jsx.ts b/packages/qwik/src/core/ssr/ssr-render-jsx.ts
index ab0429d81a6..fb9927b3076 100644
--- a/packages/qwik/src/core/ssr/ssr-render-jsx.ts
+++ b/packages/qwik/src/core/ssr/ssr-render-jsx.ts
@@ -90,11 +90,7 @@ export async function _walkJSX(
try {
await retryOnPromise(() => stack.push(trackFn()));
} catch (err) {
- let fallback = renderErrorBoundaryFallback(ssr, ssr.getOrCreateLastNode(), err);
- if (isPromise(fallback)) {
- fallback = await fallback;
- }
- stack.push(fallback);
+ stack.push(await resolveErrorBoundaryFallback(ssr, ssr.getOrCreateLastNode(), err));
}
continue;
}
@@ -105,11 +101,7 @@ export async function _walkJSX(
stack.push(await pending);
} catch (err) {
// Route an awaited child's rejection to the closest boundary, else it aborts the stream.
- let fallback = renderErrorBoundaryFallback(ssr, ssr.getOrCreateLastNode(), err);
- if (isPromise(fallback)) {
- fallback = await fallback;
- }
- stack.push(fallback);
+ stack.push(await resolveErrorBoundaryFallback(ssr, ssr.getOrCreateLastNode(), err));
}
} else {
const result = (value as StackFn).apply(ssr);
@@ -163,6 +155,19 @@ function renderErrorBoundaryFallback(
return errorStore.$fallback$(err) as ValueOrPromise;
}
+/**
+ * {@link renderErrorBoundaryFallback}, awaiting an async fallback so async drain sites get a plain
+ * value.
+ */
+async function resolveErrorBoundaryFallback(
+ ssr: SSRContainer,
+ host: ReturnType,
+ err: unknown
+): Promise {
+ const fallback = renderErrorBoundaryFallback(ssr, host, err);
+ return isPromise(fallback) ? await fallback : fallback;
+}
+
/**
* If `host` is itself a buffering `` — inside a `` segment — return its
* store. Reads the host's own context (not an ancestor's) so children don't match.
@@ -420,8 +425,7 @@ function processJSXNode(
try {
resolvedOutput = await jsxOutput;
} catch (err) {
- const fallback = renderErrorBoundaryFallback(ssr, host, err);
- resolvedOutput = isPromise(fallback) ? await fallback : fallback;
+ resolvedOutput = await resolveErrorBoundaryFallback(ssr, host, err);
}
const compStyleComponentId = addComponentStylePrefix(host.getProp(QScopedStyle));
From 346e840e1e262a83e95c79eb217070c81ff446ef Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Fri, 19 Jun 2026 20:36:28 +0200
Subject: [PATCH 025/331] fix(qwik): contain qerror handleError throw in the
document listener
The always-on `qerror` listener routed every error into handleError, which re-throws when no ErrorBoundary encloses the host. In a listener that throw has no owner (qwik-dom dispatchEvent has no per-listener try/catch; browsers report an uncaught error), so a boundary-less app's transient async/import error aborted event dispatch. Guard the listener (log instead), short-circuit already-logged importError qerrors, and leave the synchronous render-throw path untouched.
---
.../qwik/src/core/client/dom-container.ts | 19 ++++-
.../core/tests/error-boundary-qerror.spec.tsx | 76 +++++++++++++++++++
2 files changed, 93 insertions(+), 2 deletions(-)
create mode 100644 packages/qwik/src/core/tests/error-boundary-qerror.spec.tsx
diff --git a/packages/qwik/src/core/client/dom-container.ts b/packages/qwik/src/core/client/dom-container.ts
index 534e6a65390..1857f2f5022 100644
--- a/packages/qwik/src/core/client/dom-container.ts
+++ b/packages/qwik/src/core/client/dom-container.ts
@@ -152,12 +152,27 @@ export class DomContainer extends _SharedContainer implements IClientContainer {
// ErrorBoundary, matching the synchronous render-throw path. Previously each ErrorBoundary
// listened to the global event and every one of them caught every error regardless of source.
this.$qErrorHandler$ = (e: Event) => {
- const detail = (e as CustomEvent<{ error: unknown; element?: Element }>).detail;
+ const detail = (e as CustomEvent<{ error: unknown; element?: Element; importError?: string }>)
+ .detail;
+ // QRL chunk-import / missing-symbol failures are already `console.error`-ed by qwikloader and
+ // must only log so the app keeps running — never route them through boundary handling.
+ if (detail?.importError) {
+ logError(detail.error);
+ return;
+ }
const source = detail?.element;
if (source && this.element.contains(source)) {
const host = this.vNodeLocate(source);
if (host) {
- this.handleError(detail.error, host);
+ // `handleError` re-throws when no boundary encloses `host`. In a listener that throw has no
+ // owner (qwik-dom `dispatchEvent` has no per-listener try/catch; a browser reports it as an
+ // uncaught window error), so contain it here and log instead. The synchronous render-throw
+ // path calls `handleError` directly and is intentionally left to propagate.
+ try {
+ this.handleError(detail.error, host);
+ } catch (handlerError) {
+ logError(handlerError);
+ }
}
}
};
diff --git a/packages/qwik/src/core/tests/error-boundary-qerror.spec.tsx b/packages/qwik/src/core/tests/error-boundary-qerror.spec.tsx
new file mode 100644
index 00000000000..53dec9ffba0
--- /dev/null
+++ b/packages/qwik/src/core/tests/error-boundary-qerror.spec.tsx
@@ -0,0 +1,76 @@
+import { $, ErrorBoundary } from '@qwik.dev/core';
+import { domRender, waitForDrain } from '@qwik.dev/core/testing';
+import { describe, expect, it } from 'vitest';
+
+const debug = false;
+
+/** Dispatch a `qerror` CustomEvent the same way qwikloader does (see error-boundary.spec.tsx). */
+function dispatchQError(
+ target: Element,
+ detail: { error: unknown; element?: Element; importError?: string }
+): void {
+ const ev = target.ownerDocument.createEvent('Event');
+ ev.initEvent('qerror', false, false);
+ (ev as any).detail = detail;
+ target.ownerDocument.dispatchEvent(ev);
+}
+
+describe('ErrorBoundary qerror listener', () => {
+ it('does NOT throw when a qerror has no enclosing ErrorBoundary', async () => {
+ const { container } = await domRender(
+
+
+ ,
+ { debug }
+ );
+ const target = container.element.querySelector('#target')!;
+
+ // With no boundary above the host, handleError re-throws. The listener must contain that throw
+ // (and log it) instead of letting it escape document.dispatchEvent.
+ expect(() =>
+ dispatchQError(target, { error: new Error('boom'), element: target })
+ ).not.toThrow();
+ });
+
+ it('control: a with-boundary qerror still reveals the fallback', async () => {
+ const { container } = await domRender(
+ (
+
+ ))}
+ >
+
+ ,
+ { debug }
+ );
+ const target = container.element.querySelector('#target')!;
+
+ // qwikloader already console.errors QRL chunk-import / missing-symbol failures (importError set),
+ // so they must not be routed to a boundary.
+ expect(() =>
+ dispatchQError(target, { error: new Error('sym:0'), element: target, importError: 'sync' })
+ ).not.toThrow();
+ await waitForDrain(container);
+
+ expect(container.element.querySelector('#fb')).toBeFalsy();
+ });
+});
From 96c25d109317e212f676f4893f2ef79960856bad Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Fri, 19 Jun 2026 20:38:00 +0200
Subject: [PATCH 026/331] refactor(qwik): name OOOS executor host roles for
both consumers
The shared qO executor keys its swap on the q:rp host, which is the content host for Suspense but the fallback host for an ErrorBoundary, so the old content/fallback/contentNode names read backwards for boundaries. Rename to revealHost/hideHost/revealNode (the action the executor performs) and document that q:rp marks the host to reveal. Identifier-only change; positional access is unchanged.
---
.../src/core/client/process-vnode-data.ts | 9 ++---
packages/qwik/src/core/client/types.ts | 2 +-
.../qwik/src/out-of-order-executor-shared.ts | 34 +++++++++++--------
3 files changed, 25 insertions(+), 20 deletions(-)
diff --git a/packages/qwik/src/core/client/process-vnode-data.ts b/packages/qwik/src/core/client/process-vnode-data.ts
index 69a7058c3ee..2667642fec9 100644
--- a/packages/qwik/src/core/client/process-vnode-data.ts
+++ b/packages/qwik/src/core/client/process-vnode-data.ts
@@ -64,12 +64,13 @@ type VNodeDataScope = Document | ShadowRoot;
export function processOutOfOrderSegmentVNodeData(
document: Document,
segmentId: string,
- contentNode: Element | null
+ // The `q:rp` host the executor revealed: a boundary's fallback host, or Suspense's content host.
+ revealNode: Element | null
) {
- if (!__EXPERIMENTAL__.suspense || !contentNode) {
+ if (!__EXPERIMENTAL__.suspense || !revealNode) {
return;
}
- const qContainerElement = contentNode.closest('[q\\:container]') as ContainerElement | null;
+ const qContainerElement = revealNode.closest('[q\\:container]') as ContainerElement | null;
const script = qContainerElement?.querySelector(
`script[type="qwik/vnode"][q\\:r="${segmentId}"]:not([q\\:patch])`
);
@@ -77,7 +78,7 @@ export function processOutOfOrderSegmentVNodeData(
document,
segmentId,
qContainerElement,
- contentNode,
+ revealNode,
script?.textContent || undefined
);
const patches = qContainerElement?.querySelectorAll(
diff --git a/packages/qwik/src/core/client/types.ts b/packages/qwik/src/core/client/types.ts
index f6c11277208..0d0334505c8 100644
--- a/packages/qwik/src/core/client/types.ts
+++ b/packages/qwik/src/core/client/types.ts
@@ -64,7 +64,7 @@ export interface QDocument extends Document {
qProcessVNodeDataPatch?: (script: Element | null) => void;
/** Processes an out-of-order Suspense segment after its resolved HTML is swapped in. */
- qProcessOOOS?: (boundaryId: number, content: Element | null) => void;
+ qProcessOOOS?: (boundaryId: number, revealNode: Element | null) => void;
}
/**
diff --git a/packages/qwik/src/out-of-order-executor-shared.ts b/packages/qwik/src/out-of-order-executor-shared.ts
index 46ae475108e..f729f7113e2 100644
--- a/packages/qwik/src/out-of-order-executor-shared.ts
+++ b/packages/qwik/src/out-of-order-executor-shared.ts
@@ -10,6 +10,8 @@ const Q_CONTAINER_SELECTOR = '[q\\:container]:not([q\\:container=html]):not([q\\
type OutOfOrderTemplate = HTMLTemplateElement | null;
type OutOfOrderHost = Element | null;
+// [hostToReveal, hostToHide]: the `q:rp` host is the one to reveal (Suspense's content host,
+// but a boundary's fallback host), and its previous sibling is the one to hide.
type OutOfOrderEntry = [OutOfOrderHost | 0, OutOfOrderHost];
type OutOfOrderScope = Document | Element;
@@ -27,7 +29,7 @@ type OutOfOrderExecutor = {
};
type OutOfOrderDocument = Document & {
- qProcessOOOS?: (boundaryId: number, content: Element | null) => void;
+ qProcessOOOS?: (boundaryId: number, revealNode: Element | null) => void;
};
type OutOfOrderGlobal = typeof globalThis & {
@@ -37,9 +39,9 @@ type OutOfOrderGlobal = typeof globalThis & {
export const installOutOfOrderExecutor = (doc: Document) => {
const groups = new WeakMap>();
- const process = (boundaryId: number, content: Element | null) => {
+ const process = (boundaryId: number, revealNode: Element | null) => {
const executorDoc = doc as OutOfOrderDocument;
- executorDoc.qProcessOOOS?.(boundaryId, content);
+ executorDoc.qProcessOOOS?.(boundaryId, revealNode);
};
const getScope = (): OutOfOrderScope => {
@@ -82,23 +84,25 @@ export const installOutOfOrderExecutor = (doc: Document) => {
return templates.length ? (templates[templates.length - 1] as HTMLTemplateElement) : null;
};
- const getPlaceholderTemplate = (content: Element, boundaryId: number): OutOfOrderTemplate => {
- return content.querySelector(Q_RESOLVED_SELECTOR + boundaryId + '"]') as OutOfOrderTemplate;
+ const getPlaceholderTemplate = (revealHost: Element, boundaryId: number): OutOfOrderTemplate => {
+ return revealHost.querySelector(Q_RESOLVED_SELECTOR + boundaryId + '"]') as OutOfOrderTemplate;
};
const getResultParent = (scope: OutOfOrderScope, boundaryId: number): Element | null => {
return scope.querySelector(Q_RESULT_PARENT_SELECTOR + boundaryId + '"]');
};
- const reveal = (content: OutOfOrderHost, fallback: OutOfOrderHost) => {
- if (!content) {
+ // `q:rp` marks the host to reveal (the fallback host for a boundary, the content host for
+ // Suspense); `hideHost` is its previous sibling, which we hide once the result is swapped in.
+ const reveal = (revealHost: OutOfOrderHost, hideHost: OutOfOrderHost) => {
+ if (!revealHost) {
return 0;
}
- if (fallback && (fallback as HTMLElement).style) {
- (fallback as HTMLElement).style.display = 'none';
+ if (hideHost && (hideHost as HTMLElement).style) {
+ (hideHost as HTMLElement).style.display = 'none';
}
- if ((content as HTMLElement).style) {
- (content as HTMLElement).style.display = 'contents';
+ if ((revealHost as HTMLElement).style) {
+ (revealHost as HTMLElement).style.display = 'contents';
}
return 1;
};
@@ -107,16 +111,16 @@ export const installOutOfOrderExecutor = (doc: Document) => {
if (!resolved) {
return null;
}
- const content = getResultParent(scope, boundaryId);
- const placeholder = content ? getPlaceholderTemplate(content, boundaryId) : null;
+ const revealHost = getResultParent(scope, boundaryId);
+ const placeholder = revealHost ? getPlaceholderTemplate(revealHost, boundaryId) : null;
const parent = placeholder ? placeholder.parentNode : null;
- if (!placeholder || !content || !parent) {
+ if (!placeholder || !revealHost || !parent) {
return null;
}
parent.insertBefore(resolved.content, placeholder);
placeholder.remove();
resolved.remove();
- return [content, content.previousElementSibling] as OutOfOrderEntry;
+ return [revealHost, revealHost.previousElementSibling] as OutOfOrderEntry;
};
const flush = (group: OutOfOrderGroup) => {
From 1c89d78868d688e45d0e65a5e1def6697594016b Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Fri, 19 Jun 2026 20:39:38 +0200
Subject: [PATCH 027/331] fix(qwik): tear a streaming ErrorBoundary down
exactly once
`$emitFallback$` is shared by every deferred child of a boundary, so when two sibling segments both reject they both invoked streamFallback; the second read the already-detached `$fallback$` as undefined and threw 'fallback is not a function', rejecting its out-of-order segment. Guard on the detached state (first error wins, later siblings no-op) and keep `$fallback$` detached so the boundary never tears down twice.
---
.../qwik/src/core/control-flow/suspense.tsx | 21 ++---
.../tests/error-boundary-concurrent.spec.tsx | 79 +++++++++++++++++++
2 files changed, 91 insertions(+), 9 deletions(-)
create mode 100644 packages/qwik/src/core/tests/error-boundary-concurrent.spec.tsx
diff --git a/packages/qwik/src/core/control-flow/suspense.tsx b/packages/qwik/src/core/control-flow/suspense.tsx
index 01bbd96bfaf..0090b3ec15c 100644
--- a/packages/qwik/src/core/control-flow/suspense.tsx
+++ b/packages/qwik/src/core/control-flow/suspense.tsx
@@ -393,17 +393,20 @@ export const SSRErrorFallback = __EXPERIMENTAL__.errorBoundary
const store = jsx.varProps.store as ErrorBoundaryStore;
const segmentId = `${boundaryId}`;
const streamFallback = async (error: unknown): Promise => {
+ // Tear a boundary down exactly once: `$emitFallback$` is shared by every deferred child, so
+ // sibling rejections all land here. A detached `$fallback$` means teardown already started (or
+ // finished) — the first error wins, later siblings no-op instead of double-swapping the host.
+ const fallback = store.$fallback$;
+ if (!fallback) {
+ return;
+ }
store.error = error;
- // Detach `$fallback$` while it renders: if the fallback itself throws back to this boundary,
- // it must not re-render the fallback (infinite loop) — with none set, that throw propagates.
- const fallback = store.$fallback$!;
+ // Keep `$fallback$` detached for the rest of the render: if the fallback itself throws back to
+ // this boundary, it must not re-render the fallback (infinite loop) — with none set, that throw
+ // propagates. It stays detached afterwards too, so the boundary never tears down a second time.
store.$fallback$ = undefined;
- try {
- const segment = await ssr.segment(segmentId, fallback(error) as JSXOutput, options);
- await emitErrorBoundaryFallback(ssr, boundaryId, segmentId, segment);
- } finally {
- store.$fallback$ = fallback;
- }
+ const segment = await ssr.segment(segmentId, fallback(error) as JSXOutput, options);
+ await emitErrorBoundaryFallback(ssr, boundaryId, segmentId, segment);
};
store.$emitFallback$ = noSerialize(streamFallback);
writeOutOfOrderPlaceholder(ssr, boundaryId);
diff --git a/packages/qwik/src/core/tests/error-boundary-concurrent.spec.tsx b/packages/qwik/src/core/tests/error-boundary-concurrent.spec.tsx
new file mode 100644
index 00000000000..aef673d0e46
--- /dev/null
+++ b/packages/qwik/src/core/tests/error-boundary-concurrent.spec.tsx
@@ -0,0 +1,79 @@
+import { $, component$, ErrorBoundary, Suspense, type JSXOutput } from '@qwik.dev/core';
+import { createDocument, ssrRenderToDom } from '@qwik.dev/core/testing';
+import { describe, expect, it } from 'vitest';
+import { processOutOfOrderSegmentVNodeData } from '../client/process-vnode-data';
+
+const debug = false;
+
+// Throws *asynchronously*: a rejected promise child resolves after the Suspense placeholder has
+// already streamed, so the throw surfaces as the deferred segment's rejection (not a render throw).
+const AsyncThrower = component$(() => {
+ const pending = new Promise((_resolve, reject) => reject(new Error('async boom')));
+ return <>{pending}>;
+});
+
+describe('ErrorBoundary concurrent fallback teardown (experimental)', () => {
+ // Render with out-of-order streaming, then run the emitted `qO` scripts to perform the inline swap.
+ // Returns the streamed HTML (to assert the boundary didn't block) and the resumed document.
+ // Replicated from error-boundary.spec.tsx so this regression spec stands alone.
+ const streamAndResume = async (jsx: JSXOutput) => {
+ const chunks: string[] = [];
+ await ssrRenderToDom(jsx, {
+ stream: {
+ write: (c: string) => {
+ chunks.push(c);
+ },
+ },
+ streaming: { inOrder: { strategy: 'disabled' }, outOfOrder: true },
+ debug,
+ });
+ const html = chunks.join('');
+ const document = createDocument({ html });
+ (document as any).qProcessOOOS = (boundaryId: number, content: Element | null) =>
+ processOutOfOrderSegmentVNodeData(document, String(boundaryId), content);
+ const scripts = Array.from(
+ document.querySelectorAll('script[type="text/javascript"]'),
+ (s) => s.textContent || ''
+ ).filter((code) => code.includes('qO') || code.includes('qInstallOOOS'));
+ // eslint-disable-next-line no-new-func
+ new Function('document', scripts.join('\n'))(document);
+ return { html, document };
+ };
+
+ const displayOf = (el: Element | null | undefined) =>
+ (el as HTMLElement | null | undefined)?.style?.display;
+
+ it('two sibling that both reject tear the boundary down exactly once', async () => {
+ // Both wrap an async thrower, so both deferred segments reject and both invoke the
+ // boundary's shared `$emitFallback$`. Before the idempotency guard, the second invocation read the
+ // detached `store.$fallback$` as undefined and threw a TypeError ("undefined is not a function"),
+ // which rejected its out-of-order segment and surfaced as an unhandled render rejection here.
+ const result = await streamAndResume(
+
+ (
+
caught: {String(e?.message ?? e)}
+ ))}
+ >
+
sibling
+ loading a}>
+
+
+ loading b}>
+
+
+
+
+ );
+
+ // The whole boundary tore down to its fallback — exactly one fallback host, no duplicate swap.
+ const fallbacks = result.document.querySelectorAll('#fb');
+ expect(fallbacks.length).toBe(1);
+ expect(fallbacks[0]?.textContent).toContain('caught: async boom');
+ // The fallback host is revealed and the content host (with the siblings) is hidden.
+ expect(displayOf(result.document.querySelector('#fb')?.closest('[q\\:rp]'))).toBe('contents');
+ expect(displayOf(result.document.querySelector('#sibling')?.closest('div[style]'))).toBe(
+ 'none'
+ );
+ });
+});
From 2cceaad57a22cb26d39ad1e370c0adb645889b01 Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Fri, 19 Jun 2026 20:41:27 +0200
Subject: [PATCH 028/331] fix(qwik): route SSR async-generator throws to the
boundary; keep error serializable
S2: an async-generator child (and the SSRStream async-gen form, which funnels through the same drain branch) that threw mid-stream was awaited in a try/finally with no catch, so it escaped the enclosing ErrorBoundary and aborted SSR. Wrap the iteration and route via resolveErrorBoundaryFallback (no boundary -> still aborts). S4: store a serialization-safe projection of the thrown value so a non-serializable throw no longer fails verifySerializable and aborts the whole page; the fallback still receives the raw error.
---
packages/qwik/src/core/ssr/ssr-render-jsx.ts | 39 +++++++--
.../tests/error-boundary-ssr-async.spec.tsx | 85 +++++++++++++++++++
2 files changed, 118 insertions(+), 6 deletions(-)
create mode 100644 packages/qwik/src/core/tests/error-boundary-ssr-async.spec.tsx
diff --git a/packages/qwik/src/core/ssr/ssr-render-jsx.ts b/packages/qwik/src/core/ssr/ssr-render-jsx.ts
index fb9927b3076..f1a0710e1d7 100644
--- a/packages/qwik/src/core/ssr/ssr-render-jsx.ts
+++ b/packages/qwik/src/core/ssr/ssr-render-jsx.ts
@@ -17,7 +17,7 @@ import {
SSRStreamBlock,
type SSRStreamChildren,
} from '../shared/jsx/utils.public';
-import { type SerializationContext } from '../shared/serdes/index';
+import { canSerialize, type SerializationContext } from '../shared/serdes/index';
import { DEBUG_TYPE, VirtualType } from '../shared/types';
import { isAsyncGenerator } from '../shared/utils/async-generator';
import { EMPTY_OBJ } from '../shared/utils/flyweight';
@@ -123,6 +123,21 @@ export async function _walkJSX(
await drain();
}
+/**
+ * `errorStore.error` is serialized into the page, so a non-serializable throw (a class instance
+ * with methods, a function, a DOM node) would fail `verifySerializable` at state emit and abort the
+ * whole page with a confusing serializer error — masking the real throw. Keep `Error` instances and
+ * any already-serializable value as-is; project anything else to an `Error` carrying a safe message
+ * so the fallback still gets a readable `.message`.
+ */
+function toSerializableBoundaryError(err: unknown): unknown {
+ if (err instanceof Error || canSerialize(err)) {
+ return err;
+ }
+ const rawMessage = (err as { message?: unknown })?.message;
+ return new Error(typeof rawMessage === 'string' ? rawMessage : String(err));
+}
+
/**
* Route an SSR render throw to the closest ``; rethrow (aborting to the error page)
* when there's no boundary above.
@@ -148,10 +163,10 @@ function renderErrorBoundaryFallback(
// A live streaming boundary just marks the error; its fallback host streams `fallback$` and `qO`
// swaps it in (no in-place render, so streaming is never blocked).
if (__EXPERIMENTAL__.errorBoundary && ssr.outOfOrderStreaming) {
- errorStore.error = err;
+ errorStore.error = toSerializableBoundaryError(err);
return null;
}
- errorStore.error = err;
+ errorStore.error = toSerializableBoundaryError(err);
return errorStore.$fallback$(err) as ValueOrPromise;
}
@@ -225,12 +240,24 @@ function processJSXNode(
enqueue(() => ssr.streamHandler.flush());
} else if (isAsyncGenerator(value)) {
enqueue(async () => {
- for await (const chunk of value) {
- await _walkJSX(ssr, chunk as JSXOutput, {
+ try {
+ for await (const chunk of value) {
+ await _walkJSX(ssr, chunk as JSXOutput, {
+ currentStyleScoped: options.currentStyleScoped,
+ parentComponentFrame: options.parentComponentFrame,
+ });
+ await ssr.streamHandler.flush();
+ }
+ } catch (err) {
+ // Route a mid-stream throw (the async-generator child or the `SSRStream` write loop, which
+ // funnels through here) to the closest boundary and render its fallback in place — the same
+ // drain site the chunks use. With no boundary above, `resolveErrorBoundaryFallback`
+ // rethrows and SSR still aborts (matching framework-closure throws).
+ const fallback = await resolveErrorBoundaryFallback(ssr, ssr.getOrCreateLastNode(), err);
+ await _walkJSX(ssr, fallback, {
currentStyleScoped: options.currentStyleScoped,
parentComponentFrame: options.parentComponentFrame,
});
- await ssr.streamHandler.flush();
}
});
} else {
diff --git a/packages/qwik/src/core/tests/error-boundary-ssr-async.spec.tsx b/packages/qwik/src/core/tests/error-boundary-ssr-async.spec.tsx
new file mode 100644
index 00000000000..2a87a1d9225
--- /dev/null
+++ b/packages/qwik/src/core/tests/error-boundary-ssr-async.spec.tsx
@@ -0,0 +1,85 @@
+import { $, component$, ErrorBoundary, type JSXOutput } from '@qwik.dev/core';
+import { ssrRenderToDom } from '@qwik.dev/core/testing';
+import { describe, expect, it } from 'vitest';
+
+const debug = false;
+
+// An async-generator child that streams one chunk, then throws mid-stream. Before the fix this throw
+// escaped the async-generator StackFn (awaited inside a try-with-only-finally) and aborted SSR
+// instead of routing to the enclosing .
+const AsyncGenThrower = component$(() => {
+ return (
+ <>
+ {(async function* () {
+ yield
chunk
;
+ throw new Error('async gen boom');
+ })()}
+ >
+ ) as unknown as JSXOutput;
+});
+
+// Throws a NON-serializable value (a class instance with a method). Before the S4 fix this raw value
+// was stored on the error store and then failed `verifySerializable` at final state emit, aborting
+// the whole page with a confusing serializer error instead of rendering the boundary fallback.
+class NonSerializableError {
+ message = 'non-serializable boom';
+ toJSON() {
+ return this.message;
+ }
+}
+
+const NonSerializableThrower = component$((): JSXOutput => {
+ throw new NonSerializableError();
+});
+
+const NormalErrorThrower = component$((): JSXOutput => {
+ throw new Error('normal boom');
+});
+
+describe('ErrorBoundary SSR async-generator + non-serializable throws (experimental)', () => {
+ it('S2: routes an async-generator child throw to the enclosing boundary fallback', async () => {
+ const { container } = await ssrRenderToDom(
+ (
+
caught: {String(e?.message ?? e)}
+ ))}
+ >
+
+ ,
+ { debug }
+ );
+ expect(container.element.querySelector('#fb')?.textContent).toContain('caught: async gen boom');
+ });
+
+ it('S4: a non-serializable throw renders the fallback AND the page still serializes', async () => {
+ // The key assertion is that ssrRenderToDom RESOLVES (the page serialized) rather than rejecting
+ // with a verifySerializable error.
+ const { container } = await ssrRenderToDom(
+ (
+
+ ))}
+ >
+
+ ,
+ { debug }
+ );
+ expect(container.element.querySelector('#fb')?.textContent).toContain('caught: normal boom');
+ });
+});
From 62a4c9e4c2b5d765c10bc0d7d8bccd9f4263518a Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Fri, 19 Jun 2026 20:42:34 +0200
Subject: [PATCH 029/331] fix(qwik): warn when errorBoundary is enabled without
suspense
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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).
---
.../error-boundary-flag-warning.unit.ts | 61 +++++++++++++++++++
packages/qwik-vite/src/plugins/plugin.ts | 10 +++
2 files changed, 71 insertions(+)
create mode 100644 packages/qwik-vite/src/plugins/error-boundary-flag-warning.unit.ts
diff --git a/packages/qwik-vite/src/plugins/error-boundary-flag-warning.unit.ts b/packages/qwik-vite/src/plugins/error-boundary-flag-warning.unit.ts
new file mode 100644
index 00000000000..67977315616
--- /dev/null
+++ b/packages/qwik-vite/src/plugins/error-boundary-flag-warning.unit.ts
@@ -0,0 +1,61 @@
+import path from 'node:path';
+import { afterEach, expect, test, vi } from 'vitest';
+import { createQwikPlugin } from './plugin';
+
+/**
+ * Regression test for BUG S5: enabling the experimental `errorBoundary` feature without its
+ * build-time prerequisite `suspense` used to silently degrade to the synchronous in-place
+ * ErrorBoundary fallback with no diagnostic. `normalizeOptions` should now warn once per build for
+ * that misconfiguration, while staying quiet when the prerequisite is present or when
+ * `errorBoundary` is not enabled at all.
+ */
+
+async function mockPlugin() {
+ const plugin = createQwikPlugin({
+ sys: {
+ cwd: () => process.cwd(),
+ env: 'node',
+ os: process.platform,
+ dynamicImport: async (p: string) => import(p),
+ strictDynamicImport: async (p: string) => import(p),
+ path: path as any,
+ },
+ binding: { mockBinding: true },
+ } as any);
+ await plugin.init();
+ return plugin;
+}
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+test('warns when errorBoundary is enabled without suspense', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const plugin = await mockPlugin();
+
+ await plugin.normalizeOptions({ experimental: ['errorBoundary'] });
+
+ expect(warn).toHaveBeenCalledTimes(1);
+ const message = String(warn.mock.calls[0]?.[0] ?? '');
+ expect(message).toContain('errorBoundary');
+ expect(message).toContain('suspense');
+});
+
+test('does not warn when both errorBoundary and suspense are enabled', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const plugin = await mockPlugin();
+
+ await plugin.normalizeOptions({ experimental: ['errorBoundary', 'suspense'] });
+
+ expect(warn).not.toHaveBeenCalled();
+});
+
+test('does not warn when errorBoundary is not enabled', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const plugin = await mockPlugin();
+
+ await plugin.normalizeOptions({ experimental: ['suspense'] });
+
+ expect(warn).not.toHaveBeenCalled();
+});
diff --git a/packages/qwik-vite/src/plugins/plugin.ts b/packages/qwik-vite/src/plugins/plugin.ts
index 09fcba7da0a..038739bc092 100644
--- a/packages/qwik-vite/src/plugins/plugin.ts
+++ b/packages/qwik-vite/src/plugins/plugin.ts
@@ -392,6 +392,16 @@ export function createQwikPlugin(optimizerOptions: OptimizerOptions = {}) {
(opts.experimental ||= {} as any)[feature] = true;
}
}
+ // The ErrorBoundary streaming swap reuses the out-of-order Suspense machinery, so it needs
+ // `suspense` enabled here plus `streaming.outOfOrder` at render time. Without `suspense` the
+ // boundary silently degrades to the synchronous in-place fallback instead of streaming the
+ // fallback after a deferred throw. Warn once at config time rather than failing the build,
+ // since the in-place fallback still works.
+ if (opts.experimental?.errorBoundary && !opts.experimental.suspense) {
+ console.warn(
+ 'Qwik plugin: the `errorBoundary` experimental feature also requires the `suspense` experimental feature and `streaming.outOfOrder` at render time to stream the fallback. Without them the ErrorBoundary falls back to synchronous in-place rendering.'
+ );
+ }
}
const out = { ...opts };
From 99da80603e5e3ea32799aa17dc62722ac4c0e5dd Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Fri, 19 Jun 2026 20:45:49 +0200
Subject: [PATCH 030/331] fix(qwik): fully restore serialization roots on SSR
rollback
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.
---
.../shared/serdes/serialization-context.ts | 21 +++++++
.../core/tests/ssr-rollback-roots.spec.tsx | 60 +++++++++++++++++++
packages/qwik/src/server/ssr-container.ts | 6 +-
3 files changed, 84 insertions(+), 3 deletions(-)
create mode 100644 packages/qwik/src/core/tests/ssr-rollback-roots.spec.tsx
diff --git a/packages/qwik/src/core/shared/serdes/serialization-context.ts b/packages/qwik/src/core/shared/serdes/serialization-context.ts
index e0b6383e036..cc62438b4ac 100644
--- a/packages/qwik/src/core/shared/serdes/serialization-context.ts
+++ b/packages/qwik/src/core/shared/serdes/serialization-context.ts
@@ -83,6 +83,12 @@ export interface SerializationContext {
$promoteToRoot$: (ref: SeenRef, obj: unknown, index?: number) => void;
+ /**
+ * Truncate `$roots$` and `$rootObjs$` back to `length` and drop the dedup-map entries created
+ * since that point. Used by the SSR container to discard roots from a rolled-back subtree.
+ */
+ $rollbackRoots$: (length: number) => void;
+
$addSyncFn$($funcStr$: string | null, argsCount: number, fn: Function): number;
$setSyncFnOffset$(offset: number, existingFns?: string[]): void;
@@ -209,6 +215,21 @@ class SerializationContextImpl implements SerializationContext {
}
}
+ $rollbackRoots$(length: number): void {
+ // `$roots$` and `$rootObjs$` grow in lockstep, so any index >= `length` was added after the
+ // checkpoint. Drop its dedup entry, but only when the entry still points at a discarded index:
+ // an object that also lives at a surviving root index (re-referenced/promoted) must keep its
+ // entry so later dedup stays correct.
+ for (let i = length; i < this.$rootObjs$.length; i++) {
+ const seen = this.$seenObjsMap$.get(this.$rootObjs$[i]);
+ if (seen && seen.$index$ >= length) {
+ this.$seenObjsMap$.delete(this.$rootObjs$[i]);
+ }
+ }
+ this.$roots$.length = length;
+ this.$rootObjs$.length = length;
+ }
+
$addRoot$(obj: any, returnRef: true): SeenRef;
$addRoot$(obj: any, returnRef?: never): number;
$addRoot$(obj: any, returnRef: boolean = false): number | SeenRef {
diff --git a/packages/qwik/src/core/tests/ssr-rollback-roots.spec.tsx b/packages/qwik/src/core/tests/ssr-rollback-roots.spec.tsx
new file mode 100644
index 00000000000..c167106b3ce
--- /dev/null
+++ b/packages/qwik/src/core/tests/ssr-rollback-roots.spec.tsx
@@ -0,0 +1,60 @@
+import { ssrRenderToDom } from '@qwik.dev/core/testing';
+import { describe, expect, it } from 'vitest';
+import { createInternalServerComponent } from '../ssr/internal-server-component';
+
+// Regression for the rollback root-array desync (PR #8745). `rollback()` must restore the
+// serialization roots completely: `$roots$` and the parallel `$rootObjs$` stay the same length,
+// and the dedup map ($seenObjsMap$) must not keep a stale entry for a discarded root. Otherwise
+// promoteSharedSegmentRoots reads `segmentRoots[i] === undefined` past the surviving length and
+// re-adding a discarded object returns its old out-of-range index instead of a fresh one.
+type Probe = {
+ rootsLenAfter: number;
+ rootObjsLenAfter: number;
+ reAddIndex: number;
+};
+
+const probes: Probe[] = [];
+
+const RollbackProbe = createInternalServerComponent(async (ssr, _jsx, options) => {
+ ssr.streamHandler.streamBlockStart();
+ await ssr.renderJSX(
keep
, options);
+
+ const cp = ssr.checkpoint();
+ const discarded = { marker: 'discarded-root' };
+ ssr.serializationCtx.$addRoot$(discarded);
+ ssr.rollback(cp);
+
+ // Re-adding the discarded object must allocate a fresh, in-range index (equal to the
+ // post-rollback length), not the stale memoized index from before the rollback.
+ const rootsLenAfter = ssr.serializationCtx.$roots$.length;
+ const rootObjsLenAfter = ssr.serializationCtx.$rootObjs$.length;
+ const reAddIndex = ssr.serializationCtx.$addRoot$(discarded);
+ probes.push({ rootsLenAfter, rootObjsLenAfter, reAddIndex });
+
+ await ssr.renderJSX(
fallback
, options);
+ await ssr.streamHandler.streamBlockEnd();
+});
+
+describe('SSR rollback serialization roots', () => {
+ it('keeps $roots$/$rootObjs$ aligned and re-adds a discarded root at a fresh in-range index', async () => {
+ probes.length = 0;
+ const { container } = await ssrRenderToDom(
+
+
+ ,
+ { debug: false }
+ );
+
+ // The render itself must still produce the surviving + replacement output and resume.
+ const el = container.element;
+ expect(el.querySelector('#keep')?.textContent).toBe('keep');
+ expect(el.querySelector('#fb')?.textContent).toBe('fallback');
+
+ expect(probes).toHaveLength(1);
+ const probe = probes[0];
+ // After discarding a root, the two parallel arrays must be the same length.
+ expect(probe.rootObjsLenAfter).toBe(probe.rootsLenAfter);
+ // Re-adding the discarded object must return a fresh index within bounds, not the stale one.
+ expect(probe.reAddIndex).toBe(probe.rootsLenAfter);
+ });
+});
diff --git a/packages/qwik/src/server/ssr-container.ts b/packages/qwik/src/server/ssr-container.ts
index 776dd6e451e..a26baa1ff76 100644
--- a/packages/qwik/src/server/ssr-container.ts
+++ b/packages/qwik/src/server/ssr-container.ts
@@ -994,8 +994,8 @@ class SSRContainer extends _SharedContainer implements ISSRContainer {
rollback(checkpoint: SSRBufferCheckpoint): void {
const cp = checkpoint as unknown as ContainerCheckpoint;
// Discard buffered HTML, vnode-data, nodes and roots since the checkpoint and restore the cursor,
- // so rendering continues as if the subtree never ran. Styles already in and root dedup-map
- // entries are left as harmless orphans.
+ // so rendering continues as if the subtree never ran. Styles already in are left as
+ // harmless orphans; the serialization roots and their dedup-map entries are fully restored.
this.writer.truncate(cp.writer);
this.vNodeDatas.length = cp.vNodeDatasLength;
this.currentElementFrame = cp.frame;
@@ -1013,7 +1013,7 @@ class SSRContainer extends _SharedContainer implements ISSRContainer {
}
this.depthFirstElementCount = cp.depthFirstElementCount;
this.componentStack.length = cp.componentStackLength;
- this.serializationCtx.$roots$.length = cp.rootsLength;
+ this.serializationCtx.$rollbackRoots$(cp.rootsLength);
}
addUnclaimedProjection(frame: ISsrComponentFrame, name: string, children: JSXChildren): void {
From 5a85661068cf90e6f15678ac176d2a7234a7920c Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Fri, 19 Jun 2026 20:52:32 +0200
Subject: [PATCH 031/331] test(qwik): cover deferred-QRL stopPropagation
skipping a later ancestor
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
packages/qwik/src/qwikloader.behavior.unit.ts | 56 +++++++++++++++++++
1 file changed, 56 insertions(+)
diff --git a/packages/qwik/src/qwikloader.behavior.unit.ts b/packages/qwik/src/qwikloader.behavior.unit.ts
index 33d8c709805..d3f48804a9d 100644
--- a/packages/qwik/src/qwikloader.behavior.unit.ts
+++ b/packages/qwik/src/qwikloader.behavior.unit.ts
@@ -303,6 +303,62 @@ describe('qwikloader behavior', () => {
expect(logs).toEqual(['child bubble']);
});
+ test('a deferred (importing) handler that stops propagation skips a later deferred ancestor', async () => {
+ const { doc } = createLoaderEnvironment(['e:click']);
+ const logs: string[] = [];
+ const previousLogs = (globalThis as any).__qwikLoaderStopLogs;
+ // Both handlers load via QRL import, so neither runs during the synchronous bubbling walk — they
+ // run only when their per-element task group flushes. The child stops propagation after its
+ // import resolves; the fix's `cancelBubble` re-check between groups must then skip the ancestor.
+ const childModule = `data:text/javascript;charset=utf-8,${encodeURIComponent(
+ 'export const handler = (ev) => { ev.stopPropagation(); globalThis.__qwikLoaderStopLogs.push("child stop"); };'
+ )}`;
+ const parentModule = `data:text/javascript;charset=utf-8,${encodeURIComponent(
+ 'export const handler = () => globalThis.__qwikLoaderStopLogs.push("parent bubble");'
+ )}`;
+ const container = createMockElement(null, { 'q:container': 'resumed', 'q:base': './' });
+ const parent = createMockElement(container, { 'q-e:click': `${parentModule}#handler#` });
+ const child = createMockElement(parent, { 'q-e:click': `${childModule}#handler#` });
+
+ (globalThis as any).__qwikLoaderStopLogs = logs;
+ try {
+ getSingleListener(doc, 'click').handler(createMockEvent(child));
+ await vi.waitFor(() => {
+ expect(logs).toContain('child stop');
+ });
+ await flushQueuedTasks();
+ // Pre-fix the ancestor's group flushed unconditionally and 'parent bubble' was logged.
+ expect(logs).toEqual(['child stop']);
+ } finally {
+ (globalThis as any).__qwikLoaderStopLogs = previousLogs;
+ }
+ });
+
+ test('a deferred (importing) handler that does not stop still runs the deferred ancestor', async () => {
+ const { doc } = createLoaderEnvironment(['e:click']);
+ const logs: string[] = [];
+ const previousLogs = (globalThis as any).__qwikLoaderStopLogs;
+ const childModule = `data:text/javascript;charset=utf-8,${encodeURIComponent(
+ 'export const handler = () => globalThis.__qwikLoaderStopLogs.push("child run");'
+ )}`;
+ const parentModule = `data:text/javascript;charset=utf-8,${encodeURIComponent(
+ 'export const handler = () => globalThis.__qwikLoaderStopLogs.push("parent bubble");'
+ )}`;
+ const container = createMockElement(null, { 'q:container': 'resumed', 'q:base': './' });
+ const parent = createMockElement(container, { 'q-e:click': `${parentModule}#handler#` });
+ const child = createMockElement(parent, { 'q-e:click': `${childModule}#handler#` });
+
+ (globalThis as any).__qwikLoaderStopLogs = logs;
+ try {
+ getSingleListener(doc, 'click').handler(createMockEvent(child));
+ await vi.waitFor(() => {
+ expect(logs).toEqual(['child run', 'parent bubble']);
+ });
+ } finally {
+ (globalThis as any).__qwikLoaderStopLogs = previousLogs;
+ }
+ });
+
test('applies parent preventdefault synchronously before async child bubbling completes', async () => {
const { doc } = createLoaderEnvironment(['e:click']);
const logs: string[] = [];
From 6d22044574135359c85895bc227d22921279ba23 Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Fri, 19 Jun 2026 20:54:29 +0200
Subject: [PATCH 032/331] chore(qwik): regenerate api after OOOS executor param
rename
pnpm api.update for the _QDocument.qProcessOOOS parameter rename (content -> revealNode) from the OOOS executor naming change. Internal type, parameter-name only.
---
packages/qwik/src/core/qwik.core.api.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/qwik/src/core/qwik.core.api.md b/packages/qwik/src/core/qwik.core.api.md
index f4c3ecc3261..059dae8faf0 100644
--- a/packages/qwik/src/core/qwik.core.api.md
+++ b/packages/qwik/src/core/qwik.core.api.md
@@ -873,7 +873,7 @@ export type PublicProps = (PROPS extends Record ? Omit void;
+ qProcessOOOS?: (boundaryId: number, revealNode: Element | null) => void;
qProcessVNodeDataPatch?: (script: Element | null) => void;
// (undocumented)
qVNodeData: WeakMap;
From 29b8734c48826ccaba8c41c9d0480beea5280f5c Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Fri, 19 Jun 2026 21:16:23 +0200
Subject: [PATCH 033/331] test(qwik): cover multi-throw and multi-boundary
ErrorBoundary combinations
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).
---
.../error-boundary-combinations.spec.tsx | 225 ++++++++++++++++++
1 file changed, 225 insertions(+)
create mode 100644 packages/qwik/src/core/tests/error-boundary-combinations.spec.tsx
diff --git a/packages/qwik/src/core/tests/error-boundary-combinations.spec.tsx b/packages/qwik/src/core/tests/error-boundary-combinations.spec.tsx
new file mode 100644
index 00000000000..4aba0fab638
--- /dev/null
+++ b/packages/qwik/src/core/tests/error-boundary-combinations.spec.tsx
@@ -0,0 +1,225 @@
+import { $, component$, ErrorBoundary, Suspense, type JSXOutput } from '@qwik.dev/core';
+import { createDocument, domRender, ssrRenderToDom } from '@qwik.dev/core/testing';
+import { describe, expect, it } from 'vitest';
+import { processOutOfOrderSegmentVNodeData } from '../client/process-vnode-data';
+
+// Combinatorial coverage for multiple throws / multiple boundaries across the three modes
+// (CSR, in-order SSR, and out-of-order streaming). These pin down the "who catches what" semantics
+// when more than one error or boundary is in play.
+
+const debug = false;
+
+const ThrowerA = component$(() => {
+ throw new Error('boomA');
+});
+const ThrowerB = component$(() => {
+ throw new Error('boomB');
+});
+
+// Rejects after the Suspense placeholder has streamed, so the throw surfaces as a deferred segment
+// rejection rather than a synchronous render throw.
+const AsyncThrower = component$(() => {
+ const pending = new Promise((_resolve, reject) => reject(new Error('async boom')));
+ return <>{pending}>;
+});
+
+// Stream with out-of-order streaming, then run the emitted `qO` scripts to perform the inline swap.
+const streamAndResume = async (jsx: JSXOutput) => {
+ const chunks: string[] = [];
+ await ssrRenderToDom(jsx, {
+ stream: { write: (c: string) => chunks.push(c) },
+ streaming: { inOrder: { strategy: 'disabled' }, outOfOrder: true },
+ debug,
+ });
+ const document = createDocument({ html: chunks.join('') });
+ (document as any).qProcessOOOS = (boundaryId: number, content: Element | null) =>
+ processOutOfOrderSegmentVNodeData(document, String(boundaryId), content);
+ const scripts = Array.from(
+ document.querySelectorAll('script[type="text/javascript"]'),
+ (s) => s.textContent || ''
+ ).filter((code) => code.includes('qO') || code.includes('qInstallOOOS'));
+ // eslint-disable-next-line no-new-func
+ new Function('document', scripts.join('\n'))(document);
+ return document;
+};
+
+const fbCount = (root: any) => root.querySelectorAll('#fb').length;
+
+describe('ErrorBoundary combinations', () => {
+ describe('CSR', () => {
+ it('two throwing children in one boundary render a single fallback (first error wins)', async () => {
+ const { container } = await domRender(
+ (
+
caught: {e.message}
+ ))}
+ >
+
+
+ ,
+ { debug }
+ );
+ expect(fbCount(container.element)).toBe(1);
+ expect(container.element.querySelector('#fb')?.textContent).toContain('caught: boomA');
+ });
+
+ it('two adjacent boundaries that both throw each show their own fallback', async () => {
+ const { container } = await domRender(
+
+ (
+
A
+ ))}
+ >
+
+
+ (
+
B
+ ))}
+ >
+
+
+ ,
+ { debug }
+ );
+ expect(container.element.querySelector('#fb-a')).toBeTruthy();
+ expect(container.element.querySelector('#fb-b')).toBeTruthy();
+ });
+
+ it('nested boundaries: when the outer also throws it supersedes the inner fallback', async () => {
+ // The inner boundary catches its own child, but the outer boundary catches its direct-child
+ // throw and re-renders its whole subtree to the outer fallback — replacing the inner one.
+ const { container } = await domRender(
+ (
+
+ ))}
+ >
+
+
+ ,
+ { debug }
+ );
+ expect(fbCount(container.element)).toBe(1);
+ });
+
+ it('two adjacent boundaries that both throw each show their own fallback', async () => {
+ const { container } = await ssrRenderToDom(
+
+ (
+
A
+ ))}
+ >
+
+
+ (
+
B
+ ))}
+ >
+
+
+ ,
+ { debug }
+ );
+ expect(container.element.querySelector('#fb-a')).toBeTruthy();
+ expect(container.element.querySelector('#fb-b')).toBeTruthy();
+ });
+ });
+
+ describe('out-of-order streaming', () => {
+ it('two adjacent boundaries that both throw each swap in their own fallback', async () => {
+ const document = await streamAndResume(
+
+ (
+
A
+ ))}
+ >
+
+
+ (
+
B
+ ))}
+ >
+
+
+
+ );
+ expect(document.querySelector('#fb-a')).toBeTruthy();
+ expect(document.querySelector('#fb-b')).toBeTruthy();
+ });
+
+ it('two boundaries inside one Suspense each show their own fallback', async () => {
+ const document = await streamAndResume(
+
+ loading}>
+ (
+
A
+ ))}
+ >
+
+
+ (
+
B
+ ))}
+ >
+
+
+
+
+ );
+ expect(document.querySelector('#fb-a')).toBeTruthy();
+ expect(document.querySelector('#fb-b')).toBeTruthy();
+ });
+
+ it('two Suspense in one boundary that both async-throw render a single fallback', async () => {
+ const document = await streamAndResume(
+
+ (
+
caught: {String(e?.message ?? e)}
+ ))}
+ >
+ a}>
+
+
+ b}>
+
+
+
+
+ );
+ expect(fbCount(document)).toBe(1);
+ });
+ });
+});
From 65888fe159fde4f22f48005af0c7333f55bf6586 Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Fri, 19 Jun 2026 21:48:30 +0200
Subject: [PATCH 034/331] test(qwik): cover deferred-QRL capture-phase
stopPropagation
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.
---
packages/qwik/src/qwikloader.behavior.unit.ts | 64 +++++++++++++++++++
1 file changed, 64 insertions(+)
diff --git a/packages/qwik/src/qwikloader.behavior.unit.ts b/packages/qwik/src/qwikloader.behavior.unit.ts
index d3f48804a9d..794e2a20cb3 100644
--- a/packages/qwik/src/qwikloader.behavior.unit.ts
+++ b/packages/qwik/src/qwikloader.behavior.unit.ts
@@ -359,6 +359,70 @@ describe('qwikloader behavior', () => {
}
});
+ test('a deferred (importing) capture handler that stops propagation skips a later deferred bubble handler', async () => {
+ const { doc } = createLoaderEnvironment(['e:click']);
+ const logs: string[] = [];
+ const previousLogs = (globalThis as any).__qwikLoaderStopLogs;
+ // The capture handler and the deeper bubble handler both load via QRL import, so neither sets
+ // `cancelBubble` during the synchronous capture/bubble walk. The capturer stops propagation only
+ // after its import resolves, so the fix's `cancelBubble` re-check between per-element task groups
+ // must skip the later (deferred, importing) bubble handler.
+ const captureModule = `data:text/javascript;charset=utf-8,${encodeURIComponent(
+ 'export const handler = (ev) => { ev.stopPropagation(); globalThis.__qwikLoaderStopLogs.push("capturer stop"); };'
+ )}`;
+ const bubbleModule = `data:text/javascript;charset=utf-8,${encodeURIComponent(
+ 'export const handler = () => globalThis.__qwikLoaderStopLogs.push("target bubble");'
+ )}`;
+ const container = createMockElement(null, { 'q:container': 'resumed', 'q:base': './' });
+ const capturer = createMockElement(container, {
+ 'capture:click': true,
+ 'q-e:click': `${captureModule}#handler#`,
+ });
+ const target = createMockElement(capturer, { 'q-e:click': `${bubbleModule}#handler#` });
+
+ (globalThis as any).__qwikLoaderStopLogs = logs;
+ try {
+ getSingleListener(doc, 'click').handler(createMockEvent(target));
+ await vi.waitFor(() => {
+ expect(logs).toContain('capturer stop');
+ });
+ await flushQueuedTasks();
+ // Without the `cancelBubble` re-check the deeper bubble group flushed unconditionally and
+ // 'target bubble' was logged after 'capturer stop'.
+ expect(logs).toEqual(['capturer stop']);
+ } finally {
+ (globalThis as any).__qwikLoaderStopLogs = previousLogs;
+ }
+ });
+
+ test('a deferred (importing) capture handler that does not stop still runs the later deferred bubble handler', async () => {
+ const { doc } = createLoaderEnvironment(['e:click']);
+ const logs: string[] = [];
+ const previousLogs = (globalThis as any).__qwikLoaderStopLogs;
+ const captureModule = `data:text/javascript;charset=utf-8,${encodeURIComponent(
+ 'export const handler = () => globalThis.__qwikLoaderStopLogs.push("capturer capture");'
+ )}`;
+ const bubbleModule = `data:text/javascript;charset=utf-8,${encodeURIComponent(
+ 'export const handler = () => globalThis.__qwikLoaderStopLogs.push("target bubble");'
+ )}`;
+ const container = createMockElement(null, { 'q:container': 'resumed', 'q:base': './' });
+ const capturer = createMockElement(container, {
+ 'capture:click': true,
+ 'q-e:click': `${captureModule}#handler#`,
+ });
+ const target = createMockElement(capturer, { 'q-e:click': `${bubbleModule}#handler#` });
+
+ (globalThis as any).__qwikLoaderStopLogs = logs;
+ try {
+ getSingleListener(doc, 'click').handler(createMockEvent(target));
+ await vi.waitFor(() => {
+ expect(logs).toEqual(['capturer capture', 'target bubble']);
+ });
+ } finally {
+ (globalThis as any).__qwikLoaderStopLogs = previousLogs;
+ }
+ });
+
test('applies parent preventdefault synchronously before async child bubbling completes', async () => {
const { doc } = createLoaderEnvironment(['e:click']);
const logs: string[] = [];
From 89f0d498fae65c3fae9efedb2b321a75e3cd4e94 Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Fri, 19 Jun 2026 21:48:41 +0200
Subject: [PATCH 035/331] test(qwik): cover ErrorBoundary projection,
no-boundary SSR, multi-container, 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.
---
.../error-boundary-multi-container.spec.tsx | 105 +++++++++++
.../error-boundary-no-boundary-ssr.spec.tsx | 86 +++++++++
.../tests/error-boundary-projection.spec.tsx | 106 +++++++++++
.../core/tests/error-boundary-tasks.spec.tsx | 167 ++++++++++++++++++
4 files changed, 464 insertions(+)
create mode 100644 packages/qwik/src/core/tests/error-boundary-multi-container.spec.tsx
create mode 100644 packages/qwik/src/core/tests/error-boundary-no-boundary-ssr.spec.tsx
create mode 100644 packages/qwik/src/core/tests/error-boundary-projection.spec.tsx
create mode 100644 packages/qwik/src/core/tests/error-boundary-tasks.spec.tsx
diff --git a/packages/qwik/src/core/tests/error-boundary-multi-container.spec.tsx b/packages/qwik/src/core/tests/error-boundary-multi-container.spec.tsx
new file mode 100644
index 00000000000..fe82ac980ac
--- /dev/null
+++ b/packages/qwik/src/core/tests/error-boundary-multi-container.spec.tsx
@@ -0,0 +1,105 @@
+import { $, ErrorBoundary, render, setPlatform } from '@qwik.dev/core';
+import { _getDomContainer } from '@qwik.dev/core/internal';
+import { createDocument, getTestPlatform, waitForDrain } from '@qwik.dev/core/testing';
+import { describe, expect, it } from 'vitest';
+
+// Two independent resumed Qwik containers can coexist on one page. Each registers a document-level
+// `qerror` listener (DomContainer.$qErrorHandler$ in client/dom-container.ts). A qerror originating
+// inside container A must be handled ONLY by A's ErrorBoundary, never B's — the listener guards with
+// `this.element.contains(source)`, so even though both listeners fire on the shared document, only
+// the owning container acts.
+
+// domRender() creates a fresh document per call, so two domRender calls would NOT share a document.
+// We replicate its internals: one createDocument(), two sibling container divs in document.body, and
+// render() into each. render() turns each div into its own DomContainer, and each container's
+// constructor adds a `qerror` listener to the SAME ownerDocument.
+const renderTwoContainers = async (jsxA: any, jsxB: any) => {
+ setPlatform(getTestPlatform());
+ const document = createDocument();
+ const hostA = document.createElement('div');
+ const hostB = document.createElement('div');
+ document.body.appendChild(hostA);
+ document.body.appendChild(hostB);
+ await render(hostA, jsxA);
+ await render(hostB, jsxB);
+ const containerA = _getDomContainer(hostA);
+ const containerB = _getDomContainer(hostB);
+ return { document, hostA, hostB, containerA, containerB };
+};
+
+/** Dispatch a `qerror` CustomEvent the same way qwikloader does (see error-boundary.spec.tsx). */
+const dispatchQError = (
+ target: Element,
+ detail: { error: unknown; element?: Element; importError?: string }
+) => {
+ const ev = target.ownerDocument.createEvent('Event');
+ ev.initEvent('qerror', false, false);
+ (ev as any).detail = detail;
+ target.ownerDocument.dispatchEvent(ev);
+};
+
+describe('ErrorBoundary across multiple containers on one document', () => {
+ it('routes a qerror from container A only to A, leaving B untouched', async () => {
+ const { hostA, hostB, containerA } = await renderTwoContainers(
+ (
+
caught A: {e.message}
+ ))}
+ >
+
+ ,
+ (
+
caught B: {e.message}
+ ))}
+ >
+
+
+ );
+
+ // Sanity: both containers streamed their content, neither shows a fallback yet.
+ expect(hostA.querySelector('#fb-a')).toBeFalsy();
+ expect(hostB.querySelector('#fb-b')).toBeFalsy();
+ expect(hostA.querySelector('#target-a')).toBeTruthy();
+ expect(hostB.querySelector('#target-b')).toBeTruthy();
+
+ // A qerror originating INSIDE container A. Both containers' document listeners fire, but only A's
+ // `this.element.contains(source)` guard passes.
+ const targetA = hostA.querySelector('#target-a')!;
+ dispatchQError(targetA, { error: new Error('boom from A'), element: targetA });
+ await waitForDrain(containerA);
+
+ // A's boundary reveals its fallback...
+ expect(hostA.querySelector('#fb-a')?.textContent).toContain('caught A: boom from A');
+ // ...and B is completely untouched: no fallback, original content intact.
+ expect(hostB.querySelector('#fb-b')).toBeFalsy();
+ expect(hostB.querySelector('#target-b')).toBeTruthy();
+ });
+
+ it('routes a qerror from container B only to B, leaving A untouched', async () => {
+ const { hostA, hostB, containerB } = await renderTwoContainers(
+ (
+
caught A: {e.message}
+ ))}
+ >
+
+ ,
+ (
+
caught B: {e.message}
+ ))}
+ >
+
+
+ );
+
+ const targetB = hostB.querySelector('#target-b')!;
+ dispatchQError(targetB, { error: new Error('boom from B'), element: targetB });
+ await waitForDrain(containerB);
+
+ expect(hostB.querySelector('#fb-b')?.textContent).toContain('caught B: boom from B');
+ expect(hostA.querySelector('#fb-a')).toBeFalsy();
+ expect(hostA.querySelector('#target-a')).toBeTruthy();
+ });
+});
diff --git a/packages/qwik/src/core/tests/error-boundary-no-boundary-ssr.spec.tsx b/packages/qwik/src/core/tests/error-boundary-no-boundary-ssr.spec.tsx
new file mode 100644
index 00000000000..dd3390502d7
--- /dev/null
+++ b/packages/qwik/src/core/tests/error-boundary-no-boundary-ssr.spec.tsx
@@ -0,0 +1,86 @@
+import { component$, type JSXOutput } from '@qwik.dev/core';
+import { ssrRenderToDom } from '@qwik.dev/core/testing';
+import { describe, expect, it } from 'vitest';
+
+// Safety-net invariant (PR #8745): the PR now catches SSR render throws at every drain site so a
+// throw inside an renders the fallback instead of aborting. But with NO boundary
+// above, the throw MUST still propagate and reject the render (abort to the error page) — it must
+// not be silently swallowed. These in-order SSR cases pin each of the three catch paths to that
+// rethrow behavior. (`ssrRenderToDom` with no `stream`/`streaming` opts renders in-order via
+// `renderToString`, so `ssr.outOfOrderStreaming` is false and the final rethrow branch of
+// `renderErrorBoundaryFallback` is exercised.)
+
+const debug = false;
+
+// Throws synchronously during render — caught by the component-body try/catch in ssr-render-jsx.ts.
+const Thrower = component$(() => {
+ throw new Error('boom');
+});
+
+// An async component whose render itself rejects (returns a rejecting promise) — exercises the
+// awaited-output drain path rather than the promise-child path.
+const AsyncRejector = component$(
+ () => new Promise((_resolve, reject) => reject(new Error('async boom'))) as any
+);
+
+// A rejected promise child (not a component) — exercises the Promise drain path.
+const PromiseChild = component$(() => {
+ const pending = new Promise((_resolve, reject) => reject(new Error('async boom')));
+ return <>{pending}>;
+});
+
+describe('ErrorBoundary safety net: in-order SSR throw with no boundary above', () => {
+ it('(a) a synchronous render throw propagates and rejects the render', async () => {
+ await expect(
+ ssrRenderToDom(
+
+
+ ,
+ { debug }
+ )
+ ).rejects.toThrow('boom');
+ });
+
+ it('(a) the ORIGINAL error object propagates unchanged (not wrapped/projected)', async () => {
+ const original = new Error('boom');
+ const Throws = component$(() => {
+ throw original;
+ });
+ let caught: unknown;
+ try {
+ await ssrRenderToDom(
+
+
+ ,
+ { debug }
+ );
+ } catch (err) {
+ caught = err;
+ }
+ // With no boundary above, the raw throw must propagate — `toSerializableBoundaryError` must NOT
+ // run on this path, so the identity is preserved.
+ expect(caught).toBe(original);
+ });
+
+ it('(b) an async component whose render rejects propagates and rejects the render', async () => {
+ await expect(
+ ssrRenderToDom(
+
+
+ ,
+ { debug }
+ )
+ ).rejects.toThrow('async boom');
+ });
+
+ it('(c) a rejected promise child propagates and rejects the render', async () => {
+ await expect(
+ ssrRenderToDom(
+
+
+ ,
+ { debug }
+ )
+ ).rejects.toThrow('async boom');
+ });
+});
diff --git a/packages/qwik/src/core/tests/error-boundary-projection.spec.tsx b/packages/qwik/src/core/tests/error-boundary-projection.spec.tsx
new file mode 100644
index 00000000000..defcb013e75
--- /dev/null
+++ b/packages/qwik/src/core/tests/error-boundary-projection.spec.tsx
@@ -0,0 +1,106 @@
+import { $, component$, ErrorBoundary, Slot } from '@qwik.dev/core';
+import { domRender, ssrRenderToDom } from '@qwik.dev/core/testing';
+import { describe, expect, it } from 'vitest';
+
+// A throw inside Slot-PROJECTED content must resolve to the lexically-nearest via the
+// projection chain (client: getParentHost walking `slotParent`; SSR: resolveContext walking
+// `parentComponent`). When children are projected into a boundary's , the boundary the content
+// is projected *into* is the nearest provider and must catch a render throw from that content.
+
+const debug = false;
+
+const Thrower = component$(() => {
+ throw new Error('boom');
+});
+
+// Wrapper whose children are PROJECTED into an . The boundary lives inside `Boxed`, so
+// anything passed as `Boxed`'s children renders through the *inside* that boundary.
+const Boxed = component$(() => {
+ return (
+ (
+
caught: {e.message}
+ ))}
+ >
+
+
+ );
+});
+
+// Like `Boxed`, but also renders a non-throwing sibling beside the projected slot, so we can assert
+// the whole boundary subtree (sibling + projected content) is torn down to the fallback.
+const BoxedWithSibling = component$(() => {
+ return (
+ (
+
caught: {e.message}
+ ))}
+ >
+
sibling
+
+
+ );
+});
+
+describe('ErrorBoundary projection', () => {
+ describe('CSR', () => {
+ it('a render throw in projected content is caught by the boundary it is projected into', async () => {
+ const { container } = await domRender(
+
+
+ ,
+ { debug }
+ );
+ const el = container.element;
+ expect(el.querySelector('#fb')?.textContent).toContain('caught: boom');
+ });
+
+ it('only the fallback shows: a non-throwing sibling and the projected throw are both gone', async () => {
+ const { container } = await domRender(
+
+
+
projected ok
+ ,
+ { debug }
+ );
+ const el = container.element;
+ expect(el.querySelector('#fb')?.textContent).toContain('caught: boom');
+ // The boundary tore down its whole subtree to the fallback: neither the sibling nor the
+ // (non-throwing) projected child is visible.
+ expect(el.querySelector('#sibling')).toBeFalsy();
+ expect(el.querySelector('#projected-ok')).toBeFalsy();
+ });
+ });
+
+ describe('in-order SSR', () => {
+ it('a render throw in projected content is caught by the boundary it is projected into', async () => {
+ const { container } = await ssrRenderToDom(
+
+
+ ,
+ { debug }
+ );
+ const el = container.element;
+ expect(el.querySelector('#fb')?.textContent).toContain('caught: boom');
+ });
+
+ it('renders the fallback in place; siblings already streamed before the throw remain', async () => {
+ // In-order (non-streaming) SSR renders the fallback IN PLACE of the throwing child (see the
+ // "renders the fallback in place" test in error-boundary.spec.tsx). Content emitted before the
+ // throw is already in the stream and cannot be retracted, so a sibling rendered before the
+ // stays — unlike CSR (and the experimental out-of-order path), which replace the whole
+ // boundary subtree with the fallback.
+ const { container } = await ssrRenderToDom(
+
+
+
projected ok
+ ,
+ { debug }
+ );
+ const el = container.element;
+ expect(el.querySelector('#fb')?.textContent).toContain('caught: boom');
+ // The sibling streamed before the throw is retained (in-place fallback).
+ expect(el.querySelector('#sibling')).toBeTruthy();
+ });
+ });
+});
diff --git a/packages/qwik/src/core/tests/error-boundary-tasks.spec.tsx b/packages/qwik/src/core/tests/error-boundary-tasks.spec.tsx
new file mode 100644
index 00000000000..15cfbdd89aa
--- /dev/null
+++ b/packages/qwik/src/core/tests/error-boundary-tasks.spec.tsx
@@ -0,0 +1,167 @@
+import { $, component$, ErrorBoundary, useSignal, useTask$, useVisibleTask$ } from '@qwik.dev/core';
+import { domRender, ssrRenderToDom, trigger, waitForDrain } from '@qwik.dev/core/testing';
+import { describe, expect, it } from 'vitest';
+import { delay } from '../shared/utils/promises';
+
+// A throw inside useTask$ / useVisibleTask$ should route to the NEAREST enclosing ,
+// exactly like a synchronous render throw does. The existing use-task / use-visible-task specs only
+// prove a task throw reaches the generic ERROR_CONTEXT test helper (ErrorProvider), which inits
+// `error: null` and is capture-only: it never re-renders to a fallback. These tests assert the
+// user-facing behavior against a real : the fallback must show.
+//
+// Routing basis: the shared `runTask` sends both the CSR and SSR task-throw paths through
+// `container.handleError(reason, host)` (use-task.ts). The DOM container's `handleError` resolves
+// ERROR_CONTEXT to the boundary store (which inits `error: undefined`), records the error, and
+// re-renders the boundary host to its fallback. The SSR container's `handleError`, by contrast,
+// re-throws unconditionally and ignores the host — so the SSR case below documents the intuitively
+// correct expectation that an enclosing boundary catches an eager `useTask$` throw during SSR.
+
+const debug = false;
+
+describe('ErrorBoundary catches task throws', () => {
+ describe('CSR (domRender)', () => {
+ it('a useTask$ throw is caught by the nearest ', async () => {
+ const ThrowingTask = component$(() => {
+ useTask$(() => {
+ throw new Error('task boom');
+ });
+ return ok;
+ });
+
+ const { container } = await domRender(
+ (
+
caught: {e.message}
+ ))}
+ >
+
+ ,
+ { debug }
+ );
+ await waitForDrain(container);
+
+ const el = container.element;
+ // The boundary re-rendered to its fallback; the throwing subtree is gone.
+ expect(el.querySelector('#fb')?.textContent).toContain('caught: task boom');
+ expect(el.querySelector('#content')).toBeFalsy();
+ });
+
+ it('an async useTask$ throw is caught by the nearest ', async () => {
+ const ThrowingTask = component$(() => {
+ useTask$(async () => {
+ await delay(1);
+ throw new Error('async task boom');
+ });
+ return ok;
+ });
+
+ const { container } = await domRender(
+ (
+
+ ))}
+ >
+
+ ,
+ { debug }
+ );
+ // In CSR visible tasks run after resume; drain any re-render the throw scheduled.
+ await waitForDrain(container);
+
+ const el = container.element;
+ expect(el.querySelector('#fb')?.textContent).toContain('caught: visible task boom');
+ expect(el.querySelector('#content')).toBeFalsy();
+ });
+
+ it('a useTask$ throw is caught by the NEAREST of nested boundaries', async () => {
+ const ThrowingTask = component$(() => {
+ useTask$(() => {
+ throw new Error('task boom');
+ });
+ return ok;
+ });
+
+ const { container } = await domRender(
+ (
+
outer
+ ))}
+ >
+
outer ok
+ (
+
inner
+ ))}
+ >
+
+
+ ,
+ { debug }
+ );
+ await waitForDrain(container);
+
+ const el = container.element;
+ // Only the inner boundary catches; the outer subtree stays visible.
+ expect(el.querySelector('#fb-inner')).toBeTruthy();
+ expect(el.querySelector('#fb-outer')).toBeFalsy();
+ expect(el.querySelector('#outer-ok')).toBeTruthy();
+ });
+ });
+
+ describe('in-order SSR (ssrRenderToDom)', () => {
+ // Intuitive-correct expectation: an eager useTask$ throw during SSR is caught by the enclosing
+ // boundary and the fallback is rendered in place — mirroring the synchronous render-throw path
+ // (see error-boundary.spec.tsx 'SSR: renders the fallback in place when a child throws').
+ // Today this FAILS: the SSR container's handleError (server/ssr-container.ts) re-throws and
+ // ignores the host, so ssrRenderToDom rejects with the task error instead of catching it.
+ it('a useTask$ throw is caught by the nearest ', async () => {
+ const ThrowingTask = component$(() => {
+ useTask$(() => {
+ throw new Error('task boom');
+ });
+ return ok;
+ });
+
+ const { container } = await ssrRenderToDom(
+ (
+
caught: {e.message}
+ ))}
+ >
+
+ ,
+ { debug }
+ );
+
+ const el = container.element;
+ expect(el.querySelector('#fb')?.textContent).toContain('caught: task boom');
+ expect(el.querySelector('#content')).toBeFalsy();
+ });
+ });
+});
From a750e225e81512a9cc9aecb5f4ff4413223ed78b Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Fri, 19 Jun 2026 22:14:45 +0200
Subject: [PATCH 036/331] fix(qwik): treat a thrown falsy value as a caught
error in ErrorBoundary
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.
---
.../qwik/src/core/control-flow/suspense.tsx | 3 +-
.../src/core/shared/error/error-boundary.ts | 12 ++-
.../error-boundary-thrown-values.spec.tsx | 102 ++++++++++++++++++
3 files changed, 111 insertions(+), 6 deletions(-)
create mode 100644 packages/qwik/src/core/tests/error-boundary-thrown-values.spec.tsx
diff --git a/packages/qwik/src/core/control-flow/suspense.tsx b/packages/qwik/src/core/control-flow/suspense.tsx
index 0090b3ec15c..cb4592eeb65 100644
--- a/packages/qwik/src/core/control-flow/suspense.tsx
+++ b/packages/qwik/src/core/control-flow/suspense.tsx
@@ -412,7 +412,8 @@ export const SSRErrorFallback = __EXPERIMENTAL__.errorBoundary
writeOutOfOrderPlaceholder(ssr, boundaryId);
// A sync throw during the content render already set `store.error` (the content host renders
// before this); returning the promise awaits the swap inline, right after the content.
- if (store.error != null && store.$fallback$) {
+ // `!== undefined` (not truthiness) so a thrown falsy value still streams the fallback.
+ if (store.error !== undefined && store.$fallback$) {
return streamFallback(store.error);
}
}
diff --git a/packages/qwik/src/core/shared/error/error-boundary.ts b/packages/qwik/src/core/shared/error/error-boundary.ts
index 501bff215b2..9c83ef6ad29 100644
--- a/packages/qwik/src/core/shared/error/error-boundary.ts
+++ b/packages/qwik/src/core/shared/error/error-boundary.ts
@@ -31,14 +31,16 @@ export interface ErrorBoundaryProps {
* closest boundary via the container's `handleError`.
*/
+// "has errored" is `error !== undefined` (the store inits `error: undefined`), not truthiness — a
+// thrown falsy value (`0`, `''`, `false`, `null`) is still a caught error and must show the fallback.
const _ebContentStyle = (store: ErrorBoundaryStore) => ({
- display: store.error ? 'none' : 'contents',
+ display: store.error !== undefined ? 'none' : 'contents',
});
-const _ebContentStyle_str = '{display:p0.error?"none":"contents"}';
+const _ebContentStyle_str = '{display:p0.error!==undefined?"none":"contents"}';
const _ebFallbackStyle = (store: ErrorBoundaryStore) => ({
- display: store.error ? 'contents' : 'none',
+ display: store.error !== undefined ? 'contents' : 'none',
});
-const _ebFallbackStyle_str = '{display:p0.error?"contents":"none"}';
+const _ebFallbackStyle_str = '{display:p0.error!==undefined?"contents":"none"}';
/** @internal */
export const errorBoundaryCmp = (props: ErrorBoundaryProps): JSXOutput => {
@@ -77,7 +79,7 @@ export const errorBoundaryCmp = (props: ErrorBoundaryProps): JSXOutput => {
] as unknown as JSXOutput;
}
- if (store.error) {
+ if (store.error !== undefined) {
return /*#__PURE__*/ _jsxSorted(Fragment, null, null, props.fallback$(store.error), 0, null);
}
diff --git a/packages/qwik/src/core/tests/error-boundary-thrown-values.spec.tsx b/packages/qwik/src/core/tests/error-boundary-thrown-values.spec.tsx
new file mode 100644
index 00000000000..e60191127a6
--- /dev/null
+++ b/packages/qwik/src/core/tests/error-boundary-thrown-values.spec.tsx
@@ -0,0 +1,102 @@
+import { $, component$, ErrorBoundary, type JSXOutput } from '@qwik.dev/core';
+import { createDocument, domRender, ssrRenderToDom, waitForDrain } from '@qwik.dev/core/testing';
+import { describe, expect, it } from 'vitest';
+import { processOutOfOrderSegmentVNodeData } from '../client/process-vnode-data';
+
+const debug = false;
+
+// Dispatch a synthetic client `qerror` carrying an arbitrary (here: falsy) thrown value, mirroring an
+// async QRL/handler failure routed to the closest boundary.
+const dispatchQError = (target: Element, error: unknown) => {
+ const ev = target.ownerDocument.createEvent('Event');
+ ev.initEvent('qerror', false, false);
+ (ev as any).detail = { error, element: target };
+ target.ownerDocument.dispatchEvent(ev);
+};
+
+const Boundary = component$(() => {
+ return (
+ (
+
caught: {String(e)}
+ ))}
+ >
+
+
+ );
+});
+
+// A thrown value should show the fallback regardless of its truthiness: ANY thrown value (including
+// the falsy `0`, `''`, `false`, `null`) tears the boundary down to its fallback. The boundary uses
+// `store.error !== undefined` (not truthiness) as the "has error" signal precisely for this.
+describe('ErrorBoundary: falsy thrown values', () => {
+ const expectFallbackShown = async (error: unknown) => {
+ const { container } = await domRender(, { debug });
+ const el = container.element;
+ expect(el.querySelector('#content')).toBeTruthy();
+
+ dispatchQError(el.querySelector('#content')!, error);
+ await waitForDrain(container);
+
+ // The fallback must be revealed and the content torn down — same as for a truthy error.
+ expect(el.querySelector('#fb')).toBeTruthy();
+ expect(el.querySelector('#content')).toBeFalsy();
+ };
+
+ it('shows the fallback when 0 is thrown', async () => {
+ await expectFallbackShown(0);
+ });
+
+ it('shows the fallback when null is thrown', async () => {
+ await expectFallbackShown(null);
+ });
+
+ it('shows the fallback when an empty string is thrown', async () => {
+ await expectFallbackShown('');
+ });
+
+ it('shows the fallback when false is thrown', async () => {
+ await expectFallbackShown(false);
+ });
+
+ it('out-of-order streaming: a thrown falsy value reveals the fallback host', async () => {
+ const FalsyThrower = component$((): JSXOutput => {
+ // eslint-disable-next-line no-throw-literal
+ throw 0;
+ });
+ const chunks: string[] = [];
+ await ssrRenderToDom(
+
+ (
+
caught: {String(e)}
+ ))}
+ >
+
+
+ ,
+ {
+ stream: {
+ write: (c: string) => {
+ chunks.push(c);
+ },
+ },
+ streaming: { inOrder: { strategy: 'disabled' }, outOfOrder: true },
+ debug,
+ }
+ );
+ const document = createDocument({ html: chunks.join('') });
+ (document as any).qProcessOOOS = (boundaryId: number, content: Element | null) =>
+ processOutOfOrderSegmentVNodeData(document, String(boundaryId), content);
+ const scripts = Array.from(
+ document.querySelectorAll('script[type="text/javascript"]'),
+ (s) => s.textContent || ''
+ ).filter((code) => code.includes('qO') || code.includes('qInstallOOOS'));
+ // eslint-disable-next-line no-new-func
+ new Function('document', scripts.join('\n'))(document);
+
+ expect(document.querySelector('#fb')?.textContent).toContain('caught: 0');
+ const fallbackHost = document.querySelector('#fb')?.closest('[q\\:rp]') as HTMLElement | null;
+ expect(fallbackHost?.style?.display).toBe('contents');
+ });
+});
From db71784ac0ca80e1cc9c3d5a6611666606863ec8 Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Fri, 19 Jun 2026 22:14:45 +0200
Subject: [PATCH 037/331] test(qwik): satisfy strict build.core tsc in EB
combination/task specs
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.
---
.../src/core/tests/error-boundary-combinations.spec.tsx | 6 +++++-
packages/qwik/src/core/tests/error-boundary-tasks.spec.tsx | 2 +-
2 files changed, 6 insertions(+), 2 deletions(-)
diff --git a/packages/qwik/src/core/tests/error-boundary-combinations.spec.tsx b/packages/qwik/src/core/tests/error-boundary-combinations.spec.tsx
index 4aba0fab638..ce8168bc031 100644
--- a/packages/qwik/src/core/tests/error-boundary-combinations.spec.tsx
+++ b/packages/qwik/src/core/tests/error-boundary-combinations.spec.tsx
@@ -27,7 +27,11 @@ const AsyncThrower = component$(() => {
const streamAndResume = async (jsx: JSXOutput) => {
const chunks: string[] = [];
await ssrRenderToDom(jsx, {
- stream: { write: (c: string) => chunks.push(c) },
+ stream: {
+ write: (c: string) => {
+ chunks.push(c);
+ },
+ },
streaming: { inOrder: { strategy: 'disabled' }, outOfOrder: true },
debug,
});
diff --git a/packages/qwik/src/core/tests/error-boundary-tasks.spec.tsx b/packages/qwik/src/core/tests/error-boundary-tasks.spec.tsx
index 15cfbdd89aa..c8c4a352d6e 100644
--- a/packages/qwik/src/core/tests/error-boundary-tasks.spec.tsx
+++ b/packages/qwik/src/core/tests/error-boundary-tasks.spec.tsx
@@ -1,5 +1,5 @@
import { $, component$, ErrorBoundary, useSignal, useTask$, useVisibleTask$ } from '@qwik.dev/core';
-import { domRender, ssrRenderToDom, trigger, waitForDrain } from '@qwik.dev/core/testing';
+import { domRender, ssrRenderToDom, waitForDrain } from '@qwik.dev/core/testing';
import { describe, expect, it } from 'vitest';
import { delay } from '../shared/utils/promises';
From b7cfb9df484ad0648d5de5de0a9b2b1227bab2c6 Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Fri, 19 Jun 2026 22:29:07 +0200
Subject: [PATCH 038/331] fix(qwik): surface non-recoverable build errors past
the SSR ErrorBoundary
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
packages/qwik/src/core/ssr/ssr-render-jsx.ts | 13 +++-
.../tests/error-boundary-recoverable.spec.tsx | 66 +++++++++++++++++++
2 files changed, 77 insertions(+), 2 deletions(-)
create mode 100644 packages/qwik/src/core/tests/error-boundary-recoverable.spec.tsx
diff --git a/packages/qwik/src/core/ssr/ssr-render-jsx.ts b/packages/qwik/src/core/ssr/ssr-render-jsx.ts
index f1a0710e1d7..3bb83b4cfcb 100644
--- a/packages/qwik/src/core/ssr/ssr-render-jsx.ts
+++ b/packages/qwik/src/core/ssr/ssr-render-jsx.ts
@@ -34,7 +34,7 @@ import {
} from '../shared/utils/markers';
import { mapArray_get, mapArray_has } from '../client/util-mapArray';
import { isPromise, retryOnPromise } from '../shared/utils/promises';
-import { qInspector } from '../shared/utils/qdev';
+import { qDev, qInspector } from '../shared/utils/qdev';
import { addComponentStylePrefix } from '../shared/utils/scoped-styles';
import { isOutOfOrderSegmentContainer, type InnerContainer } from '../shared/utils/container';
import { isFunction, type ValueOrPromise } from '../shared/utils/types';
@@ -45,7 +45,11 @@ import {
isInternalServerComponent,
} from './internal-server-component';
import { applyInlineComponent, applyQwikComponentBody } from './ssr-render-component';
-import { ERROR_CONTEXT, type ErrorBoundaryStore } from '../shared/error/error-handling';
+import {
+ ERROR_CONTEXT,
+ isRecoverable,
+ type ErrorBoundaryStore,
+} from '../shared/error/error-handling';
import type { ISsrComponentFrame, SSRContainer, SSRRenderJSXOptions } from './ssr-types';
import { resolveSlotName } from '../shared/utils/prop';
@@ -151,6 +155,11 @@ function renderErrorBoundaryFallback(
if (!errorStore || !errorStore.$fallback$) {
throw err;
}
+ // A non-recoverable build/plugin error (e.g. a dev Vite transform error) must surface, not hide in
+ // the fallback — mirrors the client `handleError`. Dev-only, so prod SSR still degrades gracefully.
+ if (qDev && !isRecoverable(err)) {
+ throw err;
+ }
// A buffering boundary (inside a `` segment) handles the throw in its own nested render.
if (__EXPERIMENTAL__.errorBoundary && errorStore.$checkpoint$) {
throw err;
diff --git a/packages/qwik/src/core/tests/error-boundary-recoverable.spec.tsx b/packages/qwik/src/core/tests/error-boundary-recoverable.spec.tsx
new file mode 100644
index 00000000000..8ccd9365343
--- /dev/null
+++ b/packages/qwik/src/core/tests/error-boundary-recoverable.spec.tsx
@@ -0,0 +1,66 @@
+import { $, component$, ErrorBoundary, type JSXOutput } from '@qwik.dev/core';
+import { domRender, ssrRenderToDom, waitForDrain } from '@qwik.dev/core/testing';
+import { describe, expect, it } from 'vitest';
+
+const debug = false;
+
+// A normal, recoverable runtime error — the boundary should catch it and render the fallback.
+const Thrower = component$(() => {
+ throw new Error('boom');
+});
+
+// A NON-recoverable build/plugin error: Vite/Rollup stamp `.plugin` on transform/build errors.
+// `isRecoverable` returns false for these, so a boundary must NOT hide them in its fallback — in dev
+// they surface (SSR render rejects; the client rethrows past the boundary) so the developer sees the
+// real build failure instead of a generic fallback.
+const PluginThrower = component$(() => {
+ const err = new Error('build boom');
+ (err as any).plugin = 'vite:some-plugin';
+ throw err;
+});
+
+const Boxed = (child: JSXOutput) => (
+ (
+
caught: {e.message}
+ ))}
+ >
+ {child}
+
+);
+
+describe('ErrorBoundary: recoverable vs build errors (dev)', () => {
+ it('SSR: a recoverable error renders the fallback', async () => {
+ const { container } = await ssrRenderToDom(Boxed(), { debug });
+ expect(container.element.querySelector('#fb')?.textContent).toContain('caught: boom');
+ });
+
+ it('SSR: a non-recoverable build error is NOT hidden in the fallback (it surfaces)', async () => {
+ await expect(ssrRenderToDom(Boxed(), { debug })).rejects.toThrow('build boom');
+ });
+
+ it('CSR: a recoverable error renders the fallback', async () => {
+ const { container } = await domRender(Boxed(), { debug });
+ expect(container.element.querySelector('#fb')?.textContent).toContain('caught: boom');
+ });
+
+ it('CSR: a non-recoverable build error is not caught by the boundary', async () => {
+ // Routed via a client `qerror` (like an async QRL failure). The boundary must NOT show its
+ // fallback — `handleError` rethrows non-recoverable errors past it (the listener then logs).
+ const { container } = await domRender(Boxed(), { debug });
+ const el = container.element;
+ const target = el.querySelector('#content')!;
+ const err = new Error('build boom');
+ (err as any).plugin = 'vite:some-plugin';
+ const ev = target.ownerDocument.createEvent('Event');
+ ev.initEvent('qerror', false, false);
+ (ev as any).detail = { error: err, element: target };
+ target.ownerDocument.dispatchEvent(ev);
+ try {
+ await waitForDrain(container);
+ } catch {
+ /* the rethrown build error may surface during drain — the point is the fallback never shows */
+ }
+ expect(el.querySelector('#fb')).toBeFalsy();
+ });
+});
From 1dc8aa99213ccdc2e24597b5915ba968d5cfb93f Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Fri, 19 Jun 2026 22:34:17 +0200
Subject: [PATCH 039/331] test(qwik): drop duplicate 2-Suspense-in-1-boundary
combinations case
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.
---
.../error-boundary-combinations.spec.tsx | 28 ++-----------------
1 file changed, 2 insertions(+), 26 deletions(-)
diff --git a/packages/qwik/src/core/tests/error-boundary-combinations.spec.tsx b/packages/qwik/src/core/tests/error-boundary-combinations.spec.tsx
index ce8168bc031..b2570c98e11 100644
--- a/packages/qwik/src/core/tests/error-boundary-combinations.spec.tsx
+++ b/packages/qwik/src/core/tests/error-boundary-combinations.spec.tsx
@@ -16,13 +16,6 @@ const ThrowerB = component$(() => {
throw new Error('boomB');
});
-// Rejects after the Suspense placeholder has streamed, so the throw surfaces as a deferred segment
-// rejection rather than a synchronous render throw.
-const AsyncThrower = component$(() => {
- const pending = new Promise((_resolve, reject) => reject(new Error('async boom')));
- return <>{pending}>;
-});
-
// Stream with out-of-order streaming, then run the emitted `qO` scripts to perform the inline swap.
const streamAndResume = async (jsx: JSXOutput) => {
const chunks: string[] = [];
@@ -206,24 +199,7 @@ describe('ErrorBoundary combinations', () => {
expect(document.querySelector('#fb-b')).toBeTruthy();
});
- it('two Suspense in one boundary that both async-throw render a single fallback', async () => {
- const document = await streamAndResume(
-
- (
-
caught: {String(e?.message ?? e)}
- ))}
- >
- a}>
-
-
- b}>
-
-
-
-
- );
- expect(fbCount(document)).toBe(1);
- });
+ // Note: "two Suspense in one boundary both async-throw -> single fallback" lives in
+ // error-boundary-concurrent.spec.tsx (the dedicated teardown-exactly-once regression test).
});
});
From d4ab6ed63642d78a57cd3855329e123b76fbd5e9 Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Fri, 19 Jun 2026 22:49:44 +0200
Subject: [PATCH 040/331] docs(qwik): trim ErrorBoundary and SSR comments to
the essential why
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.
---
packages/docs/src/routes/api/qwik/api.json | 2 +-
packages/docs/src/routes/api/qwik/index.mdx | 2 +-
.../qwik/src/core/client/dom-container.ts | 28 +++++--------
.../src/core/control-flow/suspense-utils.ts | 5 +--
.../qwik/src/core/control-flow/suspense.tsx | 41 ++++++-------------
.../src/core/shared/error/error-boundary.ts | 25 ++++-------
.../shared/serdes/serialization-context.ts | 10 ++---
packages/qwik/src/core/ssr/ssr-render-jsx.ts | 31 ++++++--------
packages/qwik/src/core/ssr/ssr-types.ts | 5 +--
.../qwik/src/out-of-order-executor-shared.ts | 8 ++--
packages/qwik/src/qwikloader.ts | 6 +--
packages/qwik/src/server/ssr-container.ts | 8 ++--
12 files changed, 64 insertions(+), 107 deletions(-)
diff --git a/packages/docs/src/routes/api/qwik/api.json b/packages/docs/src/routes/api/qwik/api.json
index 6f0eaa9c16c..e998105709a 100644
--- a/packages/docs/src/routes/api/qwik/api.json
+++ b/packages/docs/src/routes/api/qwik/api.json
@@ -668,7 +668,7 @@
}
],
"kind": "Interface",
- "content": "```typescript\nexport interface ErrorBoundaryProps \n```\n\n\n
\n\nRendered in place of the subtree when a descendant throws during render. Lazily loaded — only fetched once the subtree errors — and receives the caught error.\n\n\n
-Rendered in place of the subtree when a descendant throws during render. Lazily loaded — only fetched once the subtree errors — and receives the caught error.
+Rendered in place of the subtree when a descendant throws; lazily loaded, receives the error.
diff --git a/packages/qwik/src/core/client/dom-container.ts b/packages/qwik/src/core/client/dom-container.ts
index 1857f2f5022..0f7995fe311 100644
--- a/packages/qwik/src/core/client/dom-container.ts
+++ b/packages/qwik/src/core/client/dom-container.ts
@@ -148,14 +148,11 @@ export class DomContainer extends _SharedContainer implements IClientContainer {
processSegmentStateScripts(this);
}
this.$hoistStyles$();
- // Route async errors (a failed QRL import/handler emits a `qerror` event) to the CLOSEST
- // ErrorBoundary, matching the synchronous render-throw path. Previously each ErrorBoundary
- // listened to the global event and every one of them caught every error regardless of source.
+ // Route an async `qerror` to the closest ErrorBoundary (previously every boundary caught it).
this.$qErrorHandler$ = (e: Event) => {
const detail = (e as CustomEvent<{ error: unknown; element?: Element; importError?: string }>)
.detail;
- // QRL chunk-import / missing-symbol failures are already `console.error`-ed by qwikloader and
- // must only log so the app keeps running — never route them through boundary handling.
+ // Import/symbol failures are already logged by qwikloader; don't route them to a boundary.
if (detail?.importError) {
logError(detail.error);
return;
@@ -164,10 +161,8 @@ export class DomContainer extends _SharedContainer implements IClientContainer {
if (source && this.element.contains(source)) {
const host = this.vNodeLocate(source);
if (host) {
- // `handleError` re-throws when no boundary encloses `host`. In a listener that throw has no
- // owner (qwik-dom `dispatchEvent` has no per-listener try/catch; a browser reports it as an
- // uncaught window error), so contain it here and log instead. The synchronous render-throw
- // path calls `handleError` directly and is intentionally left to propagate.
+ // A no-boundary error makes `handleError` re-throw, which in a listener becomes an uncaught
+ // error; log it instead.
try {
this.handleError(detail.error, host);
} catch (handlerError) {
@@ -263,10 +258,8 @@ export class DomContainer extends _SharedContainer implements IClientContainer {
handleError(err: any, host: VNode | null): void {
const errorStore = host && this.resolveContext(host, ERROR_CONTEXT);
- // Re-entrancy guard: if the closest boundary already errored (e.g. its own fallback throws),
- // report and stop instead of re-triggering — re-throwing would loop and escape the re-render
- // chore as an unhandled rejection. `!= null` covers both store inits: boundary `undefined`,
- // generic ERROR_CONTEXT `null`.
+ // If the closest boundary already errored (e.g. its own fallback throws), report and stop —
+ // re-triggering would loop.
if (errorStore && errorStore.error != null) {
logError(err);
return;
@@ -289,13 +282,12 @@ export class DomContainer extends _SharedContainer implements IClientContainer {
if (!errorStore) {
throw err;
}
- // Only a real boundary re-renders (store inits `undefined`); a generic ERROR_CONTEXT consumer
- // inits `null` and only captures.
+ // A real boundary inits `error: undefined`; a generic ERROR_CONTEXT consumer inits `null` and
+ // only captures (never re-renders).
const isErrorBoundary = errorStore.error === undefined;
errorStore.error = err;
- // An OOOS boundary never read `store.error` (its two-host branch returns early), so the reactive
- // write alone won't re-render it to the fallback — mark it explicitly (a no-op when an in-order
- // boundary's write already scheduled it).
+ // An OOOS boundary never subscribed to `store.error`, so the write alone won't re-render it to
+ // the fallback — mark it explicitly.
if (isErrorBoundary && host) {
const boundaryHost = this.resolveContextHost(host, ERROR_CONTEXT);
if (boundaryHost) {
diff --git a/packages/qwik/src/core/control-flow/suspense-utils.ts b/packages/qwik/src/core/control-flow/suspense-utils.ts
index 0131c563b25..9bf439273cb 100644
--- a/packages/qwik/src/core/control-flow/suspense-utils.ts
+++ b/packages/qwik/src/core/control-flow/suspense-utils.ts
@@ -117,9 +117,8 @@ export const nextOutOfOrderSuspenseId = (): number => {
};
/**
- * Reserve an out-of-order id for a streaming `` without arming the OOOS executor —
- * it only needs the executor if it actually throws (streaming the fallback `segment()` arms it
- * then).
+ * Reserve an out-of-order id for a streaming `` without arming the executor — it
+ * only needs the executor if it throws.
*
* @internal
*/
diff --git a/packages/qwik/src/core/control-flow/suspense.tsx b/packages/qwik/src/core/control-flow/suspense.tsx
index cb4592eeb65..1173a93f456 100644
--- a/packages/qwik/src/core/control-flow/suspense.tsx
+++ b/packages/qwik/src/core/control-flow/suspense.tsx
@@ -254,9 +254,8 @@ const SSRDeferredSlot = __EXPERIMENTAL__.suspense
const boundaryState = jsx.varProps.boundary as SSROutOfOrderBoundaryState | null;
const contentStyle = jsx.varProps.contentStyle as Signal<{ display: string }>;
const revealBoundary = jsx.varProps.reveal as OutOfOrderRevealBoundary | null;
- // Capture the enclosing `` up front: if this deferred segment later rejects, the
- // boundary already committed (when the placeholder streamed) and can't catch it in place, so we
- // route to its `$emitFallback$` below.
+ // Capture the enclosing boundary up front: once its placeholder streamed it can't catch a later
+ // rejection in place, so route to `$emitFallback$`.
const errorBoundaryStore =
__EXPERIMENTAL__.errorBoundary && options.parentComponentFrame
? (ssr.resolveContext(options.parentComponentFrame.componentNode, ERROR_CONTEXT) as
@@ -331,11 +330,8 @@ function createClaimedDeferredSlot(
}
/**
- * Finalize a streamed out-of-order segment and swap it into its placeholder host: write the
- * resolved template, emit the segment's scripts, then call `qO(id)` to perform the inline swap and
- * flush. Shared by Suspense's deferred-slot reveal and the ErrorBoundary fallback stream.
- * `emitExecutor` emits the shared `qO` executor here (the ErrorBoundary path); Suspense emits it up
- * front instead.
+ * Finalize a streamed out-of-order segment and swap it into its host via `qO(id)`. Shared by the
+ * Suspense deferred-slot reveal and the ErrorBoundary fallback stream.
*/
async function finalizeAndSwapOutOfOrderSegment(
ssr: SSRContainer,
@@ -349,11 +345,10 @@ async function finalizeAndSwapOutOfOrderSegment(
writeOutOfOrderResolvedTemplate(ssr, boundaryId, result.html, revealBoundary);
ssr.emitOutOfOrderSegmentScripts(result.scripts);
if (emitExecutor) {
- // Emit the shared executor right before its first call (idempotent), so error-free pages ship none.
+ // Emit the executor lazily so error-free pages ship none.
ssr.emitOutOfOrderExecutorIfNeeded();
}
ssr.emitInlineScript(`qO(${boundaryId})`);
- // qO() is the browser-visible handoff for this segment, so flush it immediately.
await ssr.streamHandler.flush();
}
@@ -393,26 +388,22 @@ export const SSRErrorFallback = __EXPERIMENTAL__.errorBoundary
const store = jsx.varProps.store as ErrorBoundaryStore;
const segmentId = `${boundaryId}`;
const streamFallback = async (error: unknown): Promise => {
- // Tear a boundary down exactly once: `$emitFallback$` is shared by every deferred child, so
- // sibling rejections all land here. A detached `$fallback$` means teardown already started (or
- // finished) — the first error wins, later siblings no-op instead of double-swapping the host.
+ // `$emitFallback$` is shared by every deferred child, so a detached `$fallback$` means
+ // teardown already started — first error wins, later siblings no-op.
const fallback = store.$fallback$;
if (!fallback) {
return;
}
store.error = error;
- // Keep `$fallback$` detached for the rest of the render: if the fallback itself throws back to
- // this boundary, it must not re-render the fallback (infinite loop) — with none set, that throw
- // propagates. It stays detached afterwards too, so the boundary never tears down a second time.
+ // Stay detached so a throw from the fallback itself propagates instead of re-rendering it.
store.$fallback$ = undefined;
const segment = await ssr.segment(segmentId, fallback(error) as JSXOutput, options);
await emitErrorBoundaryFallback(ssr, boundaryId, segmentId, segment);
};
store.$emitFallback$ = noSerialize(streamFallback);
writeOutOfOrderPlaceholder(ssr, boundaryId);
- // A sync throw during the content render already set `store.error` (the content host renders
- // before this); returning the promise awaits the swap inline, right after the content.
- // `!== undefined` (not truthiness) so a thrown falsy value still streams the fallback.
+ // A sync content-render throw already set `store.error`; stream the swap inline. `!== undefined`
+ // (not truthiness) so a falsy thrown value still streams.
if (store.error !== undefined && store.$fallback$) {
return streamFallback(store.error);
}
@@ -427,15 +418,9 @@ async function emitErrorBoundaryFallback(
segmentId: string,
rendered: SSROutOfOrderSegment
): Promise {
- await ssr.$runQueuedRender$(async () => {
- const result = await rendered.container.$finalizeOutOfOrderSegment$(segmentId, rendered);
- writeOutOfOrderResolvedTemplate(ssr, boundaryId, result.html, null);
- ssr.emitOutOfOrderSegmentScripts(result.scripts);
- // Emit the shared executor right before its first call (idempotent), so error-free pages ship none.
- ssr.emitOutOfOrderExecutorIfNeeded();
- ssr.emitInlineScript(`qO(${boundaryId})`);
- await ssr.streamHandler.flush();
- });
+ await ssr.$runQueuedRender$(() =>
+ finalizeAndSwapOutOfOrderSegment(ssr, boundaryId, segmentId, rendered, null, true)
+ );
}
function markOutOfOrderContentResolved(boundaryState: SSROutOfOrderBoundaryState | null): void {
diff --git a/packages/qwik/src/core/shared/error/error-boundary.ts b/packages/qwik/src/core/shared/error/error-boundary.ts
index 9c83ef6ad29..95e8c0a5beb 100644
--- a/packages/qwik/src/core/shared/error/error-boundary.ts
+++ b/packages/qwik/src/core/shared/error/error-boundary.ts
@@ -18,21 +18,15 @@ import type { ErrorBoundaryStore } from './error-handling';
/** @public */
export interface ErrorBoundaryProps {
- /**
- * Rendered in place of the subtree when a descendant throws during render. Lazily loaded — only
- * fetched once the subtree errors — and receives the caught error.
- */
+ /** Rendered in place of the subtree when a descendant throws; lazily loaded, receives the error. */
fallback$: QRL<(error: any) => any>;
}
-/*
- * ErrorBoundary lives in core (ships with the framework) but core isn't run through the optimizer, so
- * it's hand-built with `inlinedQrl` (symbol `_ebC`) rather than `component$`. Errors route to the
- * closest boundary via the container's `handleError`.
- */
+// Core isn't run through the optimizer, so ErrorBoundary is hand-built with `inlinedQrl` (symbol
+// `_ebC`) rather than `component$`.
-// "has errored" is `error !== undefined` (the store inits `error: undefined`), not truthiness — a
-// thrown falsy value (`0`, `''`, `false`, `null`) is still a caught error and must show the fallback.
+// "has errored" is `error !== undefined`, not truthiness, so a thrown falsy value (`0`, `null`, …)
+// still shows the fallback.
const _ebContentStyle = (store: ErrorBoundaryStore) => ({
display: store.error !== undefined ? 'none' : 'contents',
});
@@ -45,17 +39,16 @@ const _ebFallbackStyle_str = '{display:p0.error!==undefined?"contents":"none"}';
/** @internal */
export const errorBoundaryCmp = (props: ErrorBoundaryProps): JSXOutput => {
const store = useErrorBoundaryStore();
- // Wrap in a fresh closure before `noSerialize`: it taints the value's identity, and `props.fallback$`
- // is the same QRL object, so noSerializing it directly would drop the serialized prop too.
+ // `noSerialize` taints identity; wrap in a fresh closure so it doesn't taint the shared `fallback$`
+ // QRL and drop its serialized prop.
const fallbackQrl = props.fallback$;
store.$fallback$ = noSerialize((error: any) => fallbackQrl(error));
const isServerEnv = qTest ? isServerPlatform() : !isBrowser;
if (__EXPERIMENTAL__.errorBoundary && isServerEnv && isOutOfOrderStreaming()) {
const boundaryId = nextErrorBoundaryId();
- // A visible content host + a hidden fallback host that streams `fallback$` on a throw. This branch
- // deliberately does NOT read `store.error`: subscribing would schedule a re-render on an
- // already-streamed host for a late deferred throw. A client error re-renders it via `handleError`.
+ // Two display-toggled hosts. This branch deliberately does NOT read `store.error`: subscribing
+ // would re-render an already-streamed host on a late deferred throw.
return [
/*#__PURE__*/ _jsxSorted(
'div',
diff --git a/packages/qwik/src/core/shared/serdes/serialization-context.ts b/packages/qwik/src/core/shared/serdes/serialization-context.ts
index cc62438b4ac..8ca6c898e2c 100644
--- a/packages/qwik/src/core/shared/serdes/serialization-context.ts
+++ b/packages/qwik/src/core/shared/serdes/serialization-context.ts
@@ -84,8 +84,8 @@ export interface SerializationContext {
$promoteToRoot$: (ref: SeenRef, obj: unknown, index?: number) => void;
/**
- * Truncate `$roots$` and `$rootObjs$` back to `length` and drop the dedup-map entries created
- * since that point. Used by the SSR container to discard roots from a rolled-back subtree.
+ * Truncate `$roots$`/`$rootObjs$` to `length` and drop dedup entries added since, for SSR
+ * rollback.
*/
$rollbackRoots$: (length: number) => void;
@@ -216,10 +216,8 @@ class SerializationContextImpl implements SerializationContext {
}
$rollbackRoots$(length: number): void {
- // `$roots$` and `$rootObjs$` grow in lockstep, so any index >= `length` was added after the
- // checkpoint. Drop its dedup entry, but only when the entry still points at a discarded index:
- // an object that also lives at a surviving root index (re-referenced/promoted) must keep its
- // entry so later dedup stays correct.
+ // Drop the dedup entry for each discarded root, but only when it still points at a discarded
+ // index — an object also re-referenced at a surviving index must keep its entry.
for (let i = length; i < this.$rootObjs$.length; i++) {
const seen = this.$seenObjsMap$.get(this.$rootObjs$[i]);
if (seen && seen.$index$ >= length) {
diff --git a/packages/qwik/src/core/ssr/ssr-render-jsx.ts b/packages/qwik/src/core/ssr/ssr-render-jsx.ts
index 3bb83b4cfcb..3261784065b 100644
--- a/packages/qwik/src/core/ssr/ssr-render-jsx.ts
+++ b/packages/qwik/src/core/ssr/ssr-render-jsx.ts
@@ -128,11 +128,8 @@ export async function _walkJSX(
}
/**
- * `errorStore.error` is serialized into the page, so a non-serializable throw (a class instance
- * with methods, a function, a DOM node) would fail `verifySerializable` at state emit and abort the
- * whole page with a confusing serializer error — masking the real throw. Keep `Error` instances and
- * any already-serializable value as-is; project anything else to an `Error` carrying a safe message
- * so the fallback still gets a readable `.message`.
+ * A non-serializable thrown value would fail `verifySerializable` and abort the whole page when the
+ * error store is serialized. Project it to an `Error` so the stored value stays serializable.
*/
function toSerializableBoundaryError(err: unknown): unknown {
if (err instanceof Error || canSerialize(err)) {
@@ -156,7 +153,7 @@ function renderErrorBoundaryFallback(
throw err;
}
// A non-recoverable build/plugin error (e.g. a dev Vite transform error) must surface, not hide in
- // the fallback — mirrors the client `handleError`. Dev-only, so prod SSR still degrades gracefully.
+ // the fallback.
if (qDev && !isRecoverable(err)) {
throw err;
}
@@ -164,13 +161,13 @@ function renderErrorBoundaryFallback(
if (__EXPERIMENTAL__.errorBoundary && errorStore.$checkpoint$) {
throw err;
}
- // Inside an out-of-order segment the boundary is outside it: propagate so the segment rejects and
- // `$emitFallback$` tears the whole boundary down, rather than rendering the fallback in this slot.
+ // Inside an OOOS segment the boundary is outside it: propagate so the segment rejects and
+ // `$emitFallback$` tears it down.
if (__EXPERIMENTAL__.errorBoundary && isOutOfOrderSegmentContainer(ssr)) {
throw err;
}
- // A live streaming boundary just marks the error; its fallback host streams `fallback$` and `qO`
- // swaps it in (no in-place render, so streaming is never blocked).
+ // A live streaming boundary just marks the error; the fallback host streams it, so streaming is
+ // never blocked.
if (__EXPERIMENTAL__.errorBoundary && ssr.outOfOrderStreaming) {
errorStore.error = toSerializableBoundaryError(err);
return null;
@@ -193,8 +190,8 @@ async function resolveErrorBoundaryFallback(
}
/**
- * If `host` is itself a buffering `` — inside a `` segment — return its
- * store. Reads the host's own context (not an ancestor's) so children don't match.
+ * If `host` is itself a buffering `` (inside a `` segment), return its
+ * store.
*/
function getBufferingErrorBoundaryStore(
ssr: SSRContainer,
@@ -258,10 +255,8 @@ function processJSXNode(
await ssr.streamHandler.flush();
}
} catch (err) {
- // Route a mid-stream throw (the async-generator child or the `SSRStream` write loop, which
- // funnels through here) to the closest boundary and render its fallback in place — the same
- // drain site the chunks use. With no boundary above, `resolveErrorBoundaryFallback`
- // rethrows and SSR still aborts (matching framework-closure throws).
+ // Route a mid-stream async-generator/SSRStream throw to the closest boundary (with none, it
+ // rethrows and aborts SSR).
const fallback = await resolveErrorBoundaryFallback(ssr, ssr.getOrCreateLastNode(), err);
await _walkJSX(ssr, fallback, {
currentStyleScoped: options.currentStyleScoped,
@@ -431,8 +426,8 @@ function processJSXNode(
);
enqueue(() => ssr.closeComponent());
if (bufferingErrorStore && !isPromise(jsxOutput)) {
- // Buffering ErrorBoundary: render the subtree in a nested pass so a throw rolls back the
- // partial output and renders the fallback in its place (a clean `boundary > fallback`).
+ // Render the subtree in a nested pass so a throw can roll back the partial output and
+ // render the fallback in its place.
const content = jsxOutput as JSXOutput;
enqueue(async () => {
ssr.streamHandler.streamBlockStart();
diff --git a/packages/qwik/src/core/ssr/ssr-types.ts b/packages/qwik/src/core/ssr/ssr-types.ts
index 24270b46067..9240eda6e50 100644
--- a/packages/qwik/src/core/ssr/ssr-types.ts
+++ b/packages/qwik/src/core/ssr/ssr-types.ts
@@ -88,9 +88,8 @@ export interface ISsrComponentFrame {
export type SymbolToChunkResolver = (symbol: string) => string;
/**
- * Opaque snapshot of the container's render state, taken by `checkpoint()` and restored by
- * `rollback()`. Used to discard a partially-rendered subtree (e.g. an `` whose child
- * threw) so the fallback can be rendered in its place. The shape is internal to the container.
+ * Opaque snapshot of the container's render state for `checkpoint()`/`rollback()`, used to discard
+ * a partially-rendered subtree. The shape is internal to the container.
*/
export interface SSRBufferCheckpoint {
readonly __brand: 'SSRBufferCheckpoint';
diff --git a/packages/qwik/src/out-of-order-executor-shared.ts b/packages/qwik/src/out-of-order-executor-shared.ts
index f729f7113e2..0ebe242687a 100644
--- a/packages/qwik/src/out-of-order-executor-shared.ts
+++ b/packages/qwik/src/out-of-order-executor-shared.ts
@@ -10,8 +10,8 @@ const Q_CONTAINER_SELECTOR = '[q\\:container]:not([q\\:container=html]):not([q\\
type OutOfOrderTemplate = HTMLTemplateElement | null;
type OutOfOrderHost = Element | null;
-// [hostToReveal, hostToHide]: the `q:rp` host is the one to reveal (Suspense's content host,
-// but a boundary's fallback host), and its previous sibling is the one to hide.
+// [hostToReveal, hostToHide]: the `q:rp` host is revealed (Suspense's content, a boundary's
+// fallback), its previous sibling hidden.
type OutOfOrderEntry = [OutOfOrderHost | 0, OutOfOrderHost];
type OutOfOrderScope = Document | Element;
@@ -92,8 +92,8 @@ export const installOutOfOrderExecutor = (doc: Document) => {
return scope.querySelector(Q_RESULT_PARENT_SELECTOR + boundaryId + '"]');
};
- // `q:rp` marks the host to reveal (the fallback host for a boundary, the content host for
- // Suspense); `hideHost` is its previous sibling, which we hide once the result is swapped in.
+ // `q:rp` marks the host to reveal (a boundary's fallback, Suspense's content); `hideHost` is its
+ // previous sibling.
const reveal = (revealHost: OutOfOrderHost, hideHost: OutOfOrderHost) => {
if (!revealHost) {
return 0;
diff --git a/packages/qwik/src/qwikloader.ts b/packages/qwik/src/qwikloader.ts
index 0770912ff98..e54f03495fb 100644
--- a/packages/qwik/src/qwikloader.ts
+++ b/packages/qwik/src/qwikloader.ts
@@ -102,8 +102,7 @@ const queueTasks = (tasks: Task[]) => {
/**
* Run deferred handlers grouped per element, re-checking `ev.cancelBubble` between groups so an
- * async `stopPropagation()` (from a handler whose QRL was still importing) still skips later
- * elements. A synchronous stop already truncated the walk, so its collected groups all run.
+ * async `stopPropagation()` (from a still-importing handler) still skips later elements.
*/
const runEventTasks = (ev: Event, taskGroups: Task[][], stoppedSynchronously: boolean) => {
if (taskGroups.length) {
@@ -409,8 +408,7 @@ const processElementEvent = (
const captureAttribute = capturePrefix + kebabName;
const elements: Element[] = [];
const captureHandlers: boolean[] = [];
- // One task list per element so `runEventTasks` can re-check `cancelBubble` between them — a deferred
- // handler can only `stopPropagation()` after this synchronous walk has already moved on.
+ // One task list per element so `runEventTasks` can re-check `cancelBubble` between them.
const taskGroups: Task[][] = [];
let stoppedSynchronously = false;
let current = ev.target as Node | null;
diff --git a/packages/qwik/src/server/ssr-container.ts b/packages/qwik/src/server/ssr-container.ts
index a26baa1ff76..8bd976451da 100644
--- a/packages/qwik/src/server/ssr-container.ts
+++ b/packages/qwik/src/server/ssr-container.ts
@@ -501,8 +501,7 @@ class SSRContainer extends _SharedContainer implements ISSRContainer {
'Out-of-order Suspense streaming requires `streaming.outOfOrder` to be `true`.'
);
}
- // Creating a segment means OOOS is used — arm the executor (for a throwing ``,
- // this is what arms it).
+ // Creating a segment means OOOS is used — arm the executor.
this.outOfOrderUsed = true;
this.markVNodeRefForSerialization(options.parentComponentFrame?.componentNode);
const writer = new StringBufferSegmentWriter();
@@ -993,9 +992,8 @@ class SSRContainer extends _SharedContainer implements ISSRContainer {
rollback(checkpoint: SSRBufferCheckpoint): void {
const cp = checkpoint as unknown as ContainerCheckpoint;
- // Discard buffered HTML, vnode-data, nodes and roots since the checkpoint and restore the cursor,
- // so rendering continues as if the subtree never ran. Styles already in are left as
- // harmless orphans; the serialization roots and their dedup-map entries are fully restored.
+ // Discard everything buffered since the checkpoint and restore the cursor, so rendering continues
+ // as if the subtree never ran (styles already in are left as harmless orphans).
this.writer.truncate(cp.writer);
this.vNodeDatas.length = cp.vNodeDatasLength;
this.currentElementFrame = cp.frame;
From a2972a7da53c2f38468790dfe373fadb26acfe8f Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Fri, 19 Jun 2026 23:19:28 +0200
Subject: [PATCH 041/331] test(qwik): consolidate ErrorBoundary specs into one
file
Fold the 10 error-boundary-*.spec.tsx files into error-boundary.spec.tsx (55 tests, one describe per theme), de-duplicating the shared helpers/components and trimming the test comments to one short sentence each.
---
.../error-boundary-combinations.spec.tsx | 205 ----
.../tests/error-boundary-concurrent.spec.tsx | 79 --
.../error-boundary-multi-container.spec.tsx | 105 --
.../error-boundary-no-boundary-ssr.spec.tsx | 86 --
.../tests/error-boundary-projection.spec.tsx | 106 --
.../core/tests/error-boundary-qerror.spec.tsx | 76 --
.../tests/error-boundary-recoverable.spec.tsx | 66 --
.../tests/error-boundary-ssr-async.spec.tsx | 85 --
.../core/tests/error-boundary-tasks.spec.tsx | 167 ---
.../error-boundary-thrown-values.spec.tsx | 102 --
.../src/core/tests/error-boundary.spec.tsx | 972 +++++++++++++++++-
11 files changed, 930 insertions(+), 1119 deletions(-)
delete mode 100644 packages/qwik/src/core/tests/error-boundary-combinations.spec.tsx
delete mode 100644 packages/qwik/src/core/tests/error-boundary-concurrent.spec.tsx
delete mode 100644 packages/qwik/src/core/tests/error-boundary-multi-container.spec.tsx
delete mode 100644 packages/qwik/src/core/tests/error-boundary-no-boundary-ssr.spec.tsx
delete mode 100644 packages/qwik/src/core/tests/error-boundary-projection.spec.tsx
delete mode 100644 packages/qwik/src/core/tests/error-boundary-qerror.spec.tsx
delete mode 100644 packages/qwik/src/core/tests/error-boundary-recoverable.spec.tsx
delete mode 100644 packages/qwik/src/core/tests/error-boundary-ssr-async.spec.tsx
delete mode 100644 packages/qwik/src/core/tests/error-boundary-tasks.spec.tsx
delete mode 100644 packages/qwik/src/core/tests/error-boundary-thrown-values.spec.tsx
diff --git a/packages/qwik/src/core/tests/error-boundary-combinations.spec.tsx b/packages/qwik/src/core/tests/error-boundary-combinations.spec.tsx
deleted file mode 100644
index b2570c98e11..00000000000
--- a/packages/qwik/src/core/tests/error-boundary-combinations.spec.tsx
+++ /dev/null
@@ -1,205 +0,0 @@
-import { $, component$, ErrorBoundary, Suspense, type JSXOutput } from '@qwik.dev/core';
-import { createDocument, domRender, ssrRenderToDom } from '@qwik.dev/core/testing';
-import { describe, expect, it } from 'vitest';
-import { processOutOfOrderSegmentVNodeData } from '../client/process-vnode-data';
-
-// Combinatorial coverage for multiple throws / multiple boundaries across the three modes
-// (CSR, in-order SSR, and out-of-order streaming). These pin down the "who catches what" semantics
-// when more than one error or boundary is in play.
-
-const debug = false;
-
-const ThrowerA = component$(() => {
- throw new Error('boomA');
-});
-const ThrowerB = component$(() => {
- throw new Error('boomB');
-});
-
-// Stream with out-of-order streaming, then run the emitted `qO` scripts to perform the inline swap.
-const streamAndResume = async (jsx: JSXOutput) => {
- const chunks: string[] = [];
- await ssrRenderToDom(jsx, {
- stream: {
- write: (c: string) => {
- chunks.push(c);
- },
- },
- streaming: { inOrder: { strategy: 'disabled' }, outOfOrder: true },
- debug,
- });
- const document = createDocument({ html: chunks.join('') });
- (document as any).qProcessOOOS = (boundaryId: number, content: Element | null) =>
- processOutOfOrderSegmentVNodeData(document, String(boundaryId), content);
- const scripts = Array.from(
- document.querySelectorAll('script[type="text/javascript"]'),
- (s) => s.textContent || ''
- ).filter((code) => code.includes('qO') || code.includes('qInstallOOOS'));
- // eslint-disable-next-line no-new-func
- new Function('document', scripts.join('\n'))(document);
- return document;
-};
-
-const fbCount = (root: any) => root.querySelectorAll('#fb').length;
-
-describe('ErrorBoundary combinations', () => {
- describe('CSR', () => {
- it('two throwing children in one boundary render a single fallback (first error wins)', async () => {
- const { container } = await domRender(
- (
-
caught: {e.message}
- ))}
- >
-
-
- ,
- { debug }
- );
- expect(fbCount(container.element)).toBe(1);
- expect(container.element.querySelector('#fb')?.textContent).toContain('caught: boomA');
- });
-
- it('two adjacent boundaries that both throw each show their own fallback', async () => {
- const { container } = await domRender(
-
- (
-
A
- ))}
- >
-
-
- (
-
B
- ))}
- >
-
-
- ,
- { debug }
- );
- expect(container.element.querySelector('#fb-a')).toBeTruthy();
- expect(container.element.querySelector('#fb-b')).toBeTruthy();
- });
-
- it('nested boundaries: when the outer also throws it supersedes the inner fallback', async () => {
- // The inner boundary catches its own child, but the outer boundary catches its direct-child
- // throw and re-renders its whole subtree to the outer fallback — replacing the inner one.
- const { container } = await domRender(
- (
-
- ))}
- >
-
-
- ,
- { debug }
- );
- expect(fbCount(container.element)).toBe(1);
- });
-
- it('two adjacent boundaries that both throw each show their own fallback', async () => {
- const { container } = await ssrRenderToDom(
-
- (
-
A
- ))}
- >
-
-
- (
-
B
- ))}
- >
-
-
- ,
- { debug }
- );
- expect(container.element.querySelector('#fb-a')).toBeTruthy();
- expect(container.element.querySelector('#fb-b')).toBeTruthy();
- });
- });
-
- describe('out-of-order streaming', () => {
- it('two adjacent boundaries that both throw each swap in their own fallback', async () => {
- const document = await streamAndResume(
-
- (
-
A
- ))}
- >
-
-
- (
-
B
- ))}
- >
-
-
-
- );
- expect(document.querySelector('#fb-a')).toBeTruthy();
- expect(document.querySelector('#fb-b')).toBeTruthy();
- });
-
- it('two boundaries inside one Suspense each show their own fallback', async () => {
- const document = await streamAndResume(
-
- loading}>
- (
-
A
- ))}
- >
-
-
- (
-
B
- ))}
- >
-
-
-
-
- );
- expect(document.querySelector('#fb-a')).toBeTruthy();
- expect(document.querySelector('#fb-b')).toBeTruthy();
- });
-
- // Note: "two Suspense in one boundary both async-throw -> single fallback" lives in
- // error-boundary-concurrent.spec.tsx (the dedicated teardown-exactly-once regression test).
- });
-});
diff --git a/packages/qwik/src/core/tests/error-boundary-concurrent.spec.tsx b/packages/qwik/src/core/tests/error-boundary-concurrent.spec.tsx
deleted file mode 100644
index aef673d0e46..00000000000
--- a/packages/qwik/src/core/tests/error-boundary-concurrent.spec.tsx
+++ /dev/null
@@ -1,79 +0,0 @@
-import { $, component$, ErrorBoundary, Suspense, type JSXOutput } from '@qwik.dev/core';
-import { createDocument, ssrRenderToDom } from '@qwik.dev/core/testing';
-import { describe, expect, it } from 'vitest';
-import { processOutOfOrderSegmentVNodeData } from '../client/process-vnode-data';
-
-const debug = false;
-
-// Throws *asynchronously*: a rejected promise child resolves after the Suspense placeholder has
-// already streamed, so the throw surfaces as the deferred segment's rejection (not a render throw).
-const AsyncThrower = component$(() => {
- const pending = new Promise((_resolve, reject) => reject(new Error('async boom')));
- return <>{pending}>;
-});
-
-describe('ErrorBoundary concurrent fallback teardown (experimental)', () => {
- // Render with out-of-order streaming, then run the emitted `qO` scripts to perform the inline swap.
- // Returns the streamed HTML (to assert the boundary didn't block) and the resumed document.
- // Replicated from error-boundary.spec.tsx so this regression spec stands alone.
- const streamAndResume = async (jsx: JSXOutput) => {
- const chunks: string[] = [];
- await ssrRenderToDom(jsx, {
- stream: {
- write: (c: string) => {
- chunks.push(c);
- },
- },
- streaming: { inOrder: { strategy: 'disabled' }, outOfOrder: true },
- debug,
- });
- const html = chunks.join('');
- const document = createDocument({ html });
- (document as any).qProcessOOOS = (boundaryId: number, content: Element | null) =>
- processOutOfOrderSegmentVNodeData(document, String(boundaryId), content);
- const scripts = Array.from(
- document.querySelectorAll('script[type="text/javascript"]'),
- (s) => s.textContent || ''
- ).filter((code) => code.includes('qO') || code.includes('qInstallOOOS'));
- // eslint-disable-next-line no-new-func
- new Function('document', scripts.join('\n'))(document);
- return { html, document };
- };
-
- const displayOf = (el: Element | null | undefined) =>
- (el as HTMLElement | null | undefined)?.style?.display;
-
- it('two sibling that both reject tear the boundary down exactly once', async () => {
- // Both wrap an async thrower, so both deferred segments reject and both invoke the
- // boundary's shared `$emitFallback$`. Before the idempotency guard, the second invocation read the
- // detached `store.$fallback$` as undefined and threw a TypeError ("undefined is not a function"),
- // which rejected its out-of-order segment and surfaced as an unhandled render rejection here.
- const result = await streamAndResume(
-
- (
-
caught: {String(e?.message ?? e)}
- ))}
- >
-
sibling
- loading a}>
-
-
- loading b}>
-
-
-
-
- );
-
- // The whole boundary tore down to its fallback — exactly one fallback host, no duplicate swap.
- const fallbacks = result.document.querySelectorAll('#fb');
- expect(fallbacks.length).toBe(1);
- expect(fallbacks[0]?.textContent).toContain('caught: async boom');
- // The fallback host is revealed and the content host (with the siblings) is hidden.
- expect(displayOf(result.document.querySelector('#fb')?.closest('[q\\:rp]'))).toBe('contents');
- expect(displayOf(result.document.querySelector('#sibling')?.closest('div[style]'))).toBe(
- 'none'
- );
- });
-});
diff --git a/packages/qwik/src/core/tests/error-boundary-multi-container.spec.tsx b/packages/qwik/src/core/tests/error-boundary-multi-container.spec.tsx
deleted file mode 100644
index fe82ac980ac..00000000000
--- a/packages/qwik/src/core/tests/error-boundary-multi-container.spec.tsx
+++ /dev/null
@@ -1,105 +0,0 @@
-import { $, ErrorBoundary, render, setPlatform } from '@qwik.dev/core';
-import { _getDomContainer } from '@qwik.dev/core/internal';
-import { createDocument, getTestPlatform, waitForDrain } from '@qwik.dev/core/testing';
-import { describe, expect, it } from 'vitest';
-
-// Two independent resumed Qwik containers can coexist on one page. Each registers a document-level
-// `qerror` listener (DomContainer.$qErrorHandler$ in client/dom-container.ts). A qerror originating
-// inside container A must be handled ONLY by A's ErrorBoundary, never B's — the listener guards with
-// `this.element.contains(source)`, so even though both listeners fire on the shared document, only
-// the owning container acts.
-
-// domRender() creates a fresh document per call, so two domRender calls would NOT share a document.
-// We replicate its internals: one createDocument(), two sibling container divs in document.body, and
-// render() into each. render() turns each div into its own DomContainer, and each container's
-// constructor adds a `qerror` listener to the SAME ownerDocument.
-const renderTwoContainers = async (jsxA: any, jsxB: any) => {
- setPlatform(getTestPlatform());
- const document = createDocument();
- const hostA = document.createElement('div');
- const hostB = document.createElement('div');
- document.body.appendChild(hostA);
- document.body.appendChild(hostB);
- await render(hostA, jsxA);
- await render(hostB, jsxB);
- const containerA = _getDomContainer(hostA);
- const containerB = _getDomContainer(hostB);
- return { document, hostA, hostB, containerA, containerB };
-};
-
-/** Dispatch a `qerror` CustomEvent the same way qwikloader does (see error-boundary.spec.tsx). */
-const dispatchQError = (
- target: Element,
- detail: { error: unknown; element?: Element; importError?: string }
-) => {
- const ev = target.ownerDocument.createEvent('Event');
- ev.initEvent('qerror', false, false);
- (ev as any).detail = detail;
- target.ownerDocument.dispatchEvent(ev);
-};
-
-describe('ErrorBoundary across multiple containers on one document', () => {
- it('routes a qerror from container A only to A, leaving B untouched', async () => {
- const { hostA, hostB, containerA } = await renderTwoContainers(
- (
-
caught A: {e.message}
- ))}
- >
-
- ,
- (
-
caught B: {e.message}
- ))}
- >
-
-
- );
-
- // Sanity: both containers streamed their content, neither shows a fallback yet.
- expect(hostA.querySelector('#fb-a')).toBeFalsy();
- expect(hostB.querySelector('#fb-b')).toBeFalsy();
- expect(hostA.querySelector('#target-a')).toBeTruthy();
- expect(hostB.querySelector('#target-b')).toBeTruthy();
-
- // A qerror originating INSIDE container A. Both containers' document listeners fire, but only A's
- // `this.element.contains(source)` guard passes.
- const targetA = hostA.querySelector('#target-a')!;
- dispatchQError(targetA, { error: new Error('boom from A'), element: targetA });
- await waitForDrain(containerA);
-
- // A's boundary reveals its fallback...
- expect(hostA.querySelector('#fb-a')?.textContent).toContain('caught A: boom from A');
- // ...and B is completely untouched: no fallback, original content intact.
- expect(hostB.querySelector('#fb-b')).toBeFalsy();
- expect(hostB.querySelector('#target-b')).toBeTruthy();
- });
-
- it('routes a qerror from container B only to B, leaving A untouched', async () => {
- const { hostA, hostB, containerB } = await renderTwoContainers(
- (
-
caught A: {e.message}
- ))}
- >
-
- ,
- (
-
caught B: {e.message}
- ))}
- >
-
-
- );
-
- const targetB = hostB.querySelector('#target-b')!;
- dispatchQError(targetB, { error: new Error('boom from B'), element: targetB });
- await waitForDrain(containerB);
-
- expect(hostB.querySelector('#fb-b')?.textContent).toContain('caught B: boom from B');
- expect(hostA.querySelector('#fb-a')).toBeFalsy();
- expect(hostA.querySelector('#target-a')).toBeTruthy();
- });
-});
diff --git a/packages/qwik/src/core/tests/error-boundary-no-boundary-ssr.spec.tsx b/packages/qwik/src/core/tests/error-boundary-no-boundary-ssr.spec.tsx
deleted file mode 100644
index dd3390502d7..00000000000
--- a/packages/qwik/src/core/tests/error-boundary-no-boundary-ssr.spec.tsx
+++ /dev/null
@@ -1,86 +0,0 @@
-import { component$, type JSXOutput } from '@qwik.dev/core';
-import { ssrRenderToDom } from '@qwik.dev/core/testing';
-import { describe, expect, it } from 'vitest';
-
-// Safety-net invariant (PR #8745): the PR now catches SSR render throws at every drain site so a
-// throw inside an renders the fallback instead of aborting. But with NO boundary
-// above, the throw MUST still propagate and reject the render (abort to the error page) — it must
-// not be silently swallowed. These in-order SSR cases pin each of the three catch paths to that
-// rethrow behavior. (`ssrRenderToDom` with no `stream`/`streaming` opts renders in-order via
-// `renderToString`, so `ssr.outOfOrderStreaming` is false and the final rethrow branch of
-// `renderErrorBoundaryFallback` is exercised.)
-
-const debug = false;
-
-// Throws synchronously during render — caught by the component-body try/catch in ssr-render-jsx.ts.
-const Thrower = component$(() => {
- throw new Error('boom');
-});
-
-// An async component whose render itself rejects (returns a rejecting promise) — exercises the
-// awaited-output drain path rather than the promise-child path.
-const AsyncRejector = component$(
- () => new Promise((_resolve, reject) => reject(new Error('async boom'))) as any
-);
-
-// A rejected promise child (not a component) — exercises the Promise drain path.
-const PromiseChild = component$(() => {
- const pending = new Promise((_resolve, reject) => reject(new Error('async boom')));
- return <>{pending}>;
-});
-
-describe('ErrorBoundary safety net: in-order SSR throw with no boundary above', () => {
- it('(a) a synchronous render throw propagates and rejects the render', async () => {
- await expect(
- ssrRenderToDom(
-
-
- ,
- { debug }
- )
- ).rejects.toThrow('boom');
- });
-
- it('(a) the ORIGINAL error object propagates unchanged (not wrapped/projected)', async () => {
- const original = new Error('boom');
- const Throws = component$(() => {
- throw original;
- });
- let caught: unknown;
- try {
- await ssrRenderToDom(
-
-
- ,
- { debug }
- );
- } catch (err) {
- caught = err;
- }
- // With no boundary above, the raw throw must propagate — `toSerializableBoundaryError` must NOT
- // run on this path, so the identity is preserved.
- expect(caught).toBe(original);
- });
-
- it('(b) an async component whose render rejects propagates and rejects the render', async () => {
- await expect(
- ssrRenderToDom(
-
-
- ,
- { debug }
- )
- ).rejects.toThrow('async boom');
- });
-
- it('(c) a rejected promise child propagates and rejects the render', async () => {
- await expect(
- ssrRenderToDom(
-
-
- ,
- { debug }
- )
- ).rejects.toThrow('async boom');
- });
-});
diff --git a/packages/qwik/src/core/tests/error-boundary-projection.spec.tsx b/packages/qwik/src/core/tests/error-boundary-projection.spec.tsx
deleted file mode 100644
index defcb013e75..00000000000
--- a/packages/qwik/src/core/tests/error-boundary-projection.spec.tsx
+++ /dev/null
@@ -1,106 +0,0 @@
-import { $, component$, ErrorBoundary, Slot } from '@qwik.dev/core';
-import { domRender, ssrRenderToDom } from '@qwik.dev/core/testing';
-import { describe, expect, it } from 'vitest';
-
-// A throw inside Slot-PROJECTED content must resolve to the lexically-nearest via the
-// projection chain (client: getParentHost walking `slotParent`; SSR: resolveContext walking
-// `parentComponent`). When children are projected into a boundary's , the boundary the content
-// is projected *into* is the nearest provider and must catch a render throw from that content.
-
-const debug = false;
-
-const Thrower = component$(() => {
- throw new Error('boom');
-});
-
-// Wrapper whose children are PROJECTED into an . The boundary lives inside `Boxed`, so
-// anything passed as `Boxed`'s children renders through the *inside* that boundary.
-const Boxed = component$(() => {
- return (
- (
-
caught: {e.message}
- ))}
- >
-
-
- );
-});
-
-// Like `Boxed`, but also renders a non-throwing sibling beside the projected slot, so we can assert
-// the whole boundary subtree (sibling + projected content) is torn down to the fallback.
-const BoxedWithSibling = component$(() => {
- return (
- (
-
caught: {e.message}
- ))}
- >
-
sibling
-
-
- );
-});
-
-describe('ErrorBoundary projection', () => {
- describe('CSR', () => {
- it('a render throw in projected content is caught by the boundary it is projected into', async () => {
- const { container } = await domRender(
-
-
- ,
- { debug }
- );
- const el = container.element;
- expect(el.querySelector('#fb')?.textContent).toContain('caught: boom');
- });
-
- it('only the fallback shows: a non-throwing sibling and the projected throw are both gone', async () => {
- const { container } = await domRender(
-
-
-
projected ok
- ,
- { debug }
- );
- const el = container.element;
- expect(el.querySelector('#fb')?.textContent).toContain('caught: boom');
- // The boundary tore down its whole subtree to the fallback: neither the sibling nor the
- // (non-throwing) projected child is visible.
- expect(el.querySelector('#sibling')).toBeFalsy();
- expect(el.querySelector('#projected-ok')).toBeFalsy();
- });
- });
-
- describe('in-order SSR', () => {
- it('a render throw in projected content is caught by the boundary it is projected into', async () => {
- const { container } = await ssrRenderToDom(
-
-
- ,
- { debug }
- );
- const el = container.element;
- expect(el.querySelector('#fb')?.textContent).toContain('caught: boom');
- });
-
- it('renders the fallback in place; siblings already streamed before the throw remain', async () => {
- // In-order (non-streaming) SSR renders the fallback IN PLACE of the throwing child (see the
- // "renders the fallback in place" test in error-boundary.spec.tsx). Content emitted before the
- // throw is already in the stream and cannot be retracted, so a sibling rendered before the
- // stays — unlike CSR (and the experimental out-of-order path), which replace the whole
- // boundary subtree with the fallback.
- const { container } = await ssrRenderToDom(
-
-
-
projected ok
- ,
- { debug }
- );
- const el = container.element;
- expect(el.querySelector('#fb')?.textContent).toContain('caught: boom');
- // The sibling streamed before the throw is retained (in-place fallback).
- expect(el.querySelector('#sibling')).toBeTruthy();
- });
- });
-});
diff --git a/packages/qwik/src/core/tests/error-boundary-qerror.spec.tsx b/packages/qwik/src/core/tests/error-boundary-qerror.spec.tsx
deleted file mode 100644
index 53dec9ffba0..00000000000
--- a/packages/qwik/src/core/tests/error-boundary-qerror.spec.tsx
+++ /dev/null
@@ -1,76 +0,0 @@
-import { $, ErrorBoundary } from '@qwik.dev/core';
-import { domRender, waitForDrain } from '@qwik.dev/core/testing';
-import { describe, expect, it } from 'vitest';
-
-const debug = false;
-
-/** Dispatch a `qerror` CustomEvent the same way qwikloader does (see error-boundary.spec.tsx). */
-function dispatchQError(
- target: Element,
- detail: { error: unknown; element?: Element; importError?: string }
-): void {
- const ev = target.ownerDocument.createEvent('Event');
- ev.initEvent('qerror', false, false);
- (ev as any).detail = detail;
- target.ownerDocument.dispatchEvent(ev);
-}
-
-describe('ErrorBoundary qerror listener', () => {
- it('does NOT throw when a qerror has no enclosing ErrorBoundary', async () => {
- const { container } = await domRender(
-
-
- ,
- { debug }
- );
- const target = container.element.querySelector('#target')!;
-
- // With no boundary above the host, handleError re-throws. The listener must contain that throw
- // (and log it) instead of letting it escape document.dispatchEvent.
- expect(() =>
- dispatchQError(target, { error: new Error('boom'), element: target })
- ).not.toThrow();
- });
-
- it('control: a with-boundary qerror still reveals the fallback', async () => {
- const { container } = await domRender(
- (
-
- ))}
- >
-
- ,
- { debug }
- );
- const target = container.element.querySelector('#target')!;
-
- // qwikloader already console.errors QRL chunk-import / missing-symbol failures (importError set),
- // so they must not be routed to a boundary.
- expect(() =>
- dispatchQError(target, { error: new Error('sym:0'), element: target, importError: 'sync' })
- ).not.toThrow();
- await waitForDrain(container);
-
- expect(container.element.querySelector('#fb')).toBeFalsy();
- });
-});
diff --git a/packages/qwik/src/core/tests/error-boundary-recoverable.spec.tsx b/packages/qwik/src/core/tests/error-boundary-recoverable.spec.tsx
deleted file mode 100644
index 8ccd9365343..00000000000
--- a/packages/qwik/src/core/tests/error-boundary-recoverable.spec.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-import { $, component$, ErrorBoundary, type JSXOutput } from '@qwik.dev/core';
-import { domRender, ssrRenderToDom, waitForDrain } from '@qwik.dev/core/testing';
-import { describe, expect, it } from 'vitest';
-
-const debug = false;
-
-// A normal, recoverable runtime error — the boundary should catch it and render the fallback.
-const Thrower = component$(() => {
- throw new Error('boom');
-});
-
-// A NON-recoverable build/plugin error: Vite/Rollup stamp `.plugin` on transform/build errors.
-// `isRecoverable` returns false for these, so a boundary must NOT hide them in its fallback — in dev
-// they surface (SSR render rejects; the client rethrows past the boundary) so the developer sees the
-// real build failure instead of a generic fallback.
-const PluginThrower = component$(() => {
- const err = new Error('build boom');
- (err as any).plugin = 'vite:some-plugin';
- throw err;
-});
-
-const Boxed = (child: JSXOutput) => (
- (
-
caught: {e.message}
- ))}
- >
- {child}
-
-);
-
-describe('ErrorBoundary: recoverable vs build errors (dev)', () => {
- it('SSR: a recoverable error renders the fallback', async () => {
- const { container } = await ssrRenderToDom(Boxed(), { debug });
- expect(container.element.querySelector('#fb')?.textContent).toContain('caught: boom');
- });
-
- it('SSR: a non-recoverable build error is NOT hidden in the fallback (it surfaces)', async () => {
- await expect(ssrRenderToDom(Boxed(), { debug })).rejects.toThrow('build boom');
- });
-
- it('CSR: a recoverable error renders the fallback', async () => {
- const { container } = await domRender(Boxed(), { debug });
- expect(container.element.querySelector('#fb')?.textContent).toContain('caught: boom');
- });
-
- it('CSR: a non-recoverable build error is not caught by the boundary', async () => {
- // Routed via a client `qerror` (like an async QRL failure). The boundary must NOT show its
- // fallback — `handleError` rethrows non-recoverable errors past it (the listener then logs).
- const { container } = await domRender(Boxed(), { debug });
- const el = container.element;
- const target = el.querySelector('#content')!;
- const err = new Error('build boom');
- (err as any).plugin = 'vite:some-plugin';
- const ev = target.ownerDocument.createEvent('Event');
- ev.initEvent('qerror', false, false);
- (ev as any).detail = { error: err, element: target };
- target.ownerDocument.dispatchEvent(ev);
- try {
- await waitForDrain(container);
- } catch {
- /* the rethrown build error may surface during drain — the point is the fallback never shows */
- }
- expect(el.querySelector('#fb')).toBeFalsy();
- });
-});
diff --git a/packages/qwik/src/core/tests/error-boundary-ssr-async.spec.tsx b/packages/qwik/src/core/tests/error-boundary-ssr-async.spec.tsx
deleted file mode 100644
index 2a87a1d9225..00000000000
--- a/packages/qwik/src/core/tests/error-boundary-ssr-async.spec.tsx
+++ /dev/null
@@ -1,85 +0,0 @@
-import { $, component$, ErrorBoundary, type JSXOutput } from '@qwik.dev/core';
-import { ssrRenderToDom } from '@qwik.dev/core/testing';
-import { describe, expect, it } from 'vitest';
-
-const debug = false;
-
-// An async-generator child that streams one chunk, then throws mid-stream. Before the fix this throw
-// escaped the async-generator StackFn (awaited inside a try-with-only-finally) and aborted SSR
-// instead of routing to the enclosing .
-const AsyncGenThrower = component$(() => {
- return (
- <>
- {(async function* () {
- yield
chunk
;
- throw new Error('async gen boom');
- })()}
- >
- ) as unknown as JSXOutput;
-});
-
-// Throws a NON-serializable value (a class instance with a method). Before the S4 fix this raw value
-// was stored on the error store and then failed `verifySerializable` at final state emit, aborting
-// the whole page with a confusing serializer error instead of rendering the boundary fallback.
-class NonSerializableError {
- message = 'non-serializable boom';
- toJSON() {
- return this.message;
- }
-}
-
-const NonSerializableThrower = component$((): JSXOutput => {
- throw new NonSerializableError();
-});
-
-const NormalErrorThrower = component$((): JSXOutput => {
- throw new Error('normal boom');
-});
-
-describe('ErrorBoundary SSR async-generator + non-serializable throws (experimental)', () => {
- it('S2: routes an async-generator child throw to the enclosing boundary fallback', async () => {
- const { container } = await ssrRenderToDom(
- (
-
caught: {String(e?.message ?? e)}
- ))}
- >
-
- ,
- { debug }
- );
- expect(container.element.querySelector('#fb')?.textContent).toContain('caught: async gen boom');
- });
-
- it('S4: a non-serializable throw renders the fallback AND the page still serializes', async () => {
- // The key assertion is that ssrRenderToDom RESOLVES (the page serialized) rather than rejecting
- // with a verifySerializable error.
- const { container } = await ssrRenderToDom(
- (
-
- ))}
- >
-
- ,
- { debug }
- );
- expect(container.element.querySelector('#fb')?.textContent).toContain('caught: normal boom');
- });
-});
diff --git a/packages/qwik/src/core/tests/error-boundary-tasks.spec.tsx b/packages/qwik/src/core/tests/error-boundary-tasks.spec.tsx
deleted file mode 100644
index c8c4a352d6e..00000000000
--- a/packages/qwik/src/core/tests/error-boundary-tasks.spec.tsx
+++ /dev/null
@@ -1,167 +0,0 @@
-import { $, component$, ErrorBoundary, useSignal, useTask$, useVisibleTask$ } from '@qwik.dev/core';
-import { domRender, ssrRenderToDom, waitForDrain } from '@qwik.dev/core/testing';
-import { describe, expect, it } from 'vitest';
-import { delay } from '../shared/utils/promises';
-
-// A throw inside useTask$ / useVisibleTask$ should route to the NEAREST enclosing ,
-// exactly like a synchronous render throw does. The existing use-task / use-visible-task specs only
-// prove a task throw reaches the generic ERROR_CONTEXT test helper (ErrorProvider), which inits
-// `error: null` and is capture-only: it never re-renders to a fallback. These tests assert the
-// user-facing behavior against a real : the fallback must show.
-//
-// Routing basis: the shared `runTask` sends both the CSR and SSR task-throw paths through
-// `container.handleError(reason, host)` (use-task.ts). The DOM container's `handleError` resolves
-// ERROR_CONTEXT to the boundary store (which inits `error: undefined`), records the error, and
-// re-renders the boundary host to its fallback. The SSR container's `handleError`, by contrast,
-// re-throws unconditionally and ignores the host — so the SSR case below documents the intuitively
-// correct expectation that an enclosing boundary catches an eager `useTask$` throw during SSR.
-
-const debug = false;
-
-describe('ErrorBoundary catches task throws', () => {
- describe('CSR (domRender)', () => {
- it('a useTask$ throw is caught by the nearest ', async () => {
- const ThrowingTask = component$(() => {
- useTask$(() => {
- throw new Error('task boom');
- });
- return ok;
- });
-
- const { container } = await domRender(
- (
-
caught: {e.message}
- ))}
- >
-
- ,
- { debug }
- );
- await waitForDrain(container);
-
- const el = container.element;
- // The boundary re-rendered to its fallback; the throwing subtree is gone.
- expect(el.querySelector('#fb')?.textContent).toContain('caught: task boom');
- expect(el.querySelector('#content')).toBeFalsy();
- });
-
- it('an async useTask$ throw is caught by the nearest ', async () => {
- const ThrowingTask = component$(() => {
- useTask$(async () => {
- await delay(1);
- throw new Error('async task boom');
- });
- return ok;
- });
-
- const { container } = await domRender(
- (
-
- ))}
- >
-
- ,
- { debug }
- );
- // In CSR visible tasks run after resume; drain any re-render the throw scheduled.
- await waitForDrain(container);
-
- const el = container.element;
- expect(el.querySelector('#fb')?.textContent).toContain('caught: visible task boom');
- expect(el.querySelector('#content')).toBeFalsy();
- });
-
- it('a useTask$ throw is caught by the NEAREST of nested boundaries', async () => {
- const ThrowingTask = component$(() => {
- useTask$(() => {
- throw new Error('task boom');
- });
- return ok;
- });
-
- const { container } = await domRender(
- (
-
outer
- ))}
- >
-
outer ok
- (
-
inner
- ))}
- >
-
-
- ,
- { debug }
- );
- await waitForDrain(container);
-
- const el = container.element;
- // Only the inner boundary catches; the outer subtree stays visible.
- expect(el.querySelector('#fb-inner')).toBeTruthy();
- expect(el.querySelector('#fb-outer')).toBeFalsy();
- expect(el.querySelector('#outer-ok')).toBeTruthy();
- });
- });
-
- describe('in-order SSR (ssrRenderToDom)', () => {
- // Intuitive-correct expectation: an eager useTask$ throw during SSR is caught by the enclosing
- // boundary and the fallback is rendered in place — mirroring the synchronous render-throw path
- // (see error-boundary.spec.tsx 'SSR: renders the fallback in place when a child throws').
- // Today this FAILS: the SSR container's handleError (server/ssr-container.ts) re-throws and
- // ignores the host, so ssrRenderToDom rejects with the task error instead of catching it.
- it('a useTask$ throw is caught by the nearest ', async () => {
- const ThrowingTask = component$(() => {
- useTask$(() => {
- throw new Error('task boom');
- });
- return ok;
- });
-
- const { container } = await ssrRenderToDom(
- (
-
+ ))}
+ >
+
+
+ ,
+ { debug }
+ );
+ expect(fbCount(container.element)).toBe(1);
+ });
+
+ it('two adjacent boundaries that both throw each show their own fallback', async () => {
+ const { container } = await ssrRenderToDom(
+
+ (
+
A
+ ))}
+ >
+
+
+ (
+
B
+ ))}
+ >
+
+
+ ,
+ { debug }
+ );
+ expect(container.element.querySelector('#fb-a')).toBeTruthy();
+ expect(container.element.querySelector('#fb-b')).toBeTruthy();
+ });
+ });
+
+ describe('out-of-order streaming', () => {
+ it('two adjacent boundaries that both throw each swap in their own fallback', async () => {
+ const { document } = await streamAndResume(
+
+ (
+
A
+ ))}
+ >
+
+
+ (
+
B
+ ))}
+ >
+
+
+
+ );
+ expect(document.querySelector('#fb-a')).toBeTruthy();
+ expect(document.querySelector('#fb-b')).toBeTruthy();
+ });
+
+ it('two boundaries inside one Suspense each show their own fallback', async () => {
+ const { document } = await streamAndResume(
+
+ loading}>
+ (
+
A
+ ))}
+ >
+
+
+ (
+
B
+ ))}
+ >
+
+
+
+
+ );
+ expect(document.querySelector('#fb-a')).toBeTruthy();
+ expect(document.querySelector('#fb-b')).toBeTruthy();
+ });
+
+ // "two Suspense in one boundary both async-throw -> single fallback" lives in the concurrent
+ // teardown-exactly-once regression test below.
+ });
+});
+
+describe('ErrorBoundary concurrent fallback teardown (experimental)', () => {
+ it('two sibling that both reject tear the boundary down exactly once', async () => {
+ // Both deferred children reject into the shared `$emitFallback$`; the second must no-op, not crash
+ // on the now-detached `$fallback$`.
+ const { document } = await streamAndResume(
+
+ (
+
caught: {String(e?.message ?? e)}
+ ))}
+ >
+
sibling
+ loading a}>
+
+
+ loading b}>
+
+
+
+
+ );
+
+ // The whole boundary tore down to its fallback — exactly one fallback host, no duplicate swap.
+ const fallbacks = document.querySelectorAll('#fb');
+ expect(fallbacks.length).toBe(1);
+ expect(fallbacks[0]?.textContent).toContain('caught: async boom');
+ // The fallback host is revealed and the content host (with the siblings) is hidden.
+ expect(displayOf(document.querySelector('#fb')?.closest('[q\\:rp]'))).toBe('contents');
+ expect(displayOf(document.querySelector('#sibling')?.closest('div[style]'))).toBe('none');
+ });
+});
+
+describe('ErrorBoundary across multiple containers on one document', () => {
+ // Two resumed containers share one document; each `qerror` listener is guarded by
+ // `element.contains(source)`, so only the owning container reacts.
+ const renderTwoContainers = async (jsxA: any, jsxB: any) => {
+ setPlatform(getTestPlatform());
+ const document = createDocument();
+ const hostA = document.createElement('div');
+ const hostB = document.createElement('div');
+ document.body.appendChild(hostA);
+ document.body.appendChild(hostB);
+ await render(hostA, jsxA);
+ await render(hostB, jsxB);
+ const containerA = _getDomContainer(hostA);
+ const containerB = _getDomContainer(hostB);
+ return { document, hostA, hostB, containerA, containerB };
+ };
+
+ it('routes a qerror from container A only to A, leaving B untouched', async () => {
+ const { hostA, hostB, containerA } = await renderTwoContainers(
+ (
+
caught A: {e.message}
+ ))}
+ >
+
+ ,
+ (
+
caught B: {e.message}
+ ))}
+ >
+
+
+ );
+
+ // Sanity: both containers streamed their content, neither shows a fallback yet.
+ expect(hostA.querySelector('#fb-a')).toBeFalsy();
+ expect(hostB.querySelector('#fb-b')).toBeFalsy();
+ expect(hostA.querySelector('#target-a')).toBeTruthy();
+ expect(hostB.querySelector('#target-b')).toBeTruthy();
+
+ // A qerror originating INSIDE container A. Both listeners fire, but only A's guard passes.
+ const targetA = hostA.querySelector('#target-a')!;
+ dispatchQError(targetA, { error: new Error('boom from A'), element: targetA });
+ await waitForDrain(containerA);
+
+ // A's boundary reveals its fallback...
+ expect(hostA.querySelector('#fb-a')?.textContent).toContain('caught A: boom from A');
+ // ...and B is completely untouched: no fallback, original content intact.
+ expect(hostB.querySelector('#fb-b')).toBeFalsy();
+ expect(hostB.querySelector('#target-b')).toBeTruthy();
+ });
+
+ it('routes a qerror from container B only to B, leaving A untouched', async () => {
+ const { hostA, hostB, containerB } = await renderTwoContainers(
+ (
+
caught A: {e.message}
+ ))}
+ >
+
+ ,
+ (
+
caught B: {e.message}
+ ))}
+ >
+
+
+ );
+
+ const targetB = hostB.querySelector('#target-b')!;
+ dispatchQError(targetB, { error: new Error('boom from B'), element: targetB });
+ await waitForDrain(containerB);
+
+ expect(hostB.querySelector('#fb-b')?.textContent).toContain('caught B: boom from B');
+ expect(hostA.querySelector('#fb-a')).toBeFalsy();
+ expect(hostA.querySelector('#target-a')).toBeTruthy();
+ });
+});
+
+// With no boundary above, an SSR throw must still propagate and reject the render, not be swallowed.
+describe('ErrorBoundary safety net: in-order SSR throw with no boundary above', () => {
+ // A rejected promise child (not a component) — exercises the Promise drain path.
+ const PromiseChild = component$(() => {
+ const pending = new Promise((_resolve, reject) => reject(new Error('async boom')));
+ return <>{pending}>;
+ });
+
+ it('(a) a synchronous render throw propagates and rejects the render', async () => {
+ await expect(
+ ssrRenderToDom(
+
+
+ ,
+ { debug }
+ )
+ ).rejects.toThrow('boom');
+ });
+
+ it('(a) the ORIGINAL error object propagates unchanged (not wrapped/projected)', async () => {
+ const original = new Error('boom');
+ const Throws = component$(() => {
+ throw original;
+ });
+ let caught: unknown;
+ try {
+ await ssrRenderToDom(
+
+
+ ,
+ { debug }
+ );
+ } catch (err) {
+ caught = err;
+ }
+ // With no boundary above, the raw throw must propagate — `toSerializableBoundaryError` must NOT
+ // run on this path, so the identity is preserved.
+ expect(caught).toBe(original);
+ });
+
+ it('(b) an async component whose render rejects propagates and rejects the render', async () => {
+ await expect(
+ ssrRenderToDom(
+
+
+ ,
+ { debug }
+ )
+ ).rejects.toThrow('async boom');
+ });
+
+ it('(c) a rejected promise child propagates and rejects the render', async () => {
+ await expect(
+ ssrRenderToDom(
+
+
+ ,
+ { debug }
+ )
+ ).rejects.toThrow('async boom');
+ });
+});
+
+// A throw inside Slot-PROJECTED content must resolve to the lexically-nearest via the
+// projection chain. The boundary the content is projected *into* is the nearest provider.
+describe('ErrorBoundary projection', () => {
+ // Wrapper whose children are PROJECTED into an via the inside it.
+ const Boxed = component$(() => {
+ return (
+ (
+
caught: {e.message}
+ ))}
+ >
+
+
+ );
+ });
+
+ // Like `Boxed`, but with a non-throwing sibling beside the projected slot, so we can assert the
+ // whole boundary subtree (sibling + projected content) is torn down to the fallback.
+ const BoxedWithSibling = component$(() => {
+ return (
+ (
+
caught: {e.message}
+ ))}
+ >
+
sibling
+
+
+ );
+ });
+
+ describe('CSR', () => {
+ it('a render throw in projected content is caught by the boundary it is projected into', async () => {
+ const { container } = await domRender(
+
+
+ ,
+ { debug }
+ );
+ const el = container.element;
+ expect(el.querySelector('#fb')?.textContent).toContain('caught: boom');
+ });
+
+ it('only the fallback shows: a non-throwing sibling and the projected throw are both gone', async () => {
+ const { container } = await domRender(
+
+
+
projected ok
+ ,
+ { debug }
+ );
+ const el = container.element;
+ expect(el.querySelector('#fb')?.textContent).toContain('caught: boom');
+ // The boundary tore down its whole subtree to the fallback.
+ expect(el.querySelector('#sibling')).toBeFalsy();
+ expect(el.querySelector('#projected-ok')).toBeFalsy();
+ });
+ });
+
+ describe('in-order SSR', () => {
+ it('a render throw in projected content is caught by the boundary it is projected into', async () => {
+ const { container } = await ssrRenderToDom(
+
+
+ ,
+ { debug }
+ );
+ const el = container.element;
+ expect(el.querySelector('#fb')?.textContent).toContain('caught: boom');
+ });
+
+ it('renders the fallback in place; siblings already streamed before the throw remain', async () => {
+ // In-order SSR renders the fallback in place, so a sibling streamed before the throw stays —
+ // unlike CSR and the out-of-order path, which replace the whole subtree.
+ const { container } = await ssrRenderToDom(
+
+
+
projected ok
+ ,
+ { debug }
+ );
+ const el = container.element;
+ expect(el.querySelector('#fb')?.textContent).toContain('caught: boom');
+ // The sibling streamed before the throw is retained (in-place fallback).
+ expect(el.querySelector('#sibling')).toBeTruthy();
+ });
+ });
+});
+
+describe('ErrorBoundary qerror listener', () => {
+ it('does NOT throw when a qerror has no enclosing ErrorBoundary', async () => {
+ const { container } = await domRender(
+
+
+ ,
+ { debug }
+ );
+ const target = container.element.querySelector('#target')!;
+
+ // With no boundary above the host, handleError re-throws. The listener must contain that throw
+ // (and log it) instead of letting it escape document.dispatchEvent.
+ expect(() =>
+ dispatchQError(target, { error: new Error('boom'), element: target })
+ ).not.toThrow();
+ });
+
+ it('control: a with-boundary qerror still reveals the fallback', async () => {
+ const { container } = await domRender(
+ (
+
+ ))}
+ >
+
+ ,
+ { debug }
+ );
+ const target = container.element.querySelector('#target')!;
+
+ // qwikloader already console.errors QRL chunk-import / missing-symbol failures (importError set),
+ // so they must not be routed to a boundary.
+ expect(() =>
+ dispatchQError(target, { error: new Error('sym:0'), element: target, importError: 'sync' })
+ ).not.toThrow();
+ await waitForDrain(container);
+
+ expect(container.element.querySelector('#fb')).toBeFalsy();
+ });
+});
+
+describe('ErrorBoundary: recoverable vs build errors (dev)', () => {
+ // A NON-recoverable build/plugin error: Vite/Rollup stamp `.plugin` on transform/build errors, so
+ // `isRecoverable` returns false and a boundary must NOT hide them — the real build failure surfaces.
+ const PluginThrower = component$(() => {
+ const err = new Error('build boom');
+ (err as any).plugin = 'vite:some-plugin';
+ throw err;
+ });
+
+ const boxed = (child: JSXOutput) => (
+ (
+
caught: {e.message}
+ ))}
+ >
+ {child}
+
+ );
+
+ it('SSR: a recoverable error renders the fallback', async () => {
+ const { container } = await ssrRenderToDom(boxed(), { debug });
+ expect(container.element.querySelector('#fb')?.textContent).toContain('caught: boom');
+ });
+
+ it('SSR: a non-recoverable build error is NOT hidden in the fallback (it surfaces)', async () => {
+ await expect(ssrRenderToDom(boxed(), { debug })).rejects.toThrow('build boom');
+ });
+
+ it('CSR: a recoverable error renders the fallback', async () => {
+ const { container } = await domRender(boxed(), { debug });
+ expect(container.element.querySelector('#fb')?.textContent).toContain('caught: boom');
+ });
+
+ it('CSR: a non-recoverable build error is not caught by the boundary', async () => {
+ // Routed via a client `qerror`. `handleError` rethrows non-recoverable errors past the boundary
+ // (the listener then logs), so the fallback must NOT show.
+ const { container } = await domRender(boxed(), { debug });
+ const el = container.element;
+ const target = el.querySelector('#content')!;
+ const err = new Error('build boom');
+ (err as any).plugin = 'vite:some-plugin';
+ const ev = target.ownerDocument.createEvent('Event');
+ ev.initEvent('qerror', false, false);
+ (ev as any).detail = { error: err, element: target };
+ target.ownerDocument.dispatchEvent(ev);
+ try {
+ await waitForDrain(container);
+ } catch {
+ /* the rethrown build error may surface during drain — the point is the fallback never shows */
+ }
+ expect(el.querySelector('#fb')).toBeFalsy();
+ });
+});
+
+describe('ErrorBoundary SSR async-generator + non-serializable throws (experimental)', () => {
+ // An async-generator child that streams one chunk, then throws mid-stream. Before the fix this
+ // throw escaped the async-generator StackFn and aborted SSR instead of routing to the boundary.
+ const AsyncGenThrower = component$(() => {
+ return (
+ <>
+ {(async function* () {
+ yield
chunk
;
+ throw new Error('async gen boom');
+ })()}
+ >
+ ) as unknown as JSXOutput;
+ });
+
+ const NonSerializableThrower = component$((): JSXOutput => {
+ throw new NonSerializableError();
+ });
+
+ const NormalErrorThrower = component$((): JSXOutput => {
+ throw new Error('normal boom');
+ });
+
+ it('S2: routes an async-generator child throw to the enclosing boundary fallback', async () => {
+ const { container } = await ssrRenderToDom(
+ (
+
caught: {String(e?.message ?? e)}
+ ))}
+ >
+
+ ,
+ { debug }
+ );
+ expect(container.element.querySelector('#fb')?.textContent).toContain('caught: async gen boom');
+ });
+
+ it('S4: a non-serializable throw renders the fallback AND the page still serializes', async () => {
+ // The key assertion is that ssrRenderToDom RESOLVES (the page serialized) rather than rejecting
+ // with a verifySerializable error.
+ const { container } = await ssrRenderToDom(
+ (
+
, options);
+ ssr.closeElement();
+});
+
+describe('SSR in-order unwind (closeOpenElementsTo)', () => {
+ it('closes partial content into the content-host and renders the fallback as a sibling', async () => {
+ const { container } = await ssrRenderToDom(
+
+
+ ,
+ { debug: false }
+ );
+ const el = container.element;
+ const contentHost = el.querySelector('[q\\:ebc="1"]')!;
+ const fallbackHost = el.querySelector('[q\\:ebf="1"]')!;
+
+ // both hosts exist and the partial sits INSIDE the (closed) content-host
+ expect(contentHost).toBeTruthy();
+ expect(fallbackHost).toBeTruthy();
+ expect(contentHost.querySelector('#partial')?.textContent).toBe('partial');
+ expect(fallbackHost.querySelector('#fb')?.textContent).toBe('fallback');
+
+ // the fallback-host is a SIBLING of the content-host, not nested inside it (the swap invariant)
+ expect(contentHost.contains(fallbackHost)).toBe(false);
+ expect(fallbackHost.closest('[q\\:ebc="1"]')).toBeNull();
+ expect(contentHost.parentElement).toBe(fallbackHost.parentElement);
+
+ // prior sibling outside the boundary survived
+ expect(el.querySelector('#before')?.textContent).toBe('before');
+ });
+});
diff --git a/packages/qwik/src/server/ssr-container.ts b/packages/qwik/src/server/ssr-container.ts
index f919e0bc941..959a57b9854 100644
--- a/packages/qwik/src/server/ssr-container.ts
+++ b/packages/qwik/src/server/ssr-container.ts
@@ -817,6 +817,30 @@ class SSRContainer extends _SharedContainer implements ISSRContainer {
}
}
+ /** The currently-open element frame, captured when an ErrorBoundary's `content-host` opens. */
+ getCurrentElementFrame(): ElementFrame | null {
+ return this.currentElementFrame;
+ }
+
+ /**
+ * Close every still-open element frame back up to and INCLUDING `hostFrame`, appending
+ * well-formed closing tags. This is the in-order unwind for an ErrorBoundary whose content throws
+ * mid-render: unlike the deleted buffered rollback it NEVER erases streamed bytes — it closes the
+ * partial content into the hideable `content-host` so `qErr` can swap it out. Only element frames
+ * are closed (fragments/components/projections emit no HTML), so the byte stream is well-formed
+ * once the element tags are balanced; the dead subtree is then marked non-updatable so it never
+ * resumes.
+ */
+ closeOpenElementsTo(hostFrame: ElementFrame): void {
+ while (this.currentElementFrame) {
+ const frame = this.currentElementFrame;
+ this._closeElement();
+ if (frame === hostFrame) {
+ break;
+ }
+ }
+ }
+
/** Writes opening data to vNodeData for fragment boundaries */
openFragment(attrs: Props) {
this.lastNode = null;
From c6958fbacdc666599e8294262d1e363469ed10a3 Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Sun, 21 Jun 2026 05:46:38 +0200
Subject: [PATCH 048/331] feat(qwik): in-order ErrorBoundary two-host swap
(qErr)
When out-of-order streaming is off (e.g. a page without Suspense), an ErrorBoundary now uses the same never-buffer two-host swap as OOOS: errorBoundaryCmp emits a content-host (q:ebc, streams live) plus a sibling fallback-host (q:ebf) that renders the fallback inline in document order and emits a qErr(id) script to swap (hide content-host, reveal fallback-host). renderErrorBoundaryFallback now uniformly marks store.error and returns null for every caught throw; the fallback-host delivers. Relaxes nextOutOfOrderId to allocate a per-container id for in-order boundaries without arming the OOOS executor. Fixes the in-order contract: partial content is swapped out (hidden), not kept visible. Adds A1/A2/A5 in-order specs.
---
.../src/core/control-flow/suspense-utils.ts | 7 +-
.../qwik/src/core/control-flow/suspense.tsx | 24 +++++
.../src/core/shared/error/error-boundary.ts | 42 +++++++-
packages/qwik/src/core/ssr/ssr-render-jsx.ts | 11 +--
.../src/core/tests/error-boundary.spec.tsx | 97 +++++++++++++++++--
packages/qwik/src/server/ssr-container.ts | 7 +-
6 files changed, 166 insertions(+), 22 deletions(-)
diff --git a/packages/qwik/src/core/control-flow/suspense-utils.ts b/packages/qwik/src/core/control-flow/suspense-utils.ts
index 9bf439273cb..a92d9b2ec2a 100644
--- a/packages/qwik/src/core/control-flow/suspense-utils.ts
+++ b/packages/qwik/src/core/control-flow/suspense-utils.ts
@@ -117,8 +117,8 @@ export const nextOutOfOrderSuspenseId = (): number => {
};
/**
- * Reserve an out-of-order id for a streaming `` without arming the executor — it
- * only needs the executor if it throws.
+ * Reserve a per-container id for an ``'s two-host swap without arming the OOOS
+ * executor (`markUsed=false`). Works for both in-order (`qErr`) and out-of-order boundaries.
*
* @internal
*/
@@ -127,9 +127,6 @@ export const nextErrorBoundaryId = (): number => {
return 0;
}
const container = tryGetInvokeContext()?.$container$ as SSRContainer | undefined;
- if (container?.outOfOrderStreaming !== true) {
- return 0;
- }
return container?.nextOutOfOrderId?.(false) ?? 0;
};
diff --git a/packages/qwik/src/core/control-flow/suspense.tsx b/packages/qwik/src/core/control-flow/suspense.tsx
index 8114604ef3c..361ba5bcf2a 100644
--- a/packages/qwik/src/core/control-flow/suspense.tsx
+++ b/packages/qwik/src/core/control-flow/suspense.tsx
@@ -416,6 +416,30 @@ export const SSRErrorFallback = __EXPERIMENTAL__.errorBoundary
)
: null!;
+/**
+ * SSR host for an IN-ORDER ``'s fallback. It renders after the content-host in
+ * document order, so by the time it runs an in-order throw has already set `store.error`. It
+ * renders the fallback inline (no segment) and emits `qErr(id)` to swap — hide the content-host,
+ * reveal this fallback-host — installed independently of OOOS.
+ */
+export const SSRErrorFallbackInline = __EXPERIMENTAL__.errorBoundary
+ ? /*#__PURE__*/ createInternalServerComponent<{ boundaryId: number; store: ErrorBoundaryStore }>(
+ (ssr, jsx, _options, enqueue) => {
+ const boundaryId = jsx.varProps.boundaryId as number;
+ const store = jsx.varProps.store as ErrorBoundaryStore;
+ // `!== undefined` (not truthiness) so a falsy thrown value still swaps.
+ if (store.error !== undefined && store.$fallback$) {
+ // LIFO: enqueue `qErr` first so it runs AFTER the fallback content has rendered.
+ enqueue(() => {
+ ssr.emitErrorSwapExecutorIfNeeded();
+ ssr.emitInlineScript(`qErr(${boundaryId})`);
+ });
+ enqueue(store.$fallback$(store.error) as JSXOutput);
+ }
+ }
+ )
+ : null!;
+
/** Inject a streamed `` fallback segment into its fallback host via `qO`. */
async function emitErrorBoundaryFallback(
ssr: SSRContainer,
diff --git a/packages/qwik/src/core/shared/error/error-boundary.ts b/packages/qwik/src/core/shared/error/error-boundary.ts
index 95e8c0a5beb..49167b12dcd 100644
--- a/packages/qwik/src/core/shared/error/error-boundary.ts
+++ b/packages/qwik/src/core/shared/error/error-boundary.ts
@@ -1,6 +1,6 @@
import { isBrowser } from '@qwik.dev/core/build';
import { isOutOfOrderStreaming, nextErrorBoundaryId } from '../../control-flow/suspense-utils';
-import { SSRErrorFallback } from '../../control-flow/suspense';
+import { SSRErrorFallback, SSRErrorFallbackInline } from '../../control-flow/suspense';
import { useErrorBoundaryStore } from '../../use/use-error-boundary-store';
import { componentQrl, type Component } from '../component.public';
import { _jsxSorted } from '../jsx/jsx-internal';
@@ -12,7 +12,7 @@ import { _fnSignal } from '../qrl/inlined-fn';
import { inlinedQrl } from '../qrl/qrl';
import type { QRL } from '../qrl/qrl.public';
import { noSerialize } from '../serdes/verify';
-import { QSuspenseResultParent } from '../utils/markers';
+import { QErrorContentHost, QErrorFallbackHost, QSuspenseResultParent } from '../utils/markers';
import { qTest } from '../utils/qdev';
import type { ErrorBoundaryStore } from './error-handling';
@@ -72,6 +72,44 @@ export const errorBoundaryCmp = (props: ErrorBoundaryProps): JSXOutput => {
] as unknown as JSXOutput;
}
+ // In-order SSR: the same never-buffer two-host swap, delivered in document order. The content-host
+ // streams live; on a throw `renderErrorBoundaryFallback` just marks `store.error`, and the sibling
+ // fallback-host (rendered right after) emits the fallback inline + `qErr(id)` to swap it in.
+ if (__EXPERIMENTAL__.errorBoundary && isServerEnv) {
+ const boundaryId = nextErrorBoundaryId();
+ return [
+ /*#__PURE__*/ _jsxSorted(
+ 'div',
+ {
+ [QErrorContentHost]: String(boundaryId),
+ style: /*#__PURE__*/ _fnSignal(_ebContentStyle, [store], _ebContentStyle_str),
+ },
+ null,
+ /*#__PURE__*/ _jsxSorted(Slot, null, null, null, 0, null),
+ 1,
+ null
+ ),
+ /*#__PURE__*/ _jsxSorted(
+ 'div',
+ {
+ [QErrorFallbackHost]: String(boundaryId),
+ style: /*#__PURE__*/ _fnSignal(_ebFallbackStyle, [store], _ebFallbackStyle_str),
+ },
+ null,
+ /*#__PURE__*/ _jsxSorted(
+ SSRErrorFallbackInline,
+ { boundaryId, store },
+ null,
+ null,
+ 1,
+ null
+ ),
+ 1,
+ null
+ ),
+ ] as unknown as JSXOutput;
+ }
+
if (store.error !== undefined) {
return /*#__PURE__*/ _jsxSorted(Fragment, null, null, props.fallback$(store.error), 0, null);
}
diff --git a/packages/qwik/src/core/ssr/ssr-render-jsx.ts b/packages/qwik/src/core/ssr/ssr-render-jsx.ts
index 6e9e4881998..9bf7aa5065c 100644
--- a/packages/qwik/src/core/ssr/ssr-render-jsx.ts
+++ b/packages/qwik/src/core/ssr/ssr-render-jsx.ts
@@ -155,14 +155,11 @@ function renderErrorBoundaryFallback(
if (__EXPERIMENTAL__.errorBoundary && isOutOfOrderSegmentContainer(ssr)) {
throw err;
}
- // A live streaming boundary just marks the error; the fallback host streams it, so streaming is
- // never blocked.
- if (__EXPERIMENTAL__.errorBoundary && ssr.outOfOrderStreaming) {
- errorStore.error = toSerializableBoundaryError(err);
- return null;
- }
+ // The boundary never blocks streaming: just mark the error and render nothing in place. Its sibling
+ // fallback-host delivers the fallback (in-order inline + `qErr`, or out-of-order via segment + `qO`)
+ // and swaps the content-host out.
errorStore.error = toSerializableBoundaryError(err);
- return errorStore.$fallback$(err) as ValueOrPromise;
+ return null;
}
/**
diff --git a/packages/qwik/src/core/tests/error-boundary.spec.tsx b/packages/qwik/src/core/tests/error-boundary.spec.tsx
index 190a67c4304..52da6dac713 100644
--- a/packages/qwik/src/core/tests/error-boundary.spec.tsx
+++ b/packages/qwik/src/core/tests/error-boundary.spec.tsx
@@ -940,24 +940,109 @@ describe('ErrorBoundary projection', () => {
expect(el.querySelector('#fb')?.textContent).toContain('caught: boom');
});
- it('renders the fallback in place; siblings already streamed before the throw remain', async () => {
- // In-order SSR renders the fallback in place, so a sibling streamed before the throw stays —
- // unlike CSR and the out-of-order path, which replace the whole subtree.
+ it('swaps the partial content out (hidden) and shows the fallback instead', async () => {
+ // EB contract is "fallback INSTEAD OF content": even in-order SSR (no out-of-order streaming)
+ // swaps out everything the boundary streamed before the throw — the content-host is hidden and
+ // the sibling fallback-host is revealed, rather than leaving the partial content visible.
const { container } = await ssrRenderToDom(
projected ok
,
- { debug }
+ { debug, streaming: { outOfOrder: false } }
);
const el = container.element;
expect(el.querySelector('#fb')?.textContent).toContain('caught: boom');
- // The sibling streamed before the throw is retained (in-place fallback).
- expect(el.querySelector('#sibling')).toBeTruthy();
+ // The sibling is still in the DOM but inside the hidden content-host (swapped out, not visible).
+ const sibling = el.querySelector('#sibling');
+ expect(sibling).toBeTruthy();
+ const contentHost = el.querySelector('[q\\:ebc]') as HTMLElement;
+ expect(contentHost.style.display).toBe('none');
+ expect(contentHost.contains(sibling)).toBe(true);
});
});
});
+// The in-order swap fires when out-of-order streaming is OFF (e.g. a page without Suspense). The
+// boundary still never buffers: content streams live into a `content-host`, and on a throw the
+// sibling `fallback-host` renders the fallback in document order + a `qErr(id)` inline script swaps.
+describe('ErrorBoundary in-order swap (no out-of-order streaming)', () => {
+ const inOrder = { debug, streaming: { outOfOrder: false } } as const;
+
+ it('A1 happy path: content streams; no fallback content and no qErr swap script', async () => {
+ const { container } = await ssrRenderToDom(
+
+ (
+
, options);
- ssr.closeElement();
-});
-
-describe('SSR in-order unwind (closeOpenElementsTo)', () => {
- it('closes partial content into the content-host and renders the fallback as a sibling', async () => {
- const { container } = await ssrRenderToDom(
-
-
- ,
- { debug: false }
- );
- const el = container.element;
- const contentHost = el.querySelector('[q\\:ebc="1"]')!;
- const fallbackHost = el.querySelector('[q\\:ebf="1"]')!;
-
- // both hosts exist and the partial sits INSIDE the (closed) content-host
- expect(contentHost).toBeTruthy();
- expect(fallbackHost).toBeTruthy();
- expect(contentHost.querySelector('#partial')?.textContent).toBe('partial');
- expect(fallbackHost.querySelector('#fb')?.textContent).toBe('fallback');
-
- // the fallback-host is a SIBLING of the content-host, not nested inside it (the swap invariant)
- expect(contentHost.contains(fallbackHost)).toBe(false);
- expect(fallbackHost.closest('[q\\:ebc="1"]')).toBeNull();
- expect(contentHost.parentElement).toBe(fallbackHost.parentElement);
-
- // prior sibling outside the boundary survived
- expect(el.querySelector('#before')?.textContent).toBe('before');
- });
-});
diff --git a/packages/qwik/src/server/ssr-container.ts b/packages/qwik/src/server/ssr-container.ts
index e657a85752d..78b577df9fd 100644
--- a/packages/qwik/src/server/ssr-container.ts
+++ b/packages/qwik/src/server/ssr-container.ts
@@ -805,30 +805,6 @@ class SSRContainer extends _SharedContainer implements ISSRContainer {
}
}
- /** The currently-open element frame, captured when an ErrorBoundary's `content-host` opens. */
- getCurrentElementFrame(): ElementFrame | null {
- return this.currentElementFrame;
- }
-
- /**
- * Close every still-open element frame back up to and INCLUDING `hostFrame`, appending
- * well-formed closing tags. This is the in-order unwind for an ErrorBoundary whose content throws
- * mid-render: unlike the deleted buffered rollback it NEVER erases streamed bytes — it closes the
- * partial content into the hideable `content-host` so `qErr` can swap it out. Only element frames
- * are closed (fragments/components/projections emit no HTML), so the byte stream is well-formed
- * once the element tags are balanced; the dead subtree is then marked non-updatable so it never
- * resumes.
- */
- closeOpenElementsTo(hostFrame: ElementFrame): void {
- while (this.currentElementFrame) {
- const frame = this.currentElementFrame;
- this._closeElement();
- if (frame === hostFrame) {
- break;
- }
- }
- }
-
/** Writes opening data to vNodeData for fragment boundaries */
openFragment(attrs: Props) {
this.lastNode = null;
From 658b8a5f33194fbc05588538239cb76f0b067cf9 Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Sun, 21 Jun 2026 10:08:52 +0200
Subject: [PATCH 053/331] Revert "refactor(qwik): unify ErrorBoundary SSR onto
one fallback model"
This reverts commit 65f55605510b0c94140daace05dbea43523a70ea.
---
.../src/core/control-flow/suspense-utils.ts | 17 ----
.../qwik/src/core/control-flow/suspense.tsx | 80 ++++++++++---------
.../src/core/shared/error/error-boundary.ts | 60 ++++++++++----
.../src/core/tests/error-boundary.spec.tsx | 9 +--
4 files changed, 90 insertions(+), 76 deletions(-)
diff --git a/packages/qwik/src/core/control-flow/suspense-utils.ts b/packages/qwik/src/core/control-flow/suspense-utils.ts
index 0d5af514568..a92d9b2ec2a 100644
--- a/packages/qwik/src/core/control-flow/suspense-utils.ts
+++ b/packages/qwik/src/core/control-flow/suspense-utils.ts
@@ -104,23 +104,6 @@ export const isOutOfOrderStreaming = (): boolean => {
return container?.outOfOrderStreaming === true && !isOutOfOrderSegmentContainer(container);
};
-/**
- * Like {@link isOutOfOrderStreaming} but ALSO true inside a `` segment. An
- * `` uses it to decide whether a deferred throw is even possible (out-of-order
- * delivery is available), so it can set up the late-fallback path.
- *
- * @internal
- */
-export const isOutOfOrderActive = (): boolean => {
- if (!__EXPERIMENTAL__.suspense) {
- return false;
- }
- const container = tryGetInvokeContext()?.$container$ as
- | ({ readonly outOfOrderStreaming?: boolean } & Container)
- | undefined;
- return container?.outOfOrderStreaming === true;
-};
-
/** @internal */
export const nextOutOfOrderSuspenseId = (): number => {
if (!__EXPERIMENTAL__.suspense) {
diff --git a/packages/qwik/src/core/control-flow/suspense.tsx b/packages/qwik/src/core/control-flow/suspense.tsx
index f8ccc29a970..4f868aa62f0 100644
--- a/packages/qwik/src/core/control-flow/suspense.tsx
+++ b/packages/qwik/src/core/control-flow/suspense.tsx
@@ -33,7 +33,6 @@ import { useSignal } from '../use/use-signal';
import { useTaskQrl, type TaskCtx } from '../use/use-task';
import { revealCanReveal, useRevealBoundary, type RevealRegistration } from './reveal';
import {
- isOutOfOrderActive,
isOutOfOrderStreaming,
nextOutOfOrderSuspenseId,
SUSPENSE_QRL_SYMBOL,
@@ -392,48 +391,15 @@ async function emitRenderedOutOfOrderSegment(
}
/**
- * The single SSR fallback host for an ``. One rule for the swap:
- *
- * - The error is known IN TIME to render the fallback in place (sync throw / in-order / buffered in a
- * `` segment) → render it in document order and swap with `qErr(id)` (hide the `q:ebc`
- * content-host, reveal this `q:ebf` fallback-host). Inside a segment the inline script is inert
- * in the ``, so register it for the segment to emit at the root after its reveal.
- * - The error can only arrive LATE (a deferred child `` throws after this host already
- * streamed) → there's nothing to render now; set up `$emitFallback$` so the fallback streams
- * out-of-order and `qO` delivers + reveals it (this host is also the `q:rp` delivery target).
- *
- * So `qErr` swaps an in-place fallback; `qO` delivers (and thereby reveals) a late one.
+ * SSR host for a streaming ``'s fallback. On a throw, `store.$emitFallback$` streams
+ * `fallback$(error)` as an out-of-order segment and the shared `qO` executor swaps it in here while
+ * hiding the content host.
*/
export const SSRErrorFallback = __EXPERIMENTAL__.errorBoundary
? /*#__PURE__*/ createInternalServerComponent<{ boundaryId: number; store: ErrorBoundaryStore }>(
- (ssr, jsx, options, enqueue) => {
+ (ssr, jsx, options) => {
const boundaryId = jsx.varProps.boundaryId as number;
const store = jsx.varProps.store as ErrorBoundaryStore;
-
- // `!== undefined` (not truthiness) so a falsy thrown value still swaps.
- if (store.error !== undefined && store.$fallback$) {
- // Detach `$fallback$` while it renders: a throw from the fallback ITSELF then propagates
- // (aborts) instead of being re-caught by this same boundary.
- const fallback = store.$fallback$;
- store.$fallback$ = undefined;
- if (isOutOfOrderSegmentContainer(ssr)) {
- ssr.$registerErrorSwap$(boundaryId);
- } else {
- // LIFO: enqueue `qErr` first so it runs AFTER the fallback content has rendered.
- enqueue(() => {
- ssr.emitErrorSwapExecutorIfNeeded();
- ssr.emitInlineScript(`qErr(${boundaryId})`);
- });
- }
- enqueue(fallback(store.error) as JSXOutput);
- return;
- }
-
- // No error yet. A ``-deferred throw is only possible when out-of-order delivery is
- // available; otherwise this host just stays empty.
- if (!isOutOfOrderActive()) {
- return;
- }
const segmentId = `${boundaryId}`;
const streamFallback = async (error: unknown): Promise => {
// `$emitFallback$` is shared by every deferred child, so a detached `$fallback$` means
@@ -442,6 +408,7 @@ export const SSRErrorFallback = __EXPERIMENTAL__.errorBoundary
if (!fallback) {
return;
}
+ // Project to a serializable error so a non-serializable throw can't abort page serialization.
store.error = toSerializableBoundaryError(error);
// Stay detached so a throw from the fallback itself propagates instead of re-rendering it.
store.$fallback$ = undefined;
@@ -450,6 +417,43 @@ export const SSRErrorFallback = __EXPERIMENTAL__.errorBoundary
};
store.$emitFallback$ = noSerialize(streamFallback);
writeOutOfOrderPlaceholder(ssr, boundaryId);
+ // A sync content-render throw already set `store.error`; stream the swap inline. `!== undefined`
+ // (not truthiness) so a falsy thrown value still streams.
+ if (store.error !== undefined && store.$fallback$) {
+ return streamFallback(store.error);
+ }
+ }
+ )
+ : null!;
+
+/**
+ * SSR host for an IN-ORDER ``'s fallback. It renders after the content-host in
+ * document order, so by the time it runs an in-order throw has already set `store.error`. It
+ * renders the fallback inline (no segment) and emits `qErr(id)` to swap — hide the content-host,
+ * reveal this fallback-host — installed independently of OOOS.
+ */
+export const SSRErrorFallbackInline = __EXPERIMENTAL__.errorBoundary
+ ? /*#__PURE__*/ createInternalServerComponent<{ boundaryId: number; store: ErrorBoundaryStore }>(
+ (ssr, jsx, _options, enqueue) => {
+ const boundaryId = jsx.varProps.boundaryId as number;
+ const store = jsx.varProps.store as ErrorBoundaryStore;
+ // `!== undefined` (not truthiness) so a falsy thrown value still swaps.
+ if (store.error !== undefined && store.$fallback$) {
+ if (isOutOfOrderSegmentContainer(ssr)) {
+ // Inside a `` segment an inline `qErr` is inert in the ``: defer it so
+ // the segment emits `qErr(id)` at the root after its `qO` reveal injects these hosts.
+ ssr.$registerErrorSwap$(boundaryId);
+ enqueue(store.$fallback$(store.error) as JSXOutput);
+ } else {
+ // Standalone in-order: emit the swap inline (LIFO — enqueue `qErr` first so it runs AFTER
+ // the fallback content has rendered).
+ enqueue(() => {
+ ssr.emitErrorSwapExecutorIfNeeded();
+ ssr.emitInlineScript(`qErr(${boundaryId})`);
+ });
+ enqueue(store.$fallback$(store.error) as JSXOutput);
+ }
+ }
}
)
: null!;
diff --git a/packages/qwik/src/core/shared/error/error-boundary.ts b/packages/qwik/src/core/shared/error/error-boundary.ts
index df44b01bdb2..49167b12dcd 100644
--- a/packages/qwik/src/core/shared/error/error-boundary.ts
+++ b/packages/qwik/src/core/shared/error/error-boundary.ts
@@ -1,6 +1,6 @@
import { isBrowser } from '@qwik.dev/core/build';
-import { isOutOfOrderActive, nextErrorBoundaryId } from '../../control-flow/suspense-utils';
-import { SSRErrorFallback } from '../../control-flow/suspense';
+import { isOutOfOrderStreaming, nextErrorBoundaryId } from '../../control-flow/suspense-utils';
+import { SSRErrorFallback, SSRErrorFallbackInline } from '../../control-flow/suspense';
import { useErrorBoundaryStore } from '../../use/use-error-boundary-store';
import { componentQrl, type Component } from '../component.public';
import { _jsxSorted } from '../jsx/jsx-internal';
@@ -45,20 +45,38 @@ export const errorBoundaryCmp = (props: ErrorBoundaryProps): JSXOutput => {
store.$fallback$ = noSerialize((error: any) => fallbackQrl(error));
const isServerEnv = qTest ? isServerPlatform() : !isBrowser;
- // SSR: ONE never-buffer two-host swap for every mode. Content streams live into the content-host
- // (`q:ebc`); the sibling fallback-host (`q:ebf`) delivers the fallback. The swap is `qErr(id)` when
- // the fallback is in place (sync / in-order / buffered), or `qO` when it's delivered late (a
- // deferred `` throw) — so the fallback-host is also a `q:rp` delivery target when
- // out-of-order streaming is available.
+ if (__EXPERIMENTAL__.errorBoundary && isServerEnv && isOutOfOrderStreaming()) {
+ const boundaryId = nextErrorBoundaryId();
+ // Two display-toggled hosts. This branch deliberately does NOT read `store.error`: subscribing
+ // would re-render an already-streamed host on a late deferred throw.
+ return [
+ /*#__PURE__*/ _jsxSorted(
+ 'div',
+ { style: /*#__PURE__*/ _fnSignal(_ebContentStyle, [store], _ebContentStyle_str) },
+ null,
+ /*#__PURE__*/ _jsxSorted(Slot, null, null, null, 0, null),
+ 1,
+ null
+ ),
+ /*#__PURE__*/ _jsxSorted(
+ 'div',
+ {
+ [QSuspenseResultParent]: String(boundaryId),
+ style: /*#__PURE__*/ _fnSignal(_ebFallbackStyle, [store], _ebFallbackStyle_str),
+ },
+ null,
+ /*#__PURE__*/ _jsxSorted(SSRErrorFallback, { boundaryId, store }, null, null, 1, null),
+ 1,
+ null
+ ),
+ ] as unknown as JSXOutput;
+ }
+
+ // In-order SSR: the same never-buffer two-host swap, delivered in document order. The content-host
+ // streams live; on a throw `renderErrorBoundaryFallback` just marks `store.error`, and the sibling
+ // fallback-host (rendered right after) emits the fallback inline + `qErr(id)` to swap it in.
if (__EXPERIMENTAL__.errorBoundary && isServerEnv) {
const boundaryId = nextErrorBoundaryId();
- const fallbackHostProps: Record = {
- [QErrorFallbackHost]: String(boundaryId),
- style: /*#__PURE__*/ _fnSignal(_ebFallbackStyle, [store], _ebFallbackStyle_str),
- };
- if (isOutOfOrderActive()) {
- fallbackHostProps[QSuspenseResultParent] = String(boundaryId);
- }
return [
/*#__PURE__*/ _jsxSorted(
'div',
@@ -73,9 +91,19 @@ export const errorBoundaryCmp = (props: ErrorBoundaryProps): JSXOutput => {
),
/*#__PURE__*/ _jsxSorted(
'div',
- fallbackHostProps,
+ {
+ [QErrorFallbackHost]: String(boundaryId),
+ style: /*#__PURE__*/ _fnSignal(_ebFallbackStyle, [store], _ebFallbackStyle_str),
+ },
null,
- /*#__PURE__*/ _jsxSorted(SSRErrorFallback, { boundaryId, store }, null, null, 1, null),
+ /*#__PURE__*/ _jsxSorted(
+ SSRErrorFallbackInline,
+ { boundaryId, store },
+ null,
+ null,
+ 1,
+ null
+ ),
1,
null
),
diff --git a/packages/qwik/src/core/tests/error-boundary.spec.tsx b/packages/qwik/src/core/tests/error-boundary.spec.tsx
index b7f0e748e3f..49f8ae1176a 100644
--- a/packages/qwik/src/core/tests/error-boundary.spec.tsx
+++ b/packages/qwik/src/core/tests/error-boundary.spec.tsx
@@ -245,15 +245,14 @@ describe('ErrorBoundary streaming swap (experimental)', () => {
);
// The boundary never blocks: its content streams as usual.
expect(html).toContain('id="before"');
- // The fallback is known in place (sync throw), so the swap is `qErr`, landing right after the
- // boundary, before trailing content (not at end-of-stream) — no out-of-order segment needed.
- const swapPos = html.search(/qErr\(\d/);
+ // The swap script lands right after the boundary, before trailing content (not at end-of-stream).
+ const swapPos = html.search(/qO\(\d/);
expect(swapPos).toBeGreaterThan(html.indexOf('id="before"'));
expect(swapPos).toBeLessThan(html.indexOf('id="eb-tail"'));
// After the swap the content host is hidden and the fallback host revealed.
expect(document.querySelector('#fb')?.textContent).toContain('caught: boom');
- expect(displayOf(document.querySelector('#fb')?.closest('[q\\:ebf]'))).toBe('contents');
- expect(displayOf(document.querySelector('#before')?.closest('[q\\:ebc]'))).toBe('none');
+ expect(displayOf(document.querySelector('#fb')?.closest('[q\\:rp]'))).toBe('contents');
+ expect(displayOf(document.querySelector('#before')?.closest('div[style]'))).toBe('none');
});
it('renders the content unchanged when nothing throws (ships no swap JS)', async () => {
From 9f73845b2a4cee81718bb99227af11131b8da297 Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Sun, 21 Jun 2026 11:11:28 +0200
Subject: [PATCH 054/331] test(qwik): cover in-order ErrorBoundary gaps
A3/A4/A6
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.
---
.../src/core/tests/error-boundary.spec.tsx | 85 +++++++++++++++++++
1 file changed, 85 insertions(+)
diff --git a/packages/qwik/src/core/tests/error-boundary.spec.tsx b/packages/qwik/src/core/tests/error-boundary.spec.tsx
index 49f8ae1176a..3ce8d0516eb 100644
--- a/packages/qwik/src/core/tests/error-boundary.spec.tsx
+++ b/packages/qwik/src/core/tests/error-boundary.spec.tsx
@@ -1028,6 +1028,91 @@ describe('ErrorBoundary in-order swap (no out-of-order streaming)', () => {
expect(el.outerHTML).toContain('qErr(');
});
+ it('A3 siblings OUTSIDE the boundary that streamed before the throw remain visible', async () => {
+ const { container } = await ssrRenderToDom(
+
+
outside-before
+ (
+
caught: {e.message}
+ ))}
+ >
+
+
+
outside-after
+ ,
+ inOrder
+ );
+ const el = container.element;
+ expect(el.querySelector('#fb')?.textContent).toContain('caught: boom');
+ // Siblings outside the boundary are untouched and NOT inside the hidden content-host.
+ const outsideBefore = el.querySelector('#outside-before');
+ const outsideAfter = el.querySelector('#outside-after');
+ expect(outsideBefore?.textContent).toBe('outside-before');
+ expect(outsideAfter?.textContent).toBe('outside-after');
+ const contentHost = el.querySelector('[q\\:ebc]') as HTMLElement;
+ expect(contentHost.contains(outsideBefore)).toBe(false);
+ expect(contentHost.contains(outsideAfter)).toBe(false);
+ });
+
+ it('A4 awaited-async throw: fallback delivered in document order (sibling host)', async () => {
+ // "In-order" is timing, not position: an awaited-async throw still marks `store.error` before the
+ // sibling fallback-host renders, so the fallback lands in the sibling host (not at the throw site).
+ const { container } = await ssrRenderToDom(
+
+ (
+
caught: {String(e?.message ?? e)}
+ ))}
+ >
+
before
+
+
+ ,
+ inOrder
+ );
+ const el = container.element;
+ const contentHost = el.querySelector('[q\\:ebc]') as HTMLElement;
+ const fallbackHost = el.querySelector('[q\\:ebf]') as HTMLElement;
+ const fb = el.querySelector('#fb');
+ expect(fb?.textContent).toContain('caught: async boom');
+ expect(contentHost.style.display).toBe('none');
+ expect(fallbackHost.style.display).toBe('contents');
+ // The fallback is in the sibling fallback-host, never nested at the throw site inside the content.
+ // (qwik-dom's element.querySelector is NOT subtree-scoped, so assert placement via `contains`.)
+ expect(fallbackHost.contains(fb)).toBe(true);
+ expect(contentHost.contains(fb)).toBe(false);
+ expect(el.outerHTML).toContain('qErr(');
+ });
+
+ it('A6 a throw deep inside nested tags yields well-formed HTML (hideable content-host)', async () => {
+ const { container } = await ssrRenderToDom(
+
+ (
+
caught: {e.message}
+ ))}
+ >
+
+
+
+
+
+
+
+
+ ,
+ inOrder
+ );
+ const el = container.element;
+ const contentHost = el.querySelector('[q\\:ebc]') as HTMLElement;
+ expect(el.querySelector('#fb')?.textContent).toContain('caught: boom');
+ expect(contentHost.style.display).toBe('none');
+ // The still-open tags were closed well-formed: the partial nesting stays intact (lvl3 inside lvl2
+ // inside lvl1) under the hidden content-host, rather than flattened or broken.
+ expect(contentHost.querySelector('#lvl1 #lvl2 #lvl3')).toBeTruthy();
+ });
+
it('A5 the qErr executor installs independently of OOOS (no qO on the page)', async () => {
const chunks: string[] = [];
await ssrRenderToDom(
From 9e161969c42139531ec8ebe146dca3112e604c57 Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Sun, 21 Jun 2026 11:12:58 +0200
Subject: [PATCH 055/331] test(qwik): cover ErrorBoundary OOOS routing B4/B6
through Suspense
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
.../src/core/tests/error-boundary.spec.tsx | 62 +++++++++++++++++++
1 file changed, 62 insertions(+)
diff --git a/packages/qwik/src/core/tests/error-boundary.spec.tsx b/packages/qwik/src/core/tests/error-boundary.spec.tsx
index 3ce8d0516eb..c5b2dd4c9d0 100644
--- a/packages/qwik/src/core/tests/error-boundary.spec.tsx
+++ b/packages/qwik/src/core/tests/error-boundary.spec.tsx
@@ -704,6 +704,68 @@ describe('ErrorBoundary combinations', () => {
});
});
+// Routing through Suspense: the CLOSEST enclosing boundary catches, and any boundary that encloses the
+// catching one stays untouched. (Settled routing table, §5 of the design.)
+describe('ErrorBoundary routing through Suspense (experimental)', () => {
+ it('B4 EB-outer › Suspense › EB-inner › throw → EB-inner catches, EB-outer untouched', async () => {
+ const { document } = await streamAndResume(
+
+ (
+
+ b}>
+
+
+
+
+
+
+ );
+ // The middle boundary (closest to the throw) catches; the outer boundary stays untouched.
+ expect(document.querySelector('#fb-mid')?.textContent).toContain('caught: boom');
+ expect(document.querySelector('#fb-outer')).toBeFalsy();
+ expect(document.querySelector('#outer-ok')?.textContent).toBe('outer-ok');
+ });
+});
+
describe('ErrorBoundary concurrent fallback teardown (experimental)', () => {
it('two sibling that both reject tear the boundary down exactly once', async () => {
// Both deferred children reject into the shared `$emitFallback$`; the second must no-op, not crash
From 4176d09270c626152e9469eb4dfb50ee04ce2f2b Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Sun, 21 Jun 2026 11:17:01 +0200
Subject: [PATCH 056/331] =?UTF-8?q?test(qwik):=20cover=20ErrorBoundary=20S?=
=?UTF-8?q?SR=E2=86=92CSR=20cross-phase=20D2=20+=20in-order=20collapse?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
.../src/core/tests/error-boundary.spec.tsx | 80 +++++++++++++++++++
1 file changed, 80 insertions(+)
diff --git a/packages/qwik/src/core/tests/error-boundary.spec.tsx b/packages/qwik/src/core/tests/error-boundary.spec.tsx
index c5b2dd4c9d0..eac3ac19129 100644
--- a/packages/qwik/src/core/tests/error-boundary.spec.tsx
+++ b/packages/qwik/src/core/tests/error-boundary.spec.tsx
@@ -798,6 +798,86 @@ describe('ErrorBoundary concurrent fallback teardown (experimental)', () => {
});
});
+// SSR error in one phase meeting a client re-render in the next. `store.error` is the bridge; the
+// SSR two-host structure must reconcile cleanly when the boundary re-renders on the client.
+describe('ErrorBoundary SSR→CSR cross-phase (experimental)', () => {
+ it('D2 SSR inner error, then a client throw to the OUTER boundary replaces the whole subtree', async () => {
+ const { container } = await ssrRenderToDom(
+
+ (
+
outer: {e.message}
+ ))}
+ >
+
+ (
+
inner: {e.message}
+ ))}
+ >
+
+
+
+ ,
+ { streaming: { inOrder: { strategy: 'disabled' }, outOfOrder: true }, debug }
+ );
+ const el = container.element;
+ // SSR: the inner boundary caught; its fallback shows, the outer subtree is intact.
+ expect(el.querySelector('#fb-inner')?.textContent).toContain('inner: boom');
+ expect(el.querySelector('#fb-outer')).toBeFalsy();
+ expect(el.querySelector('#outer-btn')).toBeTruthy();
+
+ // Client: a throw attributed to an element in the OUTER subtree (outside the inner boundary) →
+ // routes to the outer boundary (distinct store), which re-renders to its own fallback.
+ const target = el.querySelector('#outer-btn')!;
+ dispatchQError(target, { error: new Error('outer boom'), element: target });
+ await waitForDrain(container);
+
+ // The outer fallback replaced the whole subtree, including the inner boundary's fallback.
+ expect(el.querySelector('#fb-outer')?.textContent).toContain('outer: outer boom');
+ expect(el.querySelector('#fb-inner')).toBeFalsy();
+ expect(el.querySelector('#outer-btn')).toBeFalsy();
+ });
+
+ it('D3(in-order) an in-order two-host collapses cleanly when a client-first error re-renders the boundary (no Missing child)', async () => {
+ // The in-order analog of the OOOS post-resume collapse test above: the SSR `q:ebc`/`q:ebf`
+ // two-host must reconcile down to the single client fallback Fragment without a "Missing
+ // child"/key mismatch. (Strict D3 — an *already SSR-errored* boundary collapsing on a later
+ // BENIGN re-render — isn't reachable in Phase 1: a pre-errored boundary has no re-render trigger
+ // and a 2nd error escalates past it; that benign-collapse path arrives with `reset()` in Phase 2.)
+ const { container } = await ssrRenderToDom(
+
+ (
+
caught: {String(e?.message ?? e)}
+ ))}
+ >
+
+
content ok
+
+ ,
+ { streaming: { outOfOrder: false }, debug }
+ );
+ const el = container.element;
+ // SSR in-order happy path: content visible in the content-host, no fallback yet.
+ expect(el.querySelector('#content')?.textContent).toBe('content ok');
+ expect(el.querySelector('#fb')).toBeFalsy();
+ expect((el.querySelector('[q\\:ebc]') as HTMLElement).style.display).toBe('contents');
+
+ // A client-time error re-renders the boundary to its keyless fallback Fragment.
+ const target = el.querySelector('#content-btn')!;
+ dispatchQError(target, { error: new Error('client boom'), element: target });
+ await waitForDrain(container);
+
+ // The in-order two-host (q:ebc/q:ebf) collapsed to a single clean fallback — no leftover hosts.
+ expect(el.querySelectorAll('#fb').length).toBe(1);
+ expect(el.querySelector('#fb')?.textContent).toContain('caught: client boom');
+ expect(el.querySelector('#content')).toBeFalsy();
+ expect(el.querySelector('[q\\:ebc]')).toBeFalsy();
+ expect(el.querySelector('[q\\:ebf]')).toBeFalsy();
+ });
+});
+
describe('ErrorBoundary across multiple containers on one document', () => {
// Two resumed containers share one document; each `qerror` listener is guarded by
// `element.contains(source)`, so only the owning container reacts.
From f394d557fd3400cc364cda819b42e84a15438c17 Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Sun, 21 Jun 2026 11:22:23 +0200
Subject: [PATCH 057/331] test(qwik): retitle stale 'fallback in place' spec to
reflect the swap-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.
---
.../src/core/tests/error-boundary.spec.tsx | 18 +++++++++++++-----
1 file changed, 13 insertions(+), 5 deletions(-)
diff --git a/packages/qwik/src/core/tests/error-boundary.spec.tsx b/packages/qwik/src/core/tests/error-boundary.spec.tsx
index eac3ac19129..ce311bf47e3 100644
--- a/packages/qwik/src/core/tests/error-boundary.spec.tsx
+++ b/packages/qwik/src/core/tests/error-boundary.spec.tsx
@@ -132,18 +132,26 @@ describe('ErrorBoundary', () => {
expect(container.element.querySelector('#fb')).toBeFalsy();
});
- it('SSR: renders the fallback in place when a child throws during render', async () => {
+ it('SSR: shows the fallback and swaps the content out when a child throws during render', async () => {
+ // The redesign never leaves the errored content visible: it streams the fallback into the sibling
+ // host and hides the content-host (a swap, not "rendered in place").
const { container } = await ssrRenderToDom(
(
caught: {e.message}
))}
>
+
content
,
- { debug }
+ { debug, streaming: { outOfOrder: false } }
);
- expect(container.element.querySelector('#fb')?.textContent).toContain('caught: boom');
+ const el = container.element;
+ expect(el.querySelector('#fb')?.textContent).toContain('caught: boom');
+ // The partial content sits (closed, well-formed) inside the hidden content-host — swapped out.
+ const contentHost = el.querySelector('[q\\:ebc]') as HTMLElement;
+ expect(contentHost.style.display).toBe('none');
+ expect(contentHost.contains(el.querySelector('#content'))).toBe(true);
});
it('client: a render throw is caught by the NEAREST boundary', async () => {
@@ -1583,8 +1591,8 @@ describe('ErrorBoundary catches task throws', () => {
});
describe('in-order SSR (ssrRenderToDom)', () => {
- // Intuitive-correct expectation: an eager useTask$ throw during SSR is caught by the enclosing
- // boundary and the fallback rendered in place, mirroring the synchronous render-throw path.
+ // An eager useTask$ throw during SSR is caught by the enclosing boundary and swaps in the fallback
+ // (content-host hidden), mirroring the synchronous render-throw path.
it('a useTask$ throw is caught by the nearest ', async () => {
const ThrowingTask = component$(() => {
useTask$(() => {
From a7013ae7e814842afcacc3be1095ae0bc83b2722 Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Sun, 21 Jun 2026 12:09:34 +0200
Subject: [PATCH 058/331] feat(qwik): make a swapped-out ErrorBoundary's
content inert on the client (A7)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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 , 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.
---
.../error-boundary/error-boundary.tsx | 35 +++++++++-
.../tests/error-boundary-streaming.e2e.ts | 22 +++++++
.../src/core/shared/error/error-boundary.ts | 5 +-
.../src/core/shared/error/error-handling.ts | 6 ++
packages/qwik/src/core/ssr/ssr-render-jsx.ts | 64 ++++++++++++++++++-
5 files changed, 129 insertions(+), 3 deletions(-)
diff --git a/e2e/qwik-e2e/apps/e2e/src/components/error-boundary/error-boundary.tsx b/e2e/qwik-e2e/apps/e2e/src/components/error-boundary/error-boundary.tsx
index 0460bdfbae6..b45b23b8ef3 100644
--- a/e2e/qwik-e2e/apps/e2e/src/components/error-boundary/error-boundary.tsx
+++ b/e2e/qwik-e2e/apps/e2e/src/components/error-boundary/error-boundary.tsx
@@ -5,7 +5,9 @@ import {
Suspense,
useServerData,
useSignal,
+ useTask$,
type JSXOutput,
+ type Signal,
} from '@qwik.dev/core';
import { ManualOutOfOrderReleaseButton } from '../suspense/ooos';
@@ -69,6 +71,24 @@ const EbSyncThrower = component$(() => {
return ;
});
+// A7 inert probe: the swapped-out content holds a useTask$ that tracks a signal and records each
+// CLIENT run on `window`. The SSR throw (its child EbSyncThrower) swaps this content out; bumping the
+// tracked signal from OUTSIDE the boundary must NOT re-run this dead task on the client.
+const EbInertContent = component$<{ trigger: Signal }>((props) => {
+ useTask$(({ track }) => {
+ track(() => props.trigger.value);
+ if (!isServer) {
+ (window as any).__ebDeadTaskClientRuns = ((window as any).__ebDeadTaskClientRuns ?? 0) + 1;
+ }
+ });
+ return (
+
- {scenario === 'async' ? (
+ {scenario === 'inert' ? (
+ // A7: SSR error swaps out content that holds a signal-tracking task; bumping the signal from
+ // OUTSIDE the boundary must not re-run the dead task on the client.
+ <>
+ }>
+
+
+
+ {inertTrigger.value}
+ >
+ ) : scenario === 'async' ? (
<>
}>
sibling
diff --git a/e2e/qwik-e2e/tests/error-boundary-streaming.e2e.ts b/e2e/qwik-e2e/tests/error-boundary-streaming.e2e.ts
index 5d73875c871..cd3718e14a0 100644
--- a/e2e/qwik-e2e/tests/error-boundary-streaming.e2e.ts
+++ b/e2e/qwik-e2e/tests/error-boundary-streaming.e2e.ts
@@ -115,4 +115,26 @@ test.describe('ErrorBoundary streaming swap', () => {
await page.locator('#eb-fallback-button').click();
await expect(page.locator('#eb-fallback-count')).toHaveText('1');
});
+
+ test('A7 inert: a swapped-out content task does not re-run when an outside signal changes', async ({
+ page,
+ }) => {
+ assertNoBrowserErrors(page);
+ await page.goto('/e2e/error-boundary-streaming?scenario=inert', { waitUntil: 'commit' });
+
+ // SSR threw inside the content → fallback shown, content swapped out (hidden).
+ await expect(page.locator('#eb-fallback')).toBeVisible({ timeout: 10000 });
+ await expect(page.locator('#eb-content')).toBeHidden();
+
+ // Baseline after resume: the dead content's task has not run on the client.
+ expect(await page.evaluate(() => (window as any).__ebDeadTaskClientRuns ?? 0)).toBe(0);
+
+ // Bump the tracked signal from OUTSIDE the boundary. The visible outside binding reacts (proving
+ // the click + resume + reactivity are live)...
+ await page.locator('#eb-inert-trigger').click();
+ await expect(page.locator('#eb-inert-val')).toHaveText('1');
+
+ // ...but the swapped-out (dead) content's task must NOT re-run on the client.
+ expect(await page.evaluate(() => (window as any).__ebDeadTaskClientRuns ?? 0)).toBe(0);
+ });
});
diff --git a/packages/qwik/src/core/shared/error/error-boundary.ts b/packages/qwik/src/core/shared/error/error-boundary.ts
index 49167b12dcd..9077637e292 100644
--- a/packages/qwik/src/core/shared/error/error-boundary.ts
+++ b/packages/qwik/src/core/shared/error/error-boundary.ts
@@ -52,7 +52,10 @@ export const errorBoundaryCmp = (props: ErrorBoundaryProps): JSXOutput => {
return [
/*#__PURE__*/ _jsxSorted(
'div',
- { style: /*#__PURE__*/ _fnSignal(_ebContentStyle, [store], _ebContentStyle_str) },
+ {
+ [QErrorContentHost]: String(boundaryId),
+ style: /*#__PURE__*/ _fnSignal(_ebContentStyle, [store], _ebContentStyle_str),
+ },
null,
/*#__PURE__*/ _jsxSorted(Slot, null, null, null, 0, null),
1,
diff --git a/packages/qwik/src/core/shared/error/error-handling.ts b/packages/qwik/src/core/shared/error/error-handling.ts
index 301da4e9384..f869d377c11 100644
--- a/packages/qwik/src/core/shared/error/error-handling.ts
+++ b/packages/qwik/src/core/shared/error/error-handling.ts
@@ -1,5 +1,6 @@
import { canSerialize } from '../serdes/can-serialize';
import { createContextId } from '../../use/use-context';
+import type { ISsrNode } from '../../ssr/ssr-types';
/** @internal */
export interface ErrorBoundaryStore {
@@ -11,6 +12,11 @@ export interface ErrorBoundaryStore {
* host.
*/
$emitFallback$?: (error: unknown) => void | Promise;
+ /**
+ * Server-only; the boundary's `content-host` SSR node, captured when it renders so a throw can
+ * mark the swapped-out subtree inert (its tasks must not resume on the client).
+ */
+ $contentHostNode$?: ISsrNode;
}
export const ERROR_CONTEXT = /*#__PURE__*/ createContextId('qk-error');
diff --git a/packages/qwik/src/core/ssr/ssr-render-jsx.ts b/packages/qwik/src/core/ssr/ssr-render-jsx.ts
index bd52e1011e1..df5365c64a5 100644
--- a/packages/qwik/src/core/ssr/ssr-render-jsx.ts
+++ b/packages/qwik/src/core/ssr/ssr-render-jsx.ts
@@ -24,13 +24,17 @@ import { EMPTY_OBJ } from '../shared/utils/flyweight';
import { getFileLocationFromJsx } from '../shared/utils/jsx-filename';
import {
ELEMENT_KEY,
+ ELEMENT_SEQ,
QCursorBoundary,
QDefaultSlot,
+ QErrorContentHost,
QScopedStyle,
QSlot,
QSlotParent,
qwikInspectorAttr,
} from '../shared/utils/markers';
+import { clearAllEffects } from '../reactive-primitives/cleanup';
+import { isTask } from '../use/use-task';
import { isPromise, retryOnPromise } from '../shared/utils/promises';
import { qDev, qInspector } from '../shared/utils/qdev';
import { addComponentStylePrefix } from '../shared/utils/scoped-styles';
@@ -47,8 +51,9 @@ import {
ERROR_CONTEXT,
isRecoverable,
toSerializableBoundaryError,
+ type ErrorBoundaryStore,
} from '../shared/error/error-handling';
-import type { ISsrComponentFrame, SSRContainer, SSRRenderJSXOptions } from './ssr-types';
+import type { ISsrComponentFrame, ISsrNode, SSRContainer, SSRRenderJSXOptions } from './ssr-types';
import { resolveSlotName } from '../shared/utils/prop';
class MaybeAsyncSignal {}
@@ -159,9 +164,62 @@ function renderErrorBoundaryFallback(
// fallback-host delivers the fallback (in-order inline + `qErr`, or out-of-order via segment + `qO`)
// and swaps the content-host out.
errorStore.error = toSerializableBoundaryError(err);
+ // The swapped-out content is dead: make it inert so the client doesn't resume its tasks/signals.
+ if (__EXPERIMENTAL__.errorBoundary && errorStore.$contentHostNode$) {
+ markErrorBoundaryContentInert(ssr, errorStore.$contentHostNode$);
+ }
return null;
}
+/**
+ * Stash the just-opened `content-host` SSR node on its boundary's store, so a throw can later mark
+ * the swapped-out subtree inert. Resolves the store from the host's own context chain (the boundary
+ * that provides `ERROR_CONTEXT` is its parent component).
+ */
+function captureErrorBoundaryContentHost(ssr: SSRContainer): void {
+ const contentHost = ssr.getOrCreateLastNode();
+ const errorStore = ssr.resolveContext(contentHost, ERROR_CONTEXT) as ErrorBoundaryStore | null;
+ if (errorStore) {
+ errorStore.$contentHostNode$ = contentHost;
+ }
+}
+
+/**
+ * Make a swapped-out boundary's content inert: unsubscribe each content task from its signals so
+ * the dead, hidden subtree is never resumed/re-run on the client.
+ *
+ * The content is PROJECTED through the boundary's ``, so its SSR nodes are NOT under the
+ * `content-host` element — they hang off the boundary COMPONENT node (the content-host's
+ * `parentComponent`). We traverse from there. This runs only on a caught throw, at which point the
+ * sibling fallback-host has not rendered yet (a deferred case-c throw rethrows earlier), so the
+ * boundary node's subtree is content-only and there is no live fallback task to clear by mistake.
+ */
+function markErrorBoundaryContentInert(
+ ssr: SSRContainer,
+ contentHost: ReturnType
+): void {
+ const boundaryNode = contentHost.parentComponent ?? contentHost;
+ clearSubtreeTaskEffects(ssr, boundaryNode);
+}
+
+function clearSubtreeTaskEffects(ssr: SSRContainer, node: ISsrNode): void {
+ const seq = node.getProp(ELEMENT_SEQ) as unknown[] | null;
+ if (seq) {
+ for (let i = 0; i < seq.length; i++) {
+ const item = seq[i];
+ if (isTask(item)) {
+ clearAllEffects(ssr, item);
+ }
+ }
+ }
+ const children = node.children;
+ if (children) {
+ for (let i = 0; i < children.length; i++) {
+ clearSubtreeTaskEffects(ssr, children[i]);
+ }
+ }
+}
+
/**
* {@link renderErrorBoundaryFallback}, awaiting an async fallback so async drain sites get a plain
* value.
@@ -258,6 +316,10 @@ function processJSXNode(
ssr.htmlNode(innerHTML);
}
+ if (__EXPERIMENTAL__.errorBoundary && jsx.varProps && QErrorContentHost in jsx.varProps) {
+ captureErrorBoundaryContentHost(ssr);
+ }
+
enqueue(ssr.closeElement);
if (type === 'head') {
From 12732253453dc3a6b21fc2b03b3b1fcffad0744f Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Sun, 21 Jun 2026 17:02:30 +0200
Subject: [PATCH 059/331] feat(qwik): make swapped-out ErrorBoundary content
fully inert (vnode-data INERT)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Extends the task-effect clearing (a7013ae7e) 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- 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.
---
packages/qwik/src/core/ssr/ssr-render-jsx.ts | 49 +++++++++++++++++---
packages/qwik/src/server/ssr-container.ts | 4 +-
packages/qwik/src/server/types.ts | 3 ++
3 files changed, 47 insertions(+), 9 deletions(-)
diff --git a/packages/qwik/src/core/ssr/ssr-render-jsx.ts b/packages/qwik/src/core/ssr/ssr-render-jsx.ts
index df5365c64a5..dd0488a2000 100644
--- a/packages/qwik/src/core/ssr/ssr-render-jsx.ts
+++ b/packages/qwik/src/core/ssr/ssr-render-jsx.ts
@@ -35,6 +35,7 @@ import {
} from '../shared/utils/markers';
import { clearAllEffects } from '../reactive-primitives/cleanup';
import { isTask } from '../use/use-task';
+import { VNodeDataFlag } from '../../server/types';
import { isPromise, retryOnPromise } from '../shared/utils/promises';
import { qDev, qInspector } from '../shared/utils/qdev';
import { addComponentStylePrefix } from '../shared/utils/scoped-styles';
@@ -185,24 +186,58 @@ function captureErrorBoundaryContentHost(ssr: SSRContainer): void {
}
/**
- * Make a swapped-out boundary's content inert: unsubscribe each content task from its signals so
- * the dead, hidden subtree is never resumed/re-run on the client.
+ * Make a swapped-out boundary's content inert so the dead, hidden subtree is never resumed/re-run
+ * on the client. Three things, per dead node:
+ *
+ * 1. Tag it `INERT` — its vnode-data keeps its `REFERENCE` marker but drops the structural/component
+ * block, so it materializes as plain, non-resumable DOM.
+ * 2. `clearAllEffects` on its tasks — unsubscribe them from their signals.
+ * 3. Cut the projection ref into it: a live `` owner (the boundary, or a live ancestor whose
+ * slot projects through it) holds `owner[slotName] = projectionId` (`consumeChildrenForSlot`,
+ * ssr-node.ts), which client resume's `ensureProjectionResolved` would index-walk into the inert
+ * subtree and crash ("Missing child"). We remove that prop on the owner.
*
* The content is PROJECTED through the boundary's ``, so its SSR nodes are NOT under the
* `content-host` element — they hang off the boundary COMPONENT node (the content-host's
- * `parentComponent`). We traverse from there. This runs only on a caught throw, at which point the
+ * `parentComponent`). We traverse from there. Runs only on a caught throw, at which point the
* sibling fallback-host has not rendered yet (a deferred case-c throw rethrows earlier), so the
- * boundary node's subtree is content-only and there is no live fallback task to clear by mistake.
+ * boundary node's subtree is content-only.
*/
function markErrorBoundaryContentInert(
ssr: SSRContainer,
contentHost: ReturnType
): void {
const boundaryNode = contentHost.parentComponent ?? contentHost;
- clearSubtreeTaskEffects(ssr, boundaryNode);
+ // The only LIVE projection owners that can point into the dead content are the boundary itself and
+ // its ancestors (owners INSIDE the content are inert, so their slot props are skipped on emit).
+ const liveOwners = new Map();
+ for (let n: ISsrNode | null = boundaryNode; n; n = n.parentComponent) {
+ if (n.id) {
+ liveOwners.set(n.id, n);
+ }
+ }
+ const children = boundaryNode.children;
+ if (children) {
+ for (let i = 0; i < children.length; i++) {
+ markSubtreeInert(ssr, children[i], liveOwners);
+ }
+ }
}
-function clearSubtreeTaskEffects(ssr: SSRContainer, node: ISsrNode): void {
+function markSubtreeInert(
+ ssr: SSRContainer,
+ node: ISsrNode,
+ liveOwners: Map
+): void {
+ node.vnodeData[0] |= VNodeDataFlag.INERT;
+ // If this is a projection node, cut its live owner's slot ref into the dead content.
+ const ownerId = node.getProp(QSlotParent) as string | null;
+ if (ownerId) {
+ const owner = liveOwners.get(ownerId);
+ if (owner) {
+ owner.removeProp((node.getProp(QSlot) as string | null) ?? QDefaultSlot);
+ }
+ }
const seq = node.getProp(ELEMENT_SEQ) as unknown[] | null;
if (seq) {
for (let i = 0; i < seq.length; i++) {
@@ -215,7 +250,7 @@ function clearSubtreeTaskEffects(ssr: SSRContainer, node: ISsrNode): void {
const children = node.children;
if (children) {
for (let i = 0; i < children.length; i++) {
- clearSubtreeTaskEffects(ssr, children[i]);
+ markSubtreeInert(ssr, children[i], liveOwners);
}
}
}
diff --git a/packages/qwik/src/server/ssr-container.ts b/packages/qwik/src/server/ssr-container.ts
index 78b577df9fd..3108fcd6679 100644
--- a/packages/qwik/src/server/ssr-container.ts
+++ b/packages/qwik/src/server/ssr-container.ts
@@ -1145,8 +1145,8 @@ class SSRContainer extends _SharedContainer implements ISSRContainer {
this.write(VNodeDataSeparator.REFERENCE_CH);
}
if (
- flag &
- (VNodeDataFlag.TEXT_DATA | VNodeDataFlag.VIRTUAL_NODE | VNodeDataFlag.ELEMENT_NODE)
+ !(flag & VNodeDataFlag.INERT) &&
+ flag & (VNodeDataFlag.TEXT_DATA | VNodeDataFlag.VIRTUAL_NODE | VNodeDataFlag.ELEMENT_NODE)
) {
let fragmentAttrs: Props | null = null;
/**
diff --git a/packages/qwik/src/server/types.ts b/packages/qwik/src/server/types.ts
index 51a76849051..3fefdd19305 100644
--- a/packages/qwik/src/server/types.ts
+++ b/packages/qwik/src/server/types.ts
@@ -216,6 +216,9 @@ export const enum VNodeDataFlag {
REFERENCE = 8,
/// Should be output during serialization.
SERIALIZE = 16,
+ /// Node is in a swapped-out ErrorBoundary subtree: keep its REFERENCE marker (so live refs resolve)
+ /// but skip its structural/component block, so it materializes as plain, non-resumable DOM.
+ INERT = 32,
}
export type BackpatchEntry = {
From c2427afbcc06a48d9f645fe9291c0cef63295259 Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Sun, 21 Jun 2026 18:11:38 +0200
Subject: [PATCH 060/331] test(qwik): a re-render deletes an ErrorBoundary's
inert swapped-out content
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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).
---
.../src/core/tests/error-boundary.spec.tsx | 66 +++++++++++++++++++
1 file changed, 66 insertions(+)
diff --git a/packages/qwik/src/core/tests/error-boundary.spec.tsx b/packages/qwik/src/core/tests/error-boundary.spec.tsx
index ce311bf47e3..039a216ec49 100644
--- a/packages/qwik/src/core/tests/error-boundary.spec.tsx
+++ b/packages/qwik/src/core/tests/error-boundary.spec.tsx
@@ -22,6 +22,7 @@ import {
} from '@qwik.dev/core/testing';
import { describe, expect, it } from 'vitest';
import { processOutOfOrderSegmentVNodeData } from '../client/process-vnode-data';
+import { rerenderComponent } from '../../testing/rendering.unit-util';
import { delay } from '../shared/utils/promises';
const debug = false;
@@ -886,6 +887,71 @@ describe('ErrorBoundary SSR→CSR cross-phase (experimental)', () => {
});
});
+// An SSR-errored boundary leaves its swapped-out content INERT + hidden, not deleted. It is removed for
+// FREE whenever the boundary re-renders (e.g. `reset()` in Phase 2): the re-render collapses the SSR
+// two-host down to the fallback Fragment and the vnode diff drops the inert content cleanly (no "Missing
+// child"). This is why no dedicated content-deletion mechanism is needed. (Drive the re-render directly
+// with `rerenderComponent` — an SSR-errored boundary has no self re-render trigger: a 2nd error escalates
+// past it.)
+describe('ErrorBoundary: a re-render deletes the inert swapped-out content', () => {
+ it('in-order: re-rendering an SSR-errored boundary drops the inert content-host, fallback stays', async () => {
+ const { container } = await ssrRenderToDom(
+
+ (
+
caught: {e.message}
+ ))}
+ >
+
content
+
+
+ ,
+ { debug, streaming: { outOfOrder: false } }
+ );
+ const el = container.element;
+ const contentHost = el.querySelector('[q\\:ebc]') as HTMLElement;
+ // SSR: the fallback shows; the inert content sits hidden inside the content-host.
+ expect(el.querySelector('#fb')?.textContent).toContain('caught: boom');
+ expect(contentHost.style.display).toBe('none');
+ expect(contentHost.contains(el.querySelector('#content'))).toBe(true);
+
+ // A re-render (what `reset()` does) collapses the two-host to the fallback Fragment.
+ await rerenderComponent(contentHost);
+ await waitForDrain(container);
+
+ // The inert content-host + content are gone; the fallback remains — cleanly, no desync.
+ expect(el.querySelector('[q\\:ebc]')).toBeFalsy();
+ expect(el.querySelector('#content')).toBeFalsy();
+ expect(el.querySelector('#fb')?.textContent).toContain('caught: boom');
+ });
+
+ it('out-of-order: re-rendering an SSR-errored boundary drops the inert content, fallback stays', async () => {
+ const { container } = await ssrRenderToDom(
+
+ (
+
caught: {e.message}
+ ))}
+ >
+
content
+
+
+ ,
+ { streaming: { inOrder: { strategy: 'disabled' }, outOfOrder: true }, debug }
+ );
+ const el = container.element;
+ const contentHost = el.querySelector('[q\\:ebc]') as HTMLElement;
+ expect(el.querySelector('#fb')?.textContent).toContain('caught: boom');
+ expect(contentHost.contains(el.querySelector('#content'))).toBe(true);
+
+ await rerenderComponent(contentHost);
+ await waitForDrain(container);
+
+ expect(el.querySelector('#content')).toBeFalsy();
+ expect(el.querySelector('#fb')?.textContent).toContain('caught: boom');
+ });
+});
+
describe('ErrorBoundary across multiple containers on one document', () => {
// Two resumed containers share one document; each `qerror` listener is guarded by
// `element.contains(source)`, so only the owning container reacts.
From 254d75e1c7e0070443df8c45b307a37274fa1322 Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Sun, 21 Jun 2026 18:26:31 +0200
Subject: [PATCH 061/331] test(qwik): rename the A7 inert e2e to E2E-6 (its
catalog name)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
e2e/qwik-e2e/tests/error-boundary-streaming.e2e.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/e2e/qwik-e2e/tests/error-boundary-streaming.e2e.ts b/e2e/qwik-e2e/tests/error-boundary-streaming.e2e.ts
index cd3718e14a0..efae4a7d310 100644
--- a/e2e/qwik-e2e/tests/error-boundary-streaming.e2e.ts
+++ b/e2e/qwik-e2e/tests/error-boundary-streaming.e2e.ts
@@ -116,7 +116,7 @@ test.describe('ErrorBoundary streaming swap', () => {
await expect(page.locator('#eb-fallback-count')).toHaveText('1');
});
- test('A7 inert: a swapped-out content task does not re-run when an outside signal changes', async ({
+ test('E2E-6 inert: a swapped-out content task does not re-run when an outside signal changes', async ({
page,
}) => {
assertNoBrowserErrors(page);
From e5e6bfcd1a7a49ebac3bc016564bd3058ed8d66b Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Sun, 21 Jun 2026 18:34:35 +0200
Subject: [PATCH 062/331] test(qwik): drop catalog numbers (A#/B#/D#/S#/E2E-#)
from ErrorBoundary test titles
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
.../error-boundary/error-boundary.tsx | 4 +--
.../tests/error-boundary-streaming.e2e.ts | 2 +-
.../src/core/tests/error-boundary.spec.tsx | 32 +++++++++----------
3 files changed, 19 insertions(+), 19 deletions(-)
diff --git a/e2e/qwik-e2e/apps/e2e/src/components/error-boundary/error-boundary.tsx b/e2e/qwik-e2e/apps/e2e/src/components/error-boundary/error-boundary.tsx
index b45b23b8ef3..98cac6a977b 100644
--- a/e2e/qwik-e2e/apps/e2e/src/components/error-boundary/error-boundary.tsx
+++ b/e2e/qwik-e2e/apps/e2e/src/components/error-boundary/error-boundary.tsx
@@ -71,7 +71,7 @@ const EbSyncThrower = component$(() => {
return ;
});
-// A7 inert probe: the swapped-out content holds a useTask$ that tracks a signal and records each
+// Inert probe: the swapped-out content holds a useTask$ that tracks a signal and records each
// CLIENT run on `window`. The SSR throw (its child EbSyncThrower) swaps this content out; bumping the
// tracked signal from OUTSIDE the boundary must NOT re-run this dead task on the client.
const EbInertContent = component$<{ trigger: Signal }>((props) => {
@@ -117,7 +117,7 @@ export const ErrorBoundaryStreamingRoot = component$(() => {
EB Streaming
{scenario === 'inert' ? (
- // A7: SSR error swaps out content that holds a signal-tracking task; bumping the signal from
+ // SSR error swaps out content that holds a signal-tracking task; bumping the signal from
// OUTSIDE the boundary must not re-run the dead task on the client.
<>
}>
diff --git a/e2e/qwik-e2e/tests/error-boundary-streaming.e2e.ts b/e2e/qwik-e2e/tests/error-boundary-streaming.e2e.ts
index efae4a7d310..390b1263fbc 100644
--- a/e2e/qwik-e2e/tests/error-boundary-streaming.e2e.ts
+++ b/e2e/qwik-e2e/tests/error-boundary-streaming.e2e.ts
@@ -116,7 +116,7 @@ test.describe('ErrorBoundary streaming swap', () => {
await expect(page.locator('#eb-fallback-count')).toHaveText('1');
});
- test('E2E-6 inert: a swapped-out content task does not re-run when an outside signal changes', async ({
+ test('inert: a swapped-out content task does not re-run when an outside signal changes', async ({
page,
}) => {
assertNoBrowserErrors(page);
diff --git a/packages/qwik/src/core/tests/error-boundary.spec.tsx b/packages/qwik/src/core/tests/error-boundary.spec.tsx
index 039a216ec49..3cf65d4e155 100644
--- a/packages/qwik/src/core/tests/error-boundary.spec.tsx
+++ b/packages/qwik/src/core/tests/error-boundary.spec.tsx
@@ -716,7 +716,7 @@ describe('ErrorBoundary combinations', () => {
// Routing through Suspense: the CLOSEST enclosing boundary catches, and any boundary that encloses the
// catching one stays untouched. (Settled routing table, §5 of the design.)
describe('ErrorBoundary routing through Suspense (experimental)', () => {
- it('B4 EB-outer › Suspense › EB-inner › throw → EB-inner catches, EB-outer untouched', async () => {
+ it('EB-outer › Suspense › EB-inner › throw → EB-inner catches, EB-outer untouched', async () => {
const { document } = await streamAndResume(
{
expect(document.querySelector('#outer-ok')?.textContent).toBe('outer-ok');
});
- it('B6 EB-outer › Suspense-A › EB-mid › Suspense-B › throw → EB-mid catches, EB-outer untouched', async () => {
+ it('EB-outer › Suspense-A › EB-mid › Suspense-B › throw → EB-mid catches, EB-outer untouched', async () => {
const { document } = await streamAndResume(
{
// SSR error in one phase meeting a client re-render in the next. `store.error` is the bridge; the
// SSR two-host structure must reconcile cleanly when the boundary re-renders on the client.
describe('ErrorBoundary SSR→CSR cross-phase (experimental)', () => {
- it('D2 SSR inner error, then a client throw to the OUTER boundary replaces the whole subtree', async () => {
+ it('SSR inner error, then a client throw to the OUTER boundary replaces the whole subtree', async () => {
const { container } = await ssrRenderToDom(
{
expect(el.querySelector('#outer-btn')).toBeFalsy();
});
- it('D3(in-order) an in-order two-host collapses cleanly when a client-first error re-renders the boundary (no Missing child)', async () => {
+ it('an in-order two-host collapses cleanly when a client-first error re-renders the boundary (no Missing child)', async () => {
// The in-order analog of the OOOS post-resume collapse test above: the SSR `q:ebc`/`q:ebf`
// two-host must reconcile down to the single client fallback Fragment without a "Missing
- // child"/key mismatch. (Strict D3 — an *already SSR-errored* boundary collapsing on a later
- // BENIGN re-render — isn't reachable in Phase 1: a pre-errored boundary has no re-render trigger
- // and a 2nd error escalates past it; that benign-collapse path arrives with `reset()` in Phase 2.)
+ // child"/key mismatch. (The stricter case — an *already SSR-errored* boundary collapsing on a
+ // later BENIGN re-render — isn't reachable in Phase 1: a pre-errored boundary has no re-render
+ // trigger and a 2nd error escalates past it; that benign-collapse path arrives with `reset()`.)
const { container } = await ssrRenderToDom(
{
describe('ErrorBoundary in-order swap (no out-of-order streaming)', () => {
const inOrder = { debug, streaming: { outOfOrder: false } } as const;
- it('A1 happy path: content streams; no fallback content and no qErr swap script', async () => {
+ it('happy path: content streams; no fallback content and no qErr swap script', async () => {
const { container } = await ssrRenderToDom(
{
expect(el.outerHTML).not.toContain('qErr(');
});
- it('A2 sync throw: content-host hidden, fallback in the sibling host, qErr swap emitted', async () => {
+ it('sync throw: content-host hidden, fallback in the sibling host, qErr swap emitted', async () => {
const { container } = await ssrRenderToDom(
{
expect(el.outerHTML).toContain('qErr(');
});
- it('A3 siblings OUTSIDE the boundary that streamed before the throw remain visible', async () => {
+ it('siblings OUTSIDE the boundary that streamed before the throw remain visible', async () => {
const { container } = await ssrRenderToDom(
outside-before
@@ -1271,7 +1271,7 @@ describe('ErrorBoundary in-order swap (no out-of-order streaming)', () => {
expect(contentHost.contains(outsideAfter)).toBe(false);
});
- it('A4 awaited-async throw: fallback delivered in document order (sibling host)', async () => {
+ it('awaited-async throw: fallback delivered in document order (sibling host)', async () => {
// "In-order" is timing, not position: an awaited-async throw still marks `store.error` before the
// sibling fallback-host renders, so the fallback lands in the sibling host (not at the throw site).
const { container } = await ssrRenderToDom(
@@ -1301,7 +1301,7 @@ describe('ErrorBoundary in-order swap (no out-of-order streaming)', () => {
expect(el.outerHTML).toContain('qErr(');
});
- it('A6 a throw deep inside nested tags yields well-formed HTML (hideable content-host)', async () => {
+ it('a throw deep inside nested tags yields well-formed HTML (hideable content-host)', async () => {
const { container } = await ssrRenderToDom(
{
expect(contentHost.querySelector('#lvl1 #lvl2 #lvl3')).toBeTruthy();
});
- it('A5 the qErr executor installs independently of OOOS (no qO on the page)', async () => {
+ it('the qErr executor installs independently of OOOS (no qO on the page)', async () => {
const chunks: string[] = [];
await ssrRenderToDom(
@@ -1491,7 +1491,7 @@ describe('ErrorBoundary SSR async-generator + non-serializable throws (experimen
throw new Error('normal boom');
});
- it('S2: routes an async-generator child throw to the enclosing boundary fallback', async () => {
+ it('routes an async-generator child throw to the enclosing boundary fallback', async () => {
const { container } = await ssrRenderToDom(
(
@@ -1505,7 +1505,7 @@ describe('ErrorBoundary SSR async-generator + non-serializable throws (experimen
expect(container.element.querySelector('#fb')?.textContent).toContain('caught: async gen boom');
});
- it('S4: a non-serializable throw renders the fallback AND the page still serializes', async () => {
+ it('a non-serializable throw renders the fallback AND the page still serializes', async () => {
// The key assertion is that ssrRenderToDom RESOLVES (the page serialized) rather than rejecting
// with a verifySerializable error.
const { container } = await ssrRenderToDom(
@@ -1523,7 +1523,7 @@ describe('ErrorBoundary SSR async-generator + non-serializable throws (experimen
);
});
- it('S4: a normal Error throw is unchanged (still renders its fallback)', async () => {
+ it('a normal Error throw is unchanged (still renders its fallback)', async () => {
const { container } = await ssrRenderToDom(
(
From 0209351ddc8872c19d00f53e749a5a7fa7827967 Mon Sep 17 00:00:00 2001
From: maiieul <45822175+maiieul@users.noreply.github.com>
Date: Sun, 21 Jun 2026 19:21:43 +0200
Subject: [PATCH 063/331] test(qwik): add ErrorBoundary e2e coverage (happy
path, in-order swap, 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 (case b), E2E-9 D2 cross-phase. Adds happy/suspense/nested fixture scenarios + an id prefix on EbFallback for nested boundaries.
---
.../error-boundary/error-boundary.tsx | 77 +++++++++++++--
.../tests/error-boundary-streaming.e2e.ts | 96 +++++++++++++++++++
2 files changed, 167 insertions(+), 6 deletions(-)
diff --git a/e2e/qwik-e2e/apps/e2e/src/components/error-boundary/error-boundary.tsx b/e2e/qwik-e2e/apps/e2e/src/components/error-boundary/error-boundary.tsx
index 98cac6a977b..f1d904d45f5 100644
--- a/e2e/qwik-e2e/apps/e2e/src/components/error-boundary/error-boundary.tsx
+++ b/e2e/qwik-e2e/apps/e2e/src/components/error-boundary/error-boundary.tsx
@@ -37,19 +37,33 @@ const waitForRelease = (requestId: string, releaseId: string): Promise =>
resolvers.add(resolve);
});
-const EbFallback = component$((props: { msg: string }) => {
+// `id` prefixes every marker so nested boundaries (the `nested` scenario) can tell their fallbacks
+// apart; it defaults to `eb-fallback` so the single-boundary scenarios keep their existing ids.
+const EbFallback = component$((props: { msg: string; id?: string }) => {
+ const id = props.id ?? 'eb-fallback';
const count = useSignal(0);
return (
-
-