You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
While hardening the <ErrorBoundary> e2e suite for #8745 we found that a failed dynamic import of a component handler chunk (chunk 404) routes into the nearest <ErrorBoundary> and swaps working, rendered UI for the error fallback. Loader-level import failures (handlers.js) already behave differently: the qwikloader emits qerror with importError: 'sync' | 'no-symbol' | 'async' and the container leaves the boundary inert.
A chunk 404 in production is almost always version skew: a deploy purged the old hashed chunks while a long-lived tab still references them. Neither current behavior is the right UX:
EB fallback (current, for component chunks): tells the user the content broke — it didn't — and its own recovery can't work (see Chromium note below).
Silent inert (prototyped in a7be1cfee, reverted): leaves a dead button with no user feedback.
The right end state is a skew-specific affordance: tell the user they're on an old version and need to reload (or reload for them, guarded).
What other frameworks do (researched + source-verified, July 2026)
SvelteKit (framework-core): build version name (version.name, defaults to build timestamp) + _app/version.json. On a navigation load error it awaits updated.check() — confirming a new version actually exists before silently falling back to a full-page navigation (avoids reload loops on transient network failures). Optional background polling (pollInterval, default off) flips the reactive updated state; the officially taught UX for that channel is a toast with a reload button, not a silent reload. Their docs are explicit that failure-only detection is insufficient: a stale tab whose JS is already loaded never errors — it renders stale content.
Nuxt 3/4: detection = the import failure itself (vite:preloadError → app:chunkError hook). Default emitRouteChunkError: 'automatic' reloads only at navigation time (hard nav to the target route). Interaction-time failures — lazy components failing on click, exactly Qwik's shape — only got recovery in Nov 2024 via the opt-in 'automatic-immediate' (nuxt/nuxt#28160), whose docs warn the downside is undesired reloads. Their reloadNuxtApp primitive ships a 10s per-path sessionStorage reload-loop TTL and optional state persistence. They also poll builds/latest.json hourly by default.
Vercel skew protection (platform layer, default-on for new projects since Nov 2024): deployment-ID cookie pins tabs to their original deployment so stale chunks stay routable. Host-specific; framework-core handling remains the portable safety net.
Converged pattern: (1) the import failure is the universal reactive signal; (2) confirm skew against a version asset before acting; (3) silent hard reload only at navigation time, prompt at interaction/idle time; (4) always guard (loop TTL, confirm-first); (5) a proactive polling channel is needed anyway — and Qwik needs it more than anyone, since most Qwik code never loads until interaction, so a stale tab can serve stale content indefinitely without ever producing the error signal.
Proposed design for Qwik
Raw material that already exists: every SSR'd container serializes q:manifest-hash (ssr-container.ts) — that's the build id, already in the DOM. The qwikloader already emits qerror + importError for its three loader-level failure classes.
Unify the detection channel: also dispatch the qerror/importError event from the core chunk-load path. The single funnel is LazyRef.$setRef$'s rejection handler in packages/qwik/src/core/shared/qrl/qrl-class.ts — every module-load rejection passes through it (importSymbol 404, $symbolFn$ rejection, constructor refs), and no user-thrown error can reach it, so no message matching is needed. Note Qwik cannot ride Vite's vite:preloadError: handler QRLs import via core's importSymbol, not Vite's preload helper.
Confirm skew before acting: on a chunk-load failure, fetch a small build-emitted version asset (SvelteKit's _app/version.json pattern) and compare with the container's q:manifest-hash. Mismatch → confirmed skew. Match → transient network failure (different remedy; do not reload-loop).
UX on confirmed skew: expose an updated-style signal/hook (router level, analogue of SvelteKit's updated) so apps render a 'new version available — reload' prompt; opt-in automatic reload with a Nuxt-style per-path TTL guard. For SPA-nav route-chunk failures, do the industry norm silently: location.href = target.
Optional proactive poll (off by default) feeding the same signal, for the stale-tab-that-never-errors case.
Open questions for the team: extend qerror vs a dedicated event; where the version asset is emitted (core vite plugin vs router); default posture at interaction time (prompt vs 'automatic-immediate'-style reload); what to show in the not-skew case (adblocker/permanent network failure — dead button vs affordance); interaction with the preloader.
Technical notes from the prototype
A working bottom-layer prototype exists: a7be1cfee on claude/gracious-poitras-0f1723 (reverted there to keep #8745 focused — the tag infrastructure, red-proofed unit tests, and route-abort e2e are directly reusable by this work):
tagChunkLoadError/isChunkLoadError via a non-enumerable, frozen-safe Symbol minted only at the $setRef$ rejection funnel (follows the tagErrorPhase precedent).
The QRL layer already does NOT cache rejections ($ref$ = null on rejection) — retry at the QRL level works.
But Chromium pins a failed dynamic import in the module map for the page's lifetime — after the chunk becomes available again, retries trigger zero network requests (verified with request instrumentation). In-page recovery is impossible; reload is the only real remedy, which is why the skew affordance matters.
e2e technique for simulating the 404: page.route abort on the built chunk pattern, with a blocked-request counter guard (see the handlers.js variant already merged in the feat(core): core ErrorBoundary in SSR and CSR #8745 e2e suite).
Context
While hardening the
<ErrorBoundary>e2e suite for #8745 we found that a failed dynamic import of a component handler chunk (chunk 404) routes into the nearest<ErrorBoundary>and swaps working, rendered UI for the error fallback. Loader-level import failures (handlers.js) already behave differently: the qwikloader emitsqerrorwithimportError: 'sync' | 'no-symbol' | 'async'and the container leaves the boundary inert.A chunk 404 in production is almost always version skew: a deploy purged the old hashed chunks while a long-lived tab still references them. Neither current behavior is the right UX:
a7be1cfee, reverted): leaves a dead button with no user feedback.The right end state is a skew-specific affordance: tell the user they're on an old version and need to reload (or reload for them, guarded).
What other frameworks do (researched + source-verified, July 2026)
SvelteKit (framework-core): build version name (
version.name, defaults to build timestamp) +_app/version.json. On a navigation load error it awaitsupdated.check()— confirming a new version actually exists before silently falling back to a full-page navigation (avoids reload loops on transient network failures). Optional background polling (pollInterval, default off) flips the reactiveupdatedstate; the officially taught UX for that channel is a toast with a reload button, not a silent reload. Their docs are explicit that failure-only detection is insufficient: a stale tab whose JS is already loaded never errors — it renders stale content.Nuxt 3/4: detection = the import failure itself (
vite:preloadError→app:chunkErrorhook). DefaultemitRouteChunkError: 'automatic'reloads only at navigation time (hard nav to the target route). Interaction-time failures — lazy components failing on click, exactly Qwik's shape — only got recovery in Nov 2024 via the opt-in'automatic-immediate'(nuxt/nuxt#28160), whose docs warn the downside is undesired reloads. TheirreloadNuxtAppprimitive ships a 10s per-path sessionStorage reload-loop TTL and optional state persistence. They also pollbuilds/latest.jsonhourly by default.Vercel skew protection (platform layer, default-on for new projects since Nov 2024): deployment-ID cookie pins tabs to their original deployment so stale chunks stay routable. Host-specific; framework-core handling remains the portable safety net.
Converged pattern: (1) the import failure is the universal reactive signal; (2) confirm skew against a version asset before acting; (3) silent hard reload only at navigation time, prompt at interaction/idle time; (4) always guard (loop TTL, confirm-first); (5) a proactive polling channel is needed anyway — and Qwik needs it more than anyone, since most Qwik code never loads until interaction, so a stale tab can serve stale content indefinitely without ever producing the error signal.
Proposed design for Qwik
Raw material that already exists: every SSR'd container serializes
q:manifest-hash(ssr-container.ts) — that's the build id, already in the DOM. The qwikloader already emitsqerror+importErrorfor its three loader-level failure classes.qerror/importError event from the core chunk-load path. The single funnel isLazyRef.$setRef$'s rejection handler inpackages/qwik/src/core/shared/qrl/qrl-class.ts— every module-load rejection passes through it (importSymbol 404,vite:preloadError: handler QRLs import via core'simportSymbol, not Vite's preload helper._app/version.jsonpattern) and compare with the container'sq:manifest-hash. Mismatch → confirmed skew. Match → transient network failure (different remedy; do not reload-loop).updated-style signal/hook (router level, analogue of SvelteKit'supdated) so apps render a 'new version available — reload' prompt; opt-in automatic reload with a Nuxt-style per-path TTL guard. For SPA-nav route-chunk failures, do the industry norm silently:location.href = target.Open questions for the team: extend
qerrorvs a dedicated event; where the version asset is emitted (core vite plugin vs router); default posture at interaction time (prompt vs'automatic-immediate'-style reload); what to show in the not-skew case (adblocker/permanent network failure — dead button vs affordance); interaction with the preloader.Technical notes from the prototype
A working bottom-layer prototype exists:
a7be1cfeeonclaude/gracious-poitras-0f1723(reverted there to keep #8745 focused — the tag infrastructure, red-proofed unit tests, and route-abort e2e are directly reusable by this work):tagChunkLoadError/isChunkLoadErrorvia a non-enumerable, frozen-safe Symbol minted only at the$setRef$rejection funnel (follows thetagErrorPhaseprecedent).$ref$ = nullon rejection) — retry at the QRL level works.page.routeabort on the built chunk pattern, with a blocked-request counter guard (see thehandlers.jsvariant already merged in the feat(core): core ErrorBoundary in SSR and CSR #8745 e2e suite).