fix(sw, assets, install): bypass HTTP cache + revalidate assets + mobile-only install banner (t-paliad-043 step 3)

After step 2 deployed the IIFE-wrapped bundles, m's browser still saw
the broken page because /assets/projects.js was being served from the
local HTTP cache (no Cache-Control, just heuristic freshness from
Last-Modified). Even after the new SW activated and cleared its own
caches, its cacheFirst handler did `fetch(req)` which goes through the
browser HTTP cache — re-fed the SW cache from the stale bundle and the
loop perpetuated forever.

Three mutually reinforcing fixes:

1. SW cacheFirst now does `fetch(req, { cache: "reload" })` for the
   network leg. Forces the network fetch to bypass the browser's HTTP
   cache, so the SW always seeds its own cache from a true network read.

2. Go static handlers for /assets/* and /icons/* set
   `Cache-Control: no-cache, must-revalidate`. Combined with the
   Last-Modified that http.FileServer already emits, browsers send
   If-Modified-Since and the server replies 304 when unchanged — fast
   for repeat loads, fresh on every deploy. Users without a SW (or after
   the kill-switch unregistered theirs) now also pick up new bundles
   immediately.

3. pwa-install.ts gates the install banner on
   `(min-width: 768px)` — same breakpoint the BottomNav and other
   mobile-shell elements use. Desktop partners no longer get an install
   prompt covering their work area.
This commit is contained in:
m
2026-04-26 14:37:02 +02:00
parent 44ad50d5e4
commit 0800ba97f3
3 changed files with 32 additions and 4 deletions

View File

@@ -56,7 +56,12 @@ async function cacheFirst(req) {
const cached = await cache.match(req);
if (cached) return cached;
try {
const res = await fetch(req);
// cache: "reload" forces the network leg to BYPASS the browser HTTP
// cache. Without this, a stale /assets/projects.js sitting in the
// browser's disk cache from a previous deploy would be returned to us,
// we'd cache it again, and the user would be stuck on the old bundle
// forever — exactly the failure mode that caused t-paliad-043.
const res = await fetch(req, { cache: "reload" });
if (res && res.ok) cache.put(req, res.clone());
return res;
} catch (err) {

View File

@@ -22,6 +22,12 @@ let deferredPrompt: BeforeInstallPromptEvent | null = null;
export function initInstallPrompt(): void {
if (localStorage.getItem(DISMISS_KEY) === "1") return;
// The install prompt is part of the PWA mobile shell — same audience as
// the BottomNav. On desktop, partners use the sidebar nav and don't want
// an install banner overlapping their work area. Gate on the same 768px
// breakpoint as global.css's mobile media queries.
if (window.matchMedia("(min-width: 768px)").matches) return;
// Skip when already running as an installed PWA.
if (window.matchMedia("(display-mode: standalone)").matches) return;
// Safari iOS exposes navigator.standalone for the same signal.

View File

@@ -10,6 +10,17 @@ import (
var authClient *auth.Client
// noCacheAssets wraps a static-file handler so /assets/* and /icons/* always
// trigger a conditional GET. http.FileServer already emits Last-Modified, so
// browsers send If-Modified-Since on the next visit and the server replies
// 304 when the file is unchanged — fast for repeat loads, fresh on deploy.
func noCacheAssets(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache, must-revalidate")
h.ServeHTTP(w, r)
})
}
// Services bundles the Phase B + C database-backed services. Pass nil if
// DATABASE_URL was unset; the matter-management endpoints will return 503.
type Services struct {
@@ -71,15 +82,21 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
// doesn't bounce unauthenticated visitors to /login.
mux.HandleFunc("GET /{$}", handleRootPage)
// Static assets (public)
mux.Handle("GET /assets/", http.StripPrefix("/assets/", http.FileServer(http.Dir("dist/assets"))))
// Static assets (public). Cache-Control: no-cache forces browsers to
// revalidate every load. Combined with the Last-Modified header that
// http.FileServer emits, repeat visits get a fast 304 when assets are
// unchanged but pick up a fresh bundle the moment we deploy. Without
// this, browsers were heuristically caching /assets/*.js for the whole
// freshness window and continuing to execute the previous deploy's
// (broken) bundle even after the SW had been kill-switched (t-paliad-043).
mux.Handle("GET /assets/", noCacheAssets(http.StripPrefix("/assets/", http.FileServer(http.Dir("dist/assets")))))
// PWA static surface (public): manifest, icons, service worker. The SW
// must be served from the application origin; its scope is /, so the file
// has to live at /sw.js (a SW served from /assets/sw.js could only claim
// /assets/* by default).
mux.HandleFunc("GET /manifest.json", servePWAManifest)
mux.Handle("GET /icons/", http.StripPrefix("/icons/", http.FileServer(http.Dir("dist/icons"))))
mux.Handle("GET /icons/", noCacheAssets(http.StripPrefix("/icons/", http.FileServer(http.Dir("dist/icons")))))
mux.HandleFunc("GET /sw.js", servePWAServiceWorker)
// Protected routes