mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user