Skip to content

perf: count member access once and scope find highlights to painting - #3464

Merged
kalvinnchau merged 2 commits into
mainfrom
peon/perf-roster-find
Nov 13, 2026
Merged

kalvinnchau merged 2 commits into
mainfrom
peon/perf-roster-find

Conversation

@kalvinnchau

Copy link
Copy Markdown
Contributor

Two independent fixes for render-path regressions introduced since desktop-v0.7.18. Each is its own commit. This replaces #3457 and #3460.

1. Members filter counts access levels once per roster

Problem. The members dialog's access filter computed one count per access level, and each count was a full pass over the roster that labelled every member. It ran on every render, so each keystroke in the member search box relabelled a large roster once per access level. Bisecting points to #2704 alone for the members-filter keystroke regression.

Change. src/bundled/channels/ChannelMembersDialog.tsx counts all access levels in a single pass into a Map, memoized on currentMembers and roles. Typing a query no longer recounts.

Why behaviour is unchanged.

  • The input list is the same currentMembers.
  • Names and order still come from accessFilterNames, and empty groups are still dropped.
  • Roles with no label were never counted and are still skipped.
  • roles is a store snapshot that keeps its identity until the store changes, so the memo holds across keystrokes and recomputes when roles refresh.

2. Find highlight styles apply only while painting

Problem. Find on screen styles its matches with two global rules, ::highlight(buzz-find) and ::highlight(buzz-find-active). When a stylesheet contains any ::highlight() rule, Chromium computes highlight pseudo styles for every restyled element on every style recalc, even with the find bar closed. The rules arrived with find on screen (#2550). A local bisect counted restyled elements per channel switch: 5485 before the rules, 6564 after, plus an extra recalc of about 100 elements. Removing the two rules brings it back to 5484.

Change.

  • FindInPage.module.css scopes both rules to :root[data-find-on-screen].
  • FindInPage.tsx: paint() sets data-find-on-screen on <html> before it registers highlights, and unpaint() removes it. close() and unmount both call unpaint().
  • FindInPage.test.tsx asserts the attribute is set while matches are painted and cleared on close.

Why behaviour is unchanged.

  • Every path that registers highlights (typing, stepping, recount on mutation, new content) goes through paint(), which sets the attribute first.
  • Only unpaint() clears the attribute, and it deletes both highlights in the same call.
  • No other ::highlight or ::selection rule exists in src/, so the higher specificity changes nothing.
  • Painted colours match in Chromium and WebKit.

Evidence

All runs are interleaved A/B, at 4× CPU throttle. Values are the mean of per-box medians.

Both commits stacked vs main (711d016e). The only change on main since then is Rust-only (#3352).

  • 9 Linux boxes and 9 macOS boxes.
  • 6 rounds × 2 samples per variant on each box, so n=108 per variant per OS.
case metric Linux main Linux PR Δ macOS main macOS PR Δ
warm channel switch style ms 79.0 68.0 −13.9% 57.1 50.1 −12.3%
task ms 268.1 257.6 −3.9% 184.1 179.9 −2.3%
LoAF blocking ms 126.6 118.8 −6.2% 52.3 49.8 −4.8%
channel open with hover style ms 118.0 102.4 −13.2% 64.1 57.1 −10.9%
task ms 483.6 464.8 −3.9% 281.8 274.8 −2.5%
LoAF blocking ms 271.0 259.1 −4.4% 129.7 125.5 −3.3%
cold channel open style ms 122.6 107.6 −12.2% 77.2 70.2 −9.1%
first rows ms 471.8 460.2 −2.5% 291.5 286.2 −1.8%
fast scroll paging (markdown) style ms 1689.0 1423.7 −15.7% 1075.5 931.5 −13.4%
task ms 11774.5 11406.2 −3.1% 7383.9 7200.2 −2.5%
LoAF blocking ms 2047.2 1866.8 −8.8% 166.1 141.8 −14.6%
rich thread open style ms 582.9 501.5 −14.0% 255.1 223.9 −12.2%
task ms 5197.5 5037.7 −3.1% 2437.3 2388.2 −2.0%
LoAF blocking ms 2433.9 2339.8 −3.9% 922.2 892.9 −3.2%

Script time is flat in these cases (within ±1.1%), as expected for a style-recalc change.

Members filter alone vs main (7f03764b).

  • 3 Linux boxes and 3 macOS boxes.
  • 6 rounds × 2 samples per variant on each box, so n=36 per variant per OS.
  • Typing into the members filter of a 5k-member channel.
metric Linux main Linux fix Δ macOS main macOS fix Δ
script ms 2434.6 2258.5 −7.2% 1580.7 1462.2 −7.5%
task ms 2769.5 2582.8 −6.7% 1804.6 1682.0 −6.8%
LoAF blocking ms 1227.1 1091.8 −11.0% 471.5 379.5 −19.5%
Event Timing keydown sum ms 2328.0 2144.0 −7.9% 1318.7 1230.7 −6.7%

All 6 boxes improved on every metric above. A control case, mention typing in the same channel, stays within ±1%.

Checks

  • vitest run src/bundled/channels src/app/shell: 1665/1665. One run hit a timing flake in the unrelated ChannelLifecycleDialog.test.tsx; it passed on rerun.
  • tsc --noEmit
  • biome check
  • find-on-screen.spec on chromium and webkit

🤖 Generated with Claude Code

kalvinnchau and others added 2 commits November 12, 2026 23:47
the access filter counted each level with its own pass over the roster on
every render, so each search keystroke in a large channel labelled every
member four times. count all levels in one pass, memoized on the roster and
its roles; the groups, their order and counts are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Kalvin Chau <[email protected]>
the two global ::highlight rules make chromium compute highlight styles in
every style recalc, even with find on screen closed. a warm channel switch
restyled about 20% more elements and ran one extra recalc. scope the rules
to a root attribute that paint sets and unpaint clears; painted matches look
the same.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Kalvin Chau <[email protected]>

@wpfleger96 wpfleger96 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 no blocking findings from me. the access counts match the old output (same order, empty groups dropped, unlabelled roles skipped), and the memo still invalidates when roles change, since the administration snapshot swaps in a new frozen roles object. every highlight registration goes through paint(), and close and unmount both go through unpaint(), so the attribute can't outlive the highlights. the find bar's MutationObserver watches document.body, so toggling the attribute on <html> doesn't trigger a recount.

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Star Lord automated source review via Wes’s account.

Reviewed b400057 against 7bb4548. No actionable findings.

The access counts preserve role labels and ordering, and the memo inputs track roster/label changes and immutable role snapshots. The highlight gate follows the existing paint/close/unmount lifecycle without triggering the body mutation observer; find scope, navigation, focus restoration and the no-Highlight-API fallback remain unchanged by inspection.

Validation: source-only review; no tests, builds or app workflows were run. Hosted CI required passed at this head, with Windows native validation skipped. The author’s performance measurements and browser/native highlight rendering were not independently reproduced; the added DOM assertions do not establish CSS painting or performance.

@wpfleger96 wpfleger96 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 approving, no blocking findings (details in the earlier review).

kalvinnchau added a commit that referenced this pull request Nov 13, 2026
…iews (#3466)

## Problem

Link previews (#3219, #3225) find a message's preview URLs with
`previewLinks`. For any body with an `https://` link, that function
called `prepareMarkdown`, then scanned the prepared content again with
`scanMarkdown`, and kept no result. The renderer already prepares the
same body through its own cache.

A link-bearing row therefore parsed its Markdown two or three more times
than a row without links, on every mount. It did this again on each
remount while scrolling or switching channels.

## Change

- `markdown-preparation.ts`:
- Owns the cache of recent prepared bodies. `recentResults` and
`preparedBodies` move here from `MessageMarkdown.tsx`, unchanged.
  - Exposes it as `preparedMarkdown(content, scanned?)`.
- `prepareMarkdown` takes an optional `scanned` callback that receives
the scan it made. It stays pure and keeps no state.
- `MessageMarkdown.tsx` reads through `preparedMarkdown`, with the same
cache and budget as before.
- `preview-links.ts`:
- Uses `preparedMarkdown`. On a fresh preparation it takes the scan
through the callback instead of scanning again.
- Keeps recent preview link lists in a `recentResults` cache. The key is
the content and exclusions; the weight counts both the key and the URLs.
Cached lists are frozen.
- `preview-links.test.ts` asserts that the renderer and previews
together scan a body once. This fails on the previous code.

## Why behaviour is unchanged

- `prepareMarkdown` is pure, so a cached prepared body is identical to a
fresh one.
- The scan passed to the callback is the scan of the returned `content`,
the same input previews scanned before.
- The link list depends only on content and exclusions, which are both
in the cache key.

## Evidence

Interleaved A/B on 9 Linux and 9 macOS boxes:
- 6 rounds × 2 samples per variant on each box, so n=108 per variant per
OS.
- 4× CPU throttle.
- Values are the mean of per-box medians.

The baseline is `main` (`7bb45482`) with #3464 and #3465 applied. This
change was measured on top of that. The measured revision handed the
scan over through module state instead of a callback; the parse count is
the same. A variant with preview scanning disabled entirely is the
ceiling.

| case | metric | Linux base | Linux PR | Δ | macOS base | macOS PR | Δ
| ceiling Δ (Linux / macOS) |
|---|---|---|---|---|---|---|---|---|
| thread scroll open | script ms | 636.4 | 618.3 | −2.8% | 381.4 | 368.2
| −3.5% | −2.9% / −3.7% |
| | task ms | 997.3 | 976.1 | −2.1% | 600.6 | 588.1 | −2.1% | −1.4% /
−2.0% |
| | LoAF blocking ms | 551.7 | 534.3 | −3.1% | 271.2 | 258.4 | −4.7% |
−2.5% / −5.4% |
| | first rows ms | 937.1 | 914.1 | −2.5% | 561.5 | 546.2 | −2.7% |
−1.8% / −2.7% |
| cold markdown channel open | script ms | 495.6 | 487.8 | −1.6% | 320.6
| 316.8 | −1.2% | −2.0% / −1.7% |
| fast scroll paging (markdown) | LoAF blocking ms | 1489.8 | 1444.2 |
−3.1% | 83.7 | 83.5 | −0.3% | −2.3% / −5.3% |

- Thread scroll open improved on every box (9/9) for script and LoAF on
both OSes.
- The change recovers all the time that link-preview scanning costs in
these scenarios.
- An earlier A/B of desktop-v0.7.18 against the same baseline put thread
scroll open at +2.6% script on both OSes, which this removes.

## Checks

- `vitest run src/features/messages`: 3119 pass.
- `link-previews.spec` and `markdown-documents.spec` pass on chromium
and webkit.
- `bin/pnpm check` is clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Kalvin Chau <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
@kalvinnchau
kalvinnchau merged commit 53e4404 into main Nov 13, 2026
66 checks passed
@kalvinnchau
kalvinnchau deleted the peon/perf-roster-find branch November 13, 2026 15:45
kalvinnchau added a commit that referenced this pull request Nov 13, 2026
## Problem

#3316 added right-click message actions by wrapping every toolbar
message row in its own Base UI `ContextMenuRoot` and
`ContextMenuTrigger`. Each of those mounts costs root state, floating-ui
setup, and trigger listeners. With a full timeline that adds up to about
a tenth of list render script time, and a quarter or more of
long-animation-frame blocking while scrolling.

Bisect against v1.0.1 isolated this commit as the per-row cost. Box A/Bs
then split the cost into parts:
- A row with no menu at all recovers almost the whole regression.
- A Base UI root and trigger per row with no props costs nearly as much
as main.
- The cost is therefore the per-row Base UI menu, not the action props.

## Change

`MessageContextMenus` in `MessageActionBar.tsx` is one real Base UI
context menu per message list. It wraps the channel timeline feed and
the thread panel's rows.
- **Rows inside a host.** Each toolbar row registers its actions by
message ID, in an effect, and renders only its content.
- **Choosing the menu.** A right-click, or the ContextMenu / Shift+F10
key, over a registered row selects that row's actions. Presses outside a
registered row keep the browser's native menu, as do the existing native
targets (links, media, fields, selection).
- **Gestures.** Base UI still owns every gesture: mouseup guards,
outside-press dismissal, and switching to another row while a menu is
open.
- **Focus.** The popup renders inside the list's React tree, so the
timeline's focus bookkeeping still sees focus enter a row's menu, as it
did with a row's own menu.
- **Updates and removal.** The open menu follows its row's latest
actions and closes if the row unmounts. The host releases the row when
the menu closes.
- **Rows outside a host** keep their own menu: standalone uses, media
review, activity and huddle. Coordination rows keep their own trigger
too, and Base UI stops their event before it reaches the host.

`ThreadPanel.tsx` only wraps the rows element. Its diff is indentation;
`git diff -w` shows 3 lines.

## Evidence

Interleaved A/B on 9 Linux and 9 macOS boxes:
- 6 rounds × 2 samples per variant on each box, so n=108 per variant per
OS.
- 4× CPU throttle.
- Values are the mean of per-box medians.

The baseline is `main` (`7c786bb4`) with #3464 applied (`90580d91`).
This change was measured stacked on top of it (`3caa47b0`). The final
commit differs from the measured one only in menu open, close and focus
handling, which these scenarios do not exercise.

| case | metric | Linux base | Linux PR | Δ | macOS base | macOS PR | Δ
|
|---|---|---|---|---|---|---|---|
| fast scroll paging (markdown) | script ms | 6148.8 | 5600.0 | −8.9% |
3859.6 | 3601.6 | −6.7% |
| | task ms | 11384.7 | 10831.0 | −4.9% | 7206.1 | 6907.9 | −4.1% |
| | LoAF blocking ms | 1849.6 | 1475.8 | −20.2% | 138.3 | 82.4 | −40.4%
|
| channel open with hover | script ms | 273.1 | 246.2 | −9.9% | 162.9 |
150.2 | −7.8% |
| | task ms | 465.7 | 438.7 | −5.8% | 276.0 | 262.2 | −5.0% |
| | LoAF blocking ms | 260.3 | 242.4 | −6.9% | 126.0 | 118.9 | −5.6% |
| cold channel open | script ms | 292.7 | 267.6 | −8.5% | 184.8 | 172.2
| −6.8% |
| | task ms | 515.5 | 492.4 | −4.5% | 332.9 | 320.4 | −3.7% |
| | LoAF blocking ms | 245.1 | 230.1 | −6.1% | 106.9 | 98.6 | −7.7% |
| rich thread open | script ms | 2417.1 | 2275.4 | −5.9% | 1138.8 |
1102.8 | −3.2% |
| | task ms | 5020.0 | 4842.7 | −3.5% | 2385.8 | 2356.3 | −1.2% |
| | LoAF blocking ms | 2335.3 | 2175.1 | −6.9% | 895.8 | 852.3 | −4.8% |
| warm channel switch | script ms | 133.1 | 129.3 | −2.9% | 91.0 | 89.8
| −1.3% |
| | LoAF blocking ms | 116.2 | 112.3 | −3.4% | 49.0 | 48.3 | −1.3% |

All 9 boxes improved on both OSes for every row except warm channel
switch, which is small and mixed (Linux script 9/9, the rest 6–7/9).
Style time is unchanged (±1.3%).

## Checks

- `vitest run src/features/messages src/features/conversation`: 3286
pass. New unit tests cover:
  - which row's actions open
  - the native menu outside rows
  - closing when the row is removed
  - keyboard open
  - focus staying in the list
- The removal and focus tests were each confirmed to fail without their
fix.
- Browser specs pass on chromium and webkit: `message-context-menu`,
`menu-dismiss`, `message-actions`, `message-actions-floating`,
`buzz-links`, `external-links`, `thread-composer-focus`,
`find-on-screen`.
- `tsc --noEmit` and `biome check` are clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Kalvin Chau <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
joshfriend added a commit that referenced this pull request Nov 13, 2026
* origin/main: (153 commits)
  feat(agents): preview where interrupted agents were working before resume (#3494)
  perf(messages): close the editor and delete dialog on submit (#3493)
  feat(messages): quote selected message text into the composer (#2392)
  perf(toast): scope docked clearance to the toast host (#3489)
  fix(unread): undo exact captured ordinary reads conditionally (#3473)
  fix(solo): keep navigation responsive during chat deletion (#3488)
  fix(channels): keep custom thread tab names until the tab closes (#3458)
  perf(relay): verify cached history in the native host (#3426)
  fix(settings): refine claude and typesafe key setup dialogs (#3391)
  Update bundled help for Bestie and agent settings (#3194)
  feat(profiles): share and show a local time zone (#3332)
  Revert "feat(link-previews): unfurl web links below messages by default" (#3480)
  Show disabled plugin chats in Solo (#3456)
  perf(messages): host one context menu per message list (#3465)
  test(claude): keep the quit-release test copy above the minimum version (#3477)
  fix(profiles): restore verified names from the device cache (#3382)
  fix(mentions): show addressed agents first in Solo audience picker (#2856)
  perf: count member access once and scope find highlights to painting (#3464)
  perf(messages): prepare a message once for its renderer and link previews (#3466)
  fix(composer): speed up long pastes and paste office text as text (#3441)
  ...

# Conflicts:
#	docs/plugin-authoring-guide.md
joshfriend added a commit that referenced this pull request Nov 13, 2026
…when-ready

* origin/main: (21 commits)
  fix(agents): report a conversation wake that fails before its turn starts (#2192)
  fix(usage): read Claude transcripts over 50 MB a page at a time (#3498)
  feat(agents): preview where interrupted agents were working before resume (#3494)
  perf(messages): close the editor and delete dialog on submit (#3493)
  feat(messages): quote selected message text into the composer (#2392)
  perf(toast): scope docked clearance to the toast host (#3489)
  fix(unread): undo exact captured ordinary reads conditionally (#3473)
  fix(solo): keep navigation responsive during chat deletion (#3488)
  fix(channels): keep custom thread tab names until the tab closes (#3458)
  perf(relay): verify cached history in the native host (#3426)
  fix(settings): refine claude and typesafe key setup dialogs (#3391)
  Update bundled help for Bestie and agent settings (#3194)
  feat(profiles): share and show a local time zone (#3332)
  Revert "feat(link-previews): unfurl web links below messages by default" (#3480)
  Show disabled plugin chats in Solo (#3456)
  perf(messages): host one context menu per message list (#3465)
  test(claude): keep the quit-release test copy above the minimum version (#3477)
  fix(profiles): restore verified names from the device cache (#3382)
  fix(mentions): show addressed agents first in Solo audience picker (#2856)
  perf: count member access once and scope find highlights to painting (#3464)
  ...
joshfriend added a commit that referenced this pull request Nov 13, 2026
…n-on-github

* origin/main: (21 commits)
  fix(agents): report a conversation wake that fails before its turn starts (#2192)
  fix(usage): read Claude transcripts over 50 MB a page at a time (#3498)
  feat(agents): preview where interrupted agents were working before resume (#3494)
  perf(messages): close the editor and delete dialog on submit (#3493)
  feat(messages): quote selected message text into the composer (#2392)
  perf(toast): scope docked clearance to the toast host (#3489)
  fix(unread): undo exact captured ordinary reads conditionally (#3473)
  fix(solo): keep navigation responsive during chat deletion (#3488)
  fix(channels): keep custom thread tab names until the tab closes (#3458)
  perf(relay): verify cached history in the native host (#3426)
  fix(settings): refine claude and typesafe key setup dialogs (#3391)
  Update bundled help for Bestie and agent settings (#3194)
  feat(profiles): share and show a local time zone (#3332)
  Revert "feat(link-previews): unfurl web links below messages by default" (#3480)
  Show disabled plugin chats in Solo (#3456)
  perf(messages): host one context menu per message list (#3465)
  test(claude): keep the quit-release test copy above the minimum version (#3477)
  fix(profiles): restore verified names from the device cache (#3382)
  fix(mentions): show addressed agents first in Solo audience picker (#2856)
  perf: count member access once and scope find highlights to painting (#3464)
  ...
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.

3 participants