diff --git a/.changeset/fix-dashboard-localstorage-quota-exhaustion.md b/.changeset/fix-dashboard-localstorage-quota-exhaustion.md new file mode 100644 index 0000000000..603307effa --- /dev/null +++ b/.changeset/fix-dashboard-localstorage-quota-exhaustion.md @@ -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. diff --git a/packages/dashboard/app/components/DashboardLoader.tsx b/packages/dashboard/app/components/DashboardLoader.tsx index 9d602d7166..60b30350e9 100644 --- a/packages/dashboard/app/components/DashboardLoader.tsx +++ b/packages/dashboard/app/components/DashboardLoader.tsx @@ -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); diff --git a/packages/dashboard/app/components/settings/sections/GeneralSection.tsx b/packages/dashboard/app/components/settings/sections/GeneralSection.tsx index bc07ca3dc9..6f58a95248 100644 --- a/packages/dashboard/app/components/settings/sections/GeneralSection.tsx +++ b/packages/dashboard/app/components/settings/sections/GeneralSection.tsx @@ -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}

{t("settings.general.general", "General")}

@@ -308,6 +325,20 @@ export function GeneralSection({ scopeBanner, form, setForm, projectId, addToast + {/* + 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. + */} +

{t("settings.general.browserData", "Browser Data")}

+
+ + {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.")} +
+ +
+
); } export default GeneralSection; diff --git a/packages/dashboard/app/utils/__tests__/swrCache.test.ts b/packages/dashboard/app/utils/__tests__/swrCache.test.ts index 8dea187510..bad1d4db4b 100644 --- a/packages/dashboard/app/utils/__tests__/swrCache.test.ts +++ b/packages/dashboard/app/utils/__tests__/swrCache.test.ts @@ -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(); + }); }); diff --git a/packages/dashboard/app/utils/swrCache.ts b/packages/dashboard/app/utils/swrCache.ts index 45d6edf779..8bbb1d5a24 100644 --- a/packages/dashboard/app/utils/swrCache.ts +++ b/packages/dashboard/app/utils/swrCache.ts @@ -95,6 +95,18 @@ export function readCache(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> = { "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; +} diff --git a/packages/dashboard/vitest.setup.ts b/packages/dashboard/vitest.setup.ts index c2b1471981..f1594ac2cc 100644 --- a/packages/dashboard/vitest.setup.ts +++ b/packages/dashboard/vitest.setup.ts @@ -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, });