Skip to content

perf: make TabItem a reference so one title write wakes one tab - #222

Merged
teamleaderleo merged 3 commits into
manaflow-ai:mainfrom
STRML:perf/per-tab-observation
Sep 30, 2026
Merged

teamleaderleo merged 3 commits into
manaflow-ai:mainfrom
STRML:perf/per-tab-observation

Conversation

@STRML

@STRML STRML commented Aug 18, 2026 •

Copy link
Copy Markdown

A tab title change currently re-renders the whole tab bar. This makes it re-render the one tab that changed.

PaneState.tabs is an @Observable property and TabItem is a value, so this line in updateTab:

pane.tabs[tabIndex].title = title

writes the array rather than the element. Observation invalidates every reader of tabs, which is all of TabBarView.body: visibleTabEntries, every TabItemView, tabScrollContent, and splitButtonRow.

Nothing observes the individual tab either. TabItemView gets let tab: TabItem by value, so the title it renders is a copy and no view is subscribed to it. Redraws happen only because the parent rebuilds.

What it costs

The host is cmux, where agent tools write a spinner frame into the terminal title. Each frame is a title change. Instruments Time Profiler, 20 seconds, 8 animating tabs:

Measurement Value
Total process CPU 6726 ms (34% of one core)
SwiftUI graph updates 65% of it
Graph update episodes 400, about 19.5 per second
Median episode 9 ms on the main thread
Episodes that touched the tab bar 294 of 400, 73.5%

The sidebar in the same profile is 0.6%, so the tab bar is where the time actually goes.

The change

TabItem becomes an @Observable final class, and updateTab writes through the reference it already had:

 let currentTab = pane.tabs[tabIndex]
 ...
-pane.tabs[tabIndex].title = title
+currentTab.title = title

pane.tabs is now only read there, so readers of the array are not invalidated. Observation delivers the title to the TabItemView that reads it, which is the view that has to redraw anyway.

tabs still changes on insert, remove, and move. Those change layout, and TabBarView does need to see them.

Why the diff is small

  • TabItem already declared == and hash(into:) by id alone, so identity comparisons behave exactly as before.
  • Codable was already hand-rolled with explicit CodingKeys, init(from:), and encode(to:). Only required was added.
  • updateTab is the only place in the package that writes a tab field, and no local copy of a TabItem is mutated anywhere. Nothing depended on value semantics.
  • PaneState and BonsplitController are already @Observable, so this brings TabItem in line with the models around it.

The whole diff is 35 added lines and 15 removed, most of it the doc comment.

Tests

Two commits: the first adds the tests and is red, the second makes them green. Check out 24bdd8f to see them fail.

Red on the first commit:

  • testChangingOneTabTitleDoesNotInvalidateTheTabsArray - a withObservationTracking block reading pane.tabs, standing in for TabBarView.body, fires on a title change.
  • testChangingOneTabTitleInvalidatesThatTab - a block reading tab.title, standing in for TabItemView.body, does not fire, and the tab it was handed still reads the old title.

Green on both commits, so they guard the parts that were already right:

  • A sibling tab is not invalidated by another tab's title.
  • Adding a tab still invalidates the array.
  • Removing a tab still invalidates the array.
  • Writing an unchanged title wakes nobody.

Full suite: 227 XCTest and 27 swift-testing tests, no failures, no changes to existing tests.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Summary by cubic

Tab title changes now re-render only the affected tab. PaneState.tabs is an @Observable property, so writing pane.tabs[i].title used to write the whole array and invalidate every view that had read it—all of TabBarView, not just the changed tab. On a host whose agent tools animate the terminal title, that meant roughly 20 full tab bar redraws per second.

  • TabItem is now an @Observable final class, and updateTab writes through the reference it already holds instead of the tabs array; observation delivers the change to the one TabItemView that reads it.
  • Insert, remove, and move still publish through tabs, which TabBarView needs for layout.
  • Equality and hashing stay by id, and Codable remains hand-rolled (only required added); no public API changes.
  • Tests pin observation scope: a title change does not invalidate the array or sibling tabs, only the changed tab's readers; insert/remove still invalidate the array; no-op writes wake nobody.

Written for commit fc64a08. Summary will update on new commits.

Review in cubic

STRML added 2 commits August 18, 2026 17:00
Two of these fail on main. A title write goes through PaneState.tabs, which is an @observable property, so it invalidates every reader of the array rather than the one tab that changed. And because TabItem is a value, the tab handed to a view is a copy, so nothing observes its title at all.

