feat(FN-4780): complete Step 1 — add swr cache utility
Fusion-Task-Id: FN-4780 Fusion-Task-Lineage: 7f052539-9d15-4997-8135-21434f065d4a
This commit is contained in:
committed by
gsxdsm
parent
a679df7d1c
commit
167b2cf0c9
68
packages/dashboard/app/utils/__tests__/swrCache.test.ts
Normal file
68
packages/dashboard/app/utils/__tests__/swrCache.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SWR_CACHE_KEYS, clearCache, readCache, writeCache } from "../swrCache";
|
||||
|
||||
describe("swrCache", () => {
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns null on cache miss", () => {
|
||||
expect(readCache("missing")).toBeNull();
|
||||
});
|
||||
|
||||
it("writes and reads cache payload", () => {
|
||||
const payload = { value: "ok", count: 2 };
|
||||
writeCache("demo", payload);
|
||||
|
||||
expect(readCache<typeof payload>("demo")).toEqual(payload);
|
||||
});
|
||||
|
||||
it("does not write when payload exceeds maxBytes", () => {
|
||||
writeCache("large", { value: "0123456789" }, { maxBytes: 5 });
|
||||
|
||||
expect(localStorage.getItem("large")).toBeNull();
|
||||
});
|
||||
|
||||
it("clearCache removes only matching prefix keys", () => {
|
||||
const backing = new Map<string, string>();
|
||||
const storage = {
|
||||
get length() {
|
||||
return backing.size;
|
||||
},
|
||||
key: (index: number) => Array.from(backing.keys())[index] ?? null,
|
||||
getItem: (key: string) => backing.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
backing.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
backing.delete(key);
|
||||
},
|
||||
clear: () => {
|
||||
backing.clear();
|
||||
},
|
||||
} satisfies Storage;
|
||||
|
||||
vi.stubGlobal("localStorage", storage);
|
||||
|
||||
writeCache(`${SWR_CACHE_KEYS.TASKS_PREFIX}a`, [{ id: "1" }]);
|
||||
writeCache(`${SWR_CACHE_KEYS.TASKS_PREFIX}b`, [{ id: "2" }]);
|
||||
writeCache(SWR_CACHE_KEYS.PROJECTS, [{ id: "p" }]);
|
||||
|
||||
clearCache(SWR_CACHE_KEYS.TASKS_PREFIX);
|
||||
|
||||
expect(readCache(`${SWR_CACHE_KEYS.TASKS_PREFIX}a`)).toBeNull();
|
||||
expect(readCache(`${SWR_CACHE_KEYS.TASKS_PREFIX}b`)).toBeNull();
|
||||
expect(readCache(SWR_CACHE_KEYS.PROJECTS)).toEqual([{ id: "p" }]);
|
||||
});
|
||||
|
||||
it("swallows quota errors", () => {
|
||||
vi.spyOn(localStorage, "setItem").mockImplementation(() => {
|
||||
throw new DOMException("Quota exceeded", "QuotaExceededError");
|
||||
});
|
||||
|
||||
expect(() => writeCache("quota", { ok: true })).not.toThrow();
|
||||
});
|
||||
});
|
||||
91
packages/dashboard/app/utils/swrCache.ts
Normal file
91
packages/dashboard/app/utils/swrCache.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Lightweight stale-while-revalidate cache helpers for dashboard reload hydration.
|
||||
*
|
||||
* Invalidation contract:
|
||||
* - Per-project task entries use `SWR_CACHE_KEYS.TASKS_PREFIX + projectId`.
|
||||
* - Version updates clear TASKS_PREFIX plus PROJECTS and CURRENT_PROJECT_ID.
|
||||
*/
|
||||
export const SWR_CACHE_KEYS = {
|
||||
PROJECTS: "kb-dashboard-projects-cache",
|
||||
CURRENT_PROJECT_ID: "kb-dashboard-current-project-cache",
|
||||
TASKS_PREFIX: "kb-dashboard-tasks-cache:",
|
||||
} as const;
|
||||
|
||||
const DEFAULT_MAX_BYTES = 500_000;
|
||||
|
||||
function getLocalStorage(): Storage | null {
|
||||
if (typeof window !== "undefined" && window.localStorage) {
|
||||
return window.localStorage;
|
||||
}
|
||||
if (typeof localStorage !== "undefined") {
|
||||
return localStorage;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function readCache<T>(key: string): T | null {
|
||||
const storage = getLocalStorage();
|
||||
if (!storage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = storage.getItem(key);
|
||||
if (raw === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeCache<T>(key: string, value: T, options?: { maxBytes?: number }): void {
|
||||
const storage = getLocalStorage();
|
||||
if (!storage) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const serialized = JSON.stringify(value);
|
||||
const maxBytes = options?.maxBytes ?? DEFAULT_MAX_BYTES;
|
||||
if (new TextEncoder().encode(serialized).length > maxBytes) {
|
||||
return;
|
||||
}
|
||||
|
||||
storage.setItem(key, serialized);
|
||||
} catch {
|
||||
// Ignore quota and storage errors.
|
||||
}
|
||||
}
|
||||
|
||||
export function clearCache(prefix: string): void {
|
||||
const storage = getLocalStorage();
|
||||
if (!storage) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const keys = new Set<string>();
|
||||
|
||||
for (const key in storage) {
|
||||
if (Object.prototype.hasOwnProperty.call(storage, key) && key.startsWith(prefix)) {
|
||||
keys.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (let index = 0; index < storage.length; index += 1) {
|
||||
const key = storage.key(index);
|
||||
if (typeof key === "string" && key.startsWith(prefix)) {
|
||||
keys.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
storage.removeItem(key);
|
||||
}
|
||||
} catch {
|
||||
// Ignore storage errors.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user