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
This commit is contained in:
gsxdsm
2026-06-10 00:20:35 -07:00
parent 17363928e9
commit c9d48fb750
7 changed files with 131 additions and 3 deletions

View File

@@ -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.

View File

@@ -115,6 +115,17 @@ describe("PWA configuration", () => {
expect(swSource).toContain('[sw] navigation cache put failed'); 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", () => { it("service worker activates updated code immediately", () => {
const swSource = readFileSync(resolve(__dirname, "../public/sw.js"), "utf8"); const swSource = readFileSync(resolve(__dirname, "../public/sw.js"), "utf8");

View File

@@ -218,6 +218,34 @@ describe("checkVersion cooldown + mismatch gating", () => {
vi.useRealTimers(); 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 () => { it("mismatch then match resets gating", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const fetchSpy = vi.fn() const fetchSpy = vi.fn()

View File

@@ -1,4 +1,4 @@
const CACHE_NAME = "fusion-cache-v2"; const CACHE_NAME = "fusion-cache-v3";
const APP_SHELL_URLS = [ const APP_SHELL_URLS = [
"/", "/",
"/index.html", "/index.html",
@@ -61,6 +61,11 @@ self.addEventListener("fetch", (event) => {
request.destination === "document" || request.destination === "document" ||
url.pathname === "/" || url.pathname === "/" ||
url.pathname === "/index.html"; 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 // EventSource requests stay open indefinitely. Waiting on cache.put() for an
// infinite response body prevents the browser from ever receiving the stream // infinite response body prevents the browser from ever receiving the stream
@@ -121,6 +126,36 @@ self.addEventListener("fetch", (event) => {
return; 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 () => { event.respondWith((async () => {
try { try {
const cache = await caches.open(CACHE_NAME); const cache = await caches.open(CACHE_NAME);

View File

@@ -4,6 +4,7 @@ declare const __BUILD_VERSION__: string;
const RELOAD_FLAG = "fusion:version-reload"; const RELOAD_FLAG = "fusion:version-reload";
const VERSION_UPDATE_FLAG = "fusion:version-update"; const VERSION_UPDATE_FLAG = "fusion:version-update";
const RELOADED_REMOTE_VERSION_FLAG = "fusion:version-reloaded-remote";
/** /**
* Module-level guard for auto-reload behavior. * Module-level guard for auto-reload behavior.
@@ -30,6 +31,11 @@ export function _resetState(): void {
lastCheckTime = 0; lastCheckTime = 0;
checkInFlight = false; checkInFlight = false;
autoReloadEnabled = true; autoReloadEnabled = true;
try {
sessionStorage.removeItem(RELOADED_REMOTE_VERSION_FLAG);
} catch {
// ignore
}
if (pollIntervalId !== null) { if (pollIntervalId !== null) {
window.clearInterval(pollIntervalId); window.clearInterval(pollIntervalId);
pollIntervalId = null; pollIntervalId = null;
@@ -65,6 +71,30 @@ export function reloadOnce(reason: string): void {
window.location.reload(); 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 { export function isStaleChunkError(error: unknown): boolean {
const message = const message =
error instanceof Error error instanceof Error
@@ -165,6 +195,7 @@ export async function checkVersion(trigger: VersionCheckTrigger = "initial"): Pr
if (remote === __BUILD_VERSION__) { if (remote === __BUILD_VERSION__) {
lastMismatchedRemote = null; lastMismatchedRemote = null;
clearReloadedRemoteVersion();
return; return;
} }
@@ -193,11 +224,25 @@ export async function checkVersion(trigger: VersionCheckTrigger = "initial"): Pr
trigger, trigger,
elapsedMs: Date.now() - lastMismatchAt, 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 { try {
sessionStorage.setItem(VERSION_UPDATE_FLAG, "1"); sessionStorage.setItem(VERSION_UPDATE_FLAG, "1");
} catch { } catch {
// ignore // ignore
} }
if (autoReloadEnabled) {
setReloadedRemoteVersion(remote);
}
reloadOnce(`build version changed: ${__BUILD_VERSION__} -> ${remote}`); reloadOnce(`build version changed: ${__BUILD_VERSION__} -> ${remote}`);
} finally { } finally {
checkInFlight = false; checkInFlight = false;

View File

@@ -3216,6 +3216,7 @@ describe("executeHeartbeat", () => {
dependencies: undefined, dependencies: undefined,
column: "triage", column: "triage",
priority: undefined, priority: undefined,
summarize: true,
source: expect.objectContaining({ source: expect.objectContaining({
sourceType: "agent_heartbeat", sourceType: "agent_heartbeat",
sourceAgentId: "agent-001", sourceAgentId: "agent-001",
@@ -3225,7 +3226,7 @@ describe("executeHeartbeat", () => {
contentFingerprint: expect.any(String), contentFingerprint: expect.any(String),
}), }),
}), }),
}), expect.objectContaining({ settings: { autoSummarizeTitles: false } })); }), { settings: {} });
}); });
it("forwards explicit priority when fn_task_create tool is called", async () => { it("forwards explicit priority when fn_task_create tool is called", async () => {

View File

@@ -201,12 +201,17 @@ describe("createHeartbeatTools", () => {
dependencies: undefined, dependencies: undefined,
column: "triage", column: "triage",
priority: undefined, priority: undefined,
summarize: true,
source: expect.objectContaining({ source: expect.objectContaining({
sourceType: "agent_heartbeat", sourceType: "agent_heartbeat",
sourceAgentId: "agent-001", sourceAgentId: "agent-001",
sourceRunId: undefined, sourceRunId: undefined,
sourceParentTaskId: "FN-001",
sourceMetadata: expect.objectContaining({
contentFingerprint: expect.any(String),
}),
}), }),
}), expect.objectContaining({ settings: { autoSummarizeTitles: false } })); }), { settings: {} });
const responseText = result.content[0] && "text" in result.content[0] ? result.content[0].text : ""; const responseText = result.content[0] && "text" in result.content[0] ? result.content[0].text : "";
expect(responseText).toContain("Created FN-100"); expect(responseText).toContain("Created FN-100");