fix(desktop): bound read-state localStorage growth and recover from quota errors (#1502)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-07-03 09:00:27 -07:00
committed by GitHub
co-authored by Brain
parent bbf8d8912a
commit a3cf7eec1d
7 changed files with 367 additions and 12 deletions
@@ -0,0 +1,61 @@
import assert from "node:assert/strict";
import test from "node:test";
import { setLocalStorageItemWithRecovery } from "./localStorageQuota.ts";
function makeQuotaLocalStorage({ maxEntries }) {
const store = new Map();
return {
store,
get length() {
return store.size;
},
key: (i) => [...store.keys()][i] ?? null,
getItem: (key) => store.get(key) ?? null,
setItem(key, value) {
if (!store.has(key) && store.size >= maxEntries) {
throw new Error("QuotaExceededError");
}
store.set(key, value);
},
removeItem: (key) => store.delete(key),
};
}
function install(ls) {
if (typeof globalThis.window === "undefined") {
globalThis.window = {};
}
globalThis.window.localStorage = ls;
globalThis.localStorage = ls;
}
test("writes normally when under quota", () => {
const ls = makeQuotaLocalStorage({ maxEntries: 10 });
install(ls);
assert.equal(setLocalStorageItemWithRecovery("k", "v"), true);
assert.equal(ls.getItem("k"), "v");
});
test("evicts pure caches and retries on quota failure", () => {
const ls = makeQuotaLocalStorage({ maxEntries: 2 });
install(ls);
ls.store.set("buzz-channel-messages.v1:relay:chan", "big");
ls.store.set("buzz-channels.v1:relay", "big");
assert.equal(setLocalStorageItemWithRecovery("k", "v"), true);
assert.equal(ls.getItem("k"), "v");
assert.equal(ls.getItem("buzz-channel-messages.v1:relay:chan"), null);
assert.equal(ls.getItem("buzz-channels.v1:relay"), null);
});
test("returns false when eviction frees nothing", () => {
const ls = makeQuotaLocalStorage({ maxEntries: 2 });
install(ls);
ls.store.set("buzz-workspaces", "keep");
ls.store.set("buzz-active-workspace-id", "keep");
assert.equal(setLocalStorageItemWithRecovery("k", "v"), false);
assert.equal(ls.getItem("k"), null);
assert.equal(ls.getItem("buzz-workspaces"), "keep");
});
@@ -0,0 +1,78 @@
/**
* Quota-aware localStorage writes with pure-cache eviction recovery.
*
* The desktop webview caps localStorage at ~5 MB per origin. Writers of
* load-bearing state (read-state, workspaces) must not leak QuotaExceededError
* into React click/render paths. On a failed write this evicts snapshot caches
* — safe to drop, they repaint from the relay — and retries the write once.
*/
const PURE_CACHE_KEY_PREFIXES = [
"buzz-channel-messages.v1",
"buzz-channels.v1",
"buzz-timeline-skeleton-shape.v1",
];
function evictPureCacheEntries(): number {
const toRemove: string[] = [];
for (let i = 0; i < window.localStorage.length; i++) {
const key = window.localStorage.key(i);
if (
key !== null &&
PURE_CACHE_KEY_PREFIXES.some((prefix) => key.startsWith(prefix))
) {
toRemove.push(key);
}
}
for (const key of toRemove) {
window.localStorage.removeItem(key);
}
return toRemove.length;
}
let warnedPersistentFailure = false;
function notifyStorageFull(): void {
if (warnedPersistentFailure) return;
warnedPersistentFailure = true;
// Dynamic import keeps this module usable from node unit tests.
import("sonner")
.then(({ toast }) => {
toast.error("Local storage is full", {
description:
"Buzz could not save some local data — read positions may not persist across restarts.",
});
})
.catch(() => {});
}
/**
* Writes to localStorage; on failure (quota exceeded), evicts pure snapshot
* caches and retries once. Returns false when the write still fails — callers
* keep working from in-memory state.
*/
export function setLocalStorageItemWithRecovery(
key: string,
value: string,
): boolean {
try {
window.localStorage.setItem(key, value);
return true;
} catch (error) {
try {
if (evictPureCacheEntries() > 0) {
window.localStorage.setItem(key, value);
return true;
}
} catch {
// Fall through to failure reporting.
}
console.warn(
"[localStorageQuota] write failed after cache eviction:",
key,
error,
);
notifyStorageFull();
return false;
}
}