Skip to content

perf(solo): subscribe to Solo navigation only for a chat that can be filed - #3335

Merged
kalvinnchau merged 3 commits into
mainfrom
perf/region-workspace-subscription
Nov 13, 2026
Merged

kalvinnchau merged 3 commits into
mainfrom
perf/region-workspace-subscription

Conversation

@kalvinnchau

@kalvinnchau kalvinnchau commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

Problem

#2788 added useRegionWorkspace(queries, solo ? currentId : undefined) to ChannelWorkspace (src/bundled/channels/ChannelsPage.tsx:795). The hook always subscribed to the Solo navigation store through useSoloNavigation, and that store publishes a new snapshot on every channel-list change (solo-navigation.ts:297, refresh). An incoming message in any channel changes the list, so every arrival re-rendered the whole channel workspace (timeline shell, composer, header and panes), even in an ordinary channel. In an ordinary channel the hook's result is always undefined.

In a benchmark where 40 messages arrive in a background channel, script time doubled at #2788 and has stayed there since.

Change

src/features/solo/chat-workspace.ts: useRegionWorkspace subscribes to Solo navigation only when it is given a channelId. Without one it reads nothing and returns undefined, as it already did.

No behavior change

  • Without a channelId: the old code returned undefined on every path (if (!channelId || …) return undefined). The new code returns the same value without subscribing.
  • With a channelId: the reader, snapshot and lookup are unchanged. Every Solo chat in ChannelsPage and every WorkspaceControl caller still re-renders on each navigation publish, as before.
  • Missing reader: useSoloNavigation fell back to an empty snapshot with no binding, which returns undefined. The new code's undefined snapshot gives the same result.
  • A channel becoming Solo: the channel's metadata already re-renders ChannelWorkspace, through its roster subscription. That render passes the channelId, and the hook subscribes from then on.
  • Test: a new test in chat-workspace.test.ts follows the hook from no chat to a chat and back. It checks that there is no subscription and no publish-driven render without a chat, that a chat gets re-rendered and resolves its workspace, and that the listener is released when the chat goes away. The test fails on main.

Evidence

Linux only, on a single machine. The Blox API returned 502 for the whole session, so no macOS run and no multi-box spread were possible.

Method:

  • Chromium production build at 4× CPU, on one 11-vCPU Linux box.
  • bl4b-ingest: Alpha is open, and 40 peer messages arrive in Beta 50 ms apart through the fixture's production relay publish path (app.append). The measurement runs from the first message until the timeline settles. -mention adds one unread top-level mention of the viewer.
  • The table shows medians. The range is the min..max of the per-round median deltas. p is a two-sided Mann–Whitney test.

Bisect. Each commit got 2 interleaved rounds × 3 samples on bl4b-ingest. Script was 496–520 ms at every commit through 02d5e2b6. It was 945–1000 ms at #2788 3b9d1354 and every later commit to 99fe700c.

This PR. Main 8697bd29 vs this change, 6 interleaved rounds × 3 samples (n=18 per variant):

scenario metric main this PR Δ (per-round range) p
ingest, 40 arrivals script 971 ms 583 ms −39.9% (−43.6..−32.9) 3e-7
ingest, 40 arrivals task 1293 ms 903 ms −30.2% (−33.1..−20.2) 3e-7
ingest + unread mention script 977 ms 568 ms −41.8% (−46.0..−28.6) 3e-7
ingest + unread mention task 1318 ms 914 ms −30.7% (−35.4..−15.7) 3e-7
idle 5 s (control) script 50 ms 56 ms +10% (−27..+64) 0.4 (noise)

For reference, desktop-v0.7.17 measured 499 ms of ingest script on the same probe (n=18).

Controls. The first run showed warm switch at +4.4% script (p=0.1) and typing LoAF at +3 ms (p=0.04), both borderline. A dedicated re-run, 6 rounds × 3 samples, found both to be noise:

scenario metric main this PR Δ p
warm channel switch script 178 ms 177 ms −0.5% 0.7
warm channel switch first rows 267 ms 264 ms −1.2% 0.7
typing, 25 keys script 401 ms 407 ms +1.5% 0.9
typing, 25 keys keydown sum (Event Timing) 872 ms 864 ms −0.9% 0.7

Tests

Checks run:

  • vitest run src/features/solo src/bundled/channels src/features/channel-navigation: 98 files, 1776 tests pass.
  • tsc --noEmit: pass.
  • biome check --error-on-warnings on the changed files: pass.
  • scripts/check-staged-names.mjs, the icon check and git diff --check: pass.

The commit and push hooks could not start, because Hermit's pinned Node 24.18.0 download returned 502. I ran the pre-commit jobs above by hand with system Node v26.13 and committed with LEFTHOOK=0. I did not run the pre-push check-push.mjs lanes. Hosted CI is the gate for those.

