mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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>
This commit is contained in:
@@ -118,3 +118,91 @@ export function addThreadActivityItems(
|
||||
|
||||
return { didAdd: true, items: capped };
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time removal of the orphaned pre-relay-scoping key
|
||||
* `buzz-thread-activity.v1:<pubkey>`. The legacy pubkey-only writer was dropped
|
||||
* when the key became relay-scoped, but the old ~400 KB blob is never read and
|
||||
* is absent from the quota-sweep whitelist, so it lingers as dead weight.
|
||||
* removeItem on an absent key is a no-op, so running this on every identity
|
||||
* load is idempotent and self-terminating.
|
||||
*/
|
||||
export function removeLegacyThreadActivityKey(pubkey: string): void {
|
||||
try {
|
||||
window.localStorage.removeItem(`${ACTIVITY_STORAGE_PREFIX}:${pubkey}`);
|
||||
} catch {
|
||||
// Ignore storage errors.
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Coalesced persistence writer (first-writer-wins + flush) ─────────────────
|
||||
//
|
||||
// The full item list is one ~600 KB JSON.stringify+setItem. Writing it on every
|
||||
// incoming reply drives the renderer stall this coalescing exists to fix, so the
|
||||
// scheduler collapses a burst of writes into one setItem ~1 s later. The live
|
||||
// buffer (itemsRef) stays the source of truth between flushes; the timer reads
|
||||
// it at fire time, so the persisted blob is always the burst's final state.
|
||||
|
||||
const WRITE_DEBOUNCE_MS = 1_000;
|
||||
|
||||
export type ThreadActivityRefs = {
|
||||
itemsRef: { current: ThreadActivityItem[] };
|
||||
scopeLoadedRef: { current: string };
|
||||
timerRef: { current: ReturnType<typeof setTimeout> | null };
|
||||
};
|
||||
|
||||
/**
|
||||
* Inverse of activityScopeKey: split "pubkey:normalizedRelayUrl" back into its
|
||||
* parts. The pubkey is colon-free, so the first colon is the boundary. Returns
|
||||
* null for an empty or malformed scope.
|
||||
*/
|
||||
function decomposeScope(
|
||||
scope: string,
|
||||
): { pubkey: string; relayUrl: string } | null {
|
||||
if (!scope) return null;
|
||||
const colonIdx = scope.indexOf(":");
|
||||
if (colonIdx === -1) return null;
|
||||
const pubkey = scope.slice(0, colonIdx);
|
||||
const relayUrl = scope.slice(colonIdx + 1);
|
||||
return pubkey && relayUrl ? { pubkey, relayUrl } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Arm a trailing-edge write for `scope`, first-writer-wins: a pending timer is
|
||||
* NOT reset, so a burst of N writes still fires once. The timer reads itemsRef
|
||||
* at fire time and re-checks the loaded scope, so a write that outlives a scope
|
||||
* switch can neither land under the new key nor persist the wrong buffer.
|
||||
*/
|
||||
export function scheduleThreadActivityWrite(
|
||||
scope: string,
|
||||
refs: ThreadActivityRefs,
|
||||
): void {
|
||||
if (!scope || refs.scopeLoadedRef.current !== scope) return;
|
||||
if (refs.timerRef.current !== null) return;
|
||||
|
||||
const parts = decomposeScope(scope);
|
||||
if (!parts) return;
|
||||
const { pubkey, relayUrl } = parts;
|
||||
|
||||
refs.timerRef.current = setTimeout(() => {
|
||||
refs.timerRef.current = null;
|
||||
if (refs.scopeLoadedRef.current !== scope) return;
|
||||
writeActivityToStorage(pubkey, relayUrl, refs.itemsRef.current);
|
||||
}, WRITE_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronously persist a pending write and cancel the timer. A no-op when no
|
||||
* write is pending (the buffer is already durable), so flushing on hidden or
|
||||
* pagehide costs a setItem only when there is unsaved state. Callers MUST flush
|
||||
* before reseeding on a scope switch so the old scope's buffer lands under the
|
||||
* old key.
|
||||
*/
|
||||
export function flushThreadActivityWrite(refs: ThreadActivityRefs): void {
|
||||
if (refs.timerRef.current === null) return;
|
||||
clearTimeout(refs.timerRef.current);
|
||||
refs.timerRef.current = null;
|
||||
const parts = decomposeScope(refs.scopeLoadedRef.current);
|
||||
if (!parts) return;
|
||||
writeActivityToStorage(parts.pubkey, parts.relayUrl, refs.itemsRef.current);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* Integration tests for useThreadActivityPersistence.
|
||||
*
|
||||
* These mount the REAL production hook via createRoot + act to exercise the
|
||||
* actual lifecycle: pagehide flush, visibilitychange→hidden flush, unmount
|
||||
* cleanup, scope-switch hydration (flush-before-reseed), legacy-key cleanup,
|
||||
* and the read-live-buffer-at-flush-time contract that distinguishes this
|
||||
* scheduler from a snapshot-at-schedule one. Debounce timing is covered by
|
||||
* fake timers in threadActivityWriteScheduler.test.mjs.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
installDOMShim,
|
||||
installFreshStorage,
|
||||
} from "./observedUnreadTestHarness.mjs";
|
||||
|
||||
installDOMShim();
|
||||
installFreshStorage();
|
||||
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import {
|
||||
activityStorageKey,
|
||||
readActivityFromStorage,
|
||||
writeActivityToStorage,
|
||||
} from "./threadActivityStorage.ts";
|
||||
import { useThreadActivityPersistence } from "./useThreadActivityPersistence.ts";
|
||||
|
||||
const RELAY = "wss://relay.example.com";
|
||||
|
||||
// Mount the hook with a caller-owned buffer ref (mirrors useUnreadChannels,
|
||||
// which owns threadActivityRef and passes it in).
|
||||
async function mountHook(itemsRef, props) {
|
||||
const apiRef = { current: null };
|
||||
|
||||
function Harness({ pubkey, relay }) {
|
||||
apiRef.current = useThreadActivityPersistence(pubkey, relay, itemsRef);
|
||||
return null;
|
||||
}
|
||||
|
||||
const root = createRoot(document.createElement("div"));
|
||||
const render = async (p) => {
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Harness, p));
|
||||
});
|
||||
};
|
||||
await render(props);
|
||||
|
||||
return {
|
||||
get api() {
|
||||
return apiRef.current;
|
||||
},
|
||||
render,
|
||||
unmount: async () => {
|
||||
await act(async () => root.unmount());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── flush contract: read the live buffer at flush time ───────────────────────
|
||||
|
||||
test("pagehide flush persists the live buffer's final state, not a schedule-time snapshot", async () => {
|
||||
installFreshStorage();
|
||||
const itemsRef = { current: [] };
|
||||
const harness = await mountHook(itemsRef, { pubkey: "pk1", relay: RELAY });
|
||||
|
||||
// A reply lands, the writer buffers it in place and arms a coalesced write.
|
||||
itemsRef.current = [{ id: "early" }];
|
||||
harness.api.schedule(harness.api.currentScope);
|
||||
// A second reply lands within the debounce window — buffer grows in place.
|
||||
itemsRef.current = [{ id: "early" }, { id: "late" }];
|
||||
|
||||
await act(async () => {
|
||||
globalThis.dispatchEvent({ type: "pagehide" });
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
readActivityFromStorage("pk1", RELAY).map((item) => item.id),
|
||||
["early", "late"],
|
||||
"flush must persist the buffer's final state including the late reply",
|
||||
);
|
||||
|
||||
await harness.unmount();
|
||||
});
|
||||
|
||||
test("visibilitychange to hidden flushes a pending write", async () => {
|
||||
installFreshStorage();
|
||||
const itemsRef = { current: [] };
|
||||
const harness = await mountHook(itemsRef, { pubkey: "pk1", relay: RELAY });
|
||||
|
||||
itemsRef.current = [{ id: "hidden-flush" }];
|
||||
harness.api.schedule(harness.api.currentScope);
|
||||
|
||||
await act(async () => {
|
||||
globalThis.document.visibilityState = "hidden";
|
||||
globalThis.document.dispatchEvent({ type: "visibilitychange" });
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
readActivityFromStorage("pk1", RELAY).map((item) => item.id),
|
||||
["hidden-flush"],
|
||||
"backgrounding the webview must persist the pending buffer",
|
||||
);
|
||||
|
||||
await harness.unmount();
|
||||
});
|
||||
|
||||
test("visibilitychange to visible does not write", async () => {
|
||||
installFreshStorage();
|
||||
const itemsRef = { current: [] };
|
||||
const harness = await mountHook(itemsRef, { pubkey: "pk1", relay: RELAY });
|
||||
|
||||
itemsRef.current = [{ id: "still-buffered" }];
|
||||
harness.api.schedule(harness.api.currentScope);
|
||||
|
||||
await act(async () => {
|
||||
globalThis.document.visibilityState = "visible";
|
||||
globalThis.document.dispatchEvent({ type: "visibilitychange" });
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
readActivityFromStorage("pk1", RELAY),
|
||||
[],
|
||||
"a visible transition must not flush",
|
||||
);
|
||||
|
||||
await harness.unmount();
|
||||
});
|
||||
|
||||
test("unmount with a pending write flushes before teardown", async () => {
|
||||
installFreshStorage();
|
||||
const itemsRef = { current: [] };
|
||||
const harness = await mountHook(itemsRef, { pubkey: "pk1", relay: RELAY });
|
||||
|
||||
itemsRef.current = [{ id: "unmount-flush" }];
|
||||
harness.api.schedule(harness.api.currentScope);
|
||||
|
||||
await harness.unmount();
|
||||
|
||||
assert.deepEqual(
|
||||
readActivityFromStorage("pk1", RELAY).map((item) => item.id),
|
||||
["unmount-flush"],
|
||||
"unmount cleanup must flush the pending write",
|
||||
);
|
||||
});
|
||||
|
||||
// ── scope switch: flush-before-reseed under the OLD key ──────────────────────
|
||||
|
||||
test("scope switch flushes A synchronously under A's key and does not leak A into B", async () => {
|
||||
installFreshStorage();
|
||||
|
||||
const pkA = "pkA";
|
||||
const relayA = "wss://relay-a.example.com";
|
||||
const pkB = "pkB";
|
||||
const relayB = "wss://relay-b.example.com";
|
||||
|
||||
const itemsRef = { current: [] };
|
||||
const harness = await mountHook(itemsRef, { pubkey: pkA, relay: relayA });
|
||||
|
||||
// A accumulates a buffered reply with a pending coalesced write.
|
||||
itemsRef.current = [{ id: "a-only" }];
|
||||
harness.api.schedule(harness.api.currentScope);
|
||||
|
||||
// Switch identity to B: the hydration effect must flush A first.
|
||||
await harness.render({ pubkey: pkB, relay: relayB });
|
||||
|
||||
assert.deepEqual(
|
||||
readActivityFromStorage(pkA, relayA).map((item) => item.id),
|
||||
["a-only"],
|
||||
"A's pending write must land under A's key on scope switch",
|
||||
);
|
||||
assert.deepEqual(
|
||||
readActivityFromStorage(pkB, relayB),
|
||||
[],
|
||||
"B's bucket must not contain A's rows",
|
||||
);
|
||||
assert.ok(
|
||||
harness.api.currentScope.includes(pkB),
|
||||
"currentScope must reflect B after the switch",
|
||||
);
|
||||
|
||||
await harness.unmount();
|
||||
});
|
||||
|
||||
test("scope switch hydrates B's buffer from B's persisted bucket", async () => {
|
||||
installFreshStorage();
|
||||
|
||||
const pkA = "pkA";
|
||||
const relayA = "wss://relay-a.example.com";
|
||||
const pkB = "pkB";
|
||||
const relayB = "wss://relay-b.example.com";
|
||||
writeActivityToStorage(pkB, relayB, [{ id: "b-persisted" }]);
|
||||
|
||||
const itemsRef = { current: [] };
|
||||
const harness = await mountHook(itemsRef, { pubkey: pkA, relay: relayA });
|
||||
|
||||
itemsRef.current = [{ id: "a-only" }];
|
||||
await harness.render({ pubkey: pkB, relay: relayB });
|
||||
|
||||
assert.deepEqual(
|
||||
itemsRef.current.map((item) => item.id),
|
||||
["b-persisted"],
|
||||
"the buffer must be reseeded from B's bucket, dropping A's rows",
|
||||
);
|
||||
|
||||
await harness.unmount();
|
||||
});
|
||||
|
||||
// ── legacy key cleanup ───────────────────────────────────────────────────────
|
||||
|
||||
test("mounting removes the orphaned legacy pubkey-only key", async () => {
|
||||
const ls = installFreshStorage();
|
||||
const pubkey = "pk-legacy";
|
||||
const legacyKey = `buzz-thread-activity.v1:${pubkey}`;
|
||||
ls.setItem(legacyKey, JSON.stringify([{ id: "stale" }]));
|
||||
|
||||
const itemsRef = { current: [] };
|
||||
const harness = await mountHook(itemsRef, { pubkey, relay: RELAY });
|
||||
|
||||
assert.equal(
|
||||
ls.getItem(legacyKey),
|
||||
null,
|
||||
"hydration must drop the orphaned legacy key",
|
||||
);
|
||||
|
||||
await harness.unmount();
|
||||
});
|
||||
|
||||
// ── scope fence: never write before a valid scope is loaded ──────────────────
|
||||
|
||||
test("isScopeLoaded is false without an identity and true after hydration", async () => {
|
||||
installFreshStorage();
|
||||
const itemsRef = { current: [] };
|
||||
const harness = await mountHook(itemsRef, { pubkey: null, relay: RELAY });
|
||||
|
||||
assert.equal(
|
||||
harness.api.isScopeLoaded(),
|
||||
false,
|
||||
"an absent pubkey must never pass the scope fence",
|
||||
);
|
||||
|
||||
await harness.render({ pubkey: "pk1", relay: RELAY });
|
||||
assert.equal(
|
||||
harness.api.isScopeLoaded(),
|
||||
true,
|
||||
"a valid scope must pass once its hydration effect commits",
|
||||
);
|
||||
|
||||
await harness.unmount();
|
||||
});
|
||||
|
||||
test("schedule under an empty scope never writes", async () => {
|
||||
const ls = installFreshStorage();
|
||||
const itemsRef = { current: [] };
|
||||
const harness = await mountHook(itemsRef, { pubkey: null, relay: RELAY });
|
||||
|
||||
itemsRef.current = [{ id: "orphan" }];
|
||||
harness.api.schedule(harness.api.currentScope);
|
||||
|
||||
await act(async () => {
|
||||
globalThis.dispatchEvent({ type: "pagehide" });
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
ls.getItem(activityStorageKey("", RELAY)),
|
||||
null,
|
||||
"no write may land when identity is unknown",
|
||||
);
|
||||
|
||||
await harness.unmount();
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
activityScopeKey,
|
||||
flushThreadActivityWrite,
|
||||
readActivityFromStorage,
|
||||
removeLegacyThreadActivityKey,
|
||||
scheduleThreadActivityWrite,
|
||||
type ThreadActivityItem,
|
||||
type ThreadActivityRefs,
|
||||
} from "@/features/channels/threadActivityStorage";
|
||||
|
||||
export type ThreadActivityPersistence = {
|
||||
/** Scope key loaded into the buffer ("" until identity is known). */
|
||||
scopeLoadedRef: React.MutableRefObject<string>;
|
||||
/** Current scope derived from normalized pubkey + relay. */
|
||||
currentScope: string;
|
||||
/**
|
||||
* True only when the hydration effect has committed for the current scope
|
||||
* AND that scope is non-empty. Reads the ref at call time so it is never a
|
||||
* stale snapshot. Use as the write/merge guard: an empty scope must never
|
||||
* pass, or a writer could fire before the first valid scope is seeded.
|
||||
*/
|
||||
isScopeLoaded: () => boolean;
|
||||
/** Arm a coalesced write after mutating the buffer for `scope`. */
|
||||
schedule: (scope: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Manages the thread-activity localStorage persistence layer for
|
||||
* useUnreadChannels: owns the loaded-scope ref, the coalescing timer, the
|
||||
* pagehide/visibility flush, and hydration on identity/relay change. The buffer
|
||||
* itself (`itemsRef`) is owned by the parent and merged in place by its writers;
|
||||
* this hook only decides when the buffer is durably persisted.
|
||||
*
|
||||
* Sibling of useObservedUnreadPersistence — same scope-fence and flush shape,
|
||||
* minus marker-prune/removeChannel/clearAll, which thread activity has no
|
||||
* analog for.
|
||||
*/
|
||||
export function useThreadActivityPersistence(
|
||||
normalizedPubkey: string | null,
|
||||
normalizedRelayUrl: string,
|
||||
itemsRef: React.MutableRefObject<ThreadActivityItem[]>,
|
||||
): ThreadActivityPersistence {
|
||||
const currentScope = activityScopeKey(normalizedPubkey, normalizedRelayUrl);
|
||||
|
||||
const scopeLoadedRef = React.useRef<string>("");
|
||||
const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const persistRefs = React.useRef<ThreadActivityRefs>({
|
||||
itemsRef,
|
||||
scopeLoadedRef,
|
||||
timerRef,
|
||||
});
|
||||
persistRefs.current.itemsRef = itemsRef;
|
||||
|
||||
// pagehide + visibilitychange→hidden: synchronously persist any pending write
|
||||
// before the webview unloads or is backgrounded. Cmd+R and #5588's idle
|
||||
// reload both tear the webview down within the coalescing window; without
|
||||
// these flushes the last burst of replies would be lost.
|
||||
React.useEffect(() => {
|
||||
const refs = persistRefs.current;
|
||||
const flush = () => flushThreadActivityWrite(refs);
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === "hidden") flush();
|
||||
};
|
||||
window.addEventListener("pagehide", flush);
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
return () => {
|
||||
window.removeEventListener("pagehide", flush);
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Hydrate the buffer whenever identity/relay changes. Flush the OLD scope
|
||||
// first so an in-flight coalesced write lands under the old key before the
|
||||
// buffer is clobbered, then drop the orphaned legacy key for this pubkey.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: normalizedRelayUrl is an intentional reset signal alongside normalizedPubkey
|
||||
React.useEffect(() => {
|
||||
flushThreadActivityWrite(persistRefs.current);
|
||||
|
||||
if (normalizedPubkey && normalizedRelayUrl) {
|
||||
removeLegacyThreadActivityKey(normalizedPubkey);
|
||||
itemsRef.current = readActivityFromStorage(
|
||||
normalizedPubkey,
|
||||
normalizedRelayUrl,
|
||||
);
|
||||
} else {
|
||||
itemsRef.current = [];
|
||||
}
|
||||
scopeLoadedRef.current = currentScope;
|
||||
|
||||
// Flush the current scope on unmount / before the next run so a pending
|
||||
// write is never dropped when refs are clobbered.
|
||||
return () => {
|
||||
flushThreadActivityWrite(persistRefs.current);
|
||||
};
|
||||
}, [normalizedPubkey, normalizedRelayUrl]);
|
||||
|
||||
const schedule = React.useCallback(
|
||||
(scope: string) => scheduleThreadActivityWrite(scope, persistRefs.current),
|
||||
[],
|
||||
);
|
||||
|
||||
const isScopeLoaded = React.useCallback(
|
||||
() => currentScope !== "" && scopeLoadedRef.current === currentScope,
|
||||
[currentScope],
|
||||
);
|
||||
|
||||
return React.useMemo(
|
||||
() => ({ scopeLoadedRef, currentScope, isScopeLoaded, schedule }),
|
||||
[currentScope, isScopeLoaded, schedule],
|
||||
);
|
||||
}
|
||||
@@ -38,11 +38,8 @@ import { useStableMap, useStableSet } from "@/shared/hooks/useStableReference";
|
||||
import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage";
|
||||
import { DM_NOTIFIABLE_EVENT_KINDS } from "./isDmNotifiableKind";
|
||||
import {
|
||||
activityScopeKey,
|
||||
addThreadActivityItems,
|
||||
projectActivityForScope,
|
||||
readActivityFromStorage,
|
||||
writeActivityToStorage,
|
||||
type ThreadActivityItem,
|
||||
} from "@/features/channels/threadActivityStorage";
|
||||
export type { ThreadActivityItem } from "@/features/channels/threadActivityStorage";
|
||||
@@ -55,6 +52,7 @@ export {
|
||||
writeActivityToStorage,
|
||||
} from "@/features/channels/threadActivityStorage";
|
||||
import { useObservedUnreadPersistence } from "@/features/channels/useObservedUnreadPersistence";
|
||||
import { useThreadActivityPersistence } from "@/features/channels/useThreadActivityPersistence";
|
||||
|
||||
type UseUnreadChannelsOptions = UseLiveChannelUpdatesOptions & {
|
||||
pubkey?: string;
|
||||
@@ -147,14 +145,6 @@ export function useUnreadChannels(
|
||||
const normalizedRelayUrl = relayUrlOption
|
||||
? normalizeRelayUrl(relayUrlOption)
|
||||
: "";
|
||||
// Single identity for the in-memory thread-activity buffer — computed once
|
||||
// per render and used at reset, both writers, and the return fence. The
|
||||
// helper returns "" when either value is absent, which never matches a valid
|
||||
// loaded scope, so the fence returns [] until the buffer is seeded.
|
||||
const currentActivityScope = activityScopeKey(
|
||||
normalizedPubkey,
|
||||
normalizedRelayUrl,
|
||||
);
|
||||
|
||||
const {
|
||||
getEffectiveTimestamp,
|
||||
@@ -227,12 +217,10 @@ export function useUnreadChannels(
|
||||
mutedChannelIdsRef.current = mutedChannelIdsOption ?? new Set();
|
||||
|
||||
// Thread reply events that triggered notifications — surfaced in the Home
|
||||
// activity feed as synthetic FeedItems.
|
||||
// activity feed as synthetic FeedItems. The buffer is the source of truth
|
||||
// between coalesced writes; useThreadActivityPersistence owns the loaded
|
||||
// scope, the write timer, flush, and hydration.
|
||||
const threadActivityRef = React.useRef<ThreadActivityItem[]>([]);
|
||||
// Tracks the (pubkey:relayUrl) scope currently loaded into threadActivityRef.
|
||||
// Writers guard against this before merging so in-flight writes from a prior
|
||||
// scope cannot corrupt the new one; renders return [] until it matches.
|
||||
const threadActivityScopeRef = React.useRef<string>("");
|
||||
|
||||
// Tracks which channels we've already issued a catch-up REQ for this
|
||||
// session. Prevents re-fetching on every channels-list refetch, while still
|
||||
@@ -266,6 +254,15 @@ export function useUnreadChannels(
|
||||
{ onPruned: bumpLatestVersion },
|
||||
);
|
||||
|
||||
// Thread-activity persistence: coalesced writes, pagehide/visibility flush,
|
||||
// hydration + legacy-key cleanup. Owns the loaded scope for the buffer above.
|
||||
const activityPersistence = useThreadActivityPersistence(
|
||||
normalizedPubkey,
|
||||
normalizedRelayUrl,
|
||||
threadActivityRef,
|
||||
);
|
||||
const currentActivityScope = activityPersistence.currentScope;
|
||||
|
||||
// Reset all in-session state when the identity or relay changes. In-memory
|
||||
// caches are cleared; persisted stores are loaded for the new pubkey (so
|
||||
// forced-unread, participation, etc. are correct for the new identity).
|
||||
@@ -285,11 +282,6 @@ export function useUnreadChannels(
|
||||
? mentionedStore.read(pubkey)
|
||||
: new Set();
|
||||
mutedRootIdsRef.current = pubkey ? mutedStore.read(pubkey) : new Set();
|
||||
threadActivityRef.current =
|
||||
normalizedPubkey && normalizedRelayUrl
|
||||
? readActivityFromStorage(normalizedPubkey, normalizedRelayUrl)
|
||||
: [];
|
||||
threadActivityScopeRef.current = currentActivityScope;
|
||||
bumpLatestVersion();
|
||||
bumpMembershipVersion();
|
||||
}, [pubkey, relayClient, normalizedRelayUrl]);
|
||||
@@ -490,15 +482,10 @@ export function useUnreadChannels(
|
||||
|
||||
const handleThreadReplyNotification = React.useCallback(
|
||||
(channelId: string, event: RelayEvent) => {
|
||||
// Guard: don't merge into a ref whose scope has drifted from the current
|
||||
// identity. Also reject an empty scope — activityScopeKey() returns ""
|
||||
// when pubkey or relay is absent, and "" !== "" is false, so without this
|
||||
// guard a writer could fire before the first valid scope is established.
|
||||
if (
|
||||
!currentActivityScope ||
|
||||
threadActivityScopeRef.current !== currentActivityScope
|
||||
)
|
||||
return;
|
||||
// Guard: don't merge into a buffer whose scope has drifted from the
|
||||
// current identity. isScopeLoaded() also rejects an empty scope, so a
|
||||
// writer can never fire before the first valid scope is seeded.
|
||||
if (!activityPersistence.isScopeLoaded()) return;
|
||||
|
||||
const channelName =
|
||||
channels.find((ch) => ch.id === channelId)?.name ?? "";
|
||||
@@ -516,25 +503,13 @@ export function useUnreadChannels(
|
||||
if (!added.didAdd) return;
|
||||
const didRecordMentionedRoot = recordMentionedRoot(event);
|
||||
threadActivityRef.current = added.items;
|
||||
if (normalizedPubkey !== null && normalizedRelayUrl) {
|
||||
writeActivityToStorage(
|
||||
normalizedPubkey,
|
||||
normalizedRelayUrl,
|
||||
added.items,
|
||||
);
|
||||
}
|
||||
activityPersistence.schedule(currentActivityScope);
|
||||
if (didRecordMentionedRoot) {
|
||||
bumpMembershipVersion();
|
||||
}
|
||||
bumpLatestVersion();
|
||||
},
|
||||
[
|
||||
channels,
|
||||
currentActivityScope,
|
||||
normalizedPubkey,
|
||||
normalizedRelayUrl,
|
||||
recordMentionedRoot,
|
||||
],
|
||||
[channels, currentActivityScope, activityPersistence, recordMentionedRoot],
|
||||
);
|
||||
|
||||
const muteThread = React.useCallback(
|
||||
@@ -785,13 +760,7 @@ export function useUnreadChannels(
|
||||
);
|
||||
if (added.didAdd) {
|
||||
threadActivityRef.current = added.items;
|
||||
if (normalizedPubkey && normalizedRelayUrl) {
|
||||
writeActivityToStorage(
|
||||
normalizedPubkey,
|
||||
normalizedRelayUrl,
|
||||
added.items,
|
||||
);
|
||||
}
|
||||
activityPersistence.schedule(currentActivityScope);
|
||||
didAdvance = true;
|
||||
}
|
||||
}
|
||||
@@ -1009,7 +978,7 @@ export function useUnreadChannels(
|
||||
mentionedRootIds,
|
||||
recordThreadInteraction,
|
||||
threadActivityItems: projectActivityForScope(
|
||||
threadActivityScopeRef.current,
|
||||
activityPersistence.scopeLoadedRef.current,
|
||||
currentActivityScope,
|
||||
threadActivityRef.current,
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user