Skip to content

perf: stop repeating render work, overlap thread-open reads and keep recent names - #3570

Draft
kalvinnchau wants to merge 7 commits into
mainfrom
peon/perf-repeated-render-work
Draft

kalvinnchau wants to merge 7 commits into
mainfrom
peon/perf-repeated-render-work

Conversation

@kalvinnchau

@kalvinnchau kalvinnchau commented Nov 15, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Seven fixes: four for work the desktop client repeats on every render or every relay update, one for thread-open latency, and two for channel switching. Each one was found in Web Inspector timeline traces of a dev build running against a large community.

  1. Name lookups re-parse execution bindings (features/agents/execution.ts).
    • Every display-name resolve rebuilds the local agent snapshot.
    • That rebuild read each agent's execution binding from storage: key regex, storage lookup, JSON.parse, validation and freeze, per agent, per label.
    • read now keeps each validated agent's last parsed binding with its storage text, and reuses it while the text is unchanged.
  2. Activity rows re-parse preview Markdown (bundled/inbox/InboxRow.tsx).
    • InboxRowButton and ReactionRowButton ran messagePreview (Markdown → plain text) on every render.
    • Every filter change or live event re-renders every row. Rows a filter hid are remounted, so useMemo would not help.
    • Recent previews are now kept by text in the existing ByteLru (500 entries, 1 MiB by source length).
  3. DM voice eligibility rescans the channel list (features/huddle/dm-agent.ts, remote-dm-voice.ts).
    • The remote DM voice watcher checks every channel on each agent, channel or choice change.
    • Each check searched the whole channel list again for the channel it was given, so every scan was quadratic in channel count.
    • The watcher now passes the channel it already holds. Other callers keep the lookup.
  4. Opening a thread waits on four relay reads in a row (features/relay/threads.ts).
    • An exact open read the target, then its overlays, then the root, then the first reply window, each starting only after the previous one resolved.
    • The overlay read now starts with the target read, and the first strict window read with the root read.
    • Admission order is unchanged: the overlapped read's rows are staged only after the read it used to wait for is validated. When the outcome is reached without them (target missing or moved, root missing, cancel, error), the overlapped read is aborted.
    • Trade-off: on those early-outcome paths one extra read is sent and then aborted. The success path sends the same requests as before.
  5. Switching channels evicts the names of the channel you return to (features/relay/profile-directory.ts).
    • An open channel reserves profile-cache room for its roster. Leaving gave the room back at the end of the task, so moving from a large channel to a small one evicted the least recently used profiles, often the authors and mentions of a channel visited seconds earlier.
    • That channel's timeline then remounted with unresolved names. Its saved row measurements are keyed by a signature that includes those names, so they no longer matched: every row was measured again, the scroll position restored again, and the evicted profiles were read again.
    • A released reservation now holds the limit at its own room for 30 seconds. Closing a smaller view during the hold does not extend it. Background reads the departing view asked for are still cancelled at the end of the task, as before.
    • Trade-off: the larger cache limit stays up to 30 seconds longer after leaving a large channel (bounded by the existing 10,240-entry maximum).
  6. A timeline mount builds its measurement signature even with nothing to compare (features/messages/geometry.ts).
    • The signature serializes every visible row with its resolved author and mention names. It was built before looking up the saved entry, so a first visit, a community switch, or a channel pushed out of the three-entry cache paid for it with no possible match.
    • get now takes the signature lazily and checks the saved entry and width first. Hits behave as before.
  7. Navigation state changes rerender every sidebar row (features/channel-navigation/ChannelInventory.tsx).
    • The sidebar's activity callbacks (open thread, open message, open working agent, open agent activity) depended on the whole navigation handoff object, which is replaced on every navigation state change (a DM being prepared, a lifecycle action starting or ending).
    • Every memoized row then received new callback props and rendered again, with its context menu and activity popover trees.
    • The callbacks now depend only on the two stable refs they write.

Behavioral changes

What users see is unchanged: the same rows, names, previews and thread contents, in the same order. What changes underneath:

  • Thread open (4): two reads start earlier, alongside the read they used to wait for. When an open ends early (target missing or moved, root missing, cancel, error), one extra read is sent to the relay and then aborted.
  • Profile cache (5): after you leave a large channel, the profile cache keeps its larger limit for up to 30 seconds, so memory is released later. Names in a channel visited seconds earlier stay resolved instead of being read again, so the relay gets fewer profile reads.
  • Activity previews (2): up to 500 recent previews (1 MiB of source text) stay in memory.
  • Fixes 1, 3, 6 and 7 skip repeated work only. Their outputs are identical.

Measurements

These are local, using a dev build (React dev mode with StrictMode, so absolute numbers are inflated) with Safari Web Inspector timelines on macOS. Before and after traces were captured with the same scripted input.

Flow Before After
Members dialog search in a channel with several thousand members, handler time per keystroke 442–554 ms 253–369 ms
Members dialog close 659 ms 124 ms
Search palette input handler per keystroke, steady state ~38–40 ms ~24 ms
Activity filter-tab click, main-thread work until idle (median / max) 136 / 209 ms 63 / 154 ms
Activity: preview Markdown time across 10 tab clicks ~250 ms 0
Activity: script time during a 6 s list scroll 204 ms 34 ms
DM voice eligibility work over 60 s idle ~15 ms ~2 ms
Thread open from an Activity row, click to last thread read resolved (median of 4) 1135 ms 727 ms
Thread open, click to root and composer shown (median of 4, screenshot-timed) 758 ms 607 ms
Warm switches cycling 3 channels (one with ~1,500 members), main-thread work per switch, median of clicks 7–12 249 ms 120 ms
Same, forced layouts across clicks 7–12 191 72
Same, profile read response bytes across 12 switches ~1.69 MB ~9.7 KB
Sidebar row renders in the first ~45 s after a reload (instrumented, ~160 rows, StrictMode doubles) ~1,920 ~1,420
Signature built on a timeline mount with no saved entry (instrumented, 15 misses across 6 channels) 0–27 ms each, ~55 ms total not built
  • The DM voice gain is small at steady state. The cost grows with channel count and with relay bursts that change the channel list.
  • Thread-open timings depend on relay round trips (each read took roughly 85–280 ms here), so they vary by network. Request bodies and response sizes were identical between the before and after traces. The traced build overlapped the same reads but did not yet abort them on early outcomes; the success path is the same.
  • Channel switch: with instrumentation, the saved measurements matched on 0 of 3 revisits of the channel left before the large one and 2 of 3 of another; with the hold, all 9 revisits matched. In the traced run the second cycle after a reload was still slow (cause not identified), so the table uses the later cycles. The traced build kept the reservation itself for 30 seconds; the committed version holds the limit instead, which keeps the same profiles.
  • Signature: in a traced switch back to a large community, the 686 ms click task spent 76 ms building it (63 ms without StrictMode's second run). In plain channel switching most channels cost 0–3 ms; the largest cost 12–27 ms.
  • One earlier trace caught a burst of about 450 ms of DM voice eligibility work within a minute, but I did not reproduce it in the A/B pair.

Tests

Each new test fails on the base and passes with its change:

  • execution.test.ts › "reuses a parsed binding until its saved text changes": on the base, the second read returns a new object.
  • remote-dm-voice.test.ts › "checks each channel once per change instead of searching the list again": with 102 channels, the base calls channels.list 206 times for one change; the fix calls it fewer than 10 times.
  • thread-target.test.ts and thread-window.test.ts: tests that assumed the old serial order now also assert that the overlapped read is aborted on early outcomes and that a late answer to it never enters the view.
  • profile-details.test.ts › "keeps a closed channel's roster room for a while, so switching back reads no names again": on the base the limit drops to 1,034 as soon as a smaller channel opens; with the fix it holds 1,524 until 30 s, and a smaller view closing during the hold does not extend it (fails if the guard is removed). "cancels a departing view's background reads after the task while its room is held": fails on the base, which shrinks at once.
  • geometry.test.ts › "builds the signature only when a saved entry could match it": fails on the base, which builds it for a missing entry and a width mismatch.
  • ChannelInventory.test.tsx › "keeps sidebar rows still when only navigation state changes": on the base, preparing a DM rerenders every row.
  • InboxRow.test.tsx › "parses a row preview's Markdown once across renders": the base calls messagePreview 3 times across a re-render and a remount; the fix calls it once.

Checks run:

  • bin/pnpm check: passes.
  • bin/pnpm exec vitest run src/bundled/inbox src/features/huddle src/features/agents/execution.test.ts: 24 files, 477 tests passed.
  • bin/pnpm exec vitest run src/features/relay src/features/messages: 254 files passed (1 skipped), 6873 tests passed.
  • bin/pnpm exec vitest run src/features/relay src/features/conversation: 163 files passed (1 skipped), 3858 tests passed.
  • bin/pnpm exec vitest run src/features/messages: 110 files, 3184 tests passed.
  • bin/pnpm exec vitest run src/features/channel-navigation src/bundled/channels src/app/shell: 104 files, 2050 tests passed.

Checklist

  • Agent review: a fresh-context review ran, and its findings are addressed (simpler read; LRU with a byte budget instead of FIFO). A separate review of the thread-read change found no defects; its naming and structure notes are addressed. A review of the profile hold found that any release restarted the hold, so small views could keep a large room up; fixed, with a test.
  • Agent exercised: the changed flows (members dialog, search palette, Activity tabs and scroll, idle, thread open, channel switching, community switching) were driven in the native dev app and traced.
  • Human test: pending.

🤖 Generated with Claude Code

kalvinnchau and others added 4 commits November 15, 2026 00:40
every display-name resolve rebuilds the local agent snapshot, which read
each agent's execution binding: key validation, storage lookup, json
parse, validation and freeze per agent per label. keep the last parsed
binding with its storage text and reuse it while the text is unchanged.

in a large channel this cut members-dialog keystroke handling roughly in
half and search-palette input handling by about 40% in a dev build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Kalvin Chau <kalvin@block.xyz>
remote dm voice scans every channel on each agent, channel or choice
change, and each eligibility check searched the channel list again for
the same channel, making every scan quadratic in channel count. pass the
channel the scan already holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Kalvin Chau <kalvin@block.xyz>
activity and reaction rows flattened their preview markdown on every
render, and every filter change or live event renders all rows again,
remounting rows a filter hid. keep recent previews by text in a bounded
least-recently-used cache whose budget counts the source text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Kalvin Chau <kalvin@block.xyz>
opening a thread ran four relay reads one after another: the target,
its overlays, the root and the first reply window. the overlay read now
starts with the target read, and the first strict window read with the
root read. rows of each overlapped read are still staged only after the
read it used to wait for is validated, and the read is aborted when the
outcome is reached without its rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Kalvin Chau <kalvin@block.xyz>
@kalvinnchau kalvinnchau changed the title perf: stop repeating name, preview and dm voice work per render perf: stop repeating render work and overlap thread-open reads Nov 15, 2026
leaving a channel gave its roster room back at the end of the task, so
switching from a large channel to a small one evicted the least recently
used profiles, often the authors of the channel the reader returns to.
the timeline then remounted with unresolved names, its saved row
measurements no longer matched, and the profiles were read again.

a released reservation now holds the cache limit at its own room for
30 seconds; closing a smaller view does not extend the hold. background
reads the departing view asked for are still cancelled after the task.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Kalvin Chau <kalvin@block.xyz>
@kalvinnchau kalvinnchau changed the title perf: stop repeating render work and overlap thread-open reads perf: stop repeating render work, overlap thread-open reads and keep recent names Nov 15, 2026
kalvinnchau and others added 2 commits November 15, 2026 05:41
… match

a timeline mount built its full geometry signature (row content plus
resolved author and mention names) before checking for a saved entry.
on a first visit, after a community switch, or for a channel pushed out
of the three-entry cache there is nothing to compare, so the work was
wasted. get now takes the signature lazily and checks the entry and
width first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Kalvin Chau <kalvin@block.xyz>
the channel sidebar activity callbacks depended on the whole navigation
handoff, which is a new object on every navigation state change (a dm
being prepared, a lifecycle action starting or ending). each change gave
every memoized sidebar row new callback props and rerendered the list.
they now depend only on the two stable refs they write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Kalvin Chau <kalvin@block.xyz>

This branch has not been deployed

No deployments
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