From cfc580b4cc37f6b5e8e280d3bcb38efda5b66139 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 9 Jun 2026 23:36:33 -0700 Subject: [PATCH] fix: stop dashboard refresh loops --- .../fn-blank-page-service-worker-assets.md | 3 ++ packages/dashboard/app/__tests__/pwa.test.ts | 11 +++++ .../app/__tests__/versionCheck.test.ts | 28 ++++++++++++ packages/dashboard/app/public/sw.js | 37 ++++++++++++++- packages/dashboard/app/versionCheck.ts | 45 +++++++++++++++++++ 5 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 .changeset/fn-blank-page-service-worker-assets.md diff --git a/.changeset/fn-blank-page-service-worker-assets.md b/.changeset/fn-blank-page-service-worker-assets.md new file mode 100644 index 0000000000..1ad0e19f28 --- /dev/null +++ b/.changeset/fn-blank-page-service-worker-assets.md @@ -0,0 +1,3 @@ +"@runfusion/fusion": patch + +Revalidate dashboard service-worker assets before falling back to cache so rebuilt tabs cannot stay on stale bundles and render a blank page. diff --git a/packages/dashboard/app/__tests__/pwa.test.ts b/packages/dashboard/app/__tests__/pwa.test.ts index d5f4126ef5..522dc5fa5d 100644 --- a/packages/dashboard/app/__tests__/pwa.test.ts +++ b/packages/dashboard/app/__tests__/pwa.test.ts @@ -115,6 +115,17 @@ describe("PWA configuration", () => { expect(swSource).toContain('[sw] navigation cache put failed'); }); + it("service worker revalidates built assets so stale bundles cannot blank the app", () => { + const swSource = readFileSync(resolve(__dirname, "../public/sw.js"), "utf8"); + + expect(swSource).toContain('url.pathname.startsWith("/assets/")'); + expect(swSource).toContain('request.destination === "script"'); + expect(swSource).toContain('request.destination === "style"'); + expect(swSource).toContain('if (isBuiltAssetRequest) {'); + expect(swSource).toContain('[sw] asset cache put failed'); + expect(swSource).toContain('[sw] asset cache lookup failed'); + }); + it("service worker activates updated code immediately", () => { const swSource = readFileSync(resolve(__dirname, "../public/sw.js"), "utf8"); diff --git a/packages/dashboard/app/__tests__/versionCheck.test.ts b/packages/dashboard/app/__tests__/versionCheck.test.ts index a823306518..43164618e7 100644 --- a/packages/dashboard/app/__tests__/versionCheck.test.ts +++ b/packages/dashboard/app/__tests__/versionCheck.test.ts @@ -218,6 +218,34 @@ describe("checkVersion cooldown + mismatch gating", () => { vi.useRealTimers(); }); + it("does not reload repeatedly for the same remote version after returning to the tab", async () => { + vi.useFakeTimers(); + const fetchSpy = vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ "content-type": "application/json" }), + json: () => Promise.resolve({ version: "different-version" }), + }); + vi.stubGlobal("fetch", fetchSpy); + + await checkVersion("initial"); + vi.advanceTimersByTime(MIN_CHECK_INTERVAL_MS + 1); + await checkVersion("visibilitychange"); + expect(reloadSpy).toHaveBeenCalledTimes(1); + + window.sessionStorage.removeItem("fusion:version-reload"); + + vi.advanceTimersByTime(MIN_CHECK_INTERVAL_MS + 1); + await checkVersion("focus"); + + expect(reloadSpy).toHaveBeenCalledTimes(1); + const suppressedTrace = getTraces().find((entry) => entry.event === "reload-suppressed"); + expect(suppressedTrace?.detail).toMatchObject({ + remote: "different-version", + reason: "already-reloaded-remote-version", + }); + vi.useRealTimers(); + }); + it("mismatch then match resets gating", async () => { vi.useFakeTimers(); const fetchSpy = vi.fn() diff --git a/packages/dashboard/app/public/sw.js b/packages/dashboard/app/public/sw.js index 0ffb9b261f..dd28a0ff50 100644 --- a/packages/dashboard/app/public/sw.js +++ b/packages/dashboard/app/public/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = "fusion-cache-v2"; +const CACHE_NAME = "fusion-cache-v3"; const APP_SHELL_URLS = [ "/", "/index.html", @@ -61,6 +61,11 @@ self.addEventListener("fetch", (event) => { 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 @@ -121,6 +126,36 @@ self.addEventListener("fetch", (event) => { 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); diff --git a/packages/dashboard/app/versionCheck.ts b/packages/dashboard/app/versionCheck.ts index 00465cf40d..7676c3f031 100644 --- a/packages/dashboard/app/versionCheck.ts +++ b/packages/dashboard/app/versionCheck.ts @@ -4,6 +4,7 @@ declare const __BUILD_VERSION__: string; const RELOAD_FLAG = "fusion:version-reload"; const VERSION_UPDATE_FLAG = "fusion:version-update"; +const RELOADED_REMOTE_VERSION_FLAG = "fusion:version-reloaded-remote"; /** * Module-level guard for auto-reload behavior. @@ -30,6 +31,11 @@ export function _resetState(): void { lastCheckTime = 0; checkInFlight = false; autoReloadEnabled = true; + try { + sessionStorage.removeItem(RELOADED_REMOTE_VERSION_FLAG); + } catch { + // ignore + } if (pollIntervalId !== null) { window.clearInterval(pollIntervalId); pollIntervalId = null; @@ -65,6 +71,30 @@ export function reloadOnce(reason: string): void { window.location.reload(); } +function getReloadedRemoteVersion(): string | null { + try { + return sessionStorage.getItem(RELOADED_REMOTE_VERSION_FLAG); + } catch { + return null; + } +} + +function setReloadedRemoteVersion(version: string): void { + try { + sessionStorage.setItem(RELOADED_REMOTE_VERSION_FLAG, version); + } catch { + // ignore + } +} + +function clearReloadedRemoteVersion(): void { + try { + sessionStorage.removeItem(RELOADED_REMOTE_VERSION_FLAG); + } catch { + // ignore + } +} + export function isStaleChunkError(error: unknown): boolean { const message = error instanceof Error @@ -165,6 +195,7 @@ export async function checkVersion(trigger: VersionCheckTrigger = "initial"): Pr if (remote === __BUILD_VERSION__) { lastMismatchedRemote = null; + clearReloadedRemoteVersion(); return; } @@ -193,11 +224,25 @@ export async function checkVersion(trigger: VersionCheckTrigger = "initial"): Pr trigger, elapsedMs: Date.now() - lastMismatchAt, }); + + if (getReloadedRemoteVersion() === remote) { + pushTrace("versionCheck", "reload-suppressed", { + remote, + trigger, + reason: "already-reloaded-remote-version", + }); + console.info("[versionCheck] reload already attempted for remote version", remote); + return; + } + try { sessionStorage.setItem(VERSION_UPDATE_FLAG, "1"); } catch { // ignore } + if (autoReloadEnabled) { + setReloadedRemoteVersion(remote); + } reloadOnce(`build version changed: ${__BUILD_VERSION__} -> ${remote}`); } finally { checkInFlight = false;