mirror of
https://github.com/block/buzz.git
synced 2026-11-10 11:21:05 +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>
209 lines
6.8 KiB
TypeScript
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);
|
|
}
|