Independent review: one agent review, 9/10, no blockers. Its optional test extension (chat → no chat) is included.

Still needed:

  • A macOS A/B.
  • Human testing.

Second commit: the New menu has the same subscription

#2788 also made the always-mounted ReadyNewMenu (src/app/shell/NewMenu.tsx) subscribe to the full Solo navigation snapshot. It used that snapshot only to derive one boolean, composing: whether the first region source composes workspaces. Every channel-list refresh, so every incoming message, re-rendered the menu.

Change: NewMenu now selects that boolean through useSyncExternalStore, with the same first-source rule.

No behavior change:

  • The boolean still changes when a source registers or is disposed. The existing PrimarySidebar.test.tsx lifecycle case covers false → true → false with the real NewMenu.
  • Workspace creation still looks up the current source when it is invoked.

Evidence (Linux only). Method: this PR's first commit vs both commits, 6 interleaved rounds × 3 samples (n=18), same box and method as above.

scenario metric first commit both commits Δ (per-round range) p
ingest, 40 arrivals script 574 ms 530 ms −7.6% (−13.3..−3.4) 0.002
ingest + unread mention script 577 ms 538 ms −6.8% (−10.0..+7.2) 0.01

That brings ingest script to within about 6% of desktop-v0.7.17, which measured 499 ms.

Checks:

  • vitest run src/app/shell: 22 files, 447 tests pass.
  • tsc --noEmit: pass.
  • biome check: pass.
  • An independent agent review: 9/10, no blockers.

CI note: the failure in browser shard chromium 7/9 was thread-unread.spec.mjs:421, in its hover background check. It passed 4/4 locally with --repeat-each=4 on both main 8697bd29 and this branch. I read it as a flake.

Final sweep: this PR stacked with #3334

Method: main 8697bd29 vs F2 + F2b + #3334, 6 interleaved rounds × 2 samples (n=12 per variant), 14 configs, same box and method as above. Linux only.

scenario metric main stacked Δ (per-round range) p
ingest, 40 arrivals script 984 ms 510 ms −48.2% (−49.4..−46.0) 3e-5
ingest, 40 arrivals task 1310 ms 820 ms −37.4% 3e-5
ingest + unread mention script 970 ms 518 ms −46.6% (−47.9..−45.6) 3e-5

Ingest script is back at the desktop-v0.7.17 level (499 ms).

Warm switch, 5k-member switch, typing, mention typing, 5k members filter, idle, hover, thread open and cold start are all unchanged (p ≥ 0.1).

Third commit: a Solo chat re-renders only when its region's workspace inputs change

With a Solo chat open, the first commit still subscribes, as intended. But a channel-list refresh rebuilds every region object, so each incoming message elsewhere still re-rendered ChannelWorkspace.

Change: useRegionWorkspace selects a cached tuple of the binding, the chat's owning source, that region's accepted snapshot and its reason. It calls source.workspace() during render, as before.

No behavior change:

  • Identity of the inputs: the source is the registered runtime entry, and the accepted snapshot is passed through by identity. A snapshot is replaced only by a newer revision (solo-navigation.ts:312,324–333,359–373,378–383; solo-navigation-model.ts:54–72).
  • Contract: SoloRegionSource.workspace says a change "shows through read's next revision" (solo-navigation-contract.ts:48–50). A new revision therefore re-renders.
  • Failed reads: a failed read() keeps the old snapshot and sets reason: "failed". reason is part of the tuple, so that also re-renders and still reaches the accessor's existing catch.
  • Test: the updated test covers three transitions: an unrelated publish doesn't re-render; a new revision re-renders with the new workspace id; a failed read re-renders. It fails on the previous commit.

Evidence (Linux only). Method: this PR's first two commits vs all three, 6 interleaved rounds × 3 samples (n=18), same box and method as above. bl4b-ingest-solo has a 120-row Solo chat open while 40 peer messages arrive in Beta.

scenario metric first two commits all three Δ (per-round range) p
ingest while a Solo chat is open script 945 ms 860 ms −9.0% (−10.5..−5.5) 9e-7
ingest while a Solo chat is open task 1285 ms 1200 ms −6.6% (−8.5..−4.0) 9e-6
ingest, ordinary channel (control) script 540 ms 527 ms −2.4% 0.4 (noise)

Bisect for this case. Commits got 2 rounds × 3 samples:

Checks:

  • vitest run src/features/solo src/bundled/channels src/features/channel-navigation src/app/shell: 120 files, 2223 tests pass.
  • tsc --noEmit: pass.
  • biome check: pass.
  • An independent agent review: its first pass found one blocker, that reason was not compared. It is fixed in this commit. The re-review scored 9/10 with no blockers.

…filed

Co-authored-by: BL4B-00 <887685ae8076d98fbe7787900d8d1df35ace3e444fc8bf9e40b59fb349547c33>
Signed-off-by: Kalvin C <[email protected]>
… the New menu

Co-authored-by: BL4B-00 <887685ae8076d98fbe7787900d8d1df35ace3e444fc8bf9e40b59fb349547c33>
Signed-off-by: Kalvin C <[email protected]>
… or snapshot changes

Co-authored-by: BL4B-00 <887685ae8076d98fbe7787900d8d1df35ace3e444fc8bf9e40b59fb349547c33>
Signed-off-by: Kalvin C <[email protected]>
@kalvinnchau
kalvinnchau marked this pull request as ready for review November 13, 2026 01:26
@kalvinnchau
kalvinnchau requested a review from a team as a code owner November 13, 2026 01:26

@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.

🤖 Reviewed at e360cab. The cached tuple is safe under the SoloRegionSource.workspace contract, which says a workspace change shows through read's next revision. Each input it holds stays the same object until it really changes. The binding is frozen once per bind, the source is the registered entry, and the snapshot is replaced only on a newer revision. A chat moving into, out of or between regions changes source, so it re-renders. Hook order stays the same whether or not there is a channelId. I found no other consumer in ChannelWorkspace or NewMenu that relied on the extra re-renders.

Nonblocking nit: const noSubscription = () => () => {} now exists in three places (solo-navigation-react.ts, NewMenu.tsx, chat-workspace.ts). It could be exported once from solo-navigation-react.ts.

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

## Problem

#3219 added `previewLinks`, which `MessageRow` runs for every mounted
Markdown row whenever a link-preview registry exists. The browser and
desktop builds always create that registry
(`src/features/conversation/service.tsx:212-217`), even when it has no
providers. `previewLinks` called `prepareMarkdown(content)` first and
checked for `https://` only afterwards
(`src/features/messages/preview-links.ts:41-42`). So every link-free,
non-plain Markdown row paid for preparation, which for non-trivial
Markdown means a `fromMarkdown` parse via `scanMarkdown`, just to return
`[]`.

The row's own body preparation is cached across remounts in
`MessageMarkdown.tsx` (`preparedBodies`). This call is not cached, so
the cost recurs on every row mount: on channel open, on revisits and
while scrolling through history.

## Change

`src/features/messages/preview-links.ts`: test the raw content for
`https://` before preparing it. If the test fails, return `[]` without
preparing.

## No behavior change

- Before, the function returned `[]` when `prepared.kind === "plain"`
**or** the raw `content` lacked `https://`. After, it checks the same
two conditions in the opposite order.
- `prepareMarkdown` is pure and immutable
(`markdown-preparation.ts:117`). Skipping it when the result would be
discarded has no other effect.
- The `https://` test still runs on the raw `content`, as before, not on
the prepared content.
- Every input that passes both checks takes the unchanged path.
- The existing `preview-links.test.ts` cases pass unchanged: bare,
labeled and reference links, angle links, code, images, spoilers and
exclusions.

## Evidence

**Linux only, on a single machine.** The Blox API returned 502 for the
whole session, so no macOS run and no multi-box spread were possible.
The macOS half of the usual evidence is missing.

Method:
- Interleaved A/B of main `b9c2edbb` vs main plus this change, using
`pnpm perf` cases and the sweep's probes.
- Chromium production build at 4× CPU, on one 11-vCPU Linux box.
- 6 rounds × 3 samples, n=18 per variant. Order alternated each round.
- The table shows medians. The range is the min..max of the per-round
median deltas. p is a two-sided Mann–Whitney test.

| scenario | metric | main | this PR | Δ (per-round range) | p |
|---|---|---|---|---|---|
| scroll-paging-fast-markdown | script | 7119 ms | 6755 ms | −5.1%
(−6.8..−3.2) | 9e-6 |
| scroll-paging-fast-markdown | LoAF blocking | 2916 ms | 2635 ms |
−9.6% (−19.1..−6.9) | 5e-5 |
| scroll-paging-fast-markdown | task | 13466 ms | 13053 ms | −3.1%
(−4.8..−2.9) | 4e-4 |
| channel-open-cold-markdown | script | 685 ms | 633 ms | −7.6%
(−16.7..+3.5) | 0.002 |
| channel-open-cold-markdown | first rows | 934 ms | 890 ms | −4.7%
(−15.9..+1.9) | 0.005 |
| hover across rows (control) | script | 81 ms | 83 ms | +1.6% | 0.4
(noise) |

**Bisect: #3219 introduced the cost.** Method: interleaved A/B of
#3219's parent `2a514ec6` vs #3219 `741f3a84`, 6 rounds × 2 samples
(n=12 per variant), same box and method as above.

| scenario | metric | #3219 parent | #3219 | Δ (per-round range) | p |
|---|---|---|---|---|---|
| channel-open-cold-markdown | script | 596 ms | 664 ms | +11.4%
(+2.0..+15.2) | 0.007 |
| channel-open-cold-markdown | first rows | 835 ms | 916 ms | +9.6%
(+0.8..+14.2) | 0.009 |
| channel-open-cold-markdown | LoAF blocking | 557 ms | 651 ms | +16.8%
(+0.9..+22.0) | 0.008 |
| scroll-paging-fast-markdown | script | 6765 ms | 7191 ms | +6.3%
(−0.9..+11.7) | 0.001 |
| scroll-paging-fast-markdown | LoAF blocking | 2686 ms | 3178 ms |
+18.3% (−5.2..+34.6) | 0.003 |
| warm switch, Markdown corpus | script | 172 ms | 172 ms | +0.1% | 0.6
(noise) |

**Replication.** I re-ran on main `8697bd29`, 6 rounds × 2 samples (n=12
per variant), in a 4-way sweep of main, this PR, #3335 and both. The
first run's warm Markdown switch result (−7.5%, p=0.02) did not repeat:
it measured −0.7% (p=0.3). It has been dropped from the table above.
These did repeat:

| scenario | metric | Δ this PR vs main | p |
|---|---|---|---|
| channel-open-cold-markdown | script | −4.9% | 4e-4 |
| channel-open-cold-markdown | first rows | −4.4% | 0.002 |
| scroll-paging-fast-markdown | script | −4.2% | 0.001 |
| scroll-paging-fast-markdown | LoAF blocking | −11.3% | 7e-4 |

**Stacked with #3335.** Main `8697bd29` vs both PRs, n=12 per variant.
The two changes touch separate paths and their wins add:

| scenario | metric | Δ | p |
|---|---|---|---|
| channel-open-cold-markdown | script | −6.6% | 5e-4 |
| channel-open-cold-markdown | first rows | −5.0% | 0.008 |
| scroll-paging-fast-markdown | script | −4.5% | 0.001 |
| scroll-paging-fast-markdown | LoAF blocking | −12.3% | 0.007 |

Ten other configs are unchanged (all p ≥ 0.1): warm switch, 5k-member
switch, typing, mention typing, 5k members filter, idle, hover, thread
open, cold start and Markdown switch.

## Tests

Checks run:
- `vitest run src/features/messages`: 103 files, 3049 tests pass.
- `tsc --noEmit`: pass.
- `biome check --error-on-warnings` on the changed file: pass.
- `scripts/check-staged-names.mjs`, the icon check and `git diff
--check`: pass.

The commit and push hooks could not start, because Hermit's pinned Node
24.18.0 download returned 502. I ran the pre-commit jobs above by hand
with system Node v26.13 and committed with `LEFTHOOK=0`. I did not run
the pre-push `check-push.mjs` lanes. Hosted CI is the gate for those.

**Independent review:** one agent review, 9/10, no blockers and no
changes recommended.

**Still needed:**
- A macOS A/B.
- Human testing.

Signed-off-by: Kalvin C <[email protected]>
@kalvinnchau
kalvinnchau merged commit 05d5008 into main Nov 13, 2026
64 checks passed
@kalvinnchau
kalvinnchau deleted the perf/region-workspace-subscription branch November 13, 2026 01:38
ljknight added a commit to ljknight/buzz-app that referenced this pull request Nov 13, 2026
* origin/main:
  feat(agents): let a plugin give one agent standing instructions in one conversation (block#3432)
  Keep Solo draft roles in source-owned JSON (block#3463)
  fix(media): save Download Image from the native menu on macOS (block#3352)
  fix(bestie): show the Bestie's profile picture in navigation (block#3423)
  Expose local agent lifecycle evidence (block#3430)
  fix(macos): recover when WebKit abandons its GPU process at launch (block#3342)
  Preserve JSON context in Solo drafts (block#3429)
  fix(messages): qualify namesake mentions so each renders as a chip (block#3206)
  perf(app): hand a page its request without rendering the shell again (block#3007)
  perf(solo): subscribe to Solo navigation only for a chat that can be filed (block#3335)
  perf(messages): skip markdown preparation for link-free preview scans (block#3334)
  feat(messages): open message actions from a right-click on the row (block#3316)
  fix(agents): read hosted agent records on foreground capacity (block#3431)

Signed-off-by: Laura Knight <[email protected]>

# Conflicts:
#	docs/plugin-authoring-guide.md
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.

2 participants