QwikDev / QwikDev/qwik

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

Open
#8,795 0 comments 1 reaction 0 assignees View on GitHub

Nobody has claimed this yet.

enhancement runtime V2
Dominant language
TypeScript
Stars
22.1k
Forks
1.4k
Avg merge
1d 10h
Merged PRs (30d)
52

Description

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 #8745 e2e suite).

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with packages/qwik/src/core/shared/qrl/qrl-class.ts and ssr-container.ts, then review prototype a7be1cfee and the reusable #8745 e2e coverage. Resolve the listed open questions around the event, version asset, and interaction-time behavior before implementing; done means an agreed skew signal and UX with coverage for chunk 404 detection and guarded recovery.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript, typescript
Domain
frontend, web-dev
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Needs clarification
Newbie friendliness
32/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.