Files
fusion/packages/dashboard/app/public/sw.js
gsxdsm c9d48fb750 FN-6180: fix stale dashboard asset reload handling
Prevent rebuilt dashboard tabs from reusing stale cached assets after a deploy.

- revalidate service-worker asset requests against the network before falling back to cache and bump the cache version
- suppress repeated version-change reloads for the same remote build and cover the new behavior with dashboard tests
- update engine heartbeat test expectations for summarized task creation metadata and add the published package changeset

Files changed:
 .changeset/fn-blank-page-service-worker-assets.md  |  3 ++
 packages/dashboard/app/__tests__/pwa.test.ts       | 11 ++++++
 packages/dashboard/app/__tests__/versionCheck.test.ts   | 28 ++++++++++++++
 packages/dashboard/app/public/sw.js                | 37 +++++++++++++++++-
 packages/dashboard/app/versionCheck.ts             | 45 ++++++++++++++++++++++
 packages/engine/src/__tests__/heartbeat-executor.test.ts |  3 +-
 packages/engine/src/__tests__/heartbeat-session-prompt.test.ts |  7 +++-
 7 files changed, 131 insertions(+), 3 deletions(-)

Fusion-Task-Id: FN-6180

Fusion-Task-Lineage: 4f07e5f0-56c7-4241-8719-f576dfd8613e
2026-06-10 00:20:35 -07:00

176 lines
5.2 KiB
JavaScript

const CACHE_NAME = "fusion-cache-v3";
const APP_SHELL_URLS = [
"/",
"/index.html",
"/manifest.json",
"/logo.svg",
"/icons/icon-192.png",
"/icons/icon-512.png",
];
self.addEventListener("message", (event) => {
if (event.data && event.data.type === "SKIP_WAITING") {
self.skipWaiting();
}
});
self.addEventListener("install", (event) => {
event.waitUntil((async () => {
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);
}
})());
});
self.addEventListener("activate", (event) => {
event.waitUntil((async () => {
try {
const keys = await caches.keys();
await Promise.all(
keys
.filter((key) => key !== CACHE_NAME)
.map((key) => caches.delete(key)),
);
await self.clients.claim();
} catch (error) {
console.warn("[sw] activate cleanup failed", error);
}
})());
});
self.addEventListener("fetch", (event) => {
const request = event.request;
if (request.method !== "GET") {
return;
}
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";
const isBuiltAssetRequest =
url.pathname.startsWith("/assets/") ||
request.destination === "script" ||
request.destination === "style" ||
request.destination === "font";
// 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 () => {
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] api cache put failed", cacheError);
}
return networkResponse;
} catch (networkError) {
try {
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
} catch (cacheError) {
console.warn("[sw] api cache lookup failed", cacheError);
}
throw networkError;
}
})());
return;
}
// Built assets are content-hashed, but an already-controlled browser can
// keep old entries in this named cache across local rebuilds. Prefer the
// server response so tabs cannot stay on stale JS/CSS and render a blank
// shell after an update. The cache remains an offline fallback.
if (isBuiltAssetRequest) {
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] asset cache put failed", cacheError);
}
return networkResponse;
} catch (networkError) {
try {
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
} catch (cacheError) {
console.warn("[sw] asset cache lookup failed", cacheError);
}
throw networkError;
}
})());
return;
}
event.respondWith((async () => {
try {
const cache = await caches.open(CACHE_NAME);
const cachedResponse = await cache.match(request);
if (cachedResponse) {
return cachedResponse;
}
const networkResponse = await fetch(request);
await cache.put(request, networkResponse.clone());
return networkResponse;
} catch (error) {
console.warn("[sw] static cache flow failed", error);
return fetch(request);
}
})());
});