From 905ac7923dcaff33694d8912d1aa23f98b1e45d8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 22 Oct 2026 10:57:34 +0000 Subject: [PATCH 1/2] Reload once when a deploy leaves a tab holding an old chunk. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A missing script showed Unable to load this page, and the service worker could store an HTML error as if it were that script. The tab now reloads once per path, and HTML is no longer cached for scripts, styles, or assets. Co-authored-by: Gáborjáni-Szabó Ádám --- public/sw.js | 15 ++++++-- src/lib/reload-stale-chunk.ts | 25 ++++++++++++++ src/main.tsx | 3 ++ src/test/lib/reload-stale-chunk.test.ts | 42 +++++++++++++++++++++++ src/test/vercel-deployment-config.test.ts | 1 + 5 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 src/lib/reload-stale-chunk.ts create mode 100644 src/test/lib/reload-stale-chunk.test.ts diff --git a/public/sw.js b/public/sw.js index bf4cb219b..6f60af19c 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,4 @@ -const CACHE_VERSION = 'debate-lab-v3'; +const CACHE_VERSION = 'debate-lab-v4'; const APP_SHELL_CACHE = `${CACHE_VERSION}-shell`; const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`; @@ -47,13 +47,22 @@ self.addEventListener('activate', (event) => { ); }); +function htmlMislabeledAsAsset(response, request) { + const type = (response.headers.get('content-type') || '').toLowerCase(); + if (!type.includes('text/html')) return false; + const path = new URL(request.url).pathname; + return request.destination === 'script' + || request.destination === 'style' + || path.startsWith('/assets/'); +} + async function cacheFirst(request) { const cached = await caches.match(request, { ignoreVary: true }); - if (cached) return cached; + if (cached && !htmlMislabeledAsAsset(cached, request)) return cached; try { const response = await fetch(request); - if (response.ok) { + if (response.ok && !htmlMislabeledAsAsset(response, request)) { const cache = await caches.open(RUNTIME_CACHE); await cache.put(request, response.clone()); } diff --git a/src/lib/reload-stale-chunk.ts b/src/lib/reload-stale-chunk.ts new file mode 100644 index 000000000..82f39bd98 --- /dev/null +++ b/src/lib/reload-stale-chunk.ts @@ -0,0 +1,25 @@ +const RELOAD_PREFIX = 'debatelab-stale-chunk:'; + +type ReloadTarget = Pick; + +/** + * After a deploy, an open tab can ask for a chunk the new build no longer has. + * Reload once so the fresh document loads the new names. A second failure on + * the same path stays put, so a missing file cannot refresh the tab forever. + */ +export function installStaleChunkReload(target: ReloadTarget = window): void { + target.addEventListener('vite:preloadError', (event) => { + const path = target.location.pathname || '/'; + const key = `${RELOAD_PREFIX}${path}`; + let alreadyReloaded = false; + try { + alreadyReloaded = target.sessionStorage.getItem(key) === '1'; + if (!alreadyReloaded) target.sessionStorage.setItem(key, '1'); + } catch { + return; + } + if (alreadyReloaded) return; + event.preventDefault(); + target.location.reload(); + }); +} diff --git a/src/main.tsx b/src/main.tsx index fb7e1d7a2..400423e09 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -6,6 +6,7 @@ import "./i18n"; import "@fontsource-variable/inter"; import "./index.css"; import { registerServiceWorker } from "./register-service-worker"; +import { installStaleChunkReload } from "./lib/reload-stale-chunk"; import { initializeObservability } from "./lib/observability"; import { preloadRoute } from "./lib/route-modules"; @@ -24,6 +25,8 @@ function prerenderedRoute(container: HTMLElement): string | undefined { return route === "*" || route === window.location.pathname ? route : undefined; } +installStaleChunkReload(); + export function mountApp(): void { const container = document.getElementById("root")!; const root = createRoot(container); diff --git a/src/test/lib/reload-stale-chunk.test.ts b/src/test/lib/reload-stale-chunk.test.ts new file mode 100644 index 000000000..42f510ac3 --- /dev/null +++ b/src/test/lib/reload-stale-chunk.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from 'vitest'; +import { installStaleChunkReload } from '@/lib/reload-stale-chunk'; + +function fakeWindow() { + const listeners = new Map void>(); + const stored = new Map(); + const reload = vi.fn(); + const target = { + addEventListener: (type: string, listener: EventListener) => { + listeners.set(type, listener as (event: Event) => void); + }, + sessionStorage: { + getItem: (key: string) => stored.get(key) ?? null, + setItem: (key: string, value: string) => { stored.set(key, value); }, + removeItem: (key: string) => { stored.delete(key); }, + clear: () => { stored.clear(); }, + key: () => null, + length: 0, + }, + location: { pathname: '/app/ask', reload }, + }; + return { target, listeners, reload }; +} + +describe('stale chunk reload', () => { + it('reloads once when a route chunk is missing and then lets the error stand', () => { + const { target, listeners, reload } = fakeWindow(); + installStaleChunkReload(target as unknown as Window); + const handler = listeners.get('vite:preloadError'); + expect(handler).toBeTypeOf('function'); + + const first = new Event('vite:preloadError', { cancelable: true }); + handler!(first); + expect(first.defaultPrevented).toBe(true); + expect(reload).toHaveBeenCalledTimes(1); + + const second = new Event('vite:preloadError', { cancelable: true }); + handler!(second); + expect(second.defaultPrevented).toBe(false); + expect(reload).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/test/vercel-deployment-config.test.ts b/src/test/vercel-deployment-config.test.ts index 4927c7854..a9fc34eb1 100644 --- a/src/test/vercel-deployment-config.test.ts +++ b/src/test/vercel-deployment-config.test.ts @@ -183,6 +183,7 @@ describe("Vercel frontend deployment configuration", () => { expect(serviceWorker).not.toContain("'/app/sparring'"); expect(serviceWorker).toContain("'/favicon-72.png'"); expect(serviceWorker).toContain('cache.put(request, response.clone())'); + expect(serviceWorker).toContain('htmlMislabeledAsAsset'); expect(serviceWorker).not.toContain('self.skipWaiting()'); }); From a39cb45d99ecc2f245f8386a1e5c82cd193cd588 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 22 Oct 2026 11:00:21 +0000 Subject: [PATCH 2/2] Annotate the stale-chunk test doubles so typecheck stays clean. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Gáborjáni-Szabó Ádám --- src/test/lib/reload-stale-chunk.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/test/lib/reload-stale-chunk.test.ts b/src/test/lib/reload-stale-chunk.test.ts index 42f510ac3..9189f47b6 100644 --- a/src/test/lib/reload-stale-chunk.test.ts +++ b/src/test/lib/reload-stale-chunk.test.ts @@ -6,15 +6,15 @@ function fakeWindow() { const stored = new Map(); const reload = vi.fn(); const target = { - addEventListener: (type: string, listener: EventListener) => { + addEventListener: (type: string, listener: EventListener): void => { listeners.set(type, listener as (event: Event) => void); }, sessionStorage: { - getItem: (key: string) => stored.get(key) ?? null, - setItem: (key: string, value: string) => { stored.set(key, value); }, - removeItem: (key: string) => { stored.delete(key); }, - clear: () => { stored.clear(); }, - key: () => null, + getItem: (key: string): string | null => stored.get(key) ?? null, + setItem: (key: string, value: string): void => { stored.set(key, value); }, + removeItem: (key: string): void => { stored.delete(key); }, + clear: (): void => { stored.clear(); }, + key: (): string | null => null, length: 0, }, location: { pathname: '/app/ask', reload },