diff --git a/src/lib/pwa/__tests__/query-persister.test.ts b/src/lib/pwa/__tests__/query-persister.test.ts index 9ddf27bdb..d04314362 100644 --- a/src/lib/pwa/__tests__/query-persister.test.ts +++ b/src/lib/pwa/__tests__/query-persister.test.ts @@ -323,3 +323,125 @@ describe("persister round-trips through a fake IndexedDB", () => { expect(store.get("react-query")).toBeUndefined(); }); }); + +describe("persister is bounded and surfaces quota failures", () => { + const original = (globalThis as { indexedDB?: unknown }).indexedDB; + + afterEach(() => { + if (original !== undefined) { + (globalThis as { indexedDB?: unknown }).indexedDB = original; + } else { + delete (globalThis as { indexedDB?: unknown }).indexedDB; + } + vi.restoreAllMocks(); + }); + + const makeReq = (run: () => void) => { + const req: Record = {}; + queueMicrotask(() => { + run(); + (req.onsuccess as (() => void) | undefined)?.(); + }); + return req; + }; + + function installFakeIdb(opts: { + onPut: (value: unknown, key: string) => void; + store: Map; + }) { + const fakeDb = { + close() {}, + transaction() { + return { + objectStore() { + return { + put(value: unknown, key: string) { + opts.onPut(value, key); + return makeReq(() => {}); + }, + get(key: string) { + const req = makeReq(() => {}); + (req as { result?: unknown }).result = opts.store.get(key); + return req; + }, + delete(key: string) { + opts.store.delete(key); + return makeReq(() => {}); + }, + }; + }, + set oncomplete(fn: () => void) { + queueMicrotask(fn); + }, + set onerror(_fn: () => void) {}, + }; + }, + }; + (globalThis as { indexedDB?: unknown }).indexedDB = { + open() { + const req: Record = { result: fakeDb }; + queueMicrotask(() => (req.onsuccess as () => void)?.()); + return req; + }, + }; + } + + it("skips a snapshot that exceeds the size cap, leaving the last write intact", async () => { + const store = new Map(); + let putCount = 0; + installFakeIdb({ store, onPut: () => (putCount += 1) }); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const source = new QueryClient(); + // A persistable family with a payload well over the 2 MB ceiling. + const huge = "x".repeat(3 * 1024 * 1024); + source.setQueryData(["chart-data", "WEIGHT"], [{ blob: huge }]); + const stop = startPersistingQueryCache(source, "v1.18.6"); + vi.useFakeTimers(); + source.setQueryData(["chart-data", "WEIGHT"], [{ blob: huge }]); + vi.advanceTimersByTime(1100); + vi.useRealTimers(); + await new Promise((r) => setTimeout(r, 0)); + stop(); + + // The oversized snapshot was never written, and the skip was logged + // rather than silently dropped. + expect(putCount).toBe(0); + expect(store.get("react-query")).toBeUndefined(); + expect(warn).toHaveBeenCalled(); + }); + + it("clears the cache and warns on an IndexedDB quota error instead of swallowing it", async () => { + const store = new Map(); + let firstPut = true; + installFakeIdb({ + store, + onPut: (value, key) => { + if (firstPut) { + firstPut = false; + // First write hits the storage quota. + throw new DOMException("quota", "QuotaExceededError"); + } + store.set(key, value); + }, + }); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const source = new QueryClient(); + source.setQueryData(["dashboard", "snapshot"], { weightKg: 81 }); + const stop = startPersistingQueryCache(source, "v1.18.6"); + vi.useFakeTimers(); + source.setQueryData(["chart-data", "WEIGHT"], [{ v: 81 }]); + vi.advanceTimersByTime(1100); + vi.useRealTimers(); + await new Promise((r) => setTimeout(r, 0)); + // Let the clear-on-quota microtasks settle. + await new Promise((r) => setTimeout(r, 0)); + stop(); + + // The quota failure was surfaced, not swallowed, and the cache key was + // cleared so the next flush has room. + expect(warn).toHaveBeenCalled(); + expect(store.get("react-query")).toBeUndefined(); + }); +}); diff --git a/src/lib/pwa/query-persister.ts b/src/lib/pwa/query-persister.ts index e512f54b7..2701f6431 100644 --- a/src/lib/pwa/query-persister.ts +++ b/src/lib/pwa/query-persister.ts @@ -38,6 +38,17 @@ const KEY = "react-query"; const VERSION_KEY = "react-query-version"; const MAX_AGE_MS = 24 * 60 * 60 * 1000; // discard anything older than a day +/** + * Upper bound on the serialized snapshot. The allowlist already caps WHAT is + * persisted (dashboard snapshot + widget layout + daily series), but a tenant + * with a long chart history can still grow a single series past what's worth + * keeping on disk — and an oversized write is the most likely trigger for an + * IndexedDB `QuotaExceededError`. A snapshot above this ceiling is skipped + * rather than written: the offline read value of a multi-megabyte blob is + * marginal, and skipping keeps the last good (smaller) snapshot in place. + */ +const MAX_PERSIST_BYTES = 2 * 1024 * 1024; // 2 MB + /** * Strict allowlist of the only query families written to disk — the * low-sensitivity reads the dashboard needs to paint last-known values when @@ -78,13 +89,30 @@ function openDb(): Promise { async function idbSet(value: unknown): Promise { const db = await openDb(); - await new Promise((resolve, reject) => { - const tx = db.transaction(STORE, "readwrite"); - tx.objectStore(STORE).put(value, KEY); - tx.oncomplete = () => resolve(); - tx.onerror = () => reject(tx.error); - }); - db.close(); + try { + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE, "readwrite"); + tx.objectStore(STORE).put(value, KEY); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } finally { + db.close(); + } +} + +/** True for the storage-quota error class across browsers. */ +function isQuotaError(err: unknown): boolean { + if (err instanceof DOMException) { + // Chromium/WebKit throw `QuotaExceededError`; Firefox historically + // throws `NS_ERROR_DOM_QUOTA_REACHED` (code 22). + return ( + err.name === "QuotaExceededError" || + err.name === "NS_ERROR_DOM_QUOTA_REACHED" || + err.code === 22 + ); + } + return false; } async function idbGet(): Promise { @@ -211,8 +239,32 @@ export function startPersistingQueryCache( userId: currentUserId(queryClient), state, }; - void idbSet(payload).catch(() => { - /* best effort */ + + // Size-cap before touching disk: a snapshot past the ceiling is the + // likeliest quota trigger and its marginal offline value isn't worth + // evicting the last good (smaller) snapshot. Skip the write, keep what's + // there. + const serialized = JSON.stringify(payload); + if (serialized.length > MAX_PERSIST_BYTES) { + console.warn( + `[query-persister] skipping persist: snapshot ${serialized.length}B exceeds ${MAX_PERSIST_BYTES}B cap`, + ); + return; + } + + void idbSet(payload).catch((err) => { + // Don't silently swallow a full-disk failure: a quota error means the + // offline cache stopped updating, and a clear signal beats a stale + // snapshot the user can't explain. Drop the persisted blob so the next + // flush has room rather than failing again against a full store. + if (isQuotaError(err)) { + console.warn( + "[query-persister] IndexedDB quota exceeded; dropping persisted cache", + ); + void clearPersistedQueryCache(); + return; + } + console.warn("[query-persister] persist failed", err); }); };