Red on this commit: testChangingOneTabTitleDoesNotInvalidateTheTabsArray, testChangingOneTabTitleInvalidatesThatTab.

Claude-Session: https://claude.ai/code/session_01Bj791kgph6Xk9CJf9c41Db
PaneState.tabs is an @observable property, so pane.tabs[i].title = x wrote the array and invalidated everything that had read it: every TabItemView, the scroll content, and the split button row. A host whose tab titles animate drove that about 20 times a second.

TabItem is now an @observable final class. updateTab takes the reference it already had for its change check and writes through that, so the array is only read. Observation then delivers the title to the one TabItemView that reads it. Insert, remove, and move still write tabs, which is what TabBarView needs to see.

Equality and hashing were already by id alone and Codable was already hand-rolled with explicit CodingKeys, so neither changes. No local copy of a TabItem is mutated anywhere in the package, so nothing depended on value semantics.

Turns the two red tests from the previous commit green. 227 XCTest and 27 swift-testing tests pass, with no changes to existing tests.

Claude-Session: https://claude.ai/code/session_01Bj791kgph6Xk9CJf9c41Db
@coderabbitai

coderabbitai Bot commented Aug 18, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 52 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: c51f4e60-fb2b-4370-af91-47bca3d021e2

📥 Commits

Reviewing files that changed from the base of the PR and between dfdc7fb and fc64a08.

📒 Files selected for processing (4)
  • CHANGELOG.md
  • Sources/Bonsplit/Internal/Models/TabItem.swift
  • Sources/Bonsplit/Public/BonsplitController.swift
  • Tests/BonsplitTests/TabObservationScopeTests.swift

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@STRML

STRML commented Aug 18, 2026

Copy link
Copy Markdown
Author

Measured this end to end. Built cmux against a bonsplit with this branch plus #221, ran the same 20-second Instruments capture on the real app.

The tab bar's share of SwiftUI graph updates goes from 73-83% to 14%, and each update gets about three times cheaper.

before (run 1) before (run 2) after
Total process CPU 6726 ms (34% of a core) 9822 ms (49%) 4271 ms (21%)
Graph-update episodes 400 (19.5/s) 342 (16.7/s) 388 (18.9/s)
Median episode 9 ms 15 ms 4 ms
Episodes touching the tab bar 294 / 400, 73.5% 283 / 342, 82.7% 56 / 388, 14.4%
Tab bar CPU 737 ms, 11.0% 1011 ms, 10.3% 109 ms, 2.6%
SF Symbol image creation 219 ms, 3.3% 286 ms, 2.9% 0 ms
CA display cycle / layout commit 23.5% 23.9% 17.6%

Both patches were in the build, so the two are not separated here. The zero on SF Symbol creation is #221; the collapse in tab bar episodes is this one.

The workload is not identical run to run, since it depends on how many agents happen to be spinning. Title events during the captures, as a control:

before (run 2) after
updatePanelTitle 59 49
refreshTabLabel 51 40
TitleUpdateIngress 99 76

So the after run carried about 80% of the title traffic. That does not account for a 5.7x drop in tab bar episodes.

One thing this does not fix, worth saying out loud: the graph still updates about 19 times a second. The rate did not move, only the cost and the blast radius. After the change 35% of episodes are cmuxApp.body alone, so something on the host side is waking the app body at roughly spinner rate. That is a cmux problem rather than a bonsplit one, and I am chasing it separately.

STRML commented Sep 26, 2026

Copy link
Copy Markdown
Author

@austinywang @teamleaderleo Current Bonsplit main still defines TabItem as a value type, stores [TabItem] in PaneState.tabs, and writes updates through pane.tabs[tabIndex] in updateTab. cmux updates terminal tab titles as agent spinner frames arrive, so these writes republish the array and invalidate readers that only need tab order or IDs. This PR's per-tab observable reference is still relevant and directly targets that invalidation mechanism. Could you review it against the current main implementation?

— Inkstone pending
run: run_cmux_bonsplit_perf_20260926
session: codex_cmux_bonsplit_perf_20260926

@teamleaderleo

Copy link
Copy Markdown

Subagent review at 89b4c44: merge. Checked every place tabs get copied or compared and nothing relies on value semantics. One follow-up we'll do: skip same-value writes in updateTab so older toolchains don't wake the whole bar. Thank you :)

@teamleaderleo
teamleaderleo merged commit d3f7e8f into manaflow-ai:main Sep 30, 2026
5 checks passed
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