From e29c4d8476154aa5c0f2282cc4c685436c69fd30 Mon Sep 17 00:00:00 2001 From: maiieul <45822175+maiieul@users.noreply.github.com> Date: Thu, 18 Jun 2026 09:25:45 +0200 Subject: [PATCH 001/331] feat(core)\!: export ErrorBoundary from core instead of router ErrorBoundary now lives in @qwik.dev/core, built with the internal componentQrl + inlinedQrl pattern (core isn't run through the optimizer). Removed from @qwik.dev/router; import it from @qwik.dev/core instead. --- .changeset/errorboundary-to-core.md | 6 ++ .../src/routes/error/index.tsx | 3 +- .../docs/src/routes/api/qwik-router/api.json | 14 ----- .../docs/src/routes/api/qwik-router/index.mdx | 8 --- packages/docs/src/routes/api/qwik/api.json | 28 +++++++++ packages/docs/src/routes/api/qwik/index.mdx | 50 +++++++++++++++ .../src/runtime/src/error-boundary.tsx | 24 ------- packages/qwik-router/src/runtime/src/index.ts | 1 - .../runtime/src/qwik-router.runtime.api.md | 5 -- packages/qwik-vite/src/manifest.ts | 3 + packages/qwik/handlers.mjs | 3 + packages/qwik/public.d.ts | 2 + packages/qwik/src/core/index.ts | 6 ++ packages/qwik/src/core/qwik.core.api.md | 19 +++++- .../src/core/shared/error/error-boundary.ts | 57 +++++++++++++++++ .../src/core/tests/error-boundary.spec.tsx | 62 +++++++++++++++++++ 16 files changed, 235 insertions(+), 56 deletions(-) create mode 100644 .changeset/errorboundary-to-core.md delete mode 100644 packages/qwik-router/src/runtime/src/error-boundary.tsx create mode 100644 packages/qwik/src/core/shared/error/error-boundary.ts create mode 100644 packages/qwik/src/core/tests/error-boundary.spec.tsx diff --git a/.changeset/errorboundary-to-core.md b/.changeset/errorboundary-to-core.md new file mode 100644 index 00000000000..5298ecb6fe8 --- /dev/null +++ b/.changeset/errorboundary-to-core.md @@ -0,0 +1,6 @@ +--- +'@qwik.dev/core': major +'@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`. diff --git a/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/error/index.tsx b/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/error/index.tsx index bf825a34318..aa13b43fd05 100644 --- a/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/error/index.tsx +++ b/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/error/index.tsx @@ -1,5 +1,4 @@ -import { component$, $ } from '@qwik.dev/core'; -import { ErrorBoundary } from '@qwik.dev/router'; +import { component$, $, ErrorBoundary } from '@qwik.dev/core'; export default component$(() => { return ; diff --git a/packages/docs/src/routes/api/qwik-router/api.json b/packages/docs/src/routes/api/qwik-router/api.json index 32299105f98..b505a1179f7 100644 --- a/packages/docs/src/routes/api/qwik-router/api.json +++ b/packages/docs/src/routes/api/qwik-router/api.json @@ -268,20 +268,6 @@ "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/types.ts", "mdFile": "router.documentstyle.md" }, - { - "name": "ErrorBoundary", - "id": "errorboundary", - "hierarchy": [ - { - "name": "ErrorBoundary", - "id": "errorboundary" - } - ], - "kind": "Variable", - "content": "```typescript\nErrorBoundary: import(\"@qwik.dev/core\").Component\n```", - "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/error-boundary.tsx", - "mdFile": "router.errorboundary.md" - }, { "name": "FailOfRest", "id": "failofrest", diff --git a/packages/docs/src/routes/api/qwik-router/index.mdx b/packages/docs/src/routes/api/qwik-router/index.mdx index bd90e998a54..aa30848f304 100644 --- a/packages/docs/src/routes/api/qwik-router/index.mdx +++ b/packages/docs/src/routes/api/qwik-router/index.mdx @@ -775,14 +775,6 @@ export type DocumentStyle = Readonly< [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/types.ts) -

ErrorBoundary

- -```typescript -ErrorBoundary: import("@qwik.dev/core").Component; -``` - -[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/error-boundary.tsx) -

FailOfRest

```typescript diff --git a/packages/docs/src/routes/api/qwik/api.json b/packages/docs/src/routes/api/qwik/api.json index 9803f97c85b..3718707c3ce 100644 --- a/packages/docs/src/routes/api/qwik/api.json +++ b/packages/docs/src/routes/api/qwik/api.json @@ -644,6 +644,34 @@ "content": "```typescript\ntype ElementType = string | FunctionComponent>;\n```\n**References:** [FunctionComponent](#functioncomponent)", "mdFile": "core.qwikjsx.elementtype.md" }, + { + "name": "ErrorBoundary", + "id": "errorboundary", + "hierarchy": [ + { + "name": "ErrorBoundary", + "id": "errorboundary" + } + ], + "kind": "Variable", + "content": "```typescript\nErrorBoundary: Component\n```", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/shared/error/error-boundary.ts", + "mdFile": "core.errorboundary.md" + }, + { + "name": "ErrorBoundaryProps", + "id": "errorboundaryprops", + "hierarchy": [ + { + "name": "ErrorBoundaryProps", + "id": "errorboundaryprops" + } + ], + "kind": "Interface", + "content": "```typescript\nexport interface ErrorBoundaryProps \n```\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nfallback$?\n\n\n\n\n\n\n\n[QRL](#qrl-type-alias)<(error: any) => any>\n\n\n\n\n_(Optional)_\n\n\n
", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/shared/error/error-boundary.ts", + "mdFile": "core.errorboundaryprops.md" + }, { "name": "ErrorBoundaryStore", "id": "errorboundarystore", diff --git a/packages/docs/src/routes/api/qwik/index.mdx b/packages/docs/src/routes/api/qwik/index.mdx index fcf6c34023a..8fe522fbed4 100644 --- a/packages/docs/src/routes/api/qwik/index.mdx +++ b/packages/docs/src/routes/api/qwik/index.mdx @@ -1620,6 +1620,56 @@ type ElementType = string | FunctionComponent>; **References:** [FunctionComponent](#functioncomponent) +

ErrorBoundary

+ +```typescript +ErrorBoundary: Component; +``` + +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/shared/error/error-boundary.ts) + +

ErrorBoundaryProps

+ +```typescript +export interface ErrorBoundaryProps +``` + + + +
+ +Property + + + +Modifiers + + + +Type + + + +Description + +
+ +fallback$? + + + + + +[QRL](#qrl-type-alias)<(error: any) => any> + + + +_(Optional)_ + +
+ +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/shared/error/error-boundary.ts) +

ErrorBoundaryStore

```typescript diff --git a/packages/qwik-router/src/runtime/src/error-boundary.tsx b/packages/qwik-router/src/runtime/src/error-boundary.tsx deleted file mode 100644 index 36a3e053792..00000000000 --- a/packages/qwik-router/src/runtime/src/error-boundary.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { component$, useErrorBoundary, Slot, type QRL, useOnWindow, $ } from '@qwik.dev/core'; - -/** @public */ -export interface ErrorBoundaryProps { - fallback$?: QRL<(error: any) => any>; -} - -/** @public */ -export const ErrorBoundary = component$((props: ErrorBoundaryProps) => { - const store = useErrorBoundary(); - - useOnWindow( - 'qerror', - $((e: CustomEvent) => { - store.error = e.detail.error; - }) - ); - - if (store.error && props.fallback$) { - return <>{props.fallback$(store.error)}; - } - - return ; -}); diff --git a/packages/qwik-router/src/runtime/src/index.ts b/packages/qwik-router/src/runtime/src/index.ts index da8644334e1..b9b94d57790 100644 --- a/packages/qwik-router/src/runtime/src/index.ts +++ b/packages/qwik-router/src/runtime/src/index.ts @@ -55,7 +55,6 @@ export type { ZodConstructor, } from './types'; -export { ErrorBoundary } from './error-boundary'; export { Link, type LinkProps, type PrefetchStrategy } from './link-component'; export { QWIK_CITY_SCROLLER, diff --git a/packages/qwik-router/src/runtime/src/qwik-router.runtime.api.md b/packages/qwik-router/src/runtime/src/qwik-router.runtime.api.md index 73f4707e458..9cf77eb0f41 100644 --- a/packages/qwik-router/src/runtime/src/qwik-router.runtime.api.md +++ b/packages/qwik-router/src/runtime/src/qwik-router.runtime.api.md @@ -188,11 +188,6 @@ export type DocumentStyle = Readonly<((Omit; -// Warning: (ae-forgotten-export) The symbol "ErrorBoundaryProps" needs to be exported by the entry point index.d.ts -// -// @public (undocumented) -export const ErrorBoundary: Component; - // @public (undocumented) export type FailOfRest = REST extends readonly DataValidator[] ? ERROR : never; diff --git a/packages/qwik-vite/src/manifest.ts b/packages/qwik-vite/src/manifest.ts index 4f11133811f..0e2328c62c8 100644 --- a/packages/qwik-vite/src/manifest.ts +++ b/packages/qwik-vite/src/manifest.ts @@ -24,6 +24,9 @@ const extraSymbols = new Set([ '_reR', '_reC', '_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 f3d09995967..de84e285781 100644 --- a/packages/qwik/handlers.mjs +++ b/packages/qwik/handlers.mjs @@ -26,4 +26,7 @@ export { _reR, _reC, _reT, + // ErrorBoundary + _ebC, + _ebL, } from '@qwik.dev/core/internal'; diff --git a/packages/qwik/public.d.ts b/packages/qwik/public.d.ts index be253302184..1f6788df306 100644 --- a/packages/qwik/public.d.ts +++ b/packages/qwik/public.d.ts @@ -13,6 +13,8 @@ export { createSignal, CSSProperties, Each, + ErrorBoundary, + ErrorBoundaryProps, // TODO remove this event$, EventHandler, diff --git a/packages/qwik/src/core/index.ts b/packages/qwik/src/core/index.ts index a6b6e0269e6..58b1206d21d 100644 --- a/packages/qwik/src/core/index.ts +++ b/packages/qwik/src/core/index.ts @@ -160,6 +160,12 @@ 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 type { ErrorBoundaryProps } from './shared/error/error-boundary'; export { type ReadonlySignal, type AsyncSignal, diff --git a/packages/qwik/src/core/qwik.core.api.md b/packages/qwik/src/core/qwik.core.api.md index 2cdf845ae97..64910090cc9 100644 --- a/packages/qwik/src/core/qwik.core.api.md +++ b/packages/qwik/src/core/qwik.core.api.md @@ -463,6 +463,12 @@ export const Each: EachComponent; // @internal (undocumented) 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; @@ -493,6 +499,15 @@ export const _EMPTY_ARRAY: any[]; // @internal (undocumented) export const _EMPTY_OBJ: Record; +// @public (undocumented) +export const ErrorBoundary: Component; + +// @public (undocumented) +export interface ErrorBoundaryProps { + // (undocumented) + fallback$?: QRL<(error: any) => any>; +} + // @public (undocumented) export interface ErrorBoundaryStore { // (undocumented) @@ -1054,10 +1069,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 new file mode 100644 index 00000000000..cf1f2a00732 --- /dev/null +++ b/packages/qwik/src/core/shared/error/error-boundary.ts @@ -0,0 +1,57 @@ +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'; + +/** @public */ +export interface ErrorBoundaryProps { + 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`). + */ + +/** @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(); + + // 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$) { + return /*#__PURE__*/ _jsxSorted(Fragment, null, null, props.fallback$(store.error), 0, null); + } + + return /*#__PURE__*/ _jsxSorted(Slot, null, null, null, 0, null); +}; + +/** @public */ +export const ErrorBoundary: Component = + /*#__PURE__*/ componentQrl( + /*#__PURE__*/ inlinedQrl(errorBoundaryCmp, '_ebC') + ); diff --git a/packages/qwik/src/core/tests/error-boundary.spec.tsx b/packages/qwik/src/core/tests/error-boundary.spec.tsx new file mode 100644 index 00000000000..21ff3a4843b --- /dev/null +++ b/packages/qwik/src/core/tests/error-boundary.spec.tsx @@ -0,0 +1,62 @@ +import { domRender, ssrRenderToDom, trigger } 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( + ( +

Caught: {error.message}

+ ))} + > +
All good
+
, + { debug } + ); + + expect(document.body.textContent).toContain('All good'); + expect(document.body.textContent).not.toContain('Caught'); + }); + + it('should render the fallback when a qerror window event fires', async () => { + const { container, document } = await render( + ( +

Caught: {error.message}

+ ))} + > +
All good
+
, + { debug } + ); + + await trigger(container.element, null, 'w:qerror', { + detail: { error: new Error('Boom!') }, + }); + + expect(document.body.querySelector('p')?.textContent).toContain('Caught: Boom!'); + expect(document.body.textContent).not.toContain('All good'); + }); + + it('should keep projecting children when no fallback is provided', async () => { + const { container, document } = await render( + +
All good
+
, + { 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\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nfallback$?\n\n\n\n\n\n\n\n[QRL](#qrl-type-alias)<(error: any) => any>\n\n\n\n\n_(Optional)_\n\n\n
", + "content": "```typescript\nexport interface ErrorBoundaryProps \n```\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nfallback$\n\n\n\n\n\n\n\n[QRL](#qrl-type-alias)<(error: any) => any>\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
", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/shared/error/error-boundary.ts", "mdFile": "core.errorboundaryprops.md" }, @@ -682,7 +682,7 @@ } ], "kind": "Interface", - "content": "```typescript\nexport interface ErrorBoundaryStore \n```\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nerror\n\n\n\n\n\n\n\nany \\| undefined\n\n\n\n\n\n
", + "content": "```typescript\nexport interface ErrorBoundaryStore \n```\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\n$fallback$?\n\n\n\n\n\n\n\n(error: any) => unknown\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
\n\nerror\n\n\n\n\n\n\n\nany \\| undefined\n\n\n\n\n\n
", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/shared/error/error-handling.ts", "mdFile": "core.errorboundarystore.md" }, diff --git a/packages/docs/src/routes/api/qwik/index.mdx b/packages/docs/src/routes/api/qwik/index.mdx index 8fe522fbed4..ea1dafcb37a 100644 --- a/packages/docs/src/routes/api/qwik/index.mdx +++ b/packages/docs/src/routes/api/qwik/index.mdx @@ -1653,7 +1653,7 @@ Description -fallback$? +fallback$ @@ -1663,7 +1663,7 @@ fallback$? -_(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( ( -

Caught: {error.message}

+ fallback$={$((e: any) => ( +

caught: {e.message}

))} > -
All good
+
, { debug } ); - - await trigger(container.element, null, 'w:qerror', { - detail: { error: new Error('Boom!') }, - }); - - expect(document.body.querySelector('p')?.textContent).toContain('Caught: Boom!'); - expect(document.body.textContent).not.toContain('All good'); + expect(container.element.querySelector('#fb')?.textContent).toContain('caught: boom'); }); - it('should keep projecting children when no fallback is provided', async () => { - const { container, document } = await render( - -
All good
+ it('client: a render throw is caught by the NEAREST boundary', async () => { + const { container } = await domRender( + ( +

outer

+ ))} + > +
ok
+ ( +

inner

+ ))} + > + +
, { debug } ); + const el = container.element; + expect(el.querySelector('#fb-inner')).toBeTruthy(); + expect(el.querySelector('#fb-outer')).toBeFalsy(); + expect(el.querySelector('#content')).toBeTruthy(); + }); - // Without a fallback the error is recorded but children keep rendering. - await trigger(container.element, null, 'w:qerror', { - detail: { error: new Error('Boom!') }, - }); + it('client: an async qerror is routed to the NEAREST boundary', async () => { + const { container } = await domRender( + ( +

outer

+ ))} + > + ( +

inner

+ ))} + > + +
+
, + { 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('async boom'), element: target }; + el.ownerDocument.dispatchEvent(ev); + await waitForDrain(container); - expect(document.body.textContent).toContain('All good'); + expect(el.querySelector('#fb-inner')).toBeTruthy(); + expect(el.querySelector('#fb-outer')).toBeFalsy(); }); }); From 70f2ff5cb6f436ded450c0b305ee4047e85fc93e Mon Sep 17 00:00:00 2001 From: maiieul <45822175+maiieul@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:49:18 +0200 Subject: [PATCH 003/331] refactor(qwik)!: remove public useErrorBoundary hook is now the single public error-boundary surface. The store-provider hook is kept as an internal helper, renamed `useErrorBoundaryStore` (shared by the component and the test boundaries) and no longer exported; the orphaned ErrorBoundaryStore type is also made internal. Updates the devtools hook registry and docs accordingly. --- .changeset/errorboundary-to-core.md | 1 + packages/devtools/kit/src/protocol/hooks.ts | 1 - .../kit/src/protocol/protocol.unit.ts | 2 - .../parse/__snapshots__/parse.test.ts.snap | 12 +--- .../devtools/plugin/src/parse/parse.test.ts | 5 +- .../ui/src/features/RenderTree/types.ts | 1 - packages/docs/src/routes/api/qwik/api.json | 28 -------- packages/docs/src/routes/api/qwik/index.mdx | 67 ------------------- .../docs/(qwik)/core/overview/index.mdx | 2 +- .../src/routes/docs/labs/suspense/index.mdx | 2 +- packages/qwik/public.d.ts | 1 - packages/qwik/src/core/index.ts | 2 - packages/qwik/src/core/qwik.core.api.md | 10 --- .../src/core/shared/error/error-boundary.ts | 8 ++- .../src/core/shared/error/error-handling.ts | 2 +- .../qwik/src/core/tests/suspense.spec.tsx | 4 +- .../qwik/src/core/tests/use-async.spec.tsx | 4 +- ...oundary.ts => use-error-boundary-store.ts} | 4 +- 18 files changed, 17 insertions(+), 139 deletions(-) rename packages/qwik/src/core/use/{use-error-boundary.ts => use-error-boundary-store.ts} (83%) diff --git a/.changeset/errorboundary-to-core.md b/.changeset/errorboundary-to-core.md index 20eb765b842..c15b92919a1 100644 --- a/.changeset/errorboundary-to-core.md +++ b/.changeset/errorboundary-to-core.md @@ -6,5 +6,6 @@ `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. diff --git a/packages/devtools/kit/src/protocol/hooks.ts b/packages/devtools/kit/src/protocol/hooks.ts index bb9d4738c91..27e2625f7e1 100644 --- a/packages/devtools/kit/src/protocol/hooks.ts +++ b/packages/devtools/kit/src/protocol/hooks.ts @@ -9,7 +9,6 @@ export const HOOK_GROUPS = { 'useStyles', 'useStylesScoped', 'useConstant', - 'useErrorBoundary', 'useSerializer', 'useServerData', 'useLocation', diff --git a/packages/devtools/kit/src/protocol/protocol.unit.ts b/packages/devtools/kit/src/protocol/protocol.unit.ts index 73ec58eb802..e8e033d9fb0 100644 --- a/packages/devtools/kit/src/protocol/protocol.unit.ts +++ b/packages/devtools/kit/src/protocol/protocol.unit.ts @@ -116,7 +116,6 @@ describe('devtools protocol', () => { 'useStyles', 'useStylesScoped', 'useConstant', - 'useErrorBoundary', 'useSerializer', 'useServerData', 'useLocation', @@ -141,7 +140,6 @@ describe('devtools protocol', () => { 'useStyles', 'useStylesScoped', 'useConstant', - 'useErrorBoundary', 'useSerializer', 'useServerData', 'useLocation', diff --git a/packages/devtools/plugin/src/parse/__snapshots__/parse.test.ts.snap b/packages/devtools/plugin/src/parse/__snapshots__/parse.test.ts.snap index 7f45e04cf0b..99168b5e5c2 100644 --- a/packages/devtools/plugin/src/parse/__snapshots__/parse.test.ts.snap +++ b/packages/devtools/plugin/src/parse/__snapshots__/parse.test.ts.snap @@ -23,8 +23,7 @@ exports[`injectCollectHooks > matches snapshot of transformed output 1`] = ` useSerializer$, useConstant, useOn, - useServerData, - useErrorBoundary + useServerData } from '@qwik.dev/core'; import { _getDomContainer, isServer, useVisibleTask$ } from '@qwik.dev/core/internal'; import type { QRL, Signal } from '@qwik.dev/core'; @@ -81,14 +80,6 @@ export default component$((props) => { data: serverData }); - const errorBoundary = useErrorBoundary(); - collecthook({ - variableName: 'errorBoundary', - hookType: 'useErrorBoundary', - category: 'VariableDeclarator', - data: errorBoundary - }); - const location = useLocation(); collecthook({ variableName: 'location', @@ -386,7 +377,6 @@ export default component$((props) => {
Context: {context.theme} - {context.size}
Button ID: {buttonId}
Constant: {constantValue}
- {errorBoundary.error &&
Error captured
}
ServerData: {serverData ? JSON.stringify(serverData) : 'N/A'}
Serialized N: {customSerialized.value.n}
((props) => { const signal = useSignal('111'); const constantValue = useConstant(() => 'CONST'); const serverData = useServerData('demo-key'); - const errorBoundary = useErrorBoundary(); const location = useLocation(); const navigate = useNavigate(); const content = useContent(); @@ -203,7 +201,6 @@ export default component$((props) => {
Context: {context.theme} - {context.size}
Button ID: {buttonId}
Constant: {constantValue}
- {errorBoundary.error &&
Error captured
}
ServerData: {serverData ? JSON.stringify(serverData) : 'N/A'}
Serialized N: {customSerialized.value.n}
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n\n\n\n$fallback$?\n\n\n\n\n\n\n\n(error: any) => unknown\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\n\n\nerror\n\n\n\n\n\n\n\nany \\| undefined\n\n\n\n\n\n\n", - "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/shared/error/error-handling.ts", - "mdFile": "core.errorboundarystore.md" - }, { "name": "event$", "id": "event_", @@ -2622,20 +2608,6 @@ "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-context.ts", "mdFile": "core.usecontextprovider.md" }, - { - "name": "useErrorBoundary", - "id": "useerrorboundary", - "hierarchy": [ - { - "name": "useErrorBoundary", - "id": "useerrorboundary" - } - ], - "kind": "Function", - "content": "```typescript\nuseErrorBoundary: () => ErrorBoundaryStore\n```\n**Returns:**\n\n[ErrorBoundaryStore](#errorboundarystore)", - "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-error-boundary.ts", - "mdFile": "core.useerrorboundary.md" - }, { "name": "useId", "id": "useid", diff --git a/packages/docs/src/routes/api/qwik/index.mdx b/packages/docs/src/routes/api/qwik/index.mdx index ea1dafcb37a..2bec95e59e3 100644 --- a/packages/docs/src/routes/api/qwik/index.mdx +++ b/packages/docs/src/routes/api/qwik/index.mdx @@ -1670,61 +1670,6 @@ Rendered in place of the subtree when a descendant throws during render. Lazily [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/shared/error/error-boundary.ts) -

ErrorBoundaryStore

- -```typescript -export interface ErrorBoundaryStore -``` - - - - -
- -Property - - - -Modifiers - - - -Type - - - -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 - - - - - -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) -

useErrorBoundary

- -```typescript -useErrorBoundary: () => ErrorBoundaryStore; -``` - -**Returns:** - -[ErrorBoundaryStore](#errorboundarystore) - -[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-error-boundary.ts) -

useId

```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\n\n\n\n\n\n
\n\nMember\n\n\n\n\nValue\n\n\n\n\nDescription\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\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\nnoSPA\n\n\n\n\n`\"noSPA\"`\n\n\n\n\nDisable SPA navigation handler in Qwik Router\n\n\n
\n\nshow\n\n\n\n\n`\"show\"`\n\n\n\n\nEnable the Show conditional primitive\n\n\n
\n\nsuspense\n\n\n\n\n`\"suspense\"`\n\n\n\n\nEnable the Suspense fallback primitive\n\n\n
\n\nvalibot\n\n\n\n\n`\"valibot\"`\n\n\n\n\nEnable the Valibot form validation\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\n\n\n\n\n\n\n
\n\nMember\n\n\n\n\nValue\n\n\n\n\nDescription\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\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\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\nnoSPA\n\n\n\n\n`\"noSPA\"`\n\n\n\n\nDisable SPA navigation handler in Qwik Router\n\n\n
\n\nshow\n\n\n\n\n`\"show\"`\n\n\n\n\nEnable the Show conditional primitive\n\n\n
\n\nsuspense\n\n\n\n\n`\"suspense\"`\n\n\n\n\nEnable the Suspense fallback primitive\n\n\n
\n\nvalibot\n\n\n\n\n`\"valibot\"`\n\n\n\n\nEnable the Valibot form validation\n\n\n
", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/plugins/plugin.ts", "mdFile": "qwik-vite.experimentalfeatures.md" }, diff --git a/packages/docs/src/routes/api/qwik-optimizer/index.mdx b/packages/docs/src/routes/api/qwik-optimizer/index.mdx index 0adfb97fc62..89174fd1c81 100644 --- a/packages/docs/src/routes/api/qwik-optimizer/index.mdx +++ b/packages/docs/src/routes/api/qwik-optimizer/index.mdx @@ -24,6 +24,8 @@ export type BundleGraphAdder = (manifest: QwikManifest) => Record<

each

+

errorBoundary

+

ExperimentalFeatures

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(
keep
, options); + const cp = ssr.checkpoint(); + await ssr.renderJSX( +
+ discarded +
, + options + ); + ssr.rollback(cp); + await ssr.renderJSX(

fallback

, 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}> + ( +

caught: {e.message}

+ ))} + > +
before
+ +
after
+
+
+
+ ); + expect(document.querySelector('#fb')?.textContent).toContain('caught: boom'); + expect(document.querySelector('#before')).toBeFalsy(); + expect(document.querySelector('#after')).toBeFalsy(); + }); + + it('nested boundaries: the inner one catches, the outer subtree is untouched', async () => { + const document = await ssrRenderResumed( +
+ ( +

outer

+ ))} + > +
outer-sibling
+ ( +

inner

+ ))} + > +
before
+ +
after
+
+
+
+ ); + 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 ( +
+

caught: {props.msg}

+ + {count.value} +
+ ); +}); + +const EbContent = component$(() => { + const count = useSignal(0); + return ( +
+

streamed content

+ + {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}> + + +
+ + + ) : ( + }> + + + + )} +
Footer shell
+
+ ); +}); 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
+
tail
); // 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( +
+ ( +

caught: {String(e?.message ?? e)}

+ ))} + > +
before
+ +
+
+ ); + expect(document.querySelector('#fb')?.textContent).toContain('caught: async signal boom'); + expect(displayOf(document.querySelector('#before')?.closest('div[style]'))).toBe('none'); + }); + + it('sibling boundaries swap independently', async () => { + const { document } = await streamAndResume( +
+ ( +

A failed

+ ))} + > + +
+ ( +

B failed

+ ))} + > +
b ok
+
+
+ ); + // 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