diff --git a/backend/src/ref_backend/builder.py b/backend/src/ref_backend/builder.py index 5b6052a9..c5029c80 100644 --- a/backend/src/ref_backend/builder.py +++ b/backend/src/ref_backend/builder.py @@ -72,9 +72,15 @@ class SPAStaticFiles(StaticFiles): async def get_response(self, path: str, scope: Scope) -> Response: try: - return await super().get_response(path, scope) + response = await super().get_response(path, scope) except HTTPException: - return await super().get_response(".", scope) + response = await super().get_response(".", scope) + + # The HTML names hashed asset files that the next deploy removes, so it must be revalidated. + if response.headers.get("content-type", "").startswith("text/html"): + response.headers["Cache-Control"] = "no-cache" + + return response def build_app(settings: Settings, ref_config: Config, database: Database) -> FastAPI: diff --git a/changelog/88.fix.md b/changelog/88.fix.md new file mode 100644 index 00000000..4ec5644b --- /dev/null +++ b/changelog/88.fix.md @@ -0,0 +1 @@ +Reloads the page when a chunk fails to load, so a tab left open across a deployment recovers instead of showing an error. diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index e91bb64f..fef5795f 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -13,6 +13,41 @@ import { routeTree } from "@/routeTree.gen"; import "./styles/global.css"; +// A deploy replaces the hashed asset files, so a tab left open asks for chunks that are gone. +// Reload once to pick up the new build, rate limited so a persistent failure cannot loop. +const RELOAD_KEY = "chunk-reload-at"; +const RELOAD_COOLDOWN_MS = 30_000; + +// Storage access throws when the browser blocks site data, so both calls are guarded. +const readReloadedAt = (): number => { + try { + return Number(sessionStorage.getItem(RELOAD_KEY) ?? 0); + } catch { + return 0; + } +}; + +const markReloaded = (): boolean => { + try { + sessionStorage.setItem(RELOAD_KEY, String(Date.now())); + return true; + } catch { + return false; + } +}; + +window.addEventListener("vite:preloadError", (event) => { + if (Date.now() - readReloadedAt() < RELOAD_COOLDOWN_MS) { + return; + } + // Without a recorded timestamp there is no cooldown, so let the error surface instead of looping. + if (!markReloaded()) { + return; + } + event.preventDefault(); + window.location.reload(); +}); + client.setConfig({ baseUrl: getStoredApiEndpoint() }); const queryClient = new QueryClient();