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');
|
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");
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user