fix(pwa): bound the persisted query cache and stop swallowing quota errors

Cap the serialized snapshot at 2 MB and skip the write when it's over — an
oversized blob is the likeliest IndexedDB quota trigger and its offline value
is marginal, so keeping the last good snapshot beats evicting it. When a write
still hits QuotaExceededError, log it and drop the persisted blob so the next
flush has room, instead of silently swallowing the failure and leaving the
offline cache stuck on a stale snapshot the user can't explain.

The allowlist and per-account binding are untouched.
This commit is contained in:
Marc-André Bombeck committed 2026-06-18 18:47:19 +02:00
1 parent 151a56fc66
commit 3465cbe81c
2 files changed
+183 -9

No files matched your search

@@ -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<string, unknown> = {};
queueMicrotask(() => {
run();
(req.onsuccess as (() => void) | undefined)?.();
});
return req;
};
function installFakeIdb(opts: {
onPut: (value: unknown, key: string) => void;
store: Map<string, unknown>;
}) {
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<string, unknown> = { 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<string, unknown>();
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<string, unknown>();
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();
});
});
+61 -9
View File
@@ -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<IDBDatabase> {
async function idbSet(value: unknown): Promise<void> {
const db = await openDb();
await new Promise<void>((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<void>((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<T>(): Promise<T | undefined> {
@@ -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);
});
};