mirror of
https://github.com/block/buzz.git
synced 2026-10-29 05:21:31 +00:00
Desktop input latency regressed sharply for users on v0.5.9 and worsened on latest main: multi-second stalls when clicking back into the app, slow fresh boots, intermittent lockups, and scroll/mouse degradation. Reverting to `119a84897` (pre-0.5.9) was confirmed to resolve it, isolating the regression to that range. Profiling a live production renderer plus a commit-level audit of the range found three independent, additive causes — fixed here — plus a long-standing `get_channels` cost that made every remaining refetch expensive, also addressed here. ## 1. Focus-return refetch storm (`refetchOnWindowFocus`) #5490 wired TanStack's `focusManager` to app focus and flipped ~20 query sites to `refetchOnWindowFocus: true`. A focus return after >60s away fires them all within milliseconds — and a click into an unfocused window *is* a focus return, so the burst runs before the click is processed. That is the "click into the composer, wait 5 seconds" symptom, and it also explains why mouse input feels worse than keyboard (clicks arrive with focus transitions; typing happens while already focused). A 5-second `sample` of a live production renderer caught a single window activity-state transition consuming ~1.25s of main-thread time, dominated by `JSON.parse` in the focus listener's microtask drain. #5535 already established the fix pattern but applied it to only two families (channels, home-feed). This PR extends the same 5-minute `staleTime` discipline to the remaining families: pulse (×5), workflows (×4), agents (×4), forum (×2), presence, user-status, custom-emoji, channel-templates, and the persona catalog. Polling cadences and push-invalidation paths are untouched — interval refetches and `invalidateQueries` both bypass `staleTime`, so live-update behavior is unchanged. Each gated family exports its focus-refetch policy as an options object that the production hook spreads into `useQuery`, and a `focusRefetchPolicy.test.mjs` drives a `QueryObserver` with that same production object — locking the policy behaviorally (fresh focus return → 0 fetches; stale → refetch) and failing if a hook's `staleTime`/`refetchOnWindowFocus` wiring drifts. Four families deliberately keep tighter freshness, all surfaces where the 5-minute gate would suppress the only refresh path and none of which feed the app-wide storm: `repo-sync-status` keeps its fresh focus refetch (its inline comment documents the "committed in a terminal, switched back to the app" flow as intended); the workflow-runs list stale-gates at 10s because a remotely-started run has no push invalidation and its conditional 1s poll is off while the cache shows no active runs; the workflow list queries (`useChannelWorkflowsQuery` and the all-channels aggregate) stale-gate at 10s because they have no poll and no relay subscription, and mutation-driven invalidation only covers this renderer — remote workflow creates/edits/deletes surface only via focus refetch; and the managed-agent log stale-gates at one poll tick (30s) so returning to a live agent log refreshes immediately. Run approvals keep the 5-minute gate under `RUN_APPROVALS_FOCUS_STALE_TIME_MS` — their focused 10s poll already covers freshness. ## 2. Synchronous localStorage sweep on the boot/focus path #5453's stale-cache sweep synchronously `getItem` + `JSON.parse`s every whitelisted localStorage entry on the main thread (multi-MB on seasoned profiles), scheduled with a `requestIdleCallback` timeout of 1.5s that guaranteed it landed mid-boot, and re-armed on every hidden→visible transition — stacking it onto the exact moment the focus storm fires. #5454's `trimSelfProfileCaches()` additionally scanned every localStorage key on every `writeSelfProfileCache()` call (which fires per relay self-profile delivery at boot). Now: the first sweep waits `BOOT_SWEEP_FLOOR_MS` (30s) after startup, the scan is time-sliced across idle callbacks, and the visibility trigger is removed — boot-delayed plus hourly still covers the 14-day TTL contract. The sliced sweep re-checks staleness immediately before each removal (a key rewritten fresh mid-sweep survives), isolates per-key storage errors so one bad entry can't strand the rest of the snapshot, defers oversized values once rather than parsing them on a zero-budget slice, guarantees forward progress on timeout-fired callbacks, and cancels its scheduled slice when stopped. The profile trim keeps a lazily-initialized memoized key count so the common under-cap write is O(1); the full parse scan runs only when the count exceeds a cap, resyncs if external deletions made it stale, and a failed scan skips the trim instead of aborting the write. Sweep semantics (rules, TTLs, eviction) are unchanged, and tests cover the scheduling, slice-progress, error-isolation, defer-once, and trim short-circuit behaviors. ## 3. The macOS window was never opaque #5478's glass appearance is correctly opt-in at the CSS layer, but the compositor cost was baked in deeper than its native `on_webview_ready` transparency call: the main window is declared `"transparent": true` in `tauri.conf.json` (added for the original glass work in #1671), which makes tao call `NSWindow.setOpaque(false)` at creation and resolve every later `set_background_color(None)` to `clearColor` — and no runtime `setOpaque(true)` path exists through tauri, while wry's runtime background setter can only force the WKWebView's `drawsBackground` off, never back on. So "restore the platform default" was unreachable: every launch, glass or not, ran with a non-opaque NSWindow, defeating WindowServer's opaque-window compositing fast path and forcing full window compositing every frame — compounded by the existing `backdrop-blur` chrome overlapping the scrolling timeline. This matches the compositor-shaped symptoms (scroll and pointer input degrading first). The window is now created opaque (`"transparent": false`) and the NSWindow layer is never made transparent at runtime. Glass never needed a transparent window: behind-window `NSVisualEffectView` vibrancy renders inside opaque windows (this is how Finder and Notes draw vibrant sidebars); it only requires a transparent WKWebView canvas, which the `set_window_vibrancy` enable path already establishes at runtime (`macos-private-api` compiles that in independent of the window flag). Enabling glass installs the vibrancy layer and then makes only the webview canvas see-through; disabling clears the vibrancy layer — the canvas may stay non-drawing afterwards (wry's flag is one-way at runtime), which is harmless because glass-off CSS paints fully opaque above an always-opaque NSWindow. The boot-path first-frame backing writes touch only the NSWindow backing color and are therefore inert to glass state regardless of how they order against the `ThemeProvider`'s vibrancy call on a persisted-glass-on cold boot. Glass-off users (the default) get an end-to-end opaque window from boot for the first time. ## 4. `get_channels`: serial round-trips and a multi-MB payload on every refetch The stale gates in (1) cut refetch frequency; this cuts the cost of the refetches that legitimately remain (boot, and focus returns after more than 5 minutes away — previously still a multi-second stall). `get_channels` made ~8 fully serial relay round-trips (~3.2–3.6s at 1,100+ channels), then shipped the full `ChannelInfo` list — including every channel's member pubkeys — across IPC, where the renderer's `JSON.parse` of the multi-MB payload froze the main thread (the ~1.25s stall captured in the live sample). - **Concurrent stages**: the membership chain, the open-channel directory scan, and the hidden-DM snapshot run concurrently, as do the member-count and last-message queries that follow. The critical path drops from ~8 sequential round-trips to 2 phases. Filters, limits, pagination, and merge semantics are unchanged. - **Not-modified short-circuit**: the command now takes a client-supplied content hash (FNV-1a 64 over the channel list, canonicalized by id and excluding `last_message_at`) and omits the channel list from the response when nothing else changed. Last-message timestamps — which change on nearly every message anywhere — ship as a small separate map that the client overlays onto its cached list with reference preservation, so React Query's structural sharing also skips downstream re-renders. On a typical refocus the renderer parses kilobytes instead of megabytes. The hash is stored in the query cache itself, tying its lifecycle to the data it describes so a community switch can never leak a stale hash. The E2E mock bridge speaks the new payload shape — including the complete `last_messages` map the client treats as authoritative — and hash canonicalization plus overlay reference-preservation are unit-tested on both sides. --------- Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
357 lines
13 KiB
TypeScript
357 lines
13 KiB
TypeScript
/**
|
|
* Best-effort time-based cleanup for disposable localStorage caches.
|
|
*
|
|
* Only explicitly whitelisted cache namespaces are eligible. Durable state
|
|
* such as identities, communities, read positions, onboarding, and preferences
|
|
* must never be added here.
|
|
*/
|
|
|
|
const DAY_MS = 24 * 60 * 60 * 1_000;
|
|
const SWEEP_INTERVAL_MS = 60 * 60 * 1_000;
|
|
/** Minimum time after app boot before the first sweep begins. */
|
|
export const BOOT_SWEEP_FLOOR_MS = 30_000;
|
|
/**
|
|
* Generous idle-callback ceiling. Unlike the original 1 500 ms value this is
|
|
* not a forcing timeout that lands during busy startup — the browser schedules
|
|
* the callback when the main thread is genuinely idle.
|
|
*/
|
|
export const SWEEP_IDLE_TIMEOUT_MS = 60_000;
|
|
/** Per-slice setTimeout delay in environments without requestIdleCallback. */
|
|
export const SWEEP_FALLBACK_TIMER_MS = 250;
|
|
/**
|
|
* Maximum number of localStorage entries to parse per chunk when an
|
|
* IdleDeadline is unavailable (rIC-free fallback path).
|
|
*/
|
|
export const SWEEP_CHUNK_ENTRY_BUDGET = 20;
|
|
/**
|
|
* Values larger than this threshold are deferred to the next slice when the
|
|
* idle deadline has already expired (timeRemaining() <= 0) and the key has
|
|
* not been previously deferred. Each key is deferred at most once, guaranteeing
|
|
* convergence: every slice either parses ≥1 entry or grows deferredKeys by
|
|
* exactly one (a set bounded by the snapshot size). Both quantities are finite,
|
|
* so the sweep always terminates.
|
|
*/
|
|
export const SWEEP_LARGE_VALUE_DEFER_BYTES = 128 * 1024;
|
|
|
|
type LocalStorageSweepRule = {
|
|
keyPrefix: string;
|
|
maxAgeMs: number;
|
|
};
|
|
|
|
/** Disposable cache namespaces and their maximum idle age. */
|
|
export const LOCAL_STORAGE_SWEEP_RULES: readonly LocalStorageSweepRule[] = [
|
|
{ keyPrefix: "buzz-channel-messages.v1:", maxAgeMs: 14 * DAY_MS },
|
|
{ keyPrefix: "buzz-channels.v1:", maxAgeMs: 14 * DAY_MS },
|
|
{ keyPrefix: "buzz-observed-unread.v1:", maxAgeMs: 14 * DAY_MS },
|
|
{ keyPrefix: "buzz-sidebar-skeleton-shape.v1:", maxAgeMs: 14 * DAY_MS },
|
|
{ keyPrefix: "buzz-timeline-skeleton-shape.v1:", maxAgeMs: 14 * DAY_MS },
|
|
{ keyPrefix: "buzz-user-labels.v1:", maxAgeMs: 14 * DAY_MS },
|
|
// Do not add buzz-self-profile.v1: here. It is the load-bearing offline
|
|
// identity fallback when the relay is unreachable, not a repaintable cache.
|
|
];
|
|
|
|
function updatedAtFromJson(value: string): number | null {
|
|
try {
|
|
const parsed = JSON.parse(value) as unknown;
|
|
if (typeof parsed !== "object" || parsed === null) return null;
|
|
const record = parsed as Record<string, unknown>;
|
|
if (
|
|
typeof record.updatedAt === "number" &&
|
|
Number.isFinite(record.updatedAt)
|
|
) {
|
|
return record.updatedAt;
|
|
}
|
|
|
|
// User-label cache buckets carry freshness per profile instead of at the
|
|
// payload root. Use the newest valid label timestamp so the bucket is only
|
|
// removed once every label in it is stale.
|
|
if (typeof record.profiles !== "object" || record.profiles === null) {
|
|
return null;
|
|
}
|
|
let newestUpdatedAt: number | null = null;
|
|
for (const profile of Object.values(record.profiles)) {
|
|
if (typeof profile !== "object" || profile === null) continue;
|
|
const updatedAt = (profile as Record<string, unknown>).updatedAt;
|
|
if (
|
|
typeof updatedAt === "number" &&
|
|
Number.isFinite(updatedAt) &&
|
|
(newestUpdatedAt === null || updatedAt > newestUpdatedAt)
|
|
) {
|
|
newestUpdatedAt = updatedAt;
|
|
}
|
|
}
|
|
return newestUpdatedAt;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Returns true when the entry is older than its rule TTL at `now`. */
|
|
function isStale(
|
|
updatedAt: number | null,
|
|
rule: LocalStorageSweepRule,
|
|
now: number,
|
|
): boolean {
|
|
return updatedAt !== null && updatedAt <= now - rule.maxAgeMs;
|
|
}
|
|
|
|
/**
|
|
* Removes whitelisted cache entries older than their configured TTL.
|
|
* Entries without a trustworthy `updatedAt` are left alone rather than guessed
|
|
* stale. Storage and parse failures never escape into app startup.
|
|
*
|
|
* This synchronous form is preserved for direct test use. The scheduler calls
|
|
* sweepChunked instead, which splits the same work across idle-time slices.
|
|
*/
|
|
export function sweepStaleLocalStorage(now = Date.now()): number {
|
|
let removed = 0;
|
|
try {
|
|
const storage = window.localStorage;
|
|
const staleKeys: string[] = [];
|
|
|
|
for (let i = 0; i < storage.length; i++) {
|
|
const key = storage.key(i);
|
|
if (key === null) continue;
|
|
const rule = LOCAL_STORAGE_SWEEP_RULES.find(({ keyPrefix }) =>
|
|
key.startsWith(keyPrefix),
|
|
);
|
|
if (!rule) continue;
|
|
|
|
const value = storage.getItem(key);
|
|
if (value === null) continue;
|
|
const updatedAt = updatedAtFromJson(value);
|
|
if (isStale(updatedAt, rule, now)) {
|
|
staleKeys.push(key);
|
|
}
|
|
}
|
|
|
|
// Collect before mutating because localStorage indexes shift on removal.
|
|
for (const key of staleKeys) {
|
|
storage.removeItem(key);
|
|
removed++;
|
|
}
|
|
} catch (error) {
|
|
console.warn("[localStorageSweep] stale cache cleanup failed:", error);
|
|
}
|
|
return removed;
|
|
}
|
|
|
|
type SliceCallback = (deadline?: { timeRemaining(): number }) => void;
|
|
|
|
/**
|
|
* Snapshots matching key names synchronously (cheap — no parsing), then
|
|
* processes entries across successive idle-time slices. Keys removed between
|
|
* the snapshot and their slice are silently skipped via a re-getItem check.
|
|
*
|
|
* `isAlive` is checked at the start of each slice; returning false aborts
|
|
* further processing without removing accumulated stale keys.
|
|
*
|
|
* Returns a cancel function that cancels any pending scheduled-slice handle.
|
|
* The `isAlive` flag remains the correctness backstop even after cancellation.
|
|
*/
|
|
function sweepChunked(now: number, isAlive: () => boolean): () => void {
|
|
type SliceHandle =
|
|
| { kind: "idle"; id: number }
|
|
| { kind: "timeout"; id: ReturnType<typeof globalThis.setTimeout> };
|
|
|
|
let currentHandle: SliceHandle | null = null;
|
|
|
|
function scheduleSlice(fn: SliceCallback): void {
|
|
if ("requestIdleCallback" in window) {
|
|
const id = window.requestIdleCallback(fn as IdleRequestCallback, {
|
|
timeout: SWEEP_IDLE_TIMEOUT_MS,
|
|
});
|
|
currentHandle = { kind: "idle", id };
|
|
} else {
|
|
const id = globalThis.setTimeout(fn, SWEEP_FALLBACK_TIMER_MS);
|
|
currentHandle = { kind: "timeout", id };
|
|
}
|
|
}
|
|
|
|
function cancelHandle(): void {
|
|
if (currentHandle === null) return;
|
|
try {
|
|
if (currentHandle.kind === "idle") {
|
|
window.cancelIdleCallback(currentHandle.id);
|
|
} else {
|
|
globalThis.clearTimeout(currentHandle.id);
|
|
}
|
|
} catch {
|
|
// Non-fatal: handle may already be consumed.
|
|
}
|
|
currentHandle = null;
|
|
}
|
|
|
|
// Step 1: cheap key-only snapshot — no getItem, no parsing.
|
|
let pendingKeys: string[];
|
|
try {
|
|
const storage = window.localStorage;
|
|
pendingKeys = [];
|
|
for (let i = 0; i < storage.length; i++) {
|
|
const key = storage.key(i);
|
|
if (!key) continue;
|
|
if (LOCAL_STORAGE_SWEEP_RULES.some((r) => key.startsWith(r.keyPrefix))) {
|
|
pendingKeys.push(key);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.warn("[localStorageSweep] key snapshot failed:", err);
|
|
return cancelHandle;
|
|
}
|
|
|
|
if (!pendingKeys.length) return cancelHandle;
|
|
|
|
// Keys judged stale during slices, accumulated for batch removal at the end.
|
|
// If the sweep is stopped mid-flight (alive=false), these are intentionally
|
|
// dropped — the next hourly sweep will re-detect them.
|
|
const staleKeys: string[] = [];
|
|
// Keys that have been deferred once due to large value + zero budget;
|
|
// a key in this set is always parsed on its second encounter regardless of budget.
|
|
const deferredKeys = new Set<string>();
|
|
let pos = 0;
|
|
let warnedThisSweep = false;
|
|
|
|
// Step 2: parse entries in idle-time slices; reschedule until all done.
|
|
const processSlice: SliceCallback = (deadline) => {
|
|
// Consume the handle — we are now inside the callback, so it has fired.
|
|
currentHandle = null;
|
|
|
|
if (!isAlive()) return;
|
|
|
|
try {
|
|
const storage = window.localStorage;
|
|
const sliceStart = pos;
|
|
|
|
while (pos < pendingKeys.length) {
|
|
// Always process at least one entry per slice: a timeout-fired idle
|
|
// callback reports timeRemaining() === 0, and breaking before any
|
|
// progress would reschedule forever on a persistently busy thread.
|
|
const processedInSlice = pos - sliceStart;
|
|
if (deadline && processedInSlice > 0 && deadline.timeRemaining() <= 0) {
|
|
break;
|
|
}
|
|
if (!deadline && processedInSlice >= SWEEP_CHUNK_ENTRY_BUDGET) break;
|
|
|
|
const key = pendingKeys[pos++];
|
|
|
|
try {
|
|
const value = storage.getItem(key);
|
|
if (value === null) continue; // deleted since snapshot — skip
|
|
|
|
// Defer-once: on a zero-budget slice, avoid parsing large values
|
|
// that would cause a jank frame. Move the key to the back of the
|
|
// queue and mark it deferred; on second encounter parse regardless.
|
|
// Invariant: each slice either parses ≥1 entry or grows deferredKeys
|
|
// by exactly one — both are bounded by pendingKeys.length, so the
|
|
// sweep always converges.
|
|
if (
|
|
deadline &&
|
|
deadline.timeRemaining() <= 0 &&
|
|
value.length > SWEEP_LARGE_VALUE_DEFER_BYTES &&
|
|
!deferredKeys.has(key)
|
|
) {
|
|
pendingKeys.push(key); // move to back of queue
|
|
deferredKeys.add(key); // will not be deferred a second time
|
|
break; // reschedule; deferredKeys grew, so progress was made
|
|
}
|
|
|
|
const rule = LOCAL_STORAGE_SWEEP_RULES.find((r) =>
|
|
key.startsWith(r.keyPrefix),
|
|
);
|
|
if (!rule) continue;
|
|
const updatedAt = updatedAtFromJson(value);
|
|
if (isStale(updatedAt, rule, now)) {
|
|
staleKeys.push(key);
|
|
}
|
|
} catch (err) {
|
|
// Per-key errors skip the entry; remaining keys are still processed.
|
|
// Log at most once per sweep to avoid flooding on a corrupt store.
|
|
if (!warnedThisSweep) {
|
|
console.warn(
|
|
"[localStorageSweep] key read failed, continuing sweep:",
|
|
err,
|
|
);
|
|
warnedThisSweep = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (pos < pendingKeys.length) {
|
|
scheduleSlice(processSlice);
|
|
return;
|
|
}
|
|
|
|
// All entries processed — batch-remove stale keys.
|
|
// Re-check each key before removing: app code may have rewritten it
|
|
// fresh between its slice judgment and this final step.
|
|
for (const key of staleKeys) {
|
|
try {
|
|
const currentValue = storage.getItem(key);
|
|
if (currentValue === null) continue; // already gone
|
|
const rule = LOCAL_STORAGE_SWEEP_RULES.find((r) =>
|
|
key.startsWith(r.keyPrefix),
|
|
);
|
|
if (!rule) continue;
|
|
const currentUpdatedAt = updatedAtFromJson(currentValue);
|
|
if (!isStale(currentUpdatedAt, rule, now)) continue; // rewritten fresh
|
|
storage.removeItem(key);
|
|
} catch {
|
|
// Non-fatal: key may have been concurrently removed by another tab.
|
|
}
|
|
}
|
|
} catch (err) {
|
|
// Last-resort catch: reached only if localStorage itself becomes
|
|
// unavailable mid-slice (e.g. SecurityError). Stale keys accumulated so
|
|
// far are dropped; the next hourly sweep re-detects them.
|
|
console.warn("[localStorageSweep] slice aborted unexpectedly:", err);
|
|
}
|
|
};
|
|
|
|
scheduleSlice(processSlice);
|
|
|
|
return cancelHandle;
|
|
}
|
|
|
|
/**
|
|
* Schedules background sweeps:
|
|
* - First sweep is delayed by BOOT_SWEEP_FLOOR_MS (30 s) so it never lands
|
|
* on the startup critical path. After the floor the browser picks the
|
|
* actual execution time (no forcing timeout).
|
|
* - Subsequent sweeps run hourly via setInterval.
|
|
* - The hidden→visible trigger has been removed. It stacked the sweep onto
|
|
* the exact moment focus-refetch storms fire. Hourly + boot-delayed covers
|
|
* the TTL contract — all rule TTLs are 14 days.
|
|
*
|
|
* Returns a cleanup function for tests or future teardown.
|
|
*/
|
|
export function startLocalStorageSweep(): () => void {
|
|
let alive = true;
|
|
let intervalId: ReturnType<typeof window.setInterval> | null = null;
|
|
let bootTimeoutId: ReturnType<typeof globalThis.setTimeout> | null = null;
|
|
let cancelCurrentSweep: (() => void) | null = null;
|
|
|
|
const runSweep = () => {
|
|
cancelCurrentSweep?.();
|
|
cancelCurrentSweep = sweepChunked(Date.now(), () => alive);
|
|
};
|
|
|
|
try {
|
|
bootTimeoutId = globalThis.setTimeout(runSweep, BOOT_SWEEP_FLOOR_MS);
|
|
intervalId = window.setInterval(runSweep, SWEEP_INTERVAL_MS);
|
|
} catch (error) {
|
|
console.warn("[localStorageSweep] scheduler setup failed:", error);
|
|
}
|
|
|
|
return () => {
|
|
alive = false;
|
|
try {
|
|
if (bootTimeoutId !== null) globalThis.clearTimeout(bootTimeoutId);
|
|
if (intervalId !== null) window.clearInterval(intervalId);
|
|
cancelCurrentSweep?.();
|
|
cancelCurrentSweep = null;
|
|
} catch (error) {
|
|
console.warn("[localStorageSweep] scheduler cleanup failed:", error);
|
|
}
|
|
};
|
|
}
|