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:
3
.changeset/fn-blank-page-service-worker-assets.md
Normal file
3
.changeset/fn-blank-page-service-worker-assets.md
Normal 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.
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3216,6 +3216,7 @@ describe("executeHeartbeat", () => {
|
||||
dependencies: undefined,
|
||||
column: "triage",
|
||||
priority: undefined,
|
||||
summarize: true,
|
||||
source: expect.objectContaining({
|
||||
sourceType: "agent_heartbeat",
|
||||
sourceAgentId: "agent-001",
|
||||
@@ -3225,7 +3226,7 @@ describe("executeHeartbeat", () => {
|
||||
contentFingerprint: expect.any(String),
|
||||
}),
|
||||
}),
|
||||
}), expect.objectContaining({ settings: { autoSummarizeTitles: false } }));
|
||||
}), { settings: {} });
|
||||
});
|
||||
|
||||
it("forwards explicit priority when fn_task_create tool is called", async () => {
|
||||
|
||||
@@ -201,12 +201,17 @@ describe("createHeartbeatTools", () => {
|
||||
dependencies: undefined,
|
||||
column: "triage",
|
||||
priority: undefined,
|
||||
summarize: true,
|
||||
source: expect.objectContaining({
|
||||
sourceType: "agent_heartbeat",
|
||||
sourceAgentId: "agent-001",
|
||||
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 : "";
|
||||
expect(responseText).toContain("Created FN-100");
|
||||
|
||||
Reference in New Issue
Block a user