From c35d84858a87bedf1019afacf15941af7cd72559 Mon Sep 17 00:00:00 2001 From: Amr Gaber Date: Sun, 28 Jun 2026 15:02:36 -0500 Subject: [PATCH 1/3] fix: auto-reload on stale code-split chunk via vite:preloadError A code-split SPA orphans content-hashed chunks for already-open tabs on every deploy; a tab on a previous build 404s when it lazily imports a route chunk the new deploy has replaced. Listen for Vite's `vite:preloadError` and reload once to pick up the current build, with a timestamp guard so a genuinely broken deploy (the chunk stays missing) can't reload-loop. --- src/main.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/main.tsx b/src/main.tsx index d1f6825..d343656 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -21,6 +21,23 @@ import { AuthProvider, useAuth } from "./lib/auth" import { FeatureFlagProvider } from "./lib/feature-flags" import { routeTree } from "./routeTree.gen" +// Recover from stale code-split chunks after a deploy. Each build content-hashes +// its chunk filenames, so a tab still running a previous build will 404 when it +// lazily imports a route chunk the new deploy has already replaced. Vite fires +// `vite:preloadError` on that failure; reload once to pick up the current build. +// The timestamp guard prevents a reload loop when a deploy is genuinely broken +// (the chunk stays missing): we reload at most once per short window, then let +// the error surface. A later deploy (outside the window) can reload again. +window.addEventListener("vite:preloadError", (event) => { + const GUARD_KEY = "vite:preloadError:lastReload" + const RELOAD_WINDOW_MS = 10_000 + const lastReload = Number(sessionStorage.getItem(GUARD_KEY) ?? 0) + if (Date.now() - lastReload < RELOAD_WINDOW_MS) return + event.preventDefault() + sessionStorage.setItem(GUARD_KEY, String(Date.now())) + window.location.reload() +}) + const queryClient = new QueryClient({ defaultOptions: { queries: { From baa1961f1b8ad28d15fda9d4335303594eb834ef Mon Sep 17 00:00:00 2001 From: Amr Gaber Date: Sun, 28 Jun 2026 15:02:41 -0500 Subject: [PATCH 2/3] docs: add CLAUDE.md with production-hardening checklist Agent-facing orientation, the template's conventions (committed generated API client, adapter-boundary coercion, global mutation errors, chunk auto-reload), and a framework-level "production hardening" checklist distilled from running a sibling SPA through a high-traffic live event: stale code-split chunks, total sort comparators, coercing nullable fields at one chokepoint, void-promise handling, intentional-cancellation noise, and API/consumer schema coordination. --- CLAUDE.md | 78 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..70cd506 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,78 @@ +# web-template + +Vite + React 19 SPA starter: TanStack Router (file-based, code-split routes) + +TanStack Query, an orval-generated typed API client (hooks + Zod + MSW mocks) over +a Ky mutator, Tailwind + shadcn/ui, and sonner toasts. See `README.md` for the +full tour. + +## How to run things + +```bash +pnpm install +pnpm dev # dev server +pnpm build # generate-routes + tsc -b + vite build + +# Checks (exactly what CI runs) +pnpm lint # eslint +pnpm format:check # prettier (use `pnpm format` to apply) +pnpm tsc --noEmit # type check +pnpm test:run # vitest (one-shot) + +pnpm generate-api # regenerate the orval client from the OpenAPI spec +``` + +CI (`.github/workflows/ci.yml`) runs lint + format-check + type-check + test, plus +a `verify-api-types` job that fails if the committed client is out of date with the +spec (only when the `OPENAPI_URL` repo variable is set). + +## Conventions + +- **The generated API client is committed.** After any backend contract change, + run `pnpm generate-api` and commit the result — `verify-api-types` enforces it. + Treat `src/api/generated/` as read-only output. +- **Coerce nullable/optional API fields at the adapter boundary, not at each + consumer.** Normalize a DTO into the shape your components rely on in one place, + so every consumer sees a total type. See hardening. +- **Global mutation errors** surface via the `MutationCache` in `main.tsx` (toast); + opt out per-mutation with `meta: { skipGlobalError: true }`. +- **Stale code-split chunks auto-reload.** A `vite:preloadError` listener in + `main.tsx` reloads once after a deploy replaces the chunk an open tab is + importing. See hardening. + +## Production hardening — gotchas learned under real live-event load + +> Distilled from running a sibling SPA through a high-traffic live event. +> General, framework-level lessons — worth a read before shipping to production, +> especially anything that deploys frequently under live traffic. + +- [ ] **A code-split SPA orphans chunks for already-open tabs on _every_ deploy.** + Each build content-hashes its chunk filenames; a tab on a previous build 404s + when it lazily imports a replaced route chunk. The `vite:preloadError` → guarded + `location.reload()` listener in `main.tsx` handles it — keep it, and expect a + per-deploy blip under live traffic (consider a deploy freeze at peak viewership). +- [ ] **A sort comparator must be total — never throw.** A comparator runs over + whatever the cache holds, including a partial or stale-shaped row served during a + deploy rollover. `a.name.localeCompare(b.name)` throws if `name` is ever + `undefined` and takes down the _entire_ list render, not just that row. Null-guard + the comparator _and_ coerce the field at the adapter boundary. +- [ ] **Coerce nullable fields at one chokepoint.** Enforce the field's type where + the DTO enters your app (the adapter), not at each call site — + `name: dto.name ?? ""`. One stale/old-shape response from a rollover then can't + crash a stricter downstream consumer. +- [ ] **`void promise` ≠ handled.** Prefixing a fire-and-forget call with `void` + silences the floating-promise lint, not the runtime rejection — and the promise + that actually rejects may not be the one you `.catch()`. Catch the real owner, or + filter the class at the reporting boundary. +- [ ] **Intentional cancellation is not an error.** An `AbortError` from a + cancelled in-flight fetch (e.g. a query invalidated mid-flight) is expected + noise — don't surface or report it. Filter it at the reporting boundary rather + than chasing every call site that might own a cancelled request. +- [ ] **API schema changes need consumer coordination.** The client is generated + from the backend's OpenAPI spec and is in lockstep via + `generate-api`/`verify-api-types`. Land a backend contract change _with_ the + client regen and any null-handling the new shape needs — not ahead of it — or CI + goes red and the live FE breaks on the new shape. +- [ ] **Green CI ≠ verified in prod.** A plausible diff that compiles and passes + tests is not proof the fix works against the real, rebuilt, deployed artifact. + Confirm against the actual deployed build before trusting a fix — especially for + errors that only reproduce under real traffic or across deploy rollovers. From b04892a52757bd9d611315d6b2a0e16496d042d6 Mon Sep 17 00:00:00 2001 From: Amr Gaber Date: Sun, 28 Jun 2026 15:18:56 -0500 Subject: [PATCH 3/3] fix: prompt to reload on stale chunk instead of force-reloading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `vite:preloadError` listener now surfaces a one-shot "new version available" toast (sonner) with a Reload action instead of calling `location.reload()` directly. The router uses `defaultPreload: "intent"`, so this event also fires for speculative hover preloads — an auto-reload would reload the page when a user merely hovers a stale link and discard any unsaved work. The toast lets the user reload on their own terms; `preventDefault()` suppresses the now-handled uncaught preload errors. Drops the sessionStorage reload-loop guard (no auto-reload to loop). --- CLAUDE.md | 16 ++++++++++------ src/main.tsx | 34 +++++++++++++++++++++------------- 2 files changed, 31 insertions(+), 19 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 70cd506..c858727 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,9 +35,10 @@ spec (only when the `OPENAPI_URL` repo variable is set). so every consumer sees a total type. See hardening. - **Global mutation errors** surface via the `MutationCache` in `main.tsx` (toast); opt out per-mutation with `meta: { skipGlobalError: true }`. -- **Stale code-split chunks auto-reload.** A `vite:preloadError` listener in - `main.tsx` reloads once after a deploy replaces the chunk an open tab is - importing. See hardening. +- **Stale code-split chunks prompt a reload.** A `vite:preloadError` listener in + `main.tsx` surfaces a "new version available" toast (sonner) when a deploy has + replaced the chunk an open tab is importing, letting the user reload on their + own terms rather than force-reloading over unsaved work. See hardening. ## Production hardening — gotchas learned under real live-event load @@ -47,9 +48,12 @@ spec (only when the `OPENAPI_URL` repo variable is set). - [ ] **A code-split SPA orphans chunks for already-open tabs on _every_ deploy.** Each build content-hashes its chunk filenames; a tab on a previous build 404s - when it lazily imports a replaced route chunk. The `vite:preloadError` → guarded - `location.reload()` listener in `main.tsx` handles it — keep it, and expect a - per-deploy blip under live traffic (consider a deploy freeze at peak viewership). + when it lazily imports a replaced route chunk. The `vite:preloadError` listener + in `main.tsx` catches it and prompts the user to reload (a toast, not a forced + reload — the event also fires for `defaultPreload: "intent"` hover preloads, + where an auto-reload would yank the page out from under the user and discard + unsaved work). Expect a per-deploy blip under live traffic (consider a deploy + freeze at peak viewership). - [ ] **A sort comparator must be total — never throw.** A comparator runs over whatever the cache holds, including a partial or stale-shaped row served during a deploy rollover. `a.name.localeCompare(b.name)` throws if `name` is ever diff --git a/src/main.tsx b/src/main.tsx index d343656..d574049 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -21,21 +21,29 @@ import { AuthProvider, useAuth } from "./lib/auth" import { FeatureFlagProvider } from "./lib/feature-flags" import { routeTree } from "./routeTree.gen" -// Recover from stale code-split chunks after a deploy. Each build content-hashes -// its chunk filenames, so a tab still running a previous build will 404 when it -// lazily imports a route chunk the new deploy has already replaced. Vite fires -// `vite:preloadError` on that failure; reload once to pick up the current build. -// The timestamp guard prevents a reload loop when a deploy is genuinely broken -// (the chunk stays missing): we reload at most once per short window, then let -// the error surface. A later deploy (outside the window) can reload again. +// A `vite:preloadError` fires when a dynamic import for a code-split chunk +// fails — which almost always means a newer build was deployed and this tab is +// now stale (it references content-hashed chunk filenames the deploy has already +// replaced). We prompt the user to reload rather than force-reloading: a forced +// reload discards any unsaved work, and because the router uses +// `defaultPreload: "intent"` this event also fires for *speculative* preloads +// (hovering a stale link), where auto-reloading the page would be jarring. +// `preventDefault()` stops Vite re-throwing the failed import (otherwise the +// hover-preload failures surface as uncaught errors); the toast lets the user +// reload on their own terms. Shown once so a burst of failures can't stack it. +let updateToastShown = false window.addEventListener("vite:preloadError", (event) => { - const GUARD_KEY = "vite:preloadError:lastReload" - const RELOAD_WINDOW_MS = 10_000 - const lastReload = Number(sessionStorage.getItem(GUARD_KEY) ?? 0) - if (Date.now() - lastReload < RELOAD_WINDOW_MS) return event.preventDefault() - sessionStorage.setItem(GUARD_KEY, String(Date.now())) - window.location.reload() + if (updateToastShown) return + updateToastShown = true + toast.info("A new version is available", { + description: "Reload to get the latest update.", + duration: Infinity, + action: { + label: "Reload", + onClick: () => window.location.reload(), + }, + }) }) const queryClient = new QueryClient({