Skip to content

perf(app): dedup sync-time status fetches + consolidate search refresh#131

Merged
graydawnc merged 1 commit intomainfrom
perf/sync-ipc-dedup
Apr 30, 2026
Merged

perf(app): dedup sync-time status fetches + consolidate search refresh#131
graydawnc merged 1 commit intomainfrom
perf/sync-ipc-dedup

Conversation

@graydawnc
Copy link
Copy Markdown
Collaborator

What

Reduce the IPC traffic + React work triggered on every sync phase transition. Renderer + IPC orchestration only; no DB, sync engine, or main-process logic touched.

  • App owns status and passes it down to Sidebar. SidebarStatus reads it from props instead of running its own getStatus.
  • The App-level getStatus / getRuntimeInfo effect was keyed on the whole syncStatus object. runtimeInfo is now mount-only; status is re-fetched only when the sync phase reaches done.
  • Sidebar's listProjectGroups effect is now keyed on status.totalSessions, so the project_groups_v aggregation runs once on mount and once per sync that actually grows the session count.
  • onSyncProgress and onNewSessions now share a single 250ms debounced search-refresh helper.

Why

After the library redesign shipped (#122#128), every sync phase change (scanningsyncingindexingdone) triggered three independent IPC paths in the renderer:

  1. App's useEffect([syncStatus]) ran getStatus + getRuntimeInfo.
  2. Sidebar's useEffect([syncStatus?.phase]) ran listProjectGroups — an aggregation view over the sessions table.
  3. SidebarStatus's own useEffect([syncStatus]) ran getStatus again.

That's roughly 12 IPC round-trips per sync just to refresh status + project counts, of which two were duplicate getStatus calls and four were full project_groups_v aggregations. Separately, onNewSessions fired doSearch directly with no debounce, racing the debounced onSyncProgress refresh and stacking overlapping FTS queries when many session files landed at once.

How it connects

  • SidebarStatus was already rendering getSyncStatusText(syncStatus, status) — it just needed status from props instead of its own state. The rendered text ("X sessions · 5m" / "Scanning…" / "Indexing N/M" / "Building index…") is unchanged.
  • Keying listProjectGroups on status.totalSessions matches the actual signal: project rows can change only when the underlying session count changes, and status is now updated exactly when sync transitions to done. On mount, status starts null so the aggregation still runs once for the initial population.
  • The shared scheduleSearchRefresh helper preserves the existing 250ms trailing-debounce semantics for syncing / indexing progress, and now extends them to onNewSessions. The immediate doSearch(query) on phase === 'done' is preserved so the user always sees the post-sync result without an extra 250ms wait.

Trade-off

Project counts in the sidebar no longer climb mid-sync; they jump at done. The intermediate counts came from aggregating partial state, and the cost (one project_groups_v aggregation per phase boundary) outweighed the value of the animation. If progressive updates are wanted later, a totalSessions-bucketed throttle is the right shape — phase-keyed refetches were just the wrong signal.

Test plan

  • pnpm exec tsc -p packages/app/tsconfig.json --noEmit clean
  • pnpm --filter @spool/app exec vitest run src/ — 12/12 pass
  • Manual: cold start — sidebar populates with project rows + correct counts; bottom status shows ${''}N sessions · …${''}
  • Manual: trigger sync via DevTools window.spool.syncNow() — sidebar status text walks through Scanning → Indexing N/M → Building index → final count, and project rows refresh once at done
  • Manual: with a committed search query open, trigger a sync — results refresh smoothly during sync (no jitter from stacked refetches), and reflect final state at done
  • Manual: pin/unpin still silent (PR perf(app): memoize message bubbles + drop pin-toggle loading flash #130 behavior preserved)

Reduce the IPC traffic + redundant React work that fires on every sync
phase transition.

- App owns status now and passes it down. Sidebar accepts a status prop
  and SidebarStatus reads it instead of running its own getStatus, which
  used to fire 4 times per sync (once per phase change).
- App's getStatus / getRuntimeInfo effect was keyed on the whole
  syncStatus object, so it also re-ran on every phase change.
  runtimeInfo is now mount-only (it never changes mid-session); status
  re-fetches only when the sync phase reaches done.
- Sidebar's listProjectGroups effect is now keyed on
  status.totalSessions, so the project_groups_v aggregation runs once on
  mount, then once after each sync that actually changed the session
  count, instead of 4 times per sync.
- onSyncProgress and onNewSessions now share a single 250ms debounced
  search refresh helper. Previously onNewSessions called doSearch
  immediately with no debounce, so a sync that emitted many new-sessions
  events back-to-back could pile up overlapping FTS queries on top of
  the debounced progress refreshes.

Trade-off: project counts in the sidebar no longer "climb" mid-sync;
they jump at done. The intermediate counts were aggregated views of
partial state and the cost (one project_groups_v aggregation per phase
boundary) outweighed the value. If we want progressive updates back
later, the right place is a totalSessions-bucketed throttle, not raw
phase events.
@graydawnc graydawnc merged commit 5c93099 into main Apr 30, 2026
4 checks passed
@graydawnc graydawnc deleted the perf/sync-ipc-dedup branch April 30, 2026 05:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant