perf(desktop): GUI performance sweep — async offload, poll reduction, render stabilization#1641
Conversation
Finding [L5] AppShell.tsx:161-164 — 4 archive/ingestion hooks mounted unconditionally at first render competed with first paint. Gate the two archive *seed* hooks (useObserverArchiveSeed, useAgentMetricArchiveSeed) behind startupReady by passing deferredPubkey instead of the eager identityQuery pubkey, matching the existing presence/user-status deferral pattern. useArchiveSync (the eager live-save path) stays unchanged — it owns live event ingestion and must not be deferred. Only the one-shot first-run seeds (mergeSaveSubscriptionKinds) move off the boot critical path. The seeds' explicit-choice guard is untouched: each hook already early-returns on `if (!pubkey) return`, so a deferred (undefined) pubkey is a no-op until startup completes, then the seed fires exactly once with identical semantics. Confirmed by useObserverArchiveSeed/ useAgentMetricArchiveSeed test suites (18/18), incl. test_undefined_pubkey_does_nothing and test_explicit_choice_set_does_not_reseed. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Finding: L7 search dialog-open kind:10100 pull.\n\nKeep message/user search behavior the same, but defer managed-agent and relay-agent queries until the debounced query reaches the existing minimum search length. Opening and closing the dialog without typing no longer triggers agent discovery/network work. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Finding: L7 pulse fixed-interval refetches.\n\nGate Pulse timeline/reaction refetch intervals on document visibility so the visible tab stays fresh while hidden windows stop issuing periodic social API requests. Initial query enablement and invalidation behavior are unchanged. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Finding: L7 huddle frontend poll reductions.\n\nKeep event-driven huddle state updates and immediate initial checks, but slow the fallback timers that hit sync/lightweight IPC and duplicate membership refresh paths. This reduces steady-state huddle IPC/relay traffic without touching Rust huddle ownership. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
…rm re-renders Finding [L4] ChannelScreen.tsx messageProfiles (Tier 1): the users-batch query re-keys on the full sorted pubkey set, so typing churn (a transient typing-only pubkey entering/leaving the set) produces a fresh lookup object identity even when no profile value changed. That new reference fails MessageRow's `prev.profiles === next.profiles` memo check and re-renders the entire timeline on every keystroke-adjacent typing event. Add `profileLookupsEqual(a, b)` — a by-value deep-equal over the 5 scalar summary fields (displayName/avatarUrl/nip05Handle/ownerPubkey/isAgent) — and use a useRef stabiliser at the ChannelScreen boundary to return the previous reference when the re-derived lookup is value-equal. Consumers read profiles by pubkey value only and never treat identity as a change signal, so returning the stale-but-value-identical reference is safe (traced c6237ef8). The stabilisation does O(profiles) equality work once at the boundary, replacing O(rows x profiles) of downstream per-row re-render. Also derive `agentPubkeys` from the stabilised lookup (was raw query data) so that Set only churns on a real profile change, demoting the downstream row scans that keyed off it. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
…rom parent Finding [L4] MessageRow resolvedAgentPubkeys (Tier 2): every mounted row re-scanned the full `profiles` lookup to rebuild the agent-pubkey Set, so the same O(profiles) work ran once per row and re-ran on every profile-lookup change. ChannelScreen already computes this exact Set once (now from the stabilised lookup) and passes it down as `agentPubkeys`, already normalised. Consume the passed Set directly, falling back to a module-level stable empty Set so rows without one keep a constant reference (a fresh `new Set()` per render would defeat the row memo). Widen `getConfigNudgeAuthorPubkey`'s parameter to `ReadonlySet<string>` — it only reads via `.has()`, and this matches how the `agentPubkeys` prop is already typed upstream. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
…n provider Finding [L4] MessageRow channelNames (Tier 3): every row ran `channels.filter(non-dm).map(name)` in its own useMemo, so the identical derivation was recomputed once per mounted row and re-ran whenever the channel list changed. Compute it once in `ChannelNavigationProvider` as `nonDmChannelNames` (memoised on `[channels]`) and have rows read it directly. Additive to the context value — `channels` is unchanged, so other consumers are unaffected. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
…callback Finding [L4] MessageActionBar (Tier 3): the action bar re-rendered with every row render even when its props were unchanged. Wrap it in `React.memo`. The memo was dead on arrival, though — the row passed an inline `onRemindLater` arrow, a fresh function identity every render that defeated the shallow prop compare. Extract it to a `handleRemindLater` useCallback (deps [channelId, openReminder]); all other props are already stable (openReminder and handleReactionSelect are useCallbacks, setBadgeBurstEmoji is a state setter), so the memo now actually skips. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
…iser Finding [L4] messageProfiles structural-share (Tier 1) — red/green evidence. `profileLookupsEqual` cases: reference identity, distinct-but-value-equal, key-count / key-set mismatch, each of the 5 summary fields breaking equality, empty lookups. Plus a render-count-discipline pair that replays the exact ChannelScreen ref idiom: value-equal re-derives hold the same reference (the memo skips — no timeline re-render on typing churn) and a real profile change swaps it (the memo fires), then re-stabilises around the new value. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Finding: L2 archive read/save-subscription/archive_events sqlite work could block the GUI IPC path or async workers under busy_timeout contention. Move archive planning, commits, save-subscription merge/remove/list, and paginated reads through spawn_blocking with each rusqlite connection scoped inside the blocking closure. Relay query awaits remain between DB phases with no connection or transaction in scope. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Finding: L2 event_sync boot reconcile did best-effort JSON reads, SQLite retention checks/writes, and event signing synchronously during Tauri setup after identity resolution. Snapshot the resolved owner keys, then spawn the reconcile after setup-critical boot work begins. The reconcile runs on spawn_blocking so no filesystem, SQLite, or signing work occupies the GUI setup path or an async worker. Secret-store identity resolution remains synchronous and out of scope by design. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Finding: L2 huddle device enumeration used a sync command on provider mount/devicechange, and start/join performed sequential membership fetches plus synchronous STT/TTS ONNX construction on async workers. Make audio output enumeration an async command backed by spawn_blocking, fetch bot/all memberships in parallel during post-connect setup, and construct STT/TTS pipelines on the blocking pool while preserving starting sentinels, stale-session checks, and final phase checks. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Finding L3-1: reconnect replay previously awaited every live subscription REQ and paged history replay serially, making reconnect latency grow with active subscription count. Start live REQs together, then replay paged channel catch-up with a bounded cap while preserving ordering inside each subscription's page loop. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Finding L3-3: fetchChunkedAuxEvents awaited each auxiliary-event chunk sequentially, delaying visible reaction/edit/deletion backfills on large history windows. Run aux chunks through a small shared concurrency helper so relay history requests overlap without unbounded fanout. The helper preserves chunk result order and the existing fail-fast semantics. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Finding L3-2: auto-heal and manual reconnect used broad queryClient.invalidateQueries(), refetching unrelated Tauri/disk/local queries after relay recovery. Route both reconnect paths through a shared relay-dependent query predicate. The taxonomy includes relay-backed messages/channels/profiles/social/workflows/projects state and excludes local-only agent/runtime/workspace-icon/repo queries. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
…nded L6 finding: useRelayAgentsQuery ran a 30s refetchInterval backed by an unfiltered relay query for ALL kind:10100 agent profiles, mounted on ~13 always-live surfaces (channel screen, members bar, mentions, sidebar, profile popovers). It re-pulled the full profile set app-wide every 30s. Relay agent profiles are near-static; 30s is far too aggressive. This poll is also the ONLY refresh path for kind:10100 (the agents-data-changed event fires only for local PERSONA/TEAM/MANAGED_AGENT reconcile, never kind:10100), so it cannot be dropped. Relaxing to 5min drops relay traffic ~10x, and refetchIntervalInBackground:false (matches channels/hooks.ts house style) pauses it while the window is unfocused. No semantics change: the set of relay agents still refreshes, just less often. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
L6 finding: usePersonasQuery, useTeamsQuery, and useManagedAgentsQuery's idle branch each ran a 30s refetchInterval that duplicated an existing event-driven refresh path. Inbound relay changes to PERSONA/TEAM/MANAGED_AGENT records emit agents-data-changed (personas/mod.rs), which useAgentsDataRefresh coalesces into a query invalidate (200ms window). The 30s poll was belt-and-suspenders disk-read IPC on top of correct event invalidation. Removed the interval from personas and teams entirely. For managed agents, kept the 5s branch for locally-running agents (process state can change with no relay event, so that poll is their only liveness signal) and dropped the idle 30s fallback (control-plane changes have the event path; the 5s branch already captures the running->stopped transition on its last poll, and user actions invalidate directly). Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
L6 finding: WorkflowsView's allWorkflowsQuery issued one get_channel_workflows relay POST per member channel (Promise.all fanout) — N round-trips scaling with channel count on the Workflows overview. A nostr #h filter matches ANY listed value, so one query with all channel ids returns the identical set. Added get_channels_workflows(channel_ids) which queries once; each WorkflowWire already carries its own channel_id (from the event h tag), so WorkflowsView groups results client-side via a channelId->name map. Neither the per-channel nor the batched command sets a limit, so batching does not change result completeness (review-bar #3: no shared-limit truncation). e2eBridge gains a matching get_channels_workflows mock so overview e2e still resolves. Single-channel get_channel_workflows stays for useChannelWorkflowsQuery. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Finding [L1] commands/** — several Tauri commands still performed filesystem, process discovery, sqlite/local-storage reads, zip/json parsing, managed-agent store work, and workspace symlink updates synchronously on the command thread. Convert the affected commands to async command handlers with explicit spawn_blocking around the blocking sections so the UI thread is not responsible for those operations. Reacquire AppState from the owned AppHandle inside blocking closures instead of moving borrowed State<'_, AppState> or non-Send guards across await points. Keep existing store mutex serialization inside the blocking closures and preserve command-specific ordering: identity import persists before swapping in-memory keys, workspace apply validates before mutation and persists the effective repos dir before symlink updates, and repos-dir-error emissions still use the AppHandle. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
wesbillman
left a comment
There was a problem hiding this comment.
Reviewed on behalf of Wes's crew — full diff read lane by lane against tip 77bd0e70, worktree build of the branch, CI green across the board (Desktop Core/Build/E2E Relay, 4× Smoke E2E, 2× E2E Integration, Rust Lint, Windows Rust).
Verified in the code:
- All 13 sync→async command conversions follow the same safe shape:
spawn_blockingclosure,std::sync::Mutexlocks acquired and released inside the closure, no lock orrusqlite::Connectioncrossing an.await. Archive commands share onerun_archive_db_taskhelper; the archive DB is WAL + 5sbusy_timeout, so cross-thread opens are safe.import_identityeven gains a correct persist-before-swap ordering. spawn_event_syncboot deferral: keys snapshotted before spawn; retention writes are last-write-wins upserts under the same store-lock discipline, so racing an inboundreconcile_inbound_persona_eventis benign.profileLookupsEqualcompares all fiveUserProfileSummaryfields — no stale-render hole — and bothMessageRowcall sites receiveagentPubkeysfrom parents that still derive it, so the per-row rescan removal loses nothing.- Poll removals are genuinely event-covered: both backend
agents-data-changedemit sites + the coalescing listener handle personas/teams; relay-agents correctly keeps its (relaxed) poll as the only kind:10100 refresh path; the managed-agents idle→falsebranch is sound because running→exited can only happen while the 5s running-branch poll is live. - Reconnect replay: per-subscription page order preserved (sequential paging inside a sub, concurrency 4 across subs),
isActivere-checked after each await; the relay-only invalidation predicate has include/exclude tests.
One non-blocking note — workflows #h batch truncation semantics: the comment says "no limit on either path → no truncation change," but the relay applies MAX_HISTORICAL_LIMIT = 2000 as the default when a filter carries no limit (crates/buzz-relay/src/handlers/req.rs). So the practical budget went from 2000 per channel to 2000 pooled across all channels. Unreachable for kind 30620 volumes today, and follow-up #4 already names this exact trap for the projects batch — but a one-line comment correction at get_channels_workflows would keep the next reader from copying the "no limit = no truncation" reasoning onto a high-volume kind.
Also flagging as a deliberate, user-visible trade (no change requested): relay-agents 30s→5min means a newly registered relay agent can take up to 5 minutes to appear in mention/member surfaces until follow-up #3's since-cursor lands.
Approving. Great discipline on one-fix-per-commit — it made a 44-file perf sweep genuinely reviewable.
Summary
Team-wide sweep of the desktop GUI for (a) sync work on the macOS main thread (beachball risk), (b) redundant network/draw calls, (c) perceived latency. 7 sweep lanes → ranked findings → fix package on
eva/gui-perf-fixesoff base5dce80af, one fix per commit, tip77bd0e70.Mechanism (Wren-verified): sync
#[tauri::command]runs on the macOS main thread;(async)alone only moves work to Tokio workers. Fix law applied throughout:asynccommand + explicitspawn_blocking; norusqlite::Connection/locks across.await.Fixes by lane
startupReady— seed delayed, never dropped (test_undefined_pubkey_does_nothing,test_explicit_choice_set_does_not_reseed).messageProfilesreference stabilization kills typing-storm timeline re-renders (render-count-discipline test pair: value-equal re-derive holds the ref → memo skips; real profile change swaps it → memo fires). Plus per-row agent-pubkey rescan removal, non-DM channel-name filter hoist, MessageActionBar memoization.agents-data-changedcovers them; managed running-5s branch kept — process liveness has no event); workflows overview N-per-channel → single#h-batched query (no limit on either path → no truncation change).spawn_blocking; event sync deferred off setup path; huddle device/model setup offloaded + membership fetches parallelized (TOCTOU sentinels preserved).REQs parallel; paged history bounded at concurrency 4 preserving per-subscription page order); aux backfill concurrency cap; relay-only invalidation taxonomy.commands/**— blocking filesystem/sqlite/CLI/store-mutex work behind explicitspawn_blocking.Verification
cargo check+ full src-tauri suite at every merge tip. Final tip77bd0e70: node tests 2047/2047, Rust 1041 passed / 11 ignored (+ rodio 3/3),tsc --noEmitclean, vite build clean.77bd0e70: PASS, no blockers. Re-checked: no locks/DB connections across.awaitin reviewed paths; huddle shutdown no longer joins workers under the mutex; deferred startup keeps live-ingestion/agent-observer coverage; reconnect replay ordering; workflows#hbatch preserves channel attribution.77bd0e70(519 specs vs local relay): 490 passed initially; 3 flaky (pass on rerun); 2 failures reproduce at base5dce80af(pre-existing: channels.spec.ts:876 intro-actions, custom-emoji.spec.ts:375); 3 integration failures were relay-env (relay started withoutBUZZ_RECONCILE_CHANNELS=true, so SQL-seeded channels had no kind:39000 discovery events) — with the correct env all 3 pass. Zero failures attributable to the package.Reviewer manual-smoke checklist (Wren's carry-forward)
Startup, channel open/archive scrollback, agents tab inbound refresh, huddle STT/TTS startup, workflows overview, relay reconnect.
Toolchain note (environment, not this PR)
Early lane pushes hit
buzz-db+sqlx 0.9 requiring rustc 1.94 in pre-push hooks: Homebrew rustc 1.89 was shadowing the repo-pinned toolchain.PATH="$PWD/bin:$PATH"clears it — hook suites then run green with no--no-verify. No lane touched Cargo.toml/Cargo.lock/crates/buzz-db.Named follow-ups (out of scope, recorded)
secret_storeboot-identity redesign — sync keychain access on boot path; HIGH risk, needs its own design.#hfilter-family merge — Perci design sketch, Wren/Mari red-team gate before build.since-cursor — cut payload, not just cadence; own tests.#a-batch (deferred Fix D) — MUST ship with scaled pooled limit (limit*N),a-tag re-split, multi-project split/truncation guard test.