Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .changeset/fn-blank-page-service-worker-assets.md
Original file line number Diff line number Diff line change
@@ -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.
Comment on lines +1 to +3

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Missing --- frontmatter fences — changeset will be silently ignored

The changeset file is missing the required --- delimiters. Without them the @changesets/cli parser does not recognize this file as a valid changeset, so pnpm release will not include this entry in the version bump or CHANGELOG.md. Every other changeset in the repo (e.g., fix-frozen-dashboard-animations.md) uses the three-line frontmatter pattern.

11 changes: 11 additions & 0 deletions packages/dashboard/app/__tests__/pwa.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
28 changes: 28 additions & 0 deletions packages/dashboard/app/__tests__/versionCheck.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
37 changes: 36 additions & 1 deletion packages/dashboard/app/public/sw.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const CACHE_NAME = "fusion-cache-v2";
const CACHE_NAME = "fusion-cache-v3";
const APP_SHELL_URLS = [
"/",
"/index.html",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
45 changes: 45 additions & 0 deletions packages/dashboard/app/versionCheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -165,6 +195,7 @@ export async function checkVersion(trigger: VersionCheckTrigger = "initial"): Pr

if (remote === __BUILD_VERSION__) {
lastMismatchedRemote = null;
clearReloadedRemoteVersion();
return;
}

Expand Down Expand Up @@ -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}`);
Comment on lines +243 to 246

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 setReloadedRemoteVersion is stored before reloadOnce is called. If reloadOnce is suppressed because RELOAD_FLAG is already in sessionStorage, the remote version gets permanently marked as "already reloaded" even though no version-mismatch reload occurred — silently blocking future reload attempts for that version. Moving the call after the flag check in reloadOnce (or reading RELOAD_FLAG state here first) would ensure the marker is only set when a reload actually proceeds.

Suggested change
if (autoReloadEnabled) {
setReloadedRemoteVersion(remote);
}
reloadOnce(`build version changed: ${__BUILD_VERSION__} -> ${remote}`);
const alreadyAttempted = Boolean((() => { try { return sessionStorage.getItem(RELOAD_FLAG); } catch { return null; } })());
if (autoReloadEnabled && !alreadyAttempted) {
setReloadedRemoteVersion(remote);
}
reloadOnce(`build version changed: ${__BUILD_VERSION__} -> ${remote}`);

} finally {
checkInFlight = false;
Expand Down
Loading