From e612e88752fe261fe2eb38fa8fe0e4eecc36ceb6 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Thu, 3 Sep 2026 11:51:07 +1000 Subject: [PATCH 1/5] Reload the page when a chunk fails to load A deploy replaces the hashed asset files, so a tab left open across a deploy asks for chunks that no longer exist and the route fails to render. Listens for Vite's `vite:preloadError` and reloads once to pick up the new build. --- frontend/src/main.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index e91bb64f..1314a315 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -13,6 +13,20 @@ 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 no longer +// exist. 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; +window.addEventListener("vite:preloadError", (event) => { + const last = Number(sessionStorage.getItem(RELOAD_KEY) ?? 0); + if (Date.now() - last < RELOAD_COOLDOWN_MS) { + return; + } + event.preventDefault(); + sessionStorage.setItem(RELOAD_KEY, String(Date.now())); + window.location.reload(); +}); + client.setConfig({ baseUrl: getStoredApiEndpoint() }); const queryClient = new QueryClient(); From 4ee19495f54add6d1bece96a1822c10314d5d1ba Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Thu, 3 Sep 2026 11:51:32 +1000 Subject: [PATCH 2/5] Add changelog fragment --- changelog/88.fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/88.fix.md diff --git a/changelog/88.fix.md b/changelog/88.fix.md new file mode 100644 index 00000000..014e6a39 --- /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 deploy recovers instead of showing an error. From f9146f0bcf32f3cf24f2e20bd8f7be62ab176589 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Thu, 3 Sep 2026 12:41:42 +1000 Subject: [PATCH 3/5] chore: fix comment --- frontend/src/main.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 1314a315..692f7ef8 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -13,8 +13,8 @@ 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 no longer -// exist. Reload once to pick up the new build, rate limited so a persistent failure cannot loop. +// A deploy replaces the hashed asset files, so a tab left open asks for chunks that no longer exist. +// 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; window.addEventListener("vite:preloadError", (event) => { From 834840bd625d3999f2732987d442f4695ead40fa Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Thu, 3 Sep 2026 13:27:45 +1000 Subject: [PATCH 4/5] Harden the chunk reload against blocked storage and cached HTML Guards the sessionStorage access, because a browser that blocks site data throws there and would otherwise swallow the preload error without reloading. Sends `Cache-Control: no-cache` with the SPA HTML, so the reload cannot be answered from cache with the old asset names. --- backend/src/ref_backend/builder.py | 10 ++++++++-- changelog/88.fix.md | 2 +- frontend/src/main.tsx | 25 +++++++++++++++++++++---- 3 files changed, 30 insertions(+), 7 deletions(-) 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 index 014e6a39..4ec5644b 100644 --- a/changelog/88.fix.md +++ b/changelog/88.fix.md @@ -1 +1 @@ -Reloads the page when a chunk fails to load, so a tab left open across a deploy recovers instead of showing an error. +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 692f7ef8..16bcf140 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -13,17 +13,34 @@ 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 no longer exist. +// 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 it must not stop the reload. +const readReloadedAt = (): number => { + try { + return Number(sessionStorage.getItem(RELOAD_KEY) ?? 0); + } catch { + return 0; + } +}; + +const markReloaded = () => { + try { + sessionStorage.setItem(RELOAD_KEY, String(Date.now())); + } catch { + // Without storage the cooldown cannot be recorded, but the reload still matters more. + } +}; + window.addEventListener("vite:preloadError", (event) => { - const last = Number(sessionStorage.getItem(RELOAD_KEY) ?? 0); - if (Date.now() - last < RELOAD_COOLDOWN_MS) { + if (Date.now() - readReloadedAt() < RELOAD_COOLDOWN_MS) { return; } + markReloaded(); event.preventDefault(); - sessionStorage.setItem(RELOAD_KEY, String(Date.now())); window.location.reload(); }); From e6fddf3baf2cfa111cd12dc368a4ef16cd02f239 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Thu, 3 Sep 2026 13:55:08 +1000 Subject: [PATCH 5/5] Only reload when the cooldown was recorded Without storage there is no cooldown, so reloading on every preload error could loop. Lets the error surface instead. --- frontend/src/main.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 16bcf140..fef5795f 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -18,7 +18,7 @@ import "./styles/global.css"; const RELOAD_KEY = "chunk-reload-at"; const RELOAD_COOLDOWN_MS = 30_000; -// Storage access throws when the browser blocks site data, so it must not stop the reload. +// 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); @@ -27,11 +27,12 @@ const readReloadedAt = (): number => { } }; -const markReloaded = () => { +const markReloaded = (): boolean => { try { sessionStorage.setItem(RELOAD_KEY, String(Date.now())); + return true; } catch { - // Without storage the cooldown cannot be recorded, but the reload still matters more. + return false; } }; @@ -39,7 +40,10 @@ window.addEventListener("vite:preloadError", (event) => { if (Date.now() - readReloadedAt() < RELOAD_COOLDOWN_MS) { return; } - markReloaded(); + // Without a recorded timestamp there is no cooldown, so let the error surface instead of looping. + if (!markReloaded()) { + return; + } event.preventDefault(); window.location.reload(); });