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..9189f47b6 --- /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): void => { + listeners.set(type, listener as (event: Event) => void); + }, + sessionStorage: { + 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 }, + }; + 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()'); });