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:
@@ -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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user