mirror of
https://github.com/block/buzz.git
synced 2026-11-01 15:01:12 +00:00
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>
199 lines
6.5 KiB
JavaScript
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);
|
|
});
|