Files
buzz/desktop/src/features/channels/threadActivityWriteScheduler.test.mjs
Will Pfleger c6c6e7eca7 perf(desktop): coalesce thread-activity localStorage writes (#5693)
Each incoming thread reply drove a full `JSON.stringify` + `setItem` of
the ~600 KB thread-activity buffer. A burst of replies serialized the
whole blob once per event on the main thread, which is one of the
renderer stalls under load in the desktop-longevity arc.

This collapses the burst into a single debounced write, applying the
coalescing pattern Wes introduced for read-state persistence in #5591
(`readStateManager`) to the thread-activity path.

## What changed

- **`threadActivityStorage.ts`** — coalescing primitives:
- `scheduleThreadActivityWrite` — first-writer-wins (a pending timer is
*not* reset), 1s trailing edge. The timer reads the live buffer *at fire
time* and re-checks the loaded scope, so N replies within the window
persist exactly once with the burst's final state, and a write that
outlives a scope switch can neither land under the new key nor persist
the wrong buffer.
- `flushThreadActivityWrite` — synchronous persist + timer cancel; a
no-op when nothing is pending.
- `removeLegacyThreadActivityKey` — idempotent one-time cleanup of the
orphaned pre-relay-scoping `buzz-thread-activity.v1:<pubkey>` key.
- **`useThreadActivityPersistence.ts`** (new companion hook) — owns the
loaded scope, the write timer, the `pagehide` /
`visibilitychange`→hidden / unmount flush, and hydration + legacy
cleanup on identity/relay change. Mirrors the existing
`useObservedUnreadPersistence` sibling.
- **`useUnreadChannels.ts`** — rewired to instantiate the hook and call
`activityPersistence.schedule(...)` at both writer sites instead of
writing per event. The buffer (`threadActivityRef`) stays parent-owned;
the hook decides when it is durably persisted. Net **990** lines (was
1021), back under the 1000-line ceiling.

## Durability

`pagehide`, `visibilitychange`→hidden, unmount, and scope-reseed all
flush synchronously, so the last burst of replies survives a `Cmd+R` or
an idle reload that tears the webview down inside the coalescing window.

## Tests

- `threadActivityWriteScheduler.test.mjs` — fake-timer unit coverage:
burst→one `setItem`, live-buffer-at-fire-time, scope-mismatch rejection,
stale-scope timer abort, flush persists+cancels, flush no-op, legacy-key
removal.
- `useThreadActivityPersistence.test.mjs` — mounts the real hook via
`createRoot`+`act`: `pagehide` / visibility / unmount flush of the live
buffer, scope switch flushing A under A's key without leaking into B,
B-bucket rehydration, legacy-key cleanup, and the empty-scope write
fence.

## Related

Based on [#5591](https://github.com/block/buzz/pull/5591) (Wes) —
`perf(desktop): coalesce read state localStorage persistence`, the
proven first-writer-wins coalescing pattern this extends to thread
activity.

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-08-12 12:06:43 -07:00

199 lines
6.5 KiB
JavaScript

/**
* Unit tests for the coalesced thread-activity write scheduler.
*
* These exercise scheduleThreadActivityWrite / flushThreadActivityWrite /
* removeLegacyThreadActivityKey directly against a ref bag, using node:test
* fake timers to drive the 1s debounce deterministically. Hook lifecycle
* (pagehide/visibility flush, scope-switch hydration) is covered separately in
* useThreadActivityPersistence.test.mjs.
*/
import assert from "node:assert/strict";
import { afterEach, mock, test } from "node:test";
import {
activityScopeKey,
activityStorageKey,
flushThreadActivityWrite,
readActivityFromStorage,
removeLegacyThreadActivityKey,
scheduleThreadActivityWrite,
} from "./threadActivityStorage.ts";
const originalWindow = globalThis.window;
afterEach(() => {
mock.timers.reset();
if (originalWindow === undefined) delete globalThis.window;
else globalThis.window = originalWindow;
});
// Install an isolated in-memory localStorage with a setItem counter so a burst
// can be asserted to collapse to exactly one write.
function installStore() {
const store = new Map();
let setItemCalls = 0;
globalThis.window = {
localStorage: {
getItem: (key) => store.get(key) ?? null,
setItem: (key, value) => {
setItemCalls += 1;
store.set(key, value);
},
removeItem: (key) => store.delete(key),
},
};
return { store, setItemCalls: () => setItemCalls };
}
function makeRefs(scope, items = []) {
return {
itemsRef: { current: items },
scopeLoadedRef: { current: scope },
timerRef: { current: null },
};
}
const RELAY = "wss://relay.example.com";
// ── scheduleThreadActivityWrite ──────────────────────────────────────────────
test("scheduleThreadActivityWrite coalesces a burst of schedules into one setItem", () => {
mock.timers.enable({ apis: ["setTimeout"] });
const { setItemCalls } = installStore();
const scope = activityScopeKey("pk1", RELAY);
const refs = makeRefs(scope, [{ id: "r1" }]);
for (let i = 0; i < 5; i += 1) scheduleThreadActivityWrite(scope, refs);
assert.equal(setItemCalls(), 0, "no write before the debounce elapses");
mock.timers.tick(1_000);
assert.equal(
setItemCalls(),
1,
"a burst of 5 schedules fires exactly one write",
);
});
test("scheduleThreadActivityWrite persists the live buffer at fire time, not at schedule time", () => {
mock.timers.enable({ apis: ["setTimeout"] });
installStore();
const scope = activityScopeKey("pk1", RELAY);
const refs = makeRefs(scope, [{ id: "early" }]);
scheduleThreadActivityWrite(scope, refs);
// A second reply lands before the timer fires — the buffer grows in place.
refs.itemsRef.current = [{ id: "early" }, { id: "late" }];
mock.timers.tick(1_000);
assert.deepEqual(
readActivityFromStorage("pk1", RELAY).map((item) => item.id),
["early", "late"],
"the persisted blob reflects the buffer's final state",
);
});
test("scheduleThreadActivityWrite ignores a scope that does not match the loaded scope", () => {
mock.timers.enable({ apis: ["setTimeout"] });
const { setItemCalls } = installStore();
const refs = makeRefs(activityScopeKey("pkA", "wss://relay-a.example.com"), [
{ id: "a1" },
]);
scheduleThreadActivityWrite(
activityScopeKey("pkB", "wss://relay-b.example.com"),
refs,
);
assert.equal(
refs.timerRef.current,
null,
"no timer armed for a mismatched scope",
);
mock.timers.tick(1_000);
assert.equal(setItemCalls(), 0, "a mismatched scope never writes");
});
test("scheduleThreadActivityWrite timer aborts when the loaded scope changed before it fired", () => {
mock.timers.enable({ apis: ["setTimeout"] });
const { setItemCalls } = installStore();
const pkA = "pkA";
const relayA = "wss://relay-a.example.com";
const scopeA = activityScopeKey(pkA, relayA);
const refs = makeRefs(scopeA, [{ id: "a1" }]);
scheduleThreadActivityWrite(scopeA, refs);
// Scope switches out from under the pending timer without cancelling it.
refs.scopeLoadedRef.current = activityScopeKey(
"pkB",
"wss://relay-b.example.com",
);
mock.timers.tick(1_000);
assert.equal(setItemCalls(), 0, "a stale-scope timer must not write");
assert.equal(
readActivityFromStorage(pkA, relayA).length,
0,
"A's key must stay empty when the timer aborts",
);
});
// ── flushThreadActivityWrite ─────────────────────────────────────────────────
test("flushThreadActivityWrite persists synchronously and cancels the pending timer", () => {
mock.timers.enable({ apis: ["setTimeout"] });
const { setItemCalls } = installStore();
const scope = activityScopeKey("pk1", RELAY);
const refs = makeRefs(scope, [{ id: "f1" }]);
scheduleThreadActivityWrite(scope, refs);
flushThreadActivityWrite(refs);
assert.equal(setItemCalls(), 1, "flush writes immediately");
assert.equal(refs.timerRef.current, null, "flush cancels the timer");
assert.deepEqual(
readActivityFromStorage("pk1", RELAY).map((item) => item.id),
["f1"],
);
mock.timers.tick(1_000);
assert.equal(
setItemCalls(),
1,
"the cancelled timer never fires a second write",
);
});
test("flushThreadActivityWrite is a no-op when no write is pending", () => {
const { setItemCalls } = installStore();
const refs = makeRefs(activityScopeKey("pk1", RELAY), [{ id: "x" }]);
flushThreadActivityWrite(refs);
assert.equal(setItemCalls(), 0, "no pending timer means no write");
});
// ── removeLegacyThreadActivityKey ────────────────────────────────────────────
test("removeLegacyThreadActivityKey removes the orphaned pubkey-only key and preserves the scoped key", () => {
const { store } = installStore();
const pubkey = "pk1";
const legacyKey = `buzz-thread-activity.v1:${pubkey}`;
const scopedKey = activityStorageKey(pubkey, RELAY);
store.set(legacyKey, JSON.stringify([{ id: "legacy" }]));
store.set(scopedKey, JSON.stringify([{ id: "scoped" }]));
removeLegacyThreadActivityKey(pubkey);
assert.equal(store.has(legacyKey), false, "legacy pubkey-only key removed");
assert.equal(store.has(scopedKey), true, "relay-scoped key preserved");
// Idempotent: a second call on the absent key is a no-op that never throws.
removeLegacyThreadActivityKey(pubkey);
assert.equal(store.has(legacyKey), false);
});