From 4745af3639d8aa4389457289292f404377f7d50c Mon Sep 17 00:00:00 2001 From: BOHA Date: Wed, 10 Jun 2026 20:31:59 +0200 Subject: [PATCH] fix(spa): self-heal stale clients after deploys (vite:preloadError + cache headers) After a release the old hashed chunks are deleted, so an open tab from the previous build failed its next lazy-page import until a manual refresh. Three-part fix per the Vite deploy guide: - vite:preloadError listener reloads the page once (10s sessionStorage guard against loops) so stale tabs heal themselves - static cache headers: /assets/* immutable for a year (content-hashed names), index.html no-cache so new visits always see fresh chunk refs - missing /assets/* now 404 instead of getting the SPA index.html fallback (HTML-as-JS masked the failure) Co-Authored-By: Claude Fable 5 --- src/main.tsx | 16 ++++++++++++++++ src/server.ts | 18 ++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/main.tsx b/src/main.tsx index fca17b2..adae4d9 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,6 +4,22 @@ import { BrowserRouter } from "react-router-dom"; import App from "./App"; import { ThemeProvider } from "./context/ThemeContext"; +// Deploy-skew recovery (official Vite mechanism): after a release the old +// hashed chunks are deleted, so a tab still running the previous build fails +// its next lazy-page import. Vite emits `vite:preloadError` for exactly this — +// swallow the error and reload once, which loads the fresh index.html and new +// chunk names. The timestamp guard prevents a reload loop when a chunk is +// genuinely unloadable (e.g. offline): a second failure within 10 s falls +// through to the normal error path. +window.addEventListener("vite:preloadError", (event) => { + const KEY = "vite-preload-reloaded-at"; + const last = Number(sessionStorage.getItem(KEY) || 0); + if (Date.now() - last < 10_000) return; + sessionStorage.setItem(KEY, String(Date.now())); + event.preventDefault(); + window.location.reload(); +}); + ReactDOM.createRoot(document.getElementById("root")!).render( { + if (filePath.includes(`${path.sep}assets${path.sep}`)) { + res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); + } else { + res.setHeader("Cache-Control", "no-cache"); + } + }, }); app.setNotFoundHandler((request, reply) => { if (request.url.startsWith("/api/")) { return reply.status(404).send({ success: false, error: "Not found" }); } + // A missing hashed asset (old chunk after a deploy) must 404, NOT get + // the SPA index.html fallback — HTML-as-JS masks the failure and breaks + // the client's vite:preloadError reload recovery. + if (request.url.startsWith("/assets/")) { + return reply.status(404).send(); + } return reply.sendFile("index.html"); }); }