Skip to content

[v2] version-skew handling — failed chunk imports should surface a 'new version available' affordance #8795

Description

@maiieul

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 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:preloadErrorapp: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.

  1. 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.
  2. 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).
  3. 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.
  4. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions