Files
buzz/desktop/src/features/channels/threadActivityStorage.ts
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

209 lines
6.8 KiB
TypeScript

import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage";
export type ThreadActivityItem = {
id: string;
kind: number;
pubkey: string;
content: string;
createdAt: number;
channelId: string;
channelName: string;
tags: string[][];
};
const ACTIVITY_STORAGE_PREFIX = "buzz-thread-activity.v1";
const MAX_ACTIVITY_ITEMS = 100;
// Scoped to relay+pubkey. The legacy pubkey-only key is intentionally not read
// — rows from an unknown relay cannot be safely attributed.
export function activityStorageKey(pubkey: string, relayUrl: string): string {
return `${ACTIVITY_STORAGE_PREFIX}:${normalizeRelayUrl(relayUrl)}:${pubkey}`;
}
/**
* Stable identity string for the in-memory thread-activity buffer.
* A single definition avoids the `${pubkey}:${relay}` expression being
* repeated at reset, both writers, and the render fence.
* Returns `""` when either value is absent — the empty string never matches a
* valid loaded scope, so the fence returns `[]` until the buffer is seeded.
*/
export function activityScopeKey(
pubkey: string | null,
relayUrl: string,
): string {
if (!pubkey || !relayUrl) return "";
return `${pubkey}:${normalizeRelayUrl(relayUrl)}`;
}
/**
* Pure projection helper used at the hook return and in tests.
*
* Returns `items` only when both scopes are non-empty and identical.
* An empty `currentScope` (absent pubkey or relay) is always rejected —
* `activityScopeKey()` returns `""` in that case, and `""` must never
* compare-equal to a valid loaded scope even if the ref was initialised to
* `""` before the first reset effect commits.
*/
export function projectActivityForScope(
loadedScope: string,
currentScope: string,
items: ThreadActivityItem[],
): ThreadActivityItem[] {
if (!currentScope || loadedScope !== currentScope) return [];
return items;
}
export function readActivityFromStorage(
pubkey: string,
relayUrl: string,
): ThreadActivityItem[] {
try {
const raw = window.localStorage.getItem(
activityStorageKey(pubkey, relayUrl),
);
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter(
(item): item is ThreadActivityItem =>
typeof item === "object" &&
item !== null &&
typeof item.id === "string",
);
} catch {
return [];
}
}
export function writeActivityToStorage(
pubkey: string,
relayUrl: string,
items: ThreadActivityItem[],
): void {
try {
const capped =
items.length > MAX_ACTIVITY_ITEMS
? items.slice(items.length - MAX_ACTIVITY_ITEMS)
: items;
window.localStorage.setItem(
activityStorageKey(pubkey, relayUrl),
JSON.stringify(capped),
);
} catch {
// Ignore storage errors.
}
}
export function addThreadActivityItems(
existing: ThreadActivityItem[],
items: ThreadActivityItem[],
) {
if (items.length === 0) {
return { didAdd: false, items: existing };
}
const existingIds = new Set(existing.map((item) => item.id));
const newItems = items.filter((item) => !existingIds.has(item.id));
if (newItems.length === 0) {
return { didAdd: false, items: existing };
}
const merged = [...existing, ...newItems].sort(
(left, right) => left.createdAt - right.createdAt,
);
const capped =
merged.length > MAX_ACTIVITY_ITEMS
? merged.slice(merged.length - MAX_ACTIVITY_ITEMS)
: merged;
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);
}