fix(FN-0000): prevent stale dashboard service worker reload traps

This commit is contained in:
Aron Prins
2026-04-24 11:38:20 +02:00
parent 51d1b78eeb
commit 0f7680a70d
3 changed files with 73 additions and 4 deletions

View File

@@ -67,6 +67,32 @@ describe("PWA configuration", () => {
expect(swSource).toMatch(/fusion-cache-v\d+/);
});
it("service worker bypasses SSE requests instead of trying to cache them", () => {
const swSource = readFileSync(resolve(__dirname, "../public/sw.js"), "utf8");
expect(swSource).toContain('text/event-stream');
expect(swSource).toContain('url.pathname === "/api/events"');
expect(swSource).toContain('url.pathname.startsWith("/api/events/")');
expect(swSource).toContain("if (isEventStreamRequest) {");
expect(swSource).toContain("return;");
});
it("service worker revalidates navigation requests so index.html cannot stay stale", () => {
const swSource = readFileSync(resolve(__dirname, "../public/sw.js"), "utf8");
expect(swSource).toContain('request.mode === "navigate"');
expect(swSource).toContain('request.destination === "document"');
expect(swSource).toContain('url.pathname === "/index.html"');
expect(swSource).toContain('[sw] navigation cache put failed');
});
it("service worker activates updated code immediately", () => {
const swSource = readFileSync(resolve(__dirname, "../public/sw.js"), "utf8");
expect(swSource).toContain("await self.skipWaiting()");
expect(swSource).toContain("await self.clients.claim()");
});
describe("logo assets", () => {
it("logo.svg uses ring + swoosh geometry matching Header.tsx brand mark", () => {
const logoSvg = readFileSync(resolve(__dirname, "../public/logo.svg"), "utf8");

View File

@@ -1,4 +1,4 @@
const CACHE_NAME = "fusion-cache-v1";
const CACHE_NAME = "fusion-cache-v2";
const APP_SHELL_URLS = [
"/",
"/index.html",
@@ -13,6 +13,7 @@ self.addEventListener("install", (event) => {
try {
const cache = await caches.open(CACHE_NAME);
await cache.addAll(APP_SHELL_URLS);
await self.skipWaiting();
} catch (error) {
console.warn("[sw] install cache warmup failed", error);
}
@@ -43,7 +44,50 @@ self.addEventListener("fetch", (event) => {
}
const url = new URL(request.url);
const accept = request.headers.get("accept") ?? "";
const isApiRequest = url.pathname.startsWith("/api/");
const isEventStreamRequest =
accept.includes("text/event-stream") ||
url.pathname === "/api/events" ||
url.pathname.startsWith("/api/events/");
const isNavigationRequest =
request.mode === "navigate" ||
request.destination === "document" ||
url.pathname === "/" ||
url.pathname === "/index.html";
// EventSource requests stay open indefinitely. Waiting on cache.put() for an
// infinite response body prevents the browser from ever receiving the stream
// and leaks the underlying connection across reloads. Let SSE bypass the
// service worker entirely so the browser talks to the network directly.
if (isEventStreamRequest) {
return;
}
// Always revalidate the HTML shell so navigation picks up the latest hashed
// asset names instead of getting stuck on a cached index.html that points at
// a stale bundle.
if (isNavigationRequest) {
event.respondWith((async () => {
try {
const networkResponse = await fetch(request);
try {
const cache = await caches.open(CACHE_NAME);
await cache.put(request, networkResponse.clone());
} catch (cacheError) {
console.warn("[sw] navigation cache put failed", cacheError);
}
return networkResponse;
} catch (networkError) {
const fallback = await caches.match(request);
if (fallback) {
return fallback;
}
throw networkError;
}
})());
return;
}
if (isApiRequest) {
event.respondWith((async () => {

View File

@@ -498,7 +498,6 @@ export function createSSE(
// --- Cleanup (all handlers are defined above, safe to reference) ---
let cleaned = false;
let heartbeat: ReturnType<typeof setInterval> | undefined;
let clientStaleTimer: ReturnType<typeof setTimeout> | undefined;
function resetClientStaleTimer(): void {
@@ -517,7 +516,7 @@ export function createSSE(
activeConnections--;
console.log(`[sse] - connection (active=${activeConnections})`);
if (clientStaleTimer) clearTimeout(clientStaleTimer);
if (heartbeat) clearInterval(heartbeat);
clearInterval(heartbeat);
store.off("task:created", onCreated);
store.off("task:moved", onMoved);
store.off("task:updated", onUpdated);
@@ -684,7 +683,7 @@ export function createSSE(
});
resetClientStaleTimer();
heartbeat = setInterval(() => {
const heartbeat = setInterval(() => {
send("event: heartbeat\ndata: \n\n");
}, 30_000);