perf(desktop): coalesce read state localStorage persistence (#5591)

Follow-on to #5453/#5454's localStorage work — found while investigating
app-slowness reports on a real profile.

## Problem

`ReadStateManager.persistLocalState()` serialized and rewrote **all
three** read-state localStorage blobs (`buzz.channel-read-state.v2`,
`.publishable.v1`, `.source-created-at.v1`) synchronously on every
context advance. On a real profile (1,643 contexts, ~450K chars across
the three blobs) this produced ~880KB of localStorage sqlite WAL growth
per 30 seconds at idle, with writes every ~5s — steady main-thread
serialization + sync IPC for no user-visible benefit. Observed WAL size
on the affected profile: 94–114MB.

## Fix

- Local persistence coalesced behind a **1s trailing-edge timer**: a
burst of N advances produces one `writeStoredReadState` (one write per
blob).
- Pending dirty state **flushes synchronously** on `pagehide`, hidden
`visibilitychange`, `destroy()`, and before each relay publish — disk is
current before any relay event goes out.
- Hydration still persists immediately. Publish debounce (5s), merge
logic, and blob formats unchanged (`DEBOUNCE_MS` renamed to
`PUBLISH_DEBOUNCE_MS` only).

## Accepted residual

A hard kill (SIGKILL/power loss — not webview teardown) inside the 1s
window loses ≤1s of local read-state advances; relay max-merge bounds
the effect to a message flickering back unread. On the record per
review.

## Validation

- `readStateManager.test.mjs`: fake-timer/mock-storage coverage —
exactly one 3-blob write per burst (zero before the timer fires),
hidden-flush cancels the timer and persists, hydrate persists
immediately, pre-publish flush. Suite 26/26.
- Push gate at the pushed commit: desktop check, typecheck, full desktop
unit suite 4,670/4,670.
- Independent adversarial FULL REVIEW: **APPROVE** at tree `371a02cf`
(commit metadata rewritten afterward for attribution; tree identical) —
all six `persistLocalState` call sites traced, lifecycle/leak checks
(StrictMode remount, pubkey change), no external readers of the blob
keys.

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Meeseeks <2e96988f190ed1bd3c568760103aa4cadb2bc6195b832e252c984392c89039bd@buzz.block.builderlab.xyz>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
This commit is contained in:
Wes
2026-08-11 16:41:35 -07:00
committed by GitHub
co-authored by Meeseeks Carl
parent 16b7ae7ce6
commit 9203bf60ee
2 changed files with 325 additions and 21 deletions
@@ -16,10 +16,72 @@ import {
function makeLocalStorage() {
const store = new Map();
const writes = [];
return {
getItem: (key) => store.get(key) ?? null,
setItem: (key, value) => store.set(key, value),
setItem: (key, value) => {
writes.push([key, value]);
store.set(key, value);
},
removeItem: (key) => store.delete(key),
writes,
};
}
function makeFakeTimers() {
let nextId = 1;
let now = 0;
const timers = new Map();
function runThrough(targetTime) {
while (true) {
const next = [...timers.entries()]
.filter(([, timer]) => timer.dueAt <= targetTime)
.sort(
([firstId, first], [secondId, second]) =>
first.dueAt - second.dueAt || firstId - secondId,
)[0];
if (!next) break;
const [id, timer] = next;
timers.delete(id);
now = timer.dueAt;
timer.fn();
}
now = targetTime;
}
return {
setTimeout(fn, delay = 0) {
const id = nextId++;
timers.set(id, { fn, dueAt: now + delay });
return id;
},
clearTimeout(id) {
timers.delete(id);
},
advanceBy(ms) {
runThrough(now + ms);
},
runAll() {
while (timers.size > 0) {
const nextDueAt = Math.min(
...[...timers.values()].map((timer) => timer.dueAt),
);
runThrough(nextDueAt);
}
},
get size() {
return timers.size;
},
};
}
function makeFakeRelay() {
return {
fetchEvents: async () => [],
publishEvent: async () => {},
subscribeLive: () => () => {},
};
}
@@ -27,19 +89,23 @@ function makeLocalStorage() {
// replaced per-test for isolation; the bare `localStorage` global proxies to it.
{
const ls = makeLocalStorage();
if (typeof globalThis.window === "undefined") {
globalThis.window = {
localStorage: ls,
clearTimeout: (id) => clearTimeout(id),
setTimeout: (fn, ms) => setTimeout(fn, ms),
};
} else {
globalThis.window.localStorage = ls;
if (!globalThis.window.clearTimeout) {
globalThis.window.clearTimeout = (id) => clearTimeout(id);
globalThis.window.setTimeout = (fn, ms) => setTimeout(fn, ms);
}
}
const windowEvents = new EventTarget();
const documentEvents = new EventTarget();
globalThis.window = {
localStorage: ls,
clearTimeout: (id) => clearTimeout(id),
setTimeout: (fn, ms) => setTimeout(fn, ms),
addEventListener: (...args) => windowEvents.addEventListener(...args),
removeEventListener: (...args) => windowEvents.removeEventListener(...args),
dispatchEvent: (...args) => windowEvents.dispatchEvent(...args),
};
globalThis.document = {
visibilityState: "visible",
addEventListener: (...args) => documentEvents.addEventListener(...args),
removeEventListener: (...args) =>
documentEvents.removeEventListener(...args),
dispatchEvent: (...args) => documentEvents.dispatchEvent(...args),
};
// Ensure bare `localStorage` always proxies to window.localStorage.
Object.defineProperty(globalThis, "localStorage", {
get: () => globalThis.window.localStorage,
@@ -52,6 +118,204 @@ const channelKey = "channel-1";
const channelResolver = (ctx) =>
ctx.startsWith("thread:") ? channelKey : null;
// ── ReadStateManager local persistence ────────────────────────────────────────
function withFakeTimers() {
const timers = makeFakeTimers();
const originalSetTimeout = globalThis.window.setTimeout;
const originalClearTimeout = globalThis.window.clearTimeout;
globalThis.window.setTimeout = (fn, ms) => timers.setTimeout(fn, ms);
globalThis.window.clearTimeout = (id) => timers.clearTimeout(id);
return {
timers,
restore() {
globalThis.window.setTimeout = originalSetTimeout;
globalThis.window.clearTimeout = originalClearTimeout;
},
};
}
test("advanceContext burst coalesces local persistence into one write", () => {
const storage = makeLocalStorage();
globalThis.window.localStorage = storage;
const { timers, restore } = withFakeTimers();
const manager = new ReadStateManager("1".repeat(64), makeFakeRelay());
const baselineWrites = storage.writes.length;
try {
manager.seedContextRead("channel-1", 100);
manager.seedContextRead("channel-2", 200);
manager.seedContextRead("channel-3", 300);
assert.equal(timers.size, 1, "burst should leave one trailing timer");
assert.equal(storage.writes.length, baselineWrites);
timers.runAll();
assert.equal(
storage.writes.length - baselineWrites,
3,
"one writeStoredReadState call writes its three blobs once",
);
} finally {
manager.destroy();
restore();
}
});
test("sustained advances persist within each one-second window", () => {
const storage = makeLocalStorage();
globalThis.window.localStorage = storage;
const { timers, restore } = withFakeTimers();
const manager = new ReadStateManager("5".repeat(64), makeFakeRelay());
const baselineWrites = storage.writes.length;
try {
manager.seedContextRead("channel-1", 100);
for (let timestamp = 200; timestamp <= 3_200; timestamp += 200) {
timers.advanceBy(200);
manager.seedContextRead("channel-1", timestamp);
if (timestamp % 1_000 === 0) {
assert.equal(
storage.writes.length - baselineWrites,
(timestamp / 1_000) * 3,
"latest state should persist once per one-second window",
);
}
}
const contexts = JSON.parse(
storage.getItem(`buzz.channel-read-state.v2:${"5".repeat(64)}`),
);
assert.equal(contexts["channel-1"], new Date(2_800_000).toISOString());
assert.equal(timers.size, 1, "latest advance should open the next window");
} finally {
manager.destroy();
restore();
}
});
test("visibility hidden flushes pending local state", () => {
const storage = makeLocalStorage();
globalThis.window.localStorage = storage;
const { timers, restore } = withFakeTimers();
const manager = new ReadStateManager("2".repeat(64), makeFakeRelay());
const baselineWrites = storage.writes.length;
try {
manager.seedContextRead("channel-1", 100);
assert.equal(storage.writes.length, baselineWrites);
globalThis.document.visibilityState = "hidden";
globalThis.document.dispatchEvent(new Event("visibilitychange"));
assert.equal(timers.size, 0, "flush should cancel the trailing timer");
assert.equal(storage.writes.length - baselineWrites, 3);
const contexts = JSON.parse(
storage.getItem(`buzz.channel-read-state.v2:${"2".repeat(64)}`),
);
assert.equal(contexts["channel-1"], new Date(100_000).toISOString());
} finally {
globalThis.document.visibilityState = "visible";
manager.destroy();
restore();
}
});
test("hydrateFromLocalStorage persists immediately", () => {
const storage = makeLocalStorage();
const pubkey = "3".repeat(64);
storage.setItem(
`buzz.channel-read-state.v2:${pubkey}`,
JSON.stringify({ "channel-1": new Date(100_000).toISOString() }),
);
globalThis.window.localStorage = storage;
const { timers, restore } = withFakeTimers();
const manager = new ReadStateManager(pubkey, makeFakeRelay());
const baselineWrites = storage.writes.length;
try {
manager.hydrateFromLocalStorage();
assert.equal(timers.size, 0);
assert.equal(storage.writes.length - baselineWrites, 3);
assert.equal(manager.getOwnTimestamp("channel-1"), 100);
} finally {
manager.destroy();
restore();
}
});
test("publish flushes pending local state first", async () => {
const storage = makeLocalStorage();
globalThis.window.localStorage = storage;
const { timers, restore } = withFakeTimers();
const manager = new ReadStateManager("4".repeat(64), makeFakeRelay());
const baselineWrites = storage.writes.length;
try {
manager.seedContextRead("channel-1", 100);
manager.fetchOwnBlobBeforePublish = async () => {};
await manager.publish();
assert.equal(timers.size, 0);
assert.equal(storage.writes.length - baselineWrites, 3);
} finally {
manager.destroy();
restore();
}
});
test("destroyed manager cannot persist after an in-flight fetch resolves", async () => {
const storage = makeLocalStorage();
globalThis.window.localStorage = storage;
const { timers, restore } = withFakeTimers();
const pubkey = "6".repeat(64);
let resolveFetch;
const fetchPending = new Promise((resolve) => {
resolveFetch = resolve;
});
const staleManager = new ReadStateManager(pubkey, {
...makeFakeRelay(),
fetchEvents: () => fetchPending,
});
try {
const initialization = staleManager.initialize();
staleManager.seedContextRead("channel-1", 100);
staleManager.destroy();
const replacementManager = new ReadStateManager(pubkey, makeFakeRelay());
replacementManager.seedContextRead("channel-1", 200);
timers.advanceBy(1_000);
const writesAfterReplacement = storage.writes.length;
resolveFetch([]);
await initialization;
timers.runAll();
assert.equal(
storage.writes.length,
writesAfterReplacement,
"the stale manager must not schedule persistence after destruction",
);
const contexts = JSON.parse(
storage.getItem(`buzz.channel-read-state.v2:${pubkey}`),
);
assert.equal(
contexts["channel-1"],
new Date(200_000).toISOString(),
"the stale manager must not overwrite the replacement manager",
);
replacementManager.destroy();
} finally {
staleManager.destroy();
restore();
}
});
test("resolveEffectiveTimestamp returns own value when context has no parent", () => {
const effectiveState = new Map([[channelKey, 200]]);
const result = resolveEffectiveTimestamp({
@@ -23,7 +23,8 @@ import { truncatePubkey } from "@/shared/lib/pubkey";
const CLIENT_ID_KEY_PREFIX = "buzz.nip-rs.client-id";
const SLOT_ID_KEY_PREFIX = "buzz.nip-rs.slot-id";
const DEBOUNCE_MS = 5_000;
const PUBLISH_DEBOUNCE_MS = 5_000;
const LOCAL_PERSIST_MAX_WAIT_MS = 1_000;
function generateHex(bytes: number): string {
const arr = new Uint8Array(bytes);
@@ -312,6 +313,7 @@ export class ReadStateManager {
private publishableContextIds = new Set<string>();
private lastPublishedContexts: Record<string, number> = {};
private debounceTimer: number | null = null;
private localPersistTimer: number | null = null;
private listeners = new Set<() => void>();
private unsubscribeLive: (() => void) | null = null;
private initialized = false;
@@ -331,6 +333,8 @@ export class ReadStateManager {
generateHex(16),
);
this.extraSlotIds = loadExtraSlotIds(pubkey);
window.addEventListener("pagehide", this.flushLocalState);
document.addEventListener("visibilitychange", this.handleVisibilityChange);
}
async initialize(): Promise<void> {
@@ -377,6 +381,7 @@ export class ReadStateManager {
unixTimestamp: number,
options: { publishable: boolean },
): void {
if (this.destroyed) return;
const current = this.effectiveState.get(contextId) ?? 0;
if (unixTimestamp <= current) {
if (!options.publishable || this.publishableContextIds.has(contextId)) {
@@ -438,7 +443,14 @@ export class ReadStateManager {
destroy(): void {
this.destroyed = true;
// Flush any pending writes immediately
window.removeEventListener("pagehide", this.flushLocalState);
document.removeEventListener(
"visibilitychange",
this.handleVisibilityChange,
);
this.flushLocalState();
// Flush any pending relay publish immediately
if (this.debounceTimer !== null) {
window.clearTimeout(this.debounceTimer);
this.debounceTimer = null;
@@ -475,6 +487,7 @@ export class ReadStateManager {
}
private async mergeEvents(events: RelayEvent[]): Promise<void> {
if (this.destroyed) return;
// Collect all own blobs (keyed by slot d-tag) to union them all.
// NIP-RS: multiple own-slot blobs must be max-merged, not winner-takes-all.
const ownBlobsBySlot = new Map<
@@ -484,6 +497,7 @@ export class ReadStateManager {
for (const event of events) {
const parsed = await parseReadStateEvent(event, this.pubkey);
if (this.destroyed) return;
if (!parsed) continue;
this.maxFetchedCreatedAt = Math.max(
@@ -520,6 +534,7 @@ export class ReadStateManager {
// d-tag coordinate. If so, rotate our slotId to avoid clobbering.
for (const event of events) {
const parsed = await parseReadStateEvent(event, this.pubkey);
if (this.destroyed) return;
if (!parsed || parsed.dTag !== `read-state:${this.slotId}`) continue;
if (parsed.blob.client_id !== this.clientId) {
this.slotId = generateHex(16);
@@ -572,14 +587,13 @@ export class ReadStateManager {
}
private async handleIncomingEvent(event: RelayEvent): Promise<void> {
if (event.pubkey !== this.pubkey) return;
if (this.destroyed) return;
if (this.destroyed || event.pubkey !== this.pubkey) return;
console.debug(
`[ReadStateManager] incoming event=${event.id.substring(0, 8)}… created_at=${event.created_at}`,
);
const parsed = await parseReadStateEvent(event, this.pubkey);
if (!parsed) return;
if (!parsed || this.destroyed) return;
this.maxFetchedCreatedAt = Math.max(
this.maxFetchedCreatedAt,
@@ -622,18 +636,21 @@ export class ReadStateManager {
}
private schedulePublish(): void {
if (this.destroyed) return;
if (this.debounceTimer !== null) {
window.clearTimeout(this.debounceTimer);
}
this.debounceTimer = window.setTimeout(() => {
this.debounceTimer = null;
void this.publish();
}, DEBOUNCE_MS);
}, PUBLISH_DEBOUNCE_MS);
}
private async publish(): Promise<void> {
console.debug(`[ReadStateManager] publish starting slotId=${this.slotId}`);
await this.fetchOwnBlobBeforePublish();
if (this.destroyed) return;
this.flushLocalState();
// Build blob from contexts this client is allowed to publish.
const contexts = this.currentContexts();
@@ -927,10 +944,33 @@ export class ReadStateManager {
for (const [contextId, createdAt] of stored.contextSourceCreatedAt) {
this.contextSourceCreatedAt.set(contextId, createdAt);
}
this.persistLocalState();
this.writeLocalState();
}
private persistLocalState(): void {
if (this.destroyed || this.localPersistTimer !== null) return;
this.localPersistTimer = window.setTimeout(() => {
this.localPersistTimer = null;
this.writeLocalState();
}, LOCAL_PERSIST_MAX_WAIT_MS);
}
private readonly flushLocalState = (): void => {
if (this.localPersistTimer === null) return;
window.clearTimeout(this.localPersistTimer);
this.localPersistTimer = null;
this.writeLocalState();
};
private readonly handleVisibilityChange = (): void => {
if (document.visibilityState === "hidden") {
this.flushLocalState();
}
};
private writeLocalState(): void {
writeStoredReadState(
this.pubkey,
this.effectiveState,