fix(dashboard): garbage-collect stale SWR caches and add Clear local data button

Stale SWR hydration entries (per-chat-session/per-room message caches up to
500KB each) were never garbage-collected — readCache returned null for stale
entries but left the bytes on disk, and nothing swept abandoned caches. This
caused localStorage quota exhaustion for users with many projects and chat
sessions.

Three fixes:
- readCache now lazily deletes stale entries (behavior-preserving; every
  reader already treats stale as a miss and re-fetches)
- Boot sweep pruneStaleCacheEntries() in DashboardLoader removes any
  kb-dashboard-* entry older than 24h before hydration hooks read caches
- Settings > General "Browser Data" panel with a Clear local data button
  that wipes all Fusion-owned browser data while preserving the auth token

Also completes the vitest localStorage mock (was missing length/key).
This commit is contained in:
gsxdsm
2026-07-02 09:08:15 -07:00
parent f92c255e45
commit 9df13c7ffa
6 changed files with 258 additions and 3 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix dashboard localStorage quota exhaustion from stale SWR caches and add a Clear local data escape hatch.
category: fix
dev: Stale SWR hydration entries (per-chat-session/per-room message caches) were never garbage-collected; readCache now lazily deletes stale entries, a boot sweep prunes anything older than 24h, and Settings → General exposes a user-facing "Clear local data" button that preserves the auth token.

View File

@@ -2,7 +2,7 @@ import { Loader2 } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { consumeVersionUpdateFlag } from "../versionCheck";
import { SWR_CACHE_KEYS, clearCache } from "../utils/swrCache";
import { SWR_CACHE_KEYS, clearCache, pruneStaleCacheEntries } from "../utils/swrCache";
import "./DashboardLoader.css";
export type DashboardLoaderStage = "projects" | "project" | "tasks" | "ready";
@@ -44,6 +44,14 @@ function getStepState(stepId: LoaderStep["id"], stage: DashboardLoaderStage): "d
export function DashboardLoader({ stage }: DashboardLoaderProps) {
const { t } = useTranslation("app");
const [isVersionUpdate] = useState(() => {
/*
FNXC:SwrCache 2026-07-02-00:00:
Prune stale SWR hydration entries once on boot, before any hydration hook reads its cache.
Removes per-session/per-room caches older than 24h (abandoned conversations) that readCache's
lazy GC never reaches because nobody reads them again. This is the fix for localStorage quota
exhaustion reported by users with many projects and chat sessions.
*/
pruneStaleCacheEntries();
const versionUpdated = consumeVersionUpdateFlag();
if (versionUpdated) {
clearCache(SWR_CACHE_KEYS.TASKS_PREFIX);

View File

@@ -4,6 +4,7 @@ import { ProjectDefaultWorkflowField } from "../../WorkflowSelector";
import { WorkflowIcon } from "../../WorkflowIcon";
import { TrackingRepoSelect, type TrackingRepoOption } from "../../TrackingRepoSelect";
import { fetchWorkflows } from "../../../api";
import { clearAllLocalCache } from "../../../utils/swrCache";
import type { ToastType } from "../../../hooks/useToast";
import type { SectionBaseProps } from "./context";
import { useTranslation } from "react-i18next";
@@ -58,6 +59,22 @@ export function GeneralSection({ scopeBanner, form, setForm, projectId, addToast
};
});
};
/*
FNXC:SettingsGeneral 2026-07-02-00:00:
User-facing escape hatch for localStorage quota exhaustion. The dashboard accumulates per-project
SWR hydration caches (chat sessions, rooms, tasks, board snapshots) whose stale entries linger
indefinitely. clearAllLocalCache wipes all Fusion-owned browser data (caches + UI prefs) while
preserving the auth token so the session survives the reload. Tasks and project settings live
server-side and are unaffected.
*/
const handleClearLocalData = () => {
const confirmed = window.confirm(t("settings.general.clearLocalDataConfirm", "Clear all cached data and UI preferences stored in this browser? This frees space used by stale chat, task, and board caches. Your tasks and project settings are safe (stored server-side). The dashboard will reload."));
if (!confirmed) {
return;
}
clearAllLocalCache();
window.location.reload();
};
return (<>
{scopeBanner}
<h4 className="settings-section-heading">{t("settings.general.general", "General")}</h4>
@@ -308,6 +325,20 @@ export function GeneralSection({ scopeBanner, form, setForm, projectId, addToast
</div>
</div>
</details>
{/*
FNXC:SettingsGeneral 2026-07-02-00:00:
"Clear local data" panel — the user-facing escape hatch when the dashboard runs out of
browser localStorage quota. Frees stale SWR hydration caches (chat sessions, rooms, tasks,
board snapshots) plus UI prefs. The auth token is preserved so the reload keeps the session.
*/}
<h4 className="settings-section-heading settings-section-heading--spaced">{t("settings.general.browserData", "Browser Data")}</h4>
<div className="form-group">
<label>{t("settings.general.clearLocalData", "Clear local data")}</label>
<small>{t("settings.general.clearLocalDataHint", "Remove cached board snapshots, chat threads, and UI preferences stored in this browser. Frees space when the dashboard runs low on browser storage. Your tasks and project settings are stored server-side and are not affected.")}</small>
<div style={{ marginTop: "var(--space-sm)" }}>
<button type="button" className="btn btn-sm" onClick={handleClearLocalData}>{t("settings.general.clearLocalDataButton", "Clear local data")}</button>
</div>
</div>
</>);
}
export default GeneralSection;

View File

@@ -5,7 +5,9 @@ import {
SWR_DEFAULT_MAX_AGE_MS,
SWR_LONG_MAX_AGE_MS,
SWR_TASKS_MAX_AGE_MS,
clearAllLocalCache,
clearCache,
pruneStaleCacheEntries,
readCache,
writeCache,
} from "../swrCache";
@@ -35,15 +37,31 @@ describe("swrCache", () => {
expect(raw.data).toEqual(payload);
});
it("respects maxAgeMs for enveloped payloads", () => {
it("respects maxAgeMs for enveloped payloads and lazily deletes stale entries", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
writeCache("ttl", { value: "fresh" });
vi.setSystemTime(new Date("2026-01-01T00:00:02.000Z"));
// A stale read returns null AND removes the entry so it stops consuming quota.
expect(readCache<{ value: string }>("ttl", { maxAgeMs: 1_000 })).toBeNull();
expect(readCache<{ value: string }>("ttl")).toEqual({ value: "fresh" });
expect(localStorage.getItem("ttl")).toBeNull();
// A subsequent read without maxAgeMs also misses because the entry was lazily GC'd.
expect(readCache<{ value: string }>("ttl")).toBeNull();
vi.useRealTimers();
});
it("does not lazily delete fresh enveloped entries", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
writeCache("fresh-ttl", { value: "ok" });
vi.setSystemTime(new Date("2026-01-01T00:00:00.500Z"));
expect(readCache<{ value: string }>("fresh-ttl", { maxAgeMs: 1_000 })).toEqual({ value: "ok" });
expect(localStorage.getItem("fresh-ttl")).not.toBeNull();
vi.useRealTimers();
});
@@ -139,4 +157,71 @@ describe("swrCache", () => {
expect(() => writeCache("quota", { ok: true })).not.toThrow();
});
it("pruneStaleCacheEntries removes SWR entries older than 24h but keeps fresh ones", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
// Stale: written 25h ago.
writeCache(`${SWR_CACHE_KEYS.TASKS_PREFIX}old`, [{ id: "1" }]);
vi.setSystemTime(new Date("2026-01-02T01:00:00.000Z")); // +25h
// Fresh: written now.
writeCache(SWR_CACHE_KEYS.PROJECTS, [{ id: "p" }]);
const removed = pruneStaleCacheEntries();
expect(removed).toBe(1);
expect(localStorage.getItem(`${SWR_CACHE_KEYS.TASKS_PREFIX}old`)).toBeNull();
expect(localStorage.getItem(SWR_CACHE_KEYS.PROJECTS)).not.toBeNull();
vi.useRealTimers();
});
it("pruneStaleCacheEntries ignores non-cache keys, malformed JSON, and envelope-less payloads", () => {
// Non-SWR key (scoped pref) — never touched by the sweep.
localStorage.setItem("kb:proj1:kb-dashboard-task-view", "board");
// Malformed JSON under a cache prefix — left alone (caught, not crashed).
localStorage.setItem(`${SWR_CACHE_KEYS.TASKS_PREFIX}bad`, "{not json");
// Cache key without a savedAt envelope — left alone.
localStorage.setItem(SWR_CACHE_KEYS.MODELS, JSON.stringify([{ id: "x" }]));
vi.useFakeTimers();
vi.setSystemTime(new Date("2025-01-01T00:00:00.000Z"));
writeCache(SWR_CACHE_KEYS.AGENTS, [{ id: "a" }]);
vi.setSystemTime(new Date("2026-01-02T00:00:00.000Z"));
const removed = pruneStaleCacheEntries();
expect(removed).toBe(1);
expect(localStorage.getItem(SWR_CACHE_KEYS.AGENTS)).toBeNull();
expect(localStorage.getItem("kb:proj1:kb-dashboard-task-view")).toBe("board");
expect(localStorage.getItem(`${SWR_CACHE_KEYS.TASKS_PREFIX}bad`)).toBe("{not json");
expect(localStorage.getItem(SWR_CACHE_KEYS.MODELS)).not.toBeNull();
vi.useRealTimers();
});
it("clearAllLocalCache removes Fusion-owned keys but preserves the auth token", () => {
localStorage.setItem("fn.authToken", "secret-token");
localStorage.setItem(`${SWR_CACHE_KEYS.TASKS_PREFIX}p1`, "[]");
localStorage.setItem("kb:proj1:kb-dashboard-task-view", "board");
localStorage.setItem("kb-dashboard-theme-mode", "dark");
localStorage.setItem("fn-agent-log-markdown", "true");
localStorage.setItem("fusion:right-dock-pinned", "true");
localStorage.setItem("fusion-insight-model", "openai/gpt-4o");
// Hypothetical non-Fusion key — left alone.
localStorage.setItem("other-app:data", "keep-me");
const removed = clearAllLocalCache();
expect(removed).toBe(6);
expect(localStorage.getItem("fn.authToken")).toBe("secret-token");
expect(localStorage.getItem("other-app:data")).toBe("keep-me");
expect(localStorage.getItem(`${SWR_CACHE_KEYS.TASKS_PREFIX}p1`)).toBeNull();
expect(localStorage.getItem("kb:proj1:kb-dashboard-task-view")).toBeNull();
expect(localStorage.getItem("kb-dashboard-theme-mode")).toBeNull();
expect(localStorage.getItem("fn-agent-log-markdown")).toBeNull();
expect(localStorage.getItem("fusion:right-dock-pinned")).toBeNull();
expect(localStorage.getItem("fusion-insight-model")).toBeNull();
});
});

View File

@@ -95,6 +95,18 @@ export function readCache<T>(key: string, options?: { maxAgeMs?: number }): T |
if (typeof maxAgeMs === "number") {
const ageMs = Date.now() - envelope.savedAt;
if (ageMs > maxAgeMs) {
/*
FNXC:SwrCache 2026-07-02-00:00:
Lazy GC: drop the stale entry so it stops consuming localStorage quota. A stale
entry is already treated as a miss by every reader (they re-fetch and overwrite),
so deleting it on read is behavior-preserving. This prevents per-session and
per-room message caches from accumulating when a reader revisits a stale key.
*/
try {
storage.removeItem(key);
} catch {
// Ignore storage errors — the stale read still returns null.
}
return null;
}
}
@@ -156,3 +168,111 @@ export function clearCache(prefix: string): void {
// Ignore storage errors.
}
}
/**
* FNXC:SwrCache 2026-07-02-00:00:
* Boot-time sweep that removes every SWR hydration entry older than SWR_LONG_MAX_AGE_MS (24h).
* Since 24h is the longest TTL any consumer passes to readCache, a pruned entry was already
* treated as a miss by every reader — this frees quota without changing hydration behavior.
* The main target is per-session / per-room message caches from abandoned conversations that
* are never read again (and therefore never hit readCache's lazy GC). Called once from the
* DashboardLoader mount so it runs before hydration hooks read their caches.
*
* Returns the number of entries removed for diagnostics.
*/
export function pruneStaleCacheEntries(): number {
const storage = getLocalStorage();
if (!storage) {
return 0;
}
let removed = 0;
try {
const staleKeys: string[] = [];
for (let index = 0; index < storage.length; index += 1) {
const key = storage.key(index);
if (typeof key !== "string" || !key.startsWith("kb-dashboard-")) {
continue;
}
const raw = storage.getItem(key);
if (raw === null) {
continue;
}
try {
const parsed: unknown = JSON.parse(raw);
if (!parsed || typeof parsed !== "object" || !("savedAt" in parsed)) {
continue;
}
const savedAt = parsed.savedAt;
if (typeof savedAt !== "number" || Number.isNaN(savedAt)) {
continue;
}
if (Date.now() - savedAt > SWR_LONG_MAX_AGE_MS) {
staleKeys.push(key);
}
} catch {
// Malformed JSON — leave it; readCache/clearCache handle their own parsing.
}
}
for (const key of staleKeys) {
storage.removeItem(key);
removed += 1;
}
} catch {
// Ignore storage errors.
}
return removed;
}
/**
* FNXC:SwrCache 2026-07-02-00:00:
* User-facing "Clear local data" helper: removes all Fusion-owned browser data — SWR
* hydration caches plus per-project scoped preferences and global UI preferences — while
* preserving the dashboard auth token so a reload keeps the session usable. Wired to
* Settings → General "Clear local data" as the escape hatch for quota exhaustion. Callers
* should reload the page after this so React state re-hydrates from a clean slate.
*
* Returns the number of keys removed for diagnostics.
*/
export const LOCAL_CACHE_PRESERVE_KEYS: Readonly<Record<string, true>> = { "fn.authToken": true };
function isFusionOwnedKey(key: string): boolean {
return (
key.startsWith("kb-") ||
key.startsWith("kb:") ||
key.startsWith("fn-agent-log-") ||
key.startsWith("fusion")
);
}
export function clearAllLocalCache(): number {
const storage = getLocalStorage();
if (!storage) {
return 0;
}
let removed = 0;
try {
const keys: string[] = [];
for (let index = 0; index < storage.length; index += 1) {
const key = storage.key(index);
if (typeof key === "string") {
keys.push(key);
}
}
for (const key of keys) {
if (key in LOCAL_CACHE_PRESERVE_KEYS || !isFusionOwnedKey(key)) {
continue;
}
storage.removeItem(key);
removed += 1;
}
} catch {
// Ignore storage errors.
}
return removed;
}

View File

@@ -121,6 +121,10 @@ if (typeof window !== "undefined") {
clear: () => {
Object.keys(localStorageMock).forEach((key) => delete localStorageMock[key]);
},
get length() {
return Object.keys(localStorageMock).length;
},
key: (index: number) => Object.keys(localStorageMock)[index] ?? null,
},
writable: true,
});