Skip to content

perf(app): memoize message bubbles + drop pin-toggle loading flash#130

Merged
graydawnc merged 1 commit intomainfrom
perf/library-render-memo
Apr 30, 2026
Merged

perf(app): memoize message bubbles + drop pin-toggle loading flash#130
graydawnc merged 1 commit intomainfrom
perf/library-render-memo

Conversation

@graydawnc
Copy link
Copy Markdown
Collaborator

What

Three small renderer-only perf wins that sit on top of the recent library-first redesign. No IPC, sync, or DB code touched.

  • MessageBubble is wrapped in React.memo. SessionDetail's find-in-page memo no longer creates a Map entry for messages with zero matches, and the active match index + active-match ref are passed only to the bubble that contains the active match.
  • LibraryLanding no longer resets recentSessions to null on the reload triggered by a pin toggle, and bucketByDate moves into useMemo.
  • ProjectView splits the effect that resets sessions to null (real reloads: identity / sort / source filter) from the silent reload triggered by a pin toggle.
  • AgentSelector is wrapped in React.memo.

Why

After the library redesign shipped (#122#128), three perf hotspots were visible without needing 100k-session libraries to reproduce:

  1. Find-in-page typed slowly on long sessions. A session with 1k+ messages re-rendered every MessageBubble on every keystroke, because the messageFindRanges map produced a fresh [] ref for non-matching messages and activeMatchIndex + onActiveMatchRef were passed unchanged to all bubbles.
  2. Pin / unpin flashed the whole feed to "Loading…". Both LibraryLanding and ProjectView set their list state to null inside the same effect that handled refetches, so the optimistic update was immediately erased and the user saw a flicker before the refetched data came back.
  3. AgentSelector re-rendered on every sync progress event. App re-renders on every progress chunk during a sync; AgentSelector is mounted in the results header and the search overlay, and without memo its body ran each time.

How it connects

  • The MessageBubble change preserves the existing find/highlight contract: only the message that contains the active match receives a non--1 activeMatchIndex and the bindActiveFindMatch callback. Highlight color, scroll-to-active behavior, match counts, and data-testid="session-find-active-match" selectors are unchanged.
  • The pin/unpin paths still end in a backend refetch — the optimistic state and the refetch result already match, so removing the setRecentSessions(null) / setSessions(null) lines just makes the reconciliation silent. Real reloads (identity / sort / source-filter changes in ProjectView) keep their loading state via a dedicated effect.
  • The AgentSelector memo is a strict superset: agents/activeAgent/onSelect are all stable refs across App renders that don't actually change agent state, so the shallow compare holds.

Test plan

  • pnpm exec tsc -p packages/app/tsconfig.json --noEmit clean
  • pnpm --filter @spool/app exec vitest run src/ — 12/12 pass
  • Manual: long session find-in-page — typing feels responsive, ⌘← / ⌘→ navigates, active highlight tracks, match count correct
  • Manual: home feed pin → moves to PINNED with no Loading flash
  • Manual: home feed unpin → leaves PINNED and reappears in the right date bucket with no flash
  • Manual: project view pin/unpin — same, no flash
  • Manual: project view sort change / source filter / project switch — still flash to "Loading sessions…" (intended)
  • Manual: ⌘K agent dropdown opens, selects, persists
  • Manual: search → result → SessionDetail target highlight still flashes for 2s

Three small renderer-only wins on top of the library redesign:

- MessageBubble wrapped in React.memo. SessionDetail now skips Map
  entries for messages with no matches, so non-matching messages keep a
  stable undefined findRanges/offset across keystrokes. activeMatchIndex
  is also passed only to the message that contains the active match.
  With 1k+ messages, find-in-page typing previously re-rendered every
  bubble per keystroke; now only the matching ones re-render.
- LibraryLanding stops resetting recentSessions to null on the reloadKey
  refetch, so pin/unpin no longer flashes the whole feed to "Loading…".
  bucketByDate moves into useMemo so it doesn't recompute on every
  unrelated render.
- ProjectView splits the "show loading" reset (identity / sort / source
  filter) from the silent reload triggered by pin toggles, removing the
  same flash there.
- AgentSelector wrapped in React.memo. App re-renders on every sync
  progress event; without memo the agent dropdown's body re-ran on
  each one.

No IPC, sync, or DB code touched. Pin/unpin still ends in a backend
refetch — the optimistic state and the refetch result match, so the
visual reconciliation is silent.
@graydawnc graydawnc merged commit 2586284 into main Apr 30, 2026
4 checks passed
@graydawnc graydawnc deleted the perf/library-render-memo branch April 30, 2026 02:57
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