diff --git a/.changeset/devtools-drop-use-error-boundary.md b/.changeset/devtools-drop-use-error-boundary.md new file mode 100644 index 00000000000..8a36385b269 --- /dev/null +++ b/.changeset/devtools-drop-use-error-boundary.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/devtools': patch +--- + +fix: drop the removed `useErrorBoundary` hook from the devtools hook lists diff --git a/.changeset/errorboundary-to-core.md b/.changeset/errorboundary-to-core.md new file mode 100644 index 00000000000..c430e472046 --- /dev/null +++ b/.changeset/errorboundary-to-core.md @@ -0,0 +1,6 @@ +--- +'@qwik.dev/core': major +'@qwik.dev/router': major +--- + +feat: `` moves to `@qwik.dev/core` behind the experimental `errorBoundary` flag and `useErrorBoundary()` is removed. `fallback$` receives `(error, reset)`, `onError$` reports caught errors, and production redacts server-origin errors to a generic message + digest while client-origin errors render as thrown. diff --git a/.changeset/errored-pages-no-store.md b/.changeset/errored-pages-no-store.md new file mode 100644 index 00000000000..916757c2976 --- /dev/null +++ b/.changeset/errored-pages-no-store.md @@ -0,0 +1,6 @@ +--- +'@qwik.dev/core': minor +'@qwik.dev/router': patch +--- + +feat: `renderToStream` reports a pre-flush `` catch via `onBeforeFirstFlush`; the router sends `Cache-Control: no-store` for those pages and for error documents. diff --git a/.changeset/redact-serialized-signal-errors.md b/.changeset/redact-serialized-signal-errors.md new file mode 100644 index 00000000000..b4f928f0b91 --- /dev/null +++ b/.changeset/redact-serialized-signal-errors.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': patch +--- + +fix: redact server-origin errors when serializing errored async signals in production diff --git a/.changeset/suspense-deferral-through-component.md b/.changeset/suspense-deferral-through-component.md new file mode 100644 index 00000000000..29b5b05c3e8 --- /dev/null +++ b/.changeset/suspense-deferral-through-component.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': patch +--- + +fix: show the `Suspense` fallback when a suspending child is wrapped in an `ErrorBoundary`. diff --git a/.changeset/tidy-moles-smoke.md b/.changeset/tidy-moles-smoke.md new file mode 100644 index 00000000000..590647daa47 --- /dev/null +++ b/.changeset/tidy-moles-smoke.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': patch +--- + +fix: a failed handler chunk import is no longer reported twice for streamed containers diff --git a/.ruler/skills/qwik-core-development/references/core-notes.md b/.ruler/skills/qwik-core-development/references/core-notes.md index a041b43fd61..81b6154590b 100644 --- a/.ruler/skills/qwik-core-development/references/core-notes.md +++ b/.ruler/skills/qwik-core-development/references/core-notes.md @@ -127,7 +127,7 @@ When a core value gains serialized state: 1. Update the serializer and inflater together. 2. Keep array positions or marker encodings documented in the code that owns them. 3. Add a round-trip test in `shared/serdes` or the closest subsystem. -4. Check SSR and client resume behavior when the value affects hydration or streamed state. +4. Check SSR and client resume behavior when the value affects resumed or streamed state. For AsyncSignal fields, inspect the serdes tests that deserialize async signals and verify concurrency, timeout, stale value, and error/loading state behavior. @@ -174,6 +174,17 @@ streaming, navigation, or integration with fixture apps. For Qwik e2e, load Never use `pnpm test.unit` for agent verification in this repo. +## ErrorBoundary (experimental `errorBoundary`) + +Keep error state non-enumerable and out of serialized state. Store the raw throw and project it only +at display sites; redaction is origin-based — the server display redacts in production unless the +app returns an `Error` from `transformError`; the client display always shows the error as thrown. + +Reset must re-render the component that authored projected children. Identify and retain that +component as soon as SSR error teardown determines it; store its VNode reference only when the +projection cut prevents the client owner walk. Re-key the highest wrapper below that author so +normal diffing recreates emptied projected content without projection-wide scheduling. + ## Keep This Reference Fresh Before finishing a core task, ask: diff --git a/.ruler/skills/qwik-e2e-verification/SKILL.md b/.ruler/skills/qwik-e2e-verification/SKILL.md index 14ea1efd797..9db94ae7ed3 100644 --- a/.ruler/skills/qwik-e2e-verification/SKILL.md +++ b/.ruler/skills/qwik-e2e-verification/SKILL.md @@ -27,6 +27,10 @@ Use this skill for Playwright e2e work in `e2e/**`. Keep the repo-wide rules fro - CLI e2e: `e2e/qwik-cli-e2e/tests/` - React integration e2e: `e2e/qwik-react-e2e/tests/` +Apps whose directory names contain `.prod` run in production mode under the shared Qwik dev server. +Keep exact production-only assertions in those fixtures; regular fixtures must pass after either +`build.core.dev` or `build.core`. + ## Commands Main Qwik e2e config uses `--browser`, not Playwright projects: @@ -58,6 +62,7 @@ pnpm playwright test e2e/qwik-react-e2e/tests/reactify.spec.ts --project=chromiu Traps: +- The core budget is skipped after `build.core.dev`; run `pnpm build.core` to measure it. - `test.e2e.router.ssg.update` regenerates the goldens only; it never touches the budgets, so a budget breach survives the update. - The update run can exit non-zero because a sibling budget assertion failed, not the golden. "N diff --git a/e2e/qwik-e2e/apps/e2e/src/components/events/events.tsx b/e2e/qwik-e2e/apps/e2e/src/components/events/events.tsx index 3913d9c3849..09934f94896 100644 --- a/e2e/qwik-e2e/apps/e2e/src/components/events/events.tsx +++ b/e2e/qwik-e2e/apps/e2e/src/components/events/events.tsx @@ -73,9 +73,9 @@ const EventsParent = component$(() => { { - ev.stopPropagation(); + onClick$={() => { store.countAnchor++; }} > diff --git a/e2e/qwik-e2e/apps/e2e/src/components/suspense/ooos.tsx b/e2e/qwik-e2e/apps/e2e/src/components/suspense/ooos.tsx index 35100d0b906..6285d5e3919 100644 --- a/e2e/qwik-e2e/apps/e2e/src/components/suspense/ooos.tsx +++ b/e2e/qwik-e2e/apps/e2e/src/components/suspense/ooos.tsx @@ -9,21 +9,8 @@ import { type Signal, } from '@qwik.dev/core'; import { SSRRaw, SSRStream, type SSRStreamWriter } from '@qwik.dev/core/internal'; - -type OutOfOrderReleaseStore = { - resolved: Set; - resolvers: Map void>>; -}; - -const getOutOfOrderReleaseStore = (): OutOfOrderReleaseStore => - ((globalThis as any).__qwikOOOSReleaseStore ||= { - resolved: new Set(), - resolvers: new Map void>>(), - }); - -const getOutOfOrderReleaseKey = (requestId: string, releaseId: string): string => { - return `${requestId}:${releaseId}`; -}; +import { waitForRelease } from '../../../../../utils/release-gate'; +import { WEBKIT_STREAMING_FLUSH } from '../../../../../utils/webkit-flush'; const getSearchParam = (url: string | undefined, name: string): string | null => { return url ? new URL(url).searchParams.get(name) : null; @@ -33,22 +20,7 @@ const waitForOutOfOrderRelease = ( requestId: string, releaseId: string, value: JSXOutput -): Promise => { - return new Promise((resolve) => { - const release = () => resolve(value); - const store = getOutOfOrderReleaseStore(); - const key = getOutOfOrderReleaseKey(requestId, releaseId); - if (store.resolved.has(key)) { - release(); - } else { - let resolvers = store.resolvers.get(key); - if (!resolvers) { - store.resolvers.set(key, (resolvers = new Set())); - } - resolvers.add(release); - } - }); -}; +): Promise => waitForRelease(requestId, releaseId).then(() => value); const escapeHtml = (value: string): string => value.replace(/&/g, '&').replace(//g, '>'); @@ -56,10 +28,6 @@ const escapeHtml = (value: string): string => const escapeAttr = (value: string): string => escapeHtml(value).replace(/"/g, '"').replace(/'/g, '''); -// WebKit buffers streamed HTML until enough early body content is emitted. -// https://bugs.webkit.org/show_bug.cgi?id=265386 -const WEBKIT_STREAMING_FLUSH = '\u200b'.repeat(512); - export const OutOfOrderSuspenseRoot = component$(() => { const shellCount = useSignal(0); const render = useSignal(0); diff --git a/e2e/qwik-e2e/apps/e2e/tsconfig.json b/e2e/qwik-e2e/apps/e2e/tsconfig.json index 7f024c4259f..ed70a50075e 100644 --- a/e2e/qwik-e2e/apps/e2e/tsconfig.json +++ b/e2e/qwik-e2e/apps/e2e/tsconfig.json @@ -18,7 +18,6 @@ "outDir": "tmp", "noEmit": true, "types": ["node", "vite/client"], - "rootDir": ".", "paths": { "~/*": ["./src/*"] } diff --git a/e2e/qwik-e2e/apps/error-handling-fragment/package.json b/e2e/qwik-e2e/apps/error-handling-fragment/package.json new file mode 100644 index 00000000000..0dec362b66e --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling-fragment/package.json @@ -0,0 +1,6 @@ +{ + "name": "error-handling-fragment", + "description": "Serves the embedded container fragment inlined by the error-handling multi-container route.", + "private": true, + "type": "module" +} diff --git a/e2e/qwik-e2e/apps/error-handling-fragment/src/entry.ssr.tsx b/e2e/qwik-e2e/apps/error-handling-fragment/src/entry.ssr.tsx new file mode 100644 index 00000000000..91f9dab42ba --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling-fragment/src/entry.ssr.tsx @@ -0,0 +1,11 @@ +import { renderToStream, type RenderToStreamOptions } from '@qwik.dev/core/server'; +import Root from './root'; + +export default function (opts: RenderToStreamOptions) { + return renderToStream(, { + containerTagName: 'container', + qwikLoader: { include: 'never' }, + ...opts, + streaming: { ...opts.streaming, outOfOrder: false }, + }); +} diff --git a/e2e/qwik-e2e/apps/error-handling-fragment/src/root.tsx b/e2e/qwik-e2e/apps/error-handling-fragment/src/root.tsx new file mode 100644 index 00000000000..3015bc2da95 --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling-fragment/src/root.tsx @@ -0,0 +1,37 @@ +import { $, component$, ErrorBoundary, isServer, sync$, type JSXOutput } from '@qwik.dev/core'; + +// onClick$ here would load a second core copy and re-trip Q30. +const bumpCount = sync$((_: Event, el: Element) => { + const count = el.parentElement!.querySelector('#eb-fallback-count')!; + count.textContent = String(Number(count.textContent) + 1); +}); + +const FragmentFallback = component$((props: { msg: string }) => ( +
+

caught: {props.msg}

+ + 0 +
+)); + +const fragmentFallback = $((e: unknown) => ( + +)); + +const FragmentThrower = component$((): JSXOutput => { + if (isServer) { + throw new Error('fragment boom'); + } + return ; +}); + +export default component$(() => ( + +
+

fragment content

+
+ +
+)); diff --git a/e2e/qwik-e2e/apps/error-handling-fragment/tsconfig.json b/e2e/qwik-e2e/apps/error-handling-fragment/tsconfig.json new file mode 100644 index 00000000000..282d52f34cc --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling-fragment/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "allowJs": true, + "target": "ES2020", + "module": "ES2022", + "lib": ["es2022", "DOM", "WebWorker", "DOM.Iterable"], + "jsx": "react-jsx", + "jsxImportSource": "@qwik.dev/core", + "strict": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "moduleResolution": "Bundler", + "esModuleInterop": true, + "allowImportingTsExtensions": true, + "skipLibCheck": true, + "incremental": true, + "isolatedModules": true, + "outDir": "tmp", + "noEmit": true, + "types": ["node", "vite/client"], + "rootDir": "." + } +} diff --git a/e2e/qwik-e2e/apps/error-handling.prod/package.json b/e2e/qwik-e2e/apps/error-handling.prod/package.json new file mode 100644 index 00000000000..f6f09fb0288 --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling.prod/package.json @@ -0,0 +1,9 @@ +{ + "name": "error-handling.prod", + "description": "Prod-mode (qDev=false) twin of the error-handling router app; re-exports its routes.", + "private": true, + "type": "module", + "__qwik__": { + "qwikRouter": true + } +} diff --git a/e2e/qwik-e2e/apps/error-handling.prod/src/entry.ssr.tsx b/e2e/qwik-e2e/apps/error-handling.prod/src/entry.ssr.tsx new file mode 100644 index 00000000000..6e7665ea545 --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling.prod/src/entry.ssr.tsx @@ -0,0 +1,15 @@ +import { renderToStream, type RenderToStreamOptions } from '@qwik.dev/core/server'; +import Root from '../../error-handling/src/root'; + +export default function (opts: RenderToStreamOptions) { + const url = opts.serverData?.url ? new URL(opts.serverData.url) : undefined; + const outOfOrder = url?.searchParams.get('outOfOrder') !== 'false'; + return renderToStream(, { + base: '/error-handling.prod/build/', + ...opts, + streaming: { + ...opts.streaming, + outOfOrder, + }, + }); +} diff --git a/e2e/qwik-e2e/apps/error-handling.prod/src/root.tsx b/e2e/qwik-e2e/apps/error-handling.prod/src/root.tsx new file mode 100644 index 00000000000..2e0d18b6df8 --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling.prod/src/root.tsx @@ -0,0 +1 @@ +export { default } from '../../error-handling/src/root'; diff --git a/e2e/qwik-e2e/apps/error-handling.prod/src/routes/async-error-captured/index.tsx b/e2e/qwik-e2e/apps/error-handling.prod/src/routes/async-error-captured/index.tsx new file mode 100644 index 00000000000..d96e4ffa0d5 --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling.prod/src/routes/async-error-captured/index.tsx @@ -0,0 +1 @@ +export { default } from '../../../../error-handling/src/routes/async-error-captured/index'; diff --git a/e2e/qwik-e2e/apps/error-handling.prod/src/routes/basic/index.tsx b/e2e/qwik-e2e/apps/error-handling.prod/src/routes/basic/index.tsx new file mode 100644 index 00000000000..519e8628745 --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling.prod/src/routes/basic/index.tsx @@ -0,0 +1 @@ +export { default } from '../../../../error-handling/src/routes/basic/index'; diff --git a/e2e/qwik-e2e/apps/error-handling.prod/src/routes/csr-event/index.tsx b/e2e/qwik-e2e/apps/error-handling.prod/src/routes/csr-event/index.tsx new file mode 100644 index 00000000000..7070a7818a6 --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling.prod/src/routes/csr-event/index.tsx @@ -0,0 +1 @@ +export { default } from '../../../../error-handling/src/routes/csr-event/index'; diff --git a/e2e/qwik-e2e/apps/error-handling.prod/src/routes/digest-forgery/index.tsx b/e2e/qwik-e2e/apps/error-handling.prod/src/routes/digest-forgery/index.tsx new file mode 100644 index 00000000000..06015cbb2b0 --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling.prod/src/routes/digest-forgery/index.tsx @@ -0,0 +1 @@ +export { default } from '../../../../error-handling/src/routes/digest-forgery/index'; diff --git a/e2e/qwik-e2e/apps/error-handling.prod/src/routes/layout.tsx b/e2e/qwik-e2e/apps/error-handling.prod/src/routes/layout.tsx new file mode 100644 index 00000000000..ab6ac0dd301 --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling.prod/src/routes/layout.tsx @@ -0,0 +1 @@ +export { default } from '../../../error-handling/src/routes/layout'; diff --git a/e2e/qwik-e2e/apps/error-handling.prod/src/routes/rederive/index.tsx b/e2e/qwik-e2e/apps/error-handling.prod/src/routes/rederive/index.tsx new file mode 100644 index 00000000000..f055e5294cf --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling.prod/src/routes/rederive/index.tsx @@ -0,0 +1 @@ +export { default } from '../../../../error-handling/src/routes/rederive/index'; diff --git a/e2e/qwik-e2e/apps/error-handling.prod/src/routes/reset/index.tsx b/e2e/qwik-e2e/apps/error-handling.prod/src/routes/reset/index.tsx new file mode 100644 index 00000000000..3668489e8bd --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling.prod/src/routes/reset/index.tsx @@ -0,0 +1 @@ +export { default } from '../../../../error-handling/src/routes/reset/index'; diff --git a/e2e/qwik-e2e/apps/error-handling.prod/tsconfig.json b/e2e/qwik-e2e/apps/error-handling.prod/tsconfig.json new file mode 100644 index 00000000000..911c151e06d --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling.prod/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "allowJs": true, + "target": "ES2020", + "module": "ES2022", + "lib": ["es2023", "DOM", "WebWorker", "DOM.Iterable"], + "jsx": "react-jsx", + "jsxImportSource": "@qwik.dev/core", + "strict": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "moduleResolution": "Bundler", + "esModuleInterop": true, + "skipLibCheck": true, + "incremental": true, + "isolatedModules": true, + "outDir": "tmp", + "noEmit": true + }, + "include": ["src", "./*.d.ts", "./*.config.ts"] +} diff --git a/e2e/qwik-e2e/apps/error-handling/package.json b/e2e/qwik-e2e/apps/error-handling/package.json new file mode 100644 index 00000000000..e4c7f4b4ffd --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling/package.json @@ -0,0 +1,9 @@ +{ + "name": "error-handling", + "description": "Error-handling e2e app: one route per ErrorBoundary/loader scenario.", + "private": true, + "type": "module", + "__qwik__": { + "qwikRouter": true + } +} diff --git a/e2e/qwik-e2e/apps/error-handling/src/components/error-boundary/error-boundary.tsx b/e2e/qwik-e2e/apps/error-handling/src/components/error-boundary/error-boundary.tsx new file mode 100644 index 00000000000..d7217c6764c --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling/src/components/error-boundary/error-boundary.tsx @@ -0,0 +1,102 @@ +import { $, component$, isServer, Slot, useSignal, type JSXOutput, type QRL } from '@qwik.dev/core'; + +export const errMsg = (e: unknown) => String((e as any)?.message ?? e); + +export const EbFallback = component$((props: { msg: string; id?: string }) => { + const id = props.id ?? 'eb-fallback'; + const count = useSignal(0); + return ( +
+

caught: {props.msg}

+ + {count.value} +
+ ); +}); + +export const defaultFallback = $((e: unknown) => ); +export const outerFallback = $((e: unknown) => ); +export const innerFallback = $((e: unknown) => ); + +export const resetFallback = $((e: unknown, reset: QRL<() => void>) => ( +
+

caught: {errMsg(e)}

+ +
+)); + +export const EbContent = component$(() => { + const count = useSignal(0); + return ( +
+

streamed content

+ + {count.value} +
+ ); +}); + +export const EbSyncThrower = component$<{ message?: string; clientId?: string }>((props) => { + if (isServer) { + throw new Error(props.message ?? 'eb sync boom'); + } + return ; +}); + +export const EbAlwaysThrower = component$<{ message?: string }>((props): JSXOutput => { + throw new Error(props.message ?? 'eb always boom'); +}); + +export const EbThrowOnClick = component$<{ idPrefix: string; message: string; label?: string }>( + (props) => { + const touched = useSignal(0); + return ( + <> + + {touched.value} + + ); + } +); + +export const EbReErrorAsync = component$(() => { + if (isServer) { + return new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error('eb reerror ssr boom')), 50); + }) as unknown as JSXOutput; + } + const runs = ((window as any).__ebReErrorRuns = ((window as any).__ebReErrorRuns ?? 0) + 1); + if (runs < 2) { + throw new Error('eb reerror client boom ' + runs); + } + return

recovered after {runs} runs

; +}); + +export const EbWrapper = component$(() => ( +
+ +
+)); + +export const EbWrapAsync = component$(() => { + if (isServer) { + return new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error('eb wrap async boom')), 200); + }) as unknown as JSXOutput; + } + return

recovered

; +}); diff --git a/e2e/qwik-e2e/apps/error-handling/src/components/release/release.tsx b/e2e/qwik-e2e/apps/error-handling/src/components/release/release.tsx new file mode 100644 index 00000000000..2380f4a3394 --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling/src/components/release/release.tsx @@ -0,0 +1,29 @@ +import { component$, isServer, type JSXOutput } from '@qwik.dev/core'; +import { releaseGated } from '../../../../../utils/release-gate'; + +// Raw inline onclick on purpose: the held stream means no framework JS has loaded yet. +export const ReleaseButton = component$<{ + requestId: string; + releaseId: string | null; + label: string; +}>(({ requestId, releaseId, label }) => { + if (!releaseId) { + return null; + } + const releaseUrl = `/__ooos-release/${encodeURIComponent(requestId)}/${encodeURIComponent( + releaseId + )}`; + const html = ``; + return ; +}); + +export const EbGatedOk = component$<{ requestId: string; releaseId: string | null }>( + ({ requestId, releaseId }) => { + if (isServer) { + return releaseGated(requestId, releaseId, () => ( + deferred ok + )) as unknown as JSXOutput; + } + return deferred ok; + } +); diff --git a/e2e/qwik-e2e/apps/error-handling/src/entry.ssr.tsx b/e2e/qwik-e2e/apps/error-handling/src/entry.ssr.tsx new file mode 100644 index 00000000000..40c741613e8 --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling/src/entry.ssr.tsx @@ -0,0 +1,17 @@ +import { renderToStream, type RenderToStreamOptions } from '@qwik.dev/core/server'; +import Root from './root'; + +export default function (opts: RenderToStreamOptions) { + const url = opts.serverData?.url ? new URL(opts.serverData.url) : undefined; + const outOfOrder = url?.searchParams.get('outOfOrder') !== 'false'; + const inOrderDirect = url?.searchParams.get('inOrderStrategy') === 'direct'; + return renderToStream(, { + base: '/error-handling/build/', + ...opts, + streaming: { + ...opts.streaming, + outOfOrder, + ...(inOrderDirect ? { inOrder: { strategy: 'direct' as const } } : undefined), + }, + }); +} diff --git a/e2e/qwik-e2e/apps/error-handling/src/global.css b/e2e/qwik-e2e/apps/error-handling/src/global.css new file mode 100644 index 00000000000..d8183051505 --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling/src/global.css @@ -0,0 +1,8 @@ +body { + margin: 0; + font-family: system-ui, sans-serif; +} + +main { + padding: 10px 20px; +} diff --git a/e2e/qwik-e2e/apps/error-handling/src/root.tsx b/e2e/qwik-e2e/apps/error-handling/src/root.tsx new file mode 100644 index 00000000000..6896ce65d00 --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling/src/root.tsx @@ -0,0 +1,20 @@ +import { component$ } from '@qwik.dev/core'; +import { RouterOutlet, useQwikRouter } from '@qwik.dev/router'; +import { LogConsole } from '../../../log-console'; +import './global.css'; + +export default component$(function Root() { + useQwikRouter(); + return ( + <> + + + Error handling e2e + + + + + + + ); +}); diff --git a/e2e/qwik-e2e/apps/error-handling/src/routes/async-error-captured/index.tsx b/e2e/qwik-e2e/apps/error-handling/src/routes/async-error-captured/index.tsx new file mode 100644 index 00000000000..1e7dc372378 --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling/src/routes/async-error-captured/index.tsx @@ -0,0 +1,28 @@ +import { component$, useComputed$, useSignal } from '@qwik.dev/core'; + +const CapturedAsyncError = component$(() => { + const digestProbe = useSignal('unread'); + const data = useComputed$(async (): Promise => { + throw new Error('captured-async-boom'); + }); + if (data.pending) { + return loading; + } + return ( +
+
{data.error ? 'errored' : 'ok'}
+ + {digestProbe.value} +
+ ); +}); + +export default component$(() => ); diff --git a/e2e/qwik-e2e/apps/error-handling/src/routes/async-error-inline/index.tsx b/e2e/qwik-e2e/apps/error-handling/src/routes/async-error-inline/index.tsx new file mode 100644 index 00000000000..49c1fe43d62 --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling/src/routes/async-error-inline/index.tsx @@ -0,0 +1,18 @@ +import { component$, ErrorBoundary, useComputed$ } from '@qwik.dev/core'; +import { defaultFallback } from '../../components/error-boundary/error-boundary'; + +const AsyncErrorInline = component$(() => { + const data = useComputed$(async () => { + throw new Error('expected-async-error'); + }); + if (data.pending) { + return loading; + } + return
handled: {(data.error as Error)?.message ?? 'none'}
; +}); + +export default component$(() => ( + + + +)); diff --git a/e2e/qwik-e2e/apps/error-handling/src/routes/async-error-throw/index.tsx b/e2e/qwik-e2e/apps/error-handling/src/routes/async-error-throw/index.tsx new file mode 100644 index 00000000000..0f4dcf120ad --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling/src/routes/async-error-throw/index.tsx @@ -0,0 +1,17 @@ +import { component$, ErrorBoundary, Suspense, useComputed$ } from '@qwik.dev/core'; +import { defaultFallback } from '../../components/error-boundary/error-boundary'; + +const AsyncValueThrows = component$(() => { + const data = useComputed$(async () => { + throw new Error('unexpected-async-error'); + }); + return
{String(data.value)}
; +}); + +export default component$(() => ( + + loading
}> + + +
+)); diff --git a/e2e/qwik-e2e/apps/error-handling/src/routes/async/index.tsx b/e2e/qwik-e2e/apps/error-handling/src/routes/async/index.tsx new file mode 100644 index 00000000000..d6cbc7f1014 --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling/src/routes/async/index.tsx @@ -0,0 +1,42 @@ +import { + component$, + ErrorBoundary, + isServer, + Suspense, + useServerData, + useSignal, + type JSXOutput, +} from '@qwik.dev/core'; +import { releaseGated } from '../../../../../utils/release-gate'; +import { defaultFallback } from '../../components/error-boundary/error-boundary'; +import { ReleaseButton } from '../../components/release/release'; + +const EbAsyncThrower = component$<{ requestId: string; releaseId: string | null }>( + ({ requestId, releaseId }) => { + if (isServer) { + return releaseGated(requestId, releaseId, (): JSXOutput => { + throw new Error('eb async boom'); + }) as unknown as JSXOutput; + } + return ; + } +); + +export default component$(() => { + const url = useServerData('url'); + const releaseId = url ? new URL(url).searchParams.get('release') : null; + const requestId = useSignal( + `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` + ).value; + return ( + <> + +
sibling
+ loading
}> + + +
+ + + ); +}); diff --git a/e2e/qwik-e2e/apps/error-handling/src/routes/basic/index.tsx b/e2e/qwik-e2e/apps/error-handling/src/routes/basic/index.tsx new file mode 100644 index 00000000000..fec6265d961 --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling/src/routes/basic/index.tsx @@ -0,0 +1,13 @@ +import { component$, ErrorBoundary } from '@qwik.dev/core'; +import { + defaultFallback, + EbContent, + EbSyncThrower, +} from '../../components/error-boundary/error-boundary'; + +export default component$(() => ( + + + + +)); diff --git a/e2e/qwik-e2e/apps/error-handling/src/routes/csr-event/index.tsx b/e2e/qwik-e2e/apps/error-handling/src/routes/csr-event/index.tsx new file mode 100644 index 00000000000..bea10c36f04 --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling/src/routes/csr-event/index.tsx @@ -0,0 +1,9 @@ +import { component$, ErrorBoundary } from '@qwik.dev/core'; +import { defaultFallback, EbThrowOnClick } from '../../components/error-boundary/error-boundary'; + +export default component$(() => ( + + +
content ok
+
+)); diff --git a/e2e/qwik-e2e/apps/error-handling/src/routes/digest-forgery/index.tsx b/e2e/qwik-e2e/apps/error-handling/src/routes/digest-forgery/index.tsx new file mode 100644 index 00000000000..c5b68b4460b --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling/src/routes/digest-forgery/index.tsx @@ -0,0 +1,24 @@ +import { $, component$, ErrorBoundary, isServer } from '@qwik.dev/core'; +import { errMsg } from '../../components/error-boundary/error-boundary'; + +const EbForgedDigestThrower = component$(() => { + if (isServer) { + const err = new Error('digest secret boom') as Error & { digest?: string }; + err.digest = 'forged-digest'; + throw err; + } + return ; +}); + +const digestFallback = $((e: Error & { digest?: string }) => ( +
+

caught: {errMsg(e)}

+ {e.digest ?? 'none'} +
+)); + +export default component$(() => ( + + + +)); diff --git a/e2e/qwik-e2e/apps/error-handling/src/routes/fallback-nested-resume/index.tsx b/e2e/qwik-e2e/apps/error-handling/src/routes/fallback-nested-resume/index.tsx new file mode 100644 index 00000000000..8d0fed34aac --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling/src/routes/fallback-nested-resume/index.tsx @@ -0,0 +1,30 @@ +import { component$, ErrorBoundary } from '@qwik.dev/core'; +import { + EbAlwaysThrower, + EbSyncThrower, + errMsg, +} from '../../components/error-boundary/error-boundary'; + +export default component$(() => ( + ( +
+

outer: {errMsg(e)}

+ ( +
+

inner: {errMsg(ie)}

+ +
+ )} + > + +
+
+ )} + > + +
+)); diff --git a/e2e/qwik-e2e/apps/error-handling/src/routes/fallback-throws/index.tsx b/e2e/qwik-e2e/apps/error-handling/src/routes/fallback-throws/index.tsx new file mode 100644 index 00000000000..3d6f76212f5 --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling/src/routes/fallback-throws/index.tsx @@ -0,0 +1,19 @@ +import { component$, ErrorBoundary } from '@qwik.dev/core'; +import { + EbContent, + EbSyncThrower, + outerFallback, +} from '../../components/error-boundary/error-boundary'; + +export default component$(() => ( + + { + throw new Error('inner fallback boom'); + }} + > + + + + +)); diff --git a/e2e/qwik-e2e/apps/error-handling/src/routes/happy/index.tsx b/e2e/qwik-e2e/apps/error-handling/src/routes/happy/index.tsx new file mode 100644 index 00000000000..13a3fd94e11 --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling/src/routes/happy/index.tsx @@ -0,0 +1,13 @@ +import { component$, ErrorBoundary } from '@qwik.dev/core'; +import { + defaultFallback, + EbContent, + EbThrowOnClick, +} from '../../components/error-boundary/error-boundary'; + +export default component$(() => ( + + + + +)); diff --git a/e2e/qwik-e2e/apps/error-handling/src/routes/inert/index.tsx b/e2e/qwik-e2e/apps/error-handling/src/routes/inert/index.tsx new file mode 100644 index 00000000000..6c6b37d6c67 --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling/src/routes/inert/index.tsx @@ -0,0 +1,39 @@ +import { + component$, + ErrorBoundary, + isServer, + useSignal, + useTask$, + type Signal, +} from '@qwik.dev/core'; +import { defaultFallback, EbSyncThrower } from '../../components/error-boundary/error-boundary'; + +const EbInertContent = component$<{ trigger: Signal }>((props) => { + useTask$(({ track }) => { + track(() => props.trigger.value); + if (!isServer) { + (window as any).__ebDeadTaskClientRuns = ((window as any).__ebDeadTaskClientRuns ?? 0) + 1; + } + }); + return ( +
+

streamed content

+ +
+ ); +}); + +export default component$(() => { + const inertTrigger = useSignal(0); + return ( + <> + + + + + {inertTrigger.value} + + ); +}); diff --git a/e2e/qwik-e2e/apps/error-handling/src/routes/last-resort/index.tsx b/e2e/qwik-e2e/apps/error-handling/src/routes/last-resort/index.tsx new file mode 100644 index 00000000000..baa67a23d14 --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling/src/routes/last-resort/index.tsx @@ -0,0 +1,9 @@ +import { component$, ErrorBoundary } from '@qwik.dev/core'; +import { defaultFallback, EbThrowOnClick } from '../../components/error-boundary/error-boundary'; + +export default component$(() => ( + + +
content ok
+
+)); diff --git a/e2e/qwik-e2e/apps/error-handling/src/routes/layout.tsx b/e2e/qwik-e2e/apps/error-handling/src/routes/layout.tsx new file mode 100644 index 00000000000..1ae9a4b27e2 --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling/src/routes/layout.tsx @@ -0,0 +1,9 @@ +import { component$, Slot } from '@qwik.dev/core'; + +export default component$(() => ( +
+

Error handling e2e

+ +
Footer shell
+
+)); diff --git a/e2e/qwik-e2e/apps/error-handling/src/routes/loader-500/index.tsx b/e2e/qwik-e2e/apps/error-handling/src/routes/loader-500/index.tsx new file mode 100644 index 00000000000..cda7463f192 --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling/src/routes/loader-500/index.tsx @@ -0,0 +1,18 @@ +import { component$, ErrorBoundary } from '@qwik.dev/core'; +import { routeLoader$ } from '@qwik.dev/router'; +import { defaultFallback } from '../../components/error-boundary/error-boundary'; + +export const useLoader500 = routeLoader$(({ error }) => { + throw error(500, 'loader-500-boom'); +}); + +const LoaderConsumer = component$(() => { + useLoader500(); + return

Should not render

; +}); + +export default component$(() => ( + + + +)); diff --git a/e2e/qwik-e2e/apps/error-handling/src/routes/loader-data-throw/index.tsx b/e2e/qwik-e2e/apps/error-handling/src/routes/loader-data-throw/index.tsx new file mode 100644 index 00000000000..c8450d9c935 --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling/src/routes/loader-data-throw/index.tsx @@ -0,0 +1,22 @@ +import { component$, ErrorBoundary } from '@qwik.dev/core'; +import { routeLoader$ } from '@qwik.dev/router'; +import { resetFallback } from '../../components/error-boundary/error-boundary'; + +export const useThrowingLoaderData = routeLoader$(() => ({ + shouldThrow: true, + secret: 'loader-data-secret', +})); + +const LoaderDataThrower = component$(() => { + const data = useThrowingLoaderData(); + if (data.value.shouldThrow) { + throw new Error('loader data boom: ' + data.value.secret); + } + return
content ok
; +}); + +export default component$(() => ( + + + +)); diff --git a/e2e/qwik-e2e/apps/error-handling/src/routes/loader-reset-no-refetch/index.tsx b/e2e/qwik-e2e/apps/error-handling/src/routes/loader-reset-no-refetch/index.tsx new file mode 100644 index 00000000000..a91dbd3afbb --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling/src/routes/loader-reset-no-refetch/index.tsx @@ -0,0 +1,19 @@ +import { component$, ErrorBoundary, isServer } from '@qwik.dev/core'; +import { routeLoader$ } from '@qwik.dev/router'; +import { resetFallback } from '../../components/error-boundary/error-boundary'; + +export const useLoaderResetData = routeLoader$(() => ({ message: 'loader-reset-data' })); + +const LoaderResetChild = component$(() => { + const data = useLoaderResetData(); + if (isServer) { + throw new Error('loader-reset boom'); + } + return
recovered: {data.value.message}
; +}); + +export default component$(() => ( + + + +)); diff --git a/e2e/qwik-e2e/apps/error-handling/src/routes/midstream/index.tsx b/e2e/qwik-e2e/apps/error-handling/src/routes/midstream/index.tsx new file mode 100644 index 00000000000..2934289fb32 --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling/src/routes/midstream/index.tsx @@ -0,0 +1,35 @@ +import { component$, ErrorBoundary, Suspense, useServerData, useSignal } from '@qwik.dev/core'; +import { + EbContent, + EbSyncThrower, + resetFallback, +} from '../../components/error-boundary/error-boundary'; +import { EbGatedOk, ReleaseButton } from '../../components/release/release'; +import { WEBKIT_STREAMING_FLUSH } from '../../../../../utils/webkit-flush'; + +export default component$(() => { + const url = useServerData('url'); + const params = url ? new URL(url).searchParams : null; + const releaseId = params?.get('release') ?? null; + const webkitFlush = params?.get('webkitFlush') === '1'; + const requestId = useSignal( + `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` + ).value; + return ( + <> + {webkitFlush ? ( + + ) : null} + + + + + + loading
}> + + + + ); +}); diff --git a/e2e/qwik-e2e/apps/error-handling/src/routes/multi-container/index.tsx b/e2e/qwik-e2e/apps/error-handling/src/routes/multi-container/index.tsx new file mode 100644 index 00000000000..63ed544818b --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling/src/routes/multi-container/index.tsx @@ -0,0 +1,34 @@ +import { component$, ErrorBoundary, Resource, useResource$, useSignal } from '@qwik.dev/core'; +import { EbFallback, errMsg } from '../../components/error-boundary/error-boundary'; + +const EmbeddedFragment = component$(() => { + const fragmentHtml = useResource$(async () => { + const url = `http://localhost:${(globalThis as any).PORT}/error-handling-fragment/`; + const res = await fetch(url); + return res.text(); + }); + return ( +
} + /> + ); +}); + +export default component$(() => { + const touched = useSignal(0); + return ( + <> + }> +
+

host content

+ + {touched.value} +
+
+ + + ); +}); diff --git a/e2e/qwik-e2e/apps/error-handling/src/routes/nested-client/index.tsx b/e2e/qwik-e2e/apps/error-handling/src/routes/nested-client/index.tsx new file mode 100644 index 00000000000..dc0440a6e80 --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling/src/routes/nested-client/index.tsx @@ -0,0 +1,16 @@ +import { component$, ErrorBoundary } from '@qwik.dev/core'; +import { + EbThrowOnClick, + innerFallback, + outerFallback, +} from '../../components/error-boundary/error-boundary'; + +export default component$(() => ( + +
outer ok
+ + +
content ok
+
+
+)); diff --git a/e2e/qwik-e2e/apps/error-handling/src/routes/nested-reset/index.tsx b/e2e/qwik-e2e/apps/error-handling/src/routes/nested-reset/index.tsx new file mode 100644 index 00000000000..2a83e2b6ccc --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling/src/routes/nested-reset/index.tsx @@ -0,0 +1,24 @@ +import { component$, ErrorBoundary, useSignal } from '@qwik.dev/core'; +import { + EbSyncThrower, + outerFallback, + resetFallback, +} from '../../components/error-boundary/error-boundary'; + +export default component$(() => { + const touched = useSignal(0); + return ( + +
+

outer ok

+ + {touched.value} +
+ + + +
+ ); +}); diff --git a/e2e/qwik-e2e/apps/error-handling/src/routes/nested-ssr/index.tsx b/e2e/qwik-e2e/apps/error-handling/src/routes/nested-ssr/index.tsx new file mode 100644 index 00000000000..3261d909be3 --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling/src/routes/nested-ssr/index.tsx @@ -0,0 +1,15 @@ +import { component$, ErrorBoundary } from '@qwik.dev/core'; +import { + EbSyncThrower, + innerFallback, + outerFallback, +} from '../../components/error-boundary/error-boundary'; + +export default component$(() => ( + + + + + + +)); diff --git a/e2e/qwik-e2e/apps/error-handling/src/routes/nested/index.tsx b/e2e/qwik-e2e/apps/error-handling/src/routes/nested/index.tsx new file mode 100644 index 00000000000..68965525ea8 --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling/src/routes/nested/index.tsx @@ -0,0 +1,16 @@ +import { component$, ErrorBoundary } from '@qwik.dev/core'; +import { + EbSyncThrower, + EbThrowOnClick, + innerFallback, + outerFallback, +} from '../../components/error-boundary/error-boundary'; + +export default component$(() => ( + + + + + + +)); diff --git a/e2e/qwik-e2e/apps/error-handling/src/routes/no-boundary/index.tsx b/e2e/qwik-e2e/apps/error-handling/src/routes/no-boundary/index.tsx new file mode 100644 index 00000000000..920b1be2dab --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling/src/routes/no-boundary/index.tsx @@ -0,0 +1,6 @@ +import { component$ } from '@qwik.dev/core'; +import { EbThrowOnClick } from '../../components/error-boundary/error-boundary'; + +export default component$(() => ( + +)); diff --git a/e2e/qwik-e2e/apps/error-handling/src/routes/onerror/index.tsx b/e2e/qwik-e2e/apps/error-handling/src/routes/onerror/index.tsx new file mode 100644 index 00000000000..44b1e3317e5 --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling/src/routes/onerror/index.tsx @@ -0,0 +1,17 @@ +import { component$, ErrorBoundary } from '@qwik.dev/core'; +import { defaultFallback, EbThrowOnClick } from '../../components/error-boundary/error-boundary'; + +export default component$(() => ( + { + (window as any).__ebOnErrorRuns = ((window as any).__ebOnErrorRuns ?? 0) + 1; + (window as any).__ebOnErrorMsg = (e as any)?.message ?? String(e); + (window as any).__ebOnErrorPhase = info?.phase; + (window as any).__ebOnErrorBoundaryId = info?.boundaryId; + }} + > + +
content ok
+
+)); diff --git a/e2e/qwik-e2e/apps/error-handling/src/routes/rederive/index.tsx b/e2e/qwik-e2e/apps/error-handling/src/routes/rederive/index.tsx new file mode 100644 index 00000000000..09e1b53c613 --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling/src/routes/rederive/index.tsx @@ -0,0 +1,15 @@ +import { $, component$, ErrorBoundary, isServer } from '@qwik.dev/core'; +import { EbAlwaysThrower, resetFallback } from '../../components/error-boundary/error-boundary'; + +export default component$(() => ( + { + if (!isServer) { + (window as any).__ebRederiveRuns = ((window as any).__ebRederiveRuns ?? 0) + 1; + } + })} + > + + +)); diff --git a/e2e/qwik-e2e/apps/error-handling/src/routes/reset-csr/index.tsx b/e2e/qwik-e2e/apps/error-handling/src/routes/reset-csr/index.tsx new file mode 100644 index 00000000000..6e5ab58ab9c --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling/src/routes/reset-csr/index.tsx @@ -0,0 +1,16 @@ +import { component$, ErrorBoundary } from '@qwik.dev/core'; +import { EbContent, resetFallback } from '../../components/error-boundary/error-boundary'; + +export default component$(() => ( + + + + +)); diff --git a/e2e/qwik-e2e/apps/error-handling/src/routes/reset-reerror/index.tsx b/e2e/qwik-e2e/apps/error-handling/src/routes/reset-reerror/index.tsx new file mode 100644 index 00000000000..f104bb6207a --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling/src/routes/reset-reerror/index.tsx @@ -0,0 +1,10 @@ +import { component$, ErrorBoundary, isServer, Suspense, type JSXOutput } from '@qwik.dev/core'; +import { EbReErrorAsync, resetFallback } from '../../components/error-boundary/error-boundary'; + +export default component$(() => ( + loading}> + + + + +)); diff --git a/e2e/qwik-e2e/apps/error-handling/src/routes/reset-spa/index.tsx b/e2e/qwik-e2e/apps/error-handling/src/routes/reset-spa/index.tsx new file mode 100644 index 00000000000..52b4c3a408b --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling/src/routes/reset-spa/index.tsx @@ -0,0 +1,27 @@ +import { + component$, + ErrorBoundary, + isServer, + Suspense, + useSignal, + type JSXOutput, +} from '@qwik.dev/core'; +import { EbReErrorAsync, resetFallback } from '../../components/error-boundary/error-boundary'; + +export default component$(() => { + const spaShow = useSignal(false); + return ( + <> + + {spaShow.value ? ( + loading}> + + + + + ) : null} + + ); +}); diff --git a/e2e/qwik-e2e/apps/error-handling/src/routes/reset-wrapped-key/index.tsx b/e2e/qwik-e2e/apps/error-handling/src/routes/reset-wrapped-key/index.tsx new file mode 100644 index 00000000000..3d9476578ee --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling/src/routes/reset-wrapped-key/index.tsx @@ -0,0 +1,25 @@ +import { component$, ErrorBoundary, Suspense, useSignal } from '@qwik.dev/core'; +import { EbWrapAsync, EbWrapper, errMsg } from '../../components/error-boundary/error-boundary'; + +export default component$(() => { + const attempt = useSignal(0); + return ( + loading}> + + ( +
+

caught: {errMsg(e)}

+ +
+ )} + > + +
+
+
+ ); +}); diff --git a/e2e/qwik-e2e/apps/error-handling/src/routes/reset-wrapped/index.tsx b/e2e/qwik-e2e/apps/error-handling/src/routes/reset-wrapped/index.tsx new file mode 100644 index 00000000000..64275603ee2 --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling/src/routes/reset-wrapped/index.tsx @@ -0,0 +1,16 @@ +import { component$, ErrorBoundary, Suspense } from '@qwik.dev/core'; +import { + EbWrapAsync, + EbWrapper, + resetFallback, +} from '../../components/error-boundary/error-boundary'; + +export default component$(() => ( + loading}> + + + + + + +)); diff --git a/e2e/qwik-e2e/apps/error-handling/src/routes/reset/index.tsx b/e2e/qwik-e2e/apps/error-handling/src/routes/reset/index.tsx new file mode 100644 index 00000000000..7e639899693 --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling/src/routes/reset/index.tsx @@ -0,0 +1,13 @@ +import { component$, ErrorBoundary } from '@qwik.dev/core'; +import { + EbContent, + EbSyncThrower, + resetFallback, +} from '../../components/error-boundary/error-boundary'; + +export default component$(() => ( + + + + +)); diff --git a/e2e/qwik-e2e/apps/error-handling/src/routes/sibling-suspense/index.tsx b/e2e/qwik-e2e/apps/error-handling/src/routes/sibling-suspense/index.tsx new file mode 100644 index 00000000000..ca73713a39b --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling/src/routes/sibling-suspense/index.tsx @@ -0,0 +1,27 @@ +import { component$, ErrorBoundary, Suspense, useServerData, useSignal } from '@qwik.dev/core'; +import { + defaultFallback, + EbContent, + EbSyncThrower, +} from '../../components/error-boundary/error-boundary'; +import { EbGatedOk, ReleaseButton } from '../../components/release/release'; + +export default component$(() => { + const url = useServerData('url'); + const releaseId = url ? new URL(url).searchParams.get('release') : null; + const requestId = useSignal( + `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` + ).value; + return ( + <> + + + + + loading}> + + + + + ); +}); diff --git a/e2e/qwik-e2e/apps/error-handling/src/routes/suspense-deferred/index.tsx b/e2e/qwik-e2e/apps/error-handling/src/routes/suspense-deferred/index.tsx new file mode 100644 index 00000000000..6c6bd40c230 --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling/src/routes/suspense-deferred/index.tsx @@ -0,0 +1,25 @@ +import { component$, ErrorBoundary, isServer, Suspense, type JSXOutput } from '@qwik.dev/core'; +import { + defaultFallback, + EbContent, + EbSyncThrower, +} from '../../components/error-boundary/error-boundary'; + +const EbDeferredOk = component$(() => { + if (isServer) { + return new Promise((resolve) => { + setTimeout(() => resolve(deferred ok), 50); + }) as unknown as JSXOutput; + } + return deferred ok; +}); + +export default component$(() => ( + loading}> + + + + + + +)); diff --git a/e2e/qwik-e2e/apps/error-handling/src/routes/visible-task/index.tsx b/e2e/qwik-e2e/apps/error-handling/src/routes/visible-task/index.tsx new file mode 100644 index 00000000000..129210b8fc4 --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling/src/routes/visible-task/index.tsx @@ -0,0 +1,15 @@ +import { component$, ErrorBoundary, useVisibleTask$ } from '@qwik.dev/core'; +import { defaultFallback } from '../../components/error-boundary/error-boundary'; + +const VisibleTaskThrower = component$(() => { + useVisibleTask$(() => { + throw new Error('visible boom'); + }); + return
streamed content
; +}); + +export default component$(() => ( + + + +)); diff --git a/e2e/qwik-e2e/apps/error-handling/tsconfig.json b/e2e/qwik-e2e/apps/error-handling/tsconfig.json new file mode 100644 index 00000000000..5526475e133 --- /dev/null +++ b/e2e/qwik-e2e/apps/error-handling/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "allowJs": true, + "target": "ES2020", + "module": "ES2022", + "lib": ["es2022", "DOM", "WebWorker", "DOM.Iterable"], + "jsx": "react-jsx", + "jsxImportSource": "@qwik.dev/core", + "strict": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "moduleResolution": "Bundler", + "esModuleInterop": true, + "allowImportingTsExtensions": true, + "skipLibCheck": true, + "incremental": true, + "isolatedModules": true, + "outDir": "tmp", + "noEmit": true, + "types": ["node", "vite/client"] + } +} 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 deleted file mode 100644 index bf825a34318..00000000000 --- a/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/error/index.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { component$, $ } from '@qwik.dev/core'; -import { ErrorBoundary } from '@qwik.dev/router'; - -export default component$(() => { - return ; -}); - -const UseErrorBoundaryNoEffectIssue7227 = component$(() => { - return ( - { - return
Caught error: {error.message}
; - })} - > -
- All good - -
-
- ); -}); diff --git a/e2e/qwik-e2e/dev-server.ts b/e2e/qwik-e2e/dev-server.ts index b4ce046dfaa..87c6555e8a7 100644 --- a/e2e/qwik-e2e/dev-server.ts +++ b/e2e/qwik-e2e/dev-server.ts @@ -13,6 +13,7 @@ import { join, relative, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { build, type InlineConfig, type PluginOption } from 'vite'; import type { PackageJSON } from '../../scripts/types.ts'; +import { getReleaseKey, getReleaseStore } from './utils/release-gate.ts'; const isWindows = process.platform === 'win32'; @@ -52,21 +53,6 @@ const repoRoot = resolve(__dirname, '..', '..'); const appsDir = join(e2eDir, 'apps'); const appNames = readdirSync(appsDir).filter((p) => statSync(join(appsDir, p)).isDirectory()); -type OOOSReleaseStore = { - resolved: Set; - resolvers: Map void>>; -}; - -const getOOOSReleaseStore = (): OOOSReleaseStore => - ((globalThis as any).__qwikOOOSReleaseStore ||= { - resolved: new Set(), - resolvers: new Map void>>(), - }); - -const getOOOSReleaseKey = (requestId: string, releaseId: string): string => { - return `${requestId}:${releaseId}`; -}; - let ooosRequestCounter = 0; /** Used when qwik-router server is enabled */ @@ -246,7 +232,7 @@ export { router } clientManifest = manifest; }, }, - experimental: ['each', 'show', 'suspense', 'blockSSR'], + experimental: ['each', 'show', 'suspense', 'errorBoundary', 'blockSSR'], }), ], }) @@ -262,7 +248,7 @@ export { router } plugins: [ ...plugins, optimizer.qwikVite({ - experimental: ['each', 'show', 'suspense', 'blockSSR'], + experimental: ['each', 'show', 'suspense', 'errorBoundary', 'blockSSR'], ssr: { manifestInput: clientManifest, }, @@ -453,8 +439,8 @@ async function main() { app.post('/__ooos-release/:requestId/:id', (req, res) => { const requestId = req.params.requestId; const id = req.params.id; - const store = getOOOSReleaseStore(); - const key = getOOOSReleaseKey(requestId, id); + const store = getReleaseStore(); + const key = getReleaseKey(requestId, id); const resolvers = store.resolvers.get(key); store.resolved.add(key); if (resolvers) { diff --git a/e2e/qwik-e2e/tests/e2e-helpers.ts b/e2e/qwik-e2e/tests/e2e-helpers.ts new file mode 100644 index 00000000000..198e4433729 --- /dev/null +++ b/e2e/qwik-e2e/tests/e2e-helpers.ts @@ -0,0 +1,35 @@ +import { expect, type Page } from '@playwright/test'; + +export 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); + } + }); +}; + +export const collectPageErrors = (page: Page) => { + const messages: string[] = []; + page.on('pageerror', (err) => messages.push(err.message)); + return messages; +}; + +export const collectConsoleErrors = (page: Page) => { + const messages: string[] = []; + page.on('console', (msg) => { + if (msg.type() === 'error') { + messages.push(msg.text()); + } + }); + return messages; +}; + +export 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(); +}; diff --git a/e2e/qwik-e2e/tests/error-handling.e2e.ts b/e2e/qwik-e2e/tests/error-handling.e2e.ts new file mode 100644 index 00000000000..f39d7c4f8c3 --- /dev/null +++ b/e2e/qwik-e2e/tests/error-handling.e2e.ts @@ -0,0 +1,814 @@ +import { test, expect } from '@playwright/test'; +import { assertNoBrowserErrors, collectPageErrors, releaseDeferred } from './e2e-helpers'; + +export const streamingModes = [ + { mode: 'in-order', outOfOrder: false }, + { mode: 'out-of-order', outOfOrder: true }, +] as const; +const caughtError = /^caught: .+/; + +type RouteApp = 'error-handling' | 'error-handling.prod'; + +export const routeUrl = ( + route: string, + { + outOfOrder = true, + app = 'error-handling' as RouteApp, + params = {}, + }: { outOfOrder?: boolean; app?: RouteApp; params?: Record } = {} +) => { + const search = new URLSearchParams(outOfOrder ? undefined : { outOfOrder: 'false' }); + for (const [key, value] of Object.entries(params)) { + search.set(key, value); + } + const query = search.toString(); + return `/${app}/${route}${query ? `?${query}` : ''}`; +}; + +test.describe('ErrorBoundary + fallback$', () => { + test('happy path: content interactive after resume, no fallback or swap script, then catches a client throw', async ({ + page, + }) => { + assertNoBrowserErrors(page); + const response = await page.goto(routeUrl('happy'), { waitUntil: 'commit' }); + const html = await response!.text(); + expect(html).not.toMatch(/qErr\(|qInstallErrorSwap|qO\(|qInstallOOOS/); + + await expect(page.locator('#eb-content')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('#eb-fallback')).toHaveCount(0); + + await page.locator('#eb-content-button').click(); + await expect(page.locator('#eb-content-count')).toHaveText('1'); + + await page.locator('#eb-content-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'); + }); + + for (const { mode, outOfOrder } of streamingModes) { + test(`${mode}: sync throw streams the shell, swaps to the fallback, keeps it interactive`, async ({ + page, + }) => { + assertNoBrowserErrors(page); + await page.goto(routeUrl('basic', { outOfOrder }), { waitUntil: 'commit' }); + + await expect(page.locator('#eb-title')).toHaveText('Error handling e2e', { timeout: 10000 }); + await expect(page.locator('#eb-footer')).toHaveText('Footer shell', { timeout: 10000 }); + + await expect(page.locator('#eb-fallback')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('#eb-fallback-msg')).toHaveText(caughtError); + await expect(page.locator('#eb-content')).toBeHidden(); + + await page.locator('#eb-fallback-button').click(); + await expect(page.locator('#eb-fallback-count')).toHaveText('1'); + }); + } + + for (const { mode, outOfOrder } of streamingModes) { + test(`${mode}: client-time throw after resume re-renders the boundary to its fallback`, async ({ + page, + }) => { + assertNoBrowserErrors(page); + await page.goto(routeUrl('csr-event', { outOfOrder }), { waitUntil: 'commit' }); + + await expect(page.locator('#eb-content')).toHaveText('content ok', { timeout: 10000 }); + await expect(page.locator('#eb-fallback')).toHaveCount(0); + + await page.locator('#eb-client-throw').click(); + await expect(page.locator('#eb-fallback')).toBeVisible({ timeout: 10000 }); + + await page.locator('#eb-fallback-button').click(); + await expect(page.locator('#eb-fallback-count')).toHaveText('1'); + }); + } + + test('a real client throw inside the inner boundary is caught by the nearest (inner) boundary, outer intact', async ({ + page, + }) => { + assertNoBrowserErrors(page); + await page.goto(routeUrl('nested-client'), { waitUntil: 'commit' }); + + await expect(page.locator('#eb-content')).toHaveText('content ok', { timeout: 10000 }); + await expect(page.locator('#eb-outer-ok')).toBeVisible(); + await expect(page.locator('#eb-inner')).toHaveCount(0); + + await page.locator('#eb-inner-throw').click(); + + await expect(page.locator('#eb-inner')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('#eb-outer')).toHaveCount(0); + await expect(page.locator('#eb-outer-ok')).toBeVisible(); + await expect(page.locator('#eb-inner-msg')).toHaveText('caught: inner client boom'); + + await page.locator('#eb-inner-button').click(); + await expect(page.locator('#eb-inner-count')).toHaveText('1'); + }); + + test('SSR nested: the outer supersedes an already-swapped inner fallback when both error server-side', async ({ + page, + }) => { + assertNoBrowserErrors(page); + await page.goto(routeUrl('nested-ssr'), { waitUntil: 'commit' }); + + await expect(page.locator('#eb-outer')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('#eb-outer-msg')).toHaveText(caughtError); + await expect(page.locator('#eb-inner')).toBeHidden(); + + await page.locator('#eb-outer-button').click(); + await expect(page.locator('#eb-outer-count')).toHaveText('1'); + }); + + for (const { mode, outOfOrder } of streamingModes) { + test(`${mode}: a throwing inner fallback escalates to the outer boundary, fallback interactive`, async ({ + page, + }) => { + assertNoBrowserErrors(page); + await page.goto(routeUrl('fallback-throws', { outOfOrder }), { waitUntil: 'commit' }); + + await expect(page.locator('#eb-title')).toHaveText('Error handling e2e', { timeout: 10000 }); + await expect(page.locator('#eb-outer')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('#eb-outer-msg')).toHaveText(caughtError); + await expect(page.locator('#eb-content')).toBeHidden(); + + await page.locator('#eb-outer-button').click(); + await expect(page.locator('#eb-outer-count')).toHaveText('1'); + }); + } + + test.describe('last-resort fallback', () => { + test('built-in last-resort node renders when the fallback$ chunk fails to load', async ({ + page, + }) => { + const blockedFallbackChunks: string[] = []; + await page.route(/\/build\/[^?]*[Ff]allback[^?]*\.js/, (route) => { + blockedFallbackChunks.push(route.request().url()); + return route.abort(); + }); + + await page.goto(routeUrl('last-resort'), { waitUntil: 'commit' }); + + await expect(page.locator('#eb-content')).toHaveText('content ok', { timeout: 10000 }); + await expect(page.locator('[role="alert"]')).toHaveCount(0); + + await page.locator('#eb-last-resort-throw').click(); + + const lastResort = page.locator('[role="alert"]'); + await expect(lastResort).toBeVisible({ timeout: 10000 }); + await expect(lastResort).toHaveText('Something went wrong.'); + await expect(page.locator('#eb-fallback')).toHaveCount(0); + expect(blockedFallbackChunks.length).toBeGreaterThan(0); + + await expect(page.locator('#eb-title')).toHaveText('Error handling e2e'); + }); + }); + + test.describe('SSR delivery & teardown', () => { + test('boundary inside a deferred : hoisted qErr swap, fallback interactive', async ({ + page, + }) => { + assertNoBrowserErrors(page); + await page.goto(routeUrl('suspense-deferred'), { waitUntil: 'commit' }); + + await expect(page.locator('#eb-title')).toHaveText('Error handling e2e', { timeout: 10000 }); + await expect(page.locator('#eb-footer')).toHaveText('Footer shell', { timeout: 10000 }); + + await expect(page.locator('#eb-deferred-ok')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('#eb-fallback')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('#eb-fallback-msg')).toHaveText(caughtError); + await expect(page.locator('#eb-content')).toBeHidden(); + + await page.locator('#eb-fallback-button').click(); + await expect(page.locator('#eb-fallback-count')).toHaveText('1'); + }); + + // https://github.com/QwikDev/qwik/issues/8877 + test.fixme('async deferred throw: streams siblings + skeleton, then tears down the whole boundary', async ({ + page, + }) => { + assertNoBrowserErrors(page); + await page.goto(routeUrl('async', { params: { release: 'eb' } }), { + waitUntil: 'commit', + }); + + await expect(page.locator('#eb-title')).toHaveText('Error handling e2e', { 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); + + await releaseDeferred(page, '#eb-release'); + + await expect(page.locator('#eb-fallback')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('#eb-fallback-msg')).toHaveText(caughtError); + await expect(page.locator('#eb-sibling')).toBeHidden(); + + await page.locator('#eb-fallback-button').click(); + await expect(page.locator('#eb-fallback-count')).toHaveText('1'); + }); + + test('qErr swap as a main-flow sibling of a live deferred segment stays interactive', async ({ + page, + }) => { + assertNoBrowserErrors(page); + const response = await page.goto( + routeUrl('sibling-suspense', { params: { release: 'eb' } }), + { + waitUntil: 'commit', + } + ); + + await expect(page.locator('#eb-fallback')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('#eb-fallback-msg')).toHaveText(caughtError); + await expect(page.locator('#eb-content')).toBeHidden(); + await expect(page.locator('#eb-skel')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('#eb-deferred-ok')).toHaveCount(0); + + await releaseDeferred(page, '#eb-release'); + + await expect(page.locator('#eb-deferred-ok')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('#eb-fallback')).toBeVisible(); + await expect(page.locator('#eb-content')).toBeHidden(); + + await page.locator('#eb-fallback-button').click(); + await expect(page.locator('#eb-fallback-count')).toHaveText('1'); + + const html = await response!.text(); + expect(html).toMatch(/qErr\(/); + expect(html).toMatch(/qO\(/); + await expect(page.locator('[q\\:ebf] #eb-fallback')).toHaveCount(1); + await expect(page.locator('[q\\:rp] #eb-fallback')).toHaveCount(0); + }); + + test('in-order mid-stream click on a swapped fallback is queued and replayed after resume', async ({ + page, + browserName, + }) => { + // https://github.com/QwikDev/qwik/issues/8891 + test.skip( + browserName === 'webkit', + 'webkit may defer async-module loader evaluation while the stream is held' + ); + // The loader module is fetched while the stream is held; loaded runners need headroom. + test.slow(); + assertNoBrowserErrors(page); + const webkitFlush = browserName === 'webkit' ? { webkitFlush: '1' } : {}; + await page.goto( + routeUrl('midstream', { + outOfOrder: false, + params: { release: 'eb', inOrderStrategy: 'direct', ...webkitFlush }, + }), + { waitUntil: 'commit' } + ); + + await expect(page.locator('#eb-fallback')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('#eb-fallback-msg')).toHaveText(caughtError); + await expect(page.locator('#eb-deferred-ok')).toHaveCount(0); + + // WebKit can starve rAF-based waitForFunction while the stream is held. + await expect + .poll(() => page.evaluate(() => !!(window as any)._qwikEv?.roots), { timeout: 30000 }) + .toBe(true); + await expect(page.locator('html')).toHaveAttribute('q:container', 'paused'); + + await page.locator('#eb-reset').click(); + await expect(page.locator('#eb-content')).toBeHidden(); + await expect(page.locator('#eb-fallback')).toBeVisible(); + await expect(page.locator('html')).toHaveAttribute('q:container', 'paused'); + + await releaseDeferred(page, '#eb-release'); + await page.waitForLoadState('load'); + + await expect(page.locator('#eb-content')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('#eb-thrower-client')).toBeAttached(); + await expect(page.locator('#eb-fallback')).toHaveCount(0); + await expect(page.locator('#eb-deferred-ok')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('html')).toHaveAttribute('q:container', 'resumed'); + }); + + test('an SSR error inside an embedded container swaps only that container boundary', async ({ + page, + }) => { + assertNoBrowserErrors(page); + await page.goto(routeUrl('multi-container'), { waitUntil: 'commit' }); + + await expect(page.locator('#eb-embed #eb-fallback')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('#eb-embed #eb-fallback-msg')).toHaveText(caughtError); + await expect(page.locator('#eb-embed #eb-content')).toBeHidden(); + + const hostBoundaryId = await page + .locator('div[q\\:ebc]:has(#eb-host-content)') + .getAttribute('q:ebc'); + const fragmentBoundaryId = await page.locator('#eb-embed [q\\:ebc]').getAttribute('q:ebc'); + expect(hostBoundaryId).not.toBeNull(); + expect(fragmentBoundaryId).toBe(hostBoundaryId); + + await expect(page.locator('#eb-host-content')).toBeVisible(); + await expect(page.locator('#eb-host-fb')).toHaveCount(0); + + await page.locator('#eb-host-button').click(); + await expect(page.locator('#eb-host-count')).toHaveText('1'); + + await page.locator('#eb-embed #eb-fallback-button').click(); + await expect(page.locator('#eb-embed #eb-fallback-count')).toHaveText('1'); + }); + }); + + test.describe('after resume', () => { + test('SSR inner error, then a client throw makes the outer boundary replace the whole subtree', async ({ + page, + }) => { + assertNoBrowserErrors(page); + await page.goto(routeUrl('nested'), { waitUntil: 'commit' }); + + await expect(page.locator('#eb-inner')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('#eb-inner-msg')).toHaveText(caughtError); + await expect(page.locator('#eb-outer')).toHaveCount(0); + + await page.locator('#eb-outer-throw').click(); + + await expect(page.locator('#eb-outer')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('#eb-inner')).toHaveCount(0); + await page.locator('#eb-outer-button').click(); + await expect(page.locator('#eb-outer-count')).toHaveText('1'); + }); + + test('inert: a swapped-out content task does not re-run when an outside signal changes', async ({ + page, + }) => { + assertNoBrowserErrors(page); + await page.goto(routeUrl('inert'), { waitUntil: 'commit' }); + + await expect(page.locator('#eb-fallback')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('#eb-content')).toBeHidden(); + + expect(await page.evaluate(() => (window as any).__ebDeadTaskClientRuns ?? 0)).toBe(0); + + await page.locator('#eb-inert-trigger').click(); + await expect(page.locator('#eb-inert-val')).toHaveText('1'); + + expect(await page.evaluate(() => (window as any).__ebDeadTaskClientRuns ?? 0)).toBe(0); + }); + }); + + test.describe('integration', () => { + test.describe('visible tasks', () => { + test('useVisibleTask$ throw after resume is routed to the boundary without interaction', async ({ + page, + }) => { + const pageErrors = collectPageErrors(page); + + await page.goto(routeUrl('visible-task'), { waitUntil: 'commit' }); + + await expect(page.locator('#eb-fallback')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('#eb-fallback-msg')).toHaveText('caught: visible boom'); + await expect(page.locator('#eb-content')).toBeHidden(); + + await page.locator('#eb-fallback-button').click(); + await expect(page.locator('#eb-fallback-count')).toHaveText('1'); + + await page.waitForTimeout(200); + expect(pageErrors.filter((message) => message.includes('visible boom'))).toEqual([]); + }); + }); + + test.describe('async signals', () => { + test('async error read via `.error` is handled inline — the boundary never sees it', async ({ + page, + }) => { + await page.goto(routeUrl('async-error-inline'), { waitUntil: 'commit' }); + + await expect(page.locator('#async-error')).toHaveText('handled: expected-async-error', { + timeout: 10000, + }); + await expect(page.locator('#eb-fallback')).toHaveCount(0); + }); + + test('async error read via `.value` propagates → caught by the ErrorBoundary', async ({ + page, + }) => { + await page.goto(routeUrl('async-error-throw'), { waitUntil: 'commit' }); + + await expect(page.locator('#eb-fallback-msg')).toHaveText(caughtError, { + timeout: 10000, + }); + await expect(page.locator('#async-value')).toHaveCount(0); + }); + }); + + test.describe('loaders', () => { + test('loader error(500) is NOT caught by an ErrorBoundary: the router returns a 500', async ({ + page, + }) => { + const response = await page.goto(routeUrl('loader-500'), { waitUntil: 'commit' }); + expect(response!.status()).toBe(500); + await expect(page.locator('#eb-fallback')).toHaveCount(0); + await expect(page.locator('#loader-500-body')).toHaveCount(0); + }); + + // reset re-invokes the loader; this asserts the opposite + test.fixme('reset re-derives the identical fallback from the serialized loader value', async ({ + page, + }) => { + assertNoBrowserErrors(page); + await page.goto(routeUrl('loader-data-throw'), { waitUntil: 'commit' }); + + await expect(page.locator('#eb-fallback-msg')).toHaveText( + 'caught: loader data boom: loader-data-secret', + { timeout: 10000 } + ); + + await page.locator('#eb-reset').click(); + await expect(page.locator('#eb-fallback-msg')).toHaveText( + 'caught: loader data boom: loader-data-secret', + { timeout: 10000 } + ); + await expect(page.locator('#eb-content')).toHaveCount(0); + }); + + // reset re-invokes the loader; this asserts the opposite + test.fixme('reset does not re-invoke the loader: no q-data request fires for the reset', async ({ + page, + }) => { + assertNoBrowserErrors(page); + await page.goto(routeUrl('loader-reset-no-refetch'), { waitUntil: 'commit' }); + + await expect(page.locator('#eb-fallback')).toBeVisible({ timeout: 10000 }); + await page.waitForLoadState('networkidle'); + + const loaderRequestsAfterReset: string[] = []; + page.on('request', (req) => { + if (/q-loader|q-data\.json/.test(req.url())) { + loaderRequestsAfterReset.push(req.url()); + } + }); + + await page.locator('#eb-reset').click(); + await expect(page.locator('#eb-content')).toBeVisible({ timeout: 10000 }); + await page.waitForTimeout(300); + + expect(loaderRequestsAfterReset).toEqual([]); + }); + }); + }); +}); + +test.describe('qerror (client event channel)', () => { + test.describe('qerror routing', () => { + test('no boundary: a client throw still surfaces to the global error handler', async ({ + page, + }) => { + const pageErrors = collectPageErrors(page); + + await page.goto(routeUrl('no-boundary'), { waitUntil: 'commit' }); + await expect(page.locator('#eb-title')).toHaveText('Error handling e2e', { timeout: 10000 }); + + await page.locator('#eb-no-boundary-throw').click(); + await expect(page.locator('#eb-no-boundary-touched')).toHaveText('1', { timeout: 10000 }); + + await expect.poll(() => pageErrors, { timeout: 10000 }).toContain('no-boundary boom'); + }); + + // https://github.com/QwikDev/qwik/issues/8962 — resumed dispatch bypasses the importError skip + test.fixme('a failed qwikloader dynamic import (chunk 404) leaves the boundary inert', async ({ + page, + }) => { + const pageErrors = collectPageErrors(page); + + await page.addInitScript(() => { + (window as any).__ebQErrors = []; + document.addEventListener('qerror', (e: any) => { + (window as any).__ebQErrors.push({ importError: e.detail?.importError ?? null }); + }); + }); + + const blockedRequests: string[] = []; + await page.route(/\/build\/.*EbThrowOnClick.*q_e_click.*\.js/, (route) => { + blockedRequests.push(route.request().url()); + return route.abort(); + }); + + await page.goto(routeUrl('happy'), { waitUntil: 'commit' }); + await expect(page.locator('#eb-content')).toBeVisible({ timeout: 10000 }); + + await page.locator('#eb-content-throw').click(); + + // Rejections surface behind the loader's deferred queue; poll the qerror channel itself. + await expect + .poll(() => page.evaluate(() => (window as any).__ebQErrors.length), { timeout: 10000 }) + .toBeGreaterThanOrEqual(1); + expect(blockedRequests.length).toBeGreaterThan(0); + + // Resume may add its own import rejection; single-report-per-dispatch is pinned in qwikloader.behavior.unit.ts. + const qErrors = await page.evaluate(() => (window as any).__ebQErrors); + expect( + qErrors.filter((q: { importError: string | null }) => q.importError !== 'async') + ).toEqual([]); + + await expect(page.locator('#eb-fallback')).toHaveCount(0); + await expect(page.locator('[role="alert"]')).toHaveCount(0); + await expect(page.locator('#eb-content')).toBeVisible(); + await expect(page.locator('#eb-content-touched')).toHaveText('0'); + expect(pageErrors).toEqual([]); + }); + }); +}); + +test.describe('onError$', () => { + test('onError$ fires once with the error on a client-time throw', async ({ page }) => { + assertNoBrowserErrors(page); + await page.goto(routeUrl('onerror'), { waitUntil: 'commit' }); + + await expect(page.locator('#eb-content')).toHaveText('content ok', { timeout: 10000 }); + await page.locator('#eb-onerror-throw').click(); + + await expect(page.locator('#eb-fallback')).toBeVisible({ timeout: 10000 }); + await page.waitForFunction(() => (window as any).__ebOnErrorRuns >= 1); + await page.waitForTimeout(100); + expect(await page.evaluate(() => (window as any).__ebOnErrorRuns)).toBe(1); + expect(await page.evaluate(() => (window as any).__ebOnErrorMsg)).toBe('onerror boom'); + }); + + test('onError$ receives info.phase "event" and a stable boundaryId for a real qwikloader throw', async ({ + page, + }) => { + assertNoBrowserErrors(page); + await page.goto(routeUrl('onerror'), { waitUntil: 'commit' }); + + await expect(page.locator('#eb-content')).toHaveText('content ok', { timeout: 10000 }); + await page.locator('#eb-onerror-throw').click(); + + await expect(page.locator('#eb-fallback')).toBeVisible({ timeout: 10000 }); + await page.waitForFunction(() => (window as any).__ebOnErrorPhase !== undefined); + expect(await page.evaluate(() => (window as any).__ebOnErrorPhase)).toBe('event'); + expect(await page.evaluate(() => (window as any).__ebOnErrorBoundaryId)).toBeTruthy(); + }); +}); + +test.describe('ErrorBoundary reset', () => { + for (const { mode, outOfOrder } of streamingModes) { + test(`${mode}: reset on SSR resume re-executes the children and recovers`, async ({ page }) => { + assertNoBrowserErrors(page); + await page.goto(routeUrl('reset', { outOfOrder }), { waitUntil: 'commit' }); + await expect(page.locator('#eb-fallback')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('#eb-content')).toBeHidden(); + await expect(page.locator('[q\\:ebc]')).toHaveCount(1); + await expect(page.locator('[q\\:ebf]')).toHaveCount(1); + + await page.locator('#eb-reset').click(); + + await expect(page.locator('#eb-content')).toHaveCount(1, { timeout: 10000 }); + await expect(page.locator('#eb-content')).toBeVisible(); + await expect(page.locator('#eb-thrower-client')).toBeAttached(); + await expect(page.locator('#eb-fallback')).toHaveCount(0); + + await page.locator('#eb-content-button').click(); + await expect(page.locator('#eb-content-count')).toHaveText('1'); + + await expect(page.locator('[q\\:ebc]')).toHaveCount(0); + await expect(page.locator('[q\\:ebf]')).toHaveCount(0); + }); + } + + test('reset on an always-throwing child re-derives the fallback client-side', async ({ + page, + }) => { + assertNoBrowserErrors(page); + await page.goto(routeUrl('rederive'), { waitUntil: 'commit' }); + + await expect(page.locator('#eb-fallback')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('#eb-fallback-msg')).toHaveText(caughtError); + expect(await page.evaluate(() => (window as any).__ebRederiveRuns ?? 0)).toBe(0); + + await page.locator('#eb-reset').click(); + + await expect(page.locator('#eb-fallback-msg')).toHaveText('caught: eb always boom', { + timeout: 10000, + }); + await expect.poll(() => page.evaluate(() => (window as any).__ebRederiveRuns ?? 0)).toBe(1); + }); + + test('client error: reset re-supplies the content interactively', async ({ page }) => { + assertNoBrowserErrors(page); + await page.goto(routeUrl('reset-csr'), { waitUntil: 'commit' }); + await expect(page.locator('#eb-content')).toBeVisible({ timeout: 10000 }); + + await page.locator('#eb-content-button').click(); + await expect(page.locator('#eb-content-count')).toHaveText('1'); + + await page.locator('#eb-csr-throw').click(); + await expect(page.locator('#eb-fallback')).toBeVisible({ timeout: 10000 }); + + await page.locator('#eb-reset').click(); + + await expect(page.locator('#eb-content')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('#eb-fallback')).toHaveCount(0); + + await page.locator('#eb-content-button').click(); + await expect(page.locator('#eb-content-count')).toHaveText('1'); + }); + + test('second reset re-executes children of an ErrorBoundary inside a Suspense (re-error then recover)', async ({ + page, + }) => { + assertNoBrowserErrors(page); + // OOOS: #8876 route shape + #8884 segment reset + await page.goto(routeUrl('reset-reerror', { outOfOrder: false }), { waitUntil: 'commit' }); + await expect(page.locator('#eb-fallback')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('#eb-fallback-msg')).toHaveText(caughtError); + + await page.locator('#eb-reset').click(); + await expect(page.locator('#eb-fallback-msg')).toContainText('client boom 1', { + timeout: 10000, + }); + + await page.locator('#eb-reset').click(); + await expect(page.locator('#eb-reerror-recovered')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('#eb-fallback')).toHaveCount(0); + }); + + test('reset re-executes children of a client-first (SPA-nav) ErrorBoundary inside a Suspense', async ({ + page, + }) => { + assertNoBrowserErrors(page); + await page.goto(routeUrl('reset-spa'), { waitUntil: 'commit' }); + await expect(page.locator('#eb-spa-show')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('#eb-fallback')).toHaveCount(0); + + await page.locator('#eb-spa-show').click(); + await expect(page.locator('#eb-fallback-msg')).toContainText('client boom 1', { + timeout: 10000, + }); + + await page.locator('#eb-reset').click(); + await expect(page.locator('#eb-reerror-recovered')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('#eb-fallback')).toHaveCount(0); + }); + + test.describe('nested boundaries', () => { + test('SSR resume (real click): reset on a nested inner boundary re-executes its children, outer intact', async ({ + page, + }) => { + assertNoBrowserErrors(page); + await page.goto(routeUrl('nested-reset', { outOfOrder: false }), { waitUntil: 'commit' }); + + await expect(page.locator('#eb-fallback')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('#eb-outer-ok')).toBeVisible(); + await expect(page.locator('#eb-thrower-client')).toHaveCount(0); + + await page.locator('#eb-reset').click(); + + await expect(page.locator('#eb-thrower-client')).toBeAttached({ timeout: 10000 }); + await expect(page.locator('#eb-fallback')).toHaveCount(0); + await expect(page.locator('#eb-outer-ok')).toBeVisible(); + + await page.locator('#eb-outer-ok-button').click(); + await expect(page.locator('#eb-outer-ok-count')).toHaveText('1'); + }); + + test('SSR resume (real click): reset on a boundary nested inside a resumed SSR fallback re-derives the outer and recovers the inner', async ({ + page, + }) => { + assertNoBrowserErrors(page); + await page.goto(routeUrl('fallback-nested-resume', { outOfOrder: false }), { + waitUntil: 'commit', + }); + + await expect(page.locator('#eb-outer-fb')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('#eb-inner-reset')).toBeVisible(); + await expect(page.locator('#eb-thrower-client')).toHaveCount(0); + + await page.locator('#eb-inner-reset').click(); + + await expect(page.locator('#eb-thrower-client')).toBeAttached({ timeout: 10000 }); + await expect(page.locator('#eb-outer-fb')).toBeVisible(); + await expect(page.locator('#eb-inner-reset')).toHaveCount(0); + }); + }); + + test.describe('through wrapper components', () => { + test('reset re-executes async children through a Slot-projecting wrapper component', async ({ + page, + }) => { + assertNoBrowserErrors(page); + // OOOS: #8876 route shape + #8884 segment reset + await page.goto(routeUrl('reset-wrapped', { outOfOrder: false }), { waitUntil: 'commit' }); + await expect(page.locator('#eb-fallback')).toBeVisible({ timeout: 10000 }); + + await page.locator('#eb-reset').click(); + + await expect(page.locator('#eb-wrap-recovered')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('#eb-fallback')).toHaveCount(0); + }); + + test('wrapper key-swap: key bump re-executes the async child through a Slot wrapper', async ({ + page, + }) => { + assertNoBrowserErrors(page); + // OOOS: #8876 route shape + #8884 segment reset + await page.goto(routeUrl('reset-wrapped-key', { outOfOrder: false }), { + waitUntil: 'commit', + }); + await expect(page.locator('#eb-fallback')).toBeVisible({ timeout: 10000 }); + await page.locator('#eb-reset').click(); + await expect(page.locator('#eb-wrap-recovered')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('#eb-fallback')).toHaveCount(0); + }); + }); +}); + +test.describe('ErrorBoundary in a production build (qDev=false)', () => { + const prodUrl = (route: string) => routeUrl(route, { app: 'error-handling.prod' }); + + test('an SSR-errored async signal serializes a redacted error, raw message nowhere in the page', async ({ + page, + }) => { + assertNoBrowserErrors(page); + const response = await page.goto(prodUrl('async-error-captured'), { waitUntil: 'commit' }); + + await expect(page.locator('#async-errored')).toHaveText('errored', { timeout: 10000 }); + const html = await response!.text(); + expect(html).not.toContain('captured-async-boom'); + + await page.locator('#async-probe').click(); + await expect(page.locator('#async-digest')).not.toHaveText('unread'); + await expect(page.locator('#async-digest')).not.toHaveText('no-digest'); + }); + + test('SSR swap: the prod-built client resumes the swapped page, fallback interactive', async ({ + page, + }) => { + assertNoBrowserErrors(page); + await page.goto(prodUrl('basic'), { waitUntil: 'commit' }); + + await expect(page.locator('#eb-fallback')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('#eb-fallback-msg')).toHaveText('caught: An error occurred'); + await expect(page.locator('#eb-content')).toBeHidden(); + + await page.locator('#eb-fallback-button').click(); + await expect(page.locator('#eb-fallback-count')).toHaveText('1'); + }); + + test('client throw after resume shows the thrown message unredacted', async ({ page }) => { + assertNoBrowserErrors(page); + await page.goto(prodUrl('csr-event'), { waitUntil: 'commit' }); + + await expect(page.locator('#eb-content')).toHaveText('content ok', { timeout: 10000 }); + await expect(page.locator('#eb-fallback')).toHaveCount(0); + + await page.locator('#eb-client-throw').click(); + + await expect(page.locator('#eb-fallback')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('#eb-fallback-msg')).toHaveText('caught: client click boom'); + + await page.locator('#eb-fallback-button').click(); + await expect(page.locator('#eb-fallback-count')).toHaveText('1'); + }); + + test('an app error carrying its own digest field still redacts', async ({ page }) => { + assertNoBrowserErrors(page); + await page.goto(prodUrl('digest-forgery'), { waitUntil: 'commit' }); + + await expect(page.locator('#eb-fallback-msg')).toHaveText('caught: An error occurred', { + timeout: 10000, + }); + const digest = await page.locator('#eb-fallback-digest').innerText(); + expect(digest).not.toBe('forged-digest'); + expect(digest).not.toBe('none'); + expect(await page.locator('body').innerText()).not.toContain('digest secret boom'); + }); + + test('reset flips a redacted SSR fallback to the raw client-derived message', async ({ + page, + }) => { + assertNoBrowserErrors(page); + await page.goto(prodUrl('rederive'), { waitUntil: 'commit' }); + + await expect(page.locator('#eb-fallback')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('#eb-fallback-msg')).toHaveText('caught: An error occurred'); + + await page.locator('#eb-reset').click(); + + await expect + .poll(() => page.evaluate(() => (window as any).__ebRederiveRuns ?? 0), { timeout: 10000 }) + .toBe(1); + await expect(page.locator('#eb-fallback-msg')).toHaveText('caught: eb always boom'); + }); + + test('reset round-trips through the prod serializer and re-executes the children', async ({ + page, + }) => { + assertNoBrowserErrors(page); + await page.goto(prodUrl('reset'), { waitUntil: 'commit' }); + + await expect(page.locator('#eb-fallback')).toBeVisible({ timeout: 10000 }); + + await page.locator('#eb-reset').click(); + + await expect(page.locator('#eb-content')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('#eb-fallback')).toHaveCount(0); + + await page.locator('#eb-content-button').click(); + await expect(page.locator('#eb-content-count')).toHaveText('1'); + }); +}); diff --git a/e2e/qwik-e2e/tests/qwikrouter/error.spec.ts b/e2e/qwik-e2e/tests/qwikrouter/error.spec.ts deleted file mode 100644 index c17147ae1b0..00000000000 --- a/e2e/qwik-e2e/tests/qwikrouter/error.spec.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { test } from '@playwright/test'; - -test.describe('Qwik Router Error boundary', () => { - test('should catch error', async ({ page }) => { - await page.goto('/qwikrouter-test/error'); - - await page.getByRole('button', { name: 'Throw error' }).click(); - - await page.waitForSelector('div:has-text("Caught error: Boom!")'); - }); -}); diff --git a/e2e/qwik-e2e/tests/suspense-ooos.e2e.ts b/e2e/qwik-e2e/tests/suspense-ooos.e2e.ts index 7fd3a0560fb..0d4afb339cc 100644 --- a/e2e/qwik-e2e/tests/suspense-ooos.e2e.ts +++ b/e2e/qwik-e2e/tests/suspense-ooos.e2e.ts @@ -1,16 +1,8 @@ -import { test, expect, type Page } from '@playwright/test'; +import { test, expect } from '@playwright/test'; +import { assertNoBrowserErrors, releaseDeferred } from './e2e-helpers'; const QWIK_EV_CONTAINER_READY = 0; -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 getOutOfOrderSuspenseUrl = (browserName: string, searchParams?: URLSearchParams): string => { const params = new URLSearchParams(searchParams); if (browserName === 'webkit') { @@ -20,15 +12,6 @@ const getOutOfOrderSuspenseUrl = (browserName: string, searchParams?: URLSearchP return search ? `/e2e/suspense-ooos?${search}` : '/e2e/suspense-ooos'; }; -const releaseOutOfOrderSuspense = 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('out-of-order suspense streaming', () => { test.beforeEach(async ({ page }) => { assertNoBrowserErrors(page); @@ -150,7 +133,7 @@ test.describe('out-of-order suspense streaming', () => { await expect(page.locator('#ooos-fallback')).toBeVisible(); await expect(page.locator('#ooos-resolved')).toHaveCount(0); - await releaseOutOfOrderSuspense(page, '#ooos-default-release'); + await releaseDeferred(page, '#ooos-default-release'); await expect(page.locator('#ooos-resolved')).toBeVisible({ timeout: 10000 }); await expect(page.locator('#ooos-fallback')).toBeHidden(); await page.waitForLoadState('load'); @@ -182,7 +165,7 @@ test.describe('out-of-order suspense streaming', () => { await expect(page.locator('#ooos-multi-first-fallback-count')).toHaveText('1'); await expect(page.locator('#ooos-multi-second-fallback-count')).toHaveText('1'); - await releaseOutOfOrderSuspense(page, '#ooos-multi-second-release'); + await releaseDeferred(page, '#ooos-multi-second-release'); await expect(page.locator('#ooos-multi-second-resolved')).toBeVisible({ timeout: 10000 }); await expect(page.locator('#ooos-multi-second-fallback')).toBeHidden(); await expect(page.locator('#ooos-multi-first-fallback')).toBeVisible(); @@ -191,7 +174,7 @@ test.describe('out-of-order suspense streaming', () => { await page.locator('#ooos-multi-second-resolved-button').click(); await expect(page.locator('#ooos-multi-second-resolved-count')).toHaveText('1'); - await releaseOutOfOrderSuspense(page, '#ooos-multi-first-release'); + await releaseDeferred(page, '#ooos-multi-first-release'); await expect(page.locator('#ooos-multi-first-resolved')).toBeVisible({ timeout: 10000 }); await expect(page.locator('#ooos-multi-first-fallback')).toBeHidden(); await page.locator('#ooos-multi-first-resolved-button').click(); @@ -205,8 +188,8 @@ test.describe('out-of-order suspense streaming', () => { await expect(page.locator('#ooos-multi-first-resolved')).toHaveCount(0); await expect(page.locator('#ooos-multi-second-resolved')).toHaveCount(0); - await releaseOutOfOrderSuspense(page, '#ooos-multi-first-release'); - await releaseOutOfOrderSuspense(page, '#ooos-multi-second-release'); + await releaseDeferred(page, '#ooos-multi-first-release'); + await releaseDeferred(page, '#ooos-multi-second-release'); await expect(page.locator('#ooos-multi-first-resolved')).toBeVisible({ timeout: 10000 }); await expect(page.locator('#ooos-multi-second-resolved')).toBeVisible({ timeout: 10000 }); }); @@ -234,7 +217,7 @@ test.describe('out-of-order suspense streaming', () => { await expect(page.locator('#ooos-cross-shell-count')).toHaveText('shared=1'); await expect(page.locator('#ooos-cross-fallback-count')).toHaveText('shared=1'); - await releaseOutOfOrderSuspense(page, '#ooos-cross-release'); + await releaseDeferred(page, '#ooos-cross-release'); await expect(page.locator('#ooos-cross-resolved')).toBeVisible({ timeout: 10000 }); await expect(page.locator('#ooos-cross-fallback')).toBeHidden(); await expect(page.locator('#ooos-cross-resolved-count')).toHaveText('shared=1'); @@ -273,7 +256,7 @@ test.describe('out-of-order suspense streaming', () => { await page.locator('#ooos-delay-fallback-button').click(); await expect(page.locator('#ooos-delay-fallback-count')).toHaveText('1'); - await releaseOutOfOrderSuspense(page, '#ooos-delay-release'); + await releaseDeferred(page, '#ooos-delay-release'); await expect(page.locator('#ooos-delay-resolved')).toBeVisible({ timeout: 10000 }); await expect(page.locator('#ooos-delay-fallback')).toBeHidden(); await page.waitForLoadState('load'); @@ -347,14 +330,14 @@ test.describe('out-of-order suspense streaming', () => { await expect(page.locator('#ooos-reveal-first-resolved')).toHaveCount(0); await expect(page.locator('#ooos-reveal-second-resolved')).toHaveCount(0); - await releaseOutOfOrderSuspense(page, '#ooos-reveal-second-release'); + await releaseDeferred(page, '#ooos-reveal-second-release'); await page.waitForTimeout(300); await expect(page.locator('#ooos-reveal-first-fallback')).toBeVisible(); await expect(page.locator('#ooos-reveal-second-fallback')).toBeHidden(); await expect(page.locator('#ooos-reveal-first-resolved')).toHaveCount(0); await expect(page.locator('#ooos-reveal-second-resolved')).toBeHidden(); - await releaseOutOfOrderSuspense(page, '#ooos-reveal-first-release'); + await releaseDeferred(page, '#ooos-reveal-first-release'); await expect(page.locator('#ooos-reveal-first-resolved')).toBeVisible({ timeout: 10000 }); await expect(page.locator('#ooos-reveal-second-resolved')).toBeVisible({ timeout: 10000 }); await expect(page.locator('#ooos-reveal-first-fallback')).toBeHidden(); @@ -385,7 +368,7 @@ test.describe('out-of-order suspense streaming', () => { await expect(page.locator('#ooos-rerender-resolved')).toHaveCount(0); await expect(page.locator('#ooos-rerender-keyed')).toHaveAttribute('data-value', '0'); - await releaseOutOfOrderSuspense(page, '#ooos-rerender-release'); + await releaseDeferred(page, '#ooos-rerender-release'); await expect(page.locator('#ooos-rerender-resolved')).toBeVisible({ timeout: 10000 }); await expect(page.locator('#ooos-rerender-fallback')).toBeHidden(); await expect(page.locator('#ooos-rerender-resolved-label')).toHaveText('Resolved rerender 0'); @@ -423,14 +406,14 @@ test.describe('out-of-order suspense streaming', () => { await expect(page.locator('#ooos-container-first-fallback')).toBeVisible({ timeout: 10000 }); await expect(page.locator('#ooos-container-first-resolved')).toHaveCount(0); - await releaseOutOfOrderSuspense(page, '#ooos-container-first-release'); + await releaseDeferred(page, '#ooos-container-first-release'); await expect(page.locator('#ooos-container-first-resolved')).toBeVisible({ timeout: 10000 }); await expect(page.locator('#ooos-container-first-fallback')).toBeHidden(); await expect(page.locator('#ooos-container-second-fallback')).toBeVisible({ timeout: 10000 }); await expect(page.locator('#ooos-container-second-resolved')).toHaveCount(0); - await releaseOutOfOrderSuspense(page, '#ooos-container-second-release'); + await releaseDeferred(page, '#ooos-container-second-release'); await expect(page.locator('#ooos-container-second-resolved')).toBeVisible({ timeout: 10000 }); await expect(page.locator('#ooos-container-second-fallback')).toBeHidden(); await expect(page.locator('#ooos-container-first-resolved')).toBeVisible(); @@ -458,7 +441,7 @@ test.describe('out-of-order suspense streaming', () => { }); await expect(page.locator('#ooos-container-first-fallback')).toBeVisible({ timeout: 10000 }); - await releaseOutOfOrderSuspense(page, '#ooos-container-first-release'); + await releaseDeferred(page, '#ooos-container-first-release'); await expect(page.locator('#ooos-container-first-resolved')).toBeVisible({ timeout: 10000 }); await expect(page.locator('#ooos-container-second-fallback')).toBeVisible({ timeout: 10000 }); @@ -478,7 +461,10 @@ test.describe('out-of-order suspense streaming', () => { expect(hashes.second).toBeTruthy(); expect(hashes.first).not.toBe(hashes.second); - await page.waitForFunction(() => !!(window as any)._qwikEv?.roots); + // WebKit can starve rAF-based waitForFunction while the stream is held. + await expect + .poll(() => page.evaluate(() => !!(window as any)._qwikEv?.roots), { timeout: 30000 }) + .toBe(true); const blockedSecondHash = await page.evaluate(({ second }) => { const documentWithQwik = document as any; @@ -518,7 +504,7 @@ test.describe('out-of-order suspense streaming', () => { ); await expect(page.locator('#ooos-container-second-fallback-count')).toHaveText('1'); - await releaseOutOfOrderSuspense(page, '#ooos-container-second-release'); + await releaseDeferred(page, '#ooos-container-second-release'); await expect(page.locator('#ooos-container-second-resolved')).toBeVisible({ timeout: 10000 }); await page.waitForLoadState('load'); }); diff --git a/e2e/qwik-e2e/utils/release-gate.ts b/e2e/qwik-e2e/utils/release-gate.ts new file mode 100644 index 00000000000..31c6444cc95 --- /dev/null +++ b/e2e/qwik-e2e/utils/release-gate.ts @@ -0,0 +1,40 @@ +type ReleaseStore = { + resolved: Set; + resolvers: Map void>>; +}; + +// Each app bundle and the dev-server resolver rendezvous on this globalThis store. +export const getReleaseStore = (): ReleaseStore => + ((globalThis as any).__qwikOOOSReleaseStore ||= { + resolved: new Set(), + resolvers: new Map void>>(), + }); + +export const getReleaseKey = (requestId: string, releaseId: string): string => + `${requestId}:${releaseId}`; + +export const waitForRelease = (requestId: string, releaseId: string): Promise => + new Promise((resolve) => { + const store = getReleaseStore(); + const key = getReleaseKey(requestId, releaseId); + if (store.resolved.has(key)) { + resolve(); + } else { + let resolvers = store.resolvers.get(key); + if (!resolvers) { + store.resolvers.set(key, (resolvers = new Set())); + } + resolvers.add(resolve); + } + }); + +// Runs produce after the test's release POST, or after a short fallback delay. +export const releaseGated = ( + requestId: string, + releaseId: string | null, + produce: () => T +): Promise => + (releaseId + ? waitForRelease(requestId, releaseId) + : new Promise((resolve) => setTimeout(resolve, 1000)) + ).then(produce); diff --git a/e2e/qwik-e2e/utils/webkit-flush.ts b/e2e/qwik-e2e/utils/webkit-flush.ts new file mode 100644 index 00000000000..453ea7256bf --- /dev/null +++ b/e2e/qwik-e2e/utils/webkit-flush.ts @@ -0,0 +1,3 @@ +// WebKit buffers streamed HTML until enough early body content is emitted. +// https://bugs.webkit.org/show_bug.cgi?id=265386 +export const WEBKIT_STREAMING_FLUSH = '\u200b'.repeat(512); diff --git a/packages/devtools/kit/src/protocol/hooks.ts b/packages/devtools/kit/src/protocol/hooks.ts index 5efeae9ceb2..9c24db19326 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\nMember\n\n\n\n\nValue\n\n\n\n\nDescription\n\n\n\n\n\nblockSSR\n\n\n\n\n`\"blockSSR\"`\n\n\n\n\nEnable the `blockSSR: false` route loader option\n\n\n\n\n\neach\n\n\n\n\n`\"each\"`\n\n\n\n\nEnable the Each keyed-list primitive\n\n\n\n\n\ninsights\n\n\n\n\n`\"insights\"`\n\n\n\n\nEnable the ability to use the Qwik Insights vite plugin and `` component\n\n\n\n\n\nnoSPA\n\n\n\n\n`\"noSPA\"`\n\n\n\n\nDisable SPA navigation handler in Qwik Router\n\n\n\n\n\nshow\n\n\n\n\n`\"show\"`\n\n\n\n\nEnable the Show conditional primitive\n\n\n\n\n\nsuspense\n\n\n\n\n`\"suspense\"`\n\n\n\n\nEnable the Suspense fallback primitive\n\n\n\n\n\nvalibot\n\n\n\n\n`\"valibot\"`\n\n\n\n\nEnable the Valibot form validation\n\n\n\n", + "content": "Use `__EXPERIMENTAL__.x` to check if feature `x` is enabled. It will be replaced with `true` or `false` via an exact string replacement.\n\nAdd experimental features to this enum definition.\n\n\n```typescript\nexport declare enum ExperimentalFeatures \n```\n\n\n\n\n\n\n\n\n\n\n\n
\n\nMember\n\n\n\n\nValue\n\n\n\n\nDescription\n\n\n
\n\nblockSSR\n\n\n\n\n`\"blockSSR\"`\n\n\n\n\nEnable the `blockSSR: false` route loader option\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 the ErrorBoundary 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
", "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 7f09d180e4e..9da7af1eab7 100644 --- a/packages/docs/src/routes/api/qwik-optimizer/index.mdx +++ b/packages/docs/src/routes/api/qwik-optimizer/index.mdx @@ -26,6 +26,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. @@ -77,6 +79,19 @@ Enable the Each keyed-list primitive +errorBoundary + + + +`"errorBoundary"` + + + +Enable the ErrorBoundary primitive + + + + insights diff --git a/packages/docs/src/routes/api/qwik-router/api.json b/packages/docs/src/routes/api/qwik-router/api.json index e15014dc3e4..a87c281f193 100644 --- a/packages/docs/src/routes/api/qwik-router/api.json +++ b/packages/docs/src/routes/api/qwik-router/api.json @@ -282,20 +282,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": "ExcludeControlFlow", "id": "excludecontrolflow", diff --git a/packages/docs/src/routes/api/qwik-router/index.mdx b/packages/docs/src/routes/api/qwik-router/index.mdx index c708cc07105..a532bf813fb 100644 --- a/packages/docs/src/routes/api/qwik-router/index.mdx +++ b/packages/docs/src/routes/api/qwik-router/index.mdx @@ -784,14 +784,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) -

ExcludeControlFlow

Drops control-flow signals (`ev.redirect()`, `ev.error()`, etc.) from a loader/action return type: those are thrown, not surfaced as data. `ev.fail()` is plain data and is kept. diff --git a/packages/docs/src/routes/api/qwik-server/api.json b/packages/docs/src/routes/api/qwik-server/api.json index 3b8ad0754ad..7a81712809a 100644 --- a/packages/docs/src/routes/api/qwik-server/api.json +++ b/packages/docs/src/routes/api/qwik-server/api.json @@ -166,7 +166,7 @@ } ], "kind": "Interface", - "content": "```typescript\nexport interface RenderOptions extends SerializeDocumentOptions \n```\n**Extends:** [SerializeDocumentOptions](#serializedocumentoptions)\n\n\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\nbase?\n\n\n\n\n\n\n\nstring \\| ((options: [RenderOptions](#renderoptions)) => string)\n\n\n\n\n_(Optional)_ Specifies the root of the JS files of the client build. Setting a base, will cause the render of the `q:base` attribute in the `q:container` element.\n\n\n
\n\ncontainerAttributes?\n\n\n\n\n\n\n\nRecord<string, string>\n\n\n\n\n_(Optional)_\n\n\n
\n\ncontainerTagName?\n\n\n\n\n\n\n\nstring\n\n\n\n\n_(Optional)_ When set, the app is serialized into a fragment. And the returned html is not a complete document. Defaults to `html`\n\n\n
\n\nlocale?\n\n\n\n\n\n\n\nstring \\| ((options: [RenderOptions](#renderoptions)) => string)\n\n\n\n\n_(Optional)_ Language to use when rendering the document.\n\n\n
\n\npreloader?\n\n\n\n\n\n\n\n[PreloaderOptions](#preloaderoptions) \\| false\n\n\n\n\n_(Optional)_ Specifies how preloading is handled. This ensures that code is instantly available when needed.\n\n\n
\n\nqwikLoader?\n\n\n\n\n\n\n\n[QwikLoaderOptions](#qwikloaderoptions)\n\n\n\n\n_(Optional)_ Specifies how the Qwik Loader is included in the document. This enables interactivity and lazy loading.\n\n`module`: Use a `