Host each pane once; build nested splits in AppKit
Summary
A deep split chain froze cmux's main thread on its first mount, for about 28 s at depth 16 and about 2 minutes at depth 18 (manaflow-ai/cmux#10311, manaflow-ai/cmux#16393).
Each child of a split was an NSHostingController hosting another SplitNodeView, so every split level nested one more NSHostingView. SwiftUI walks the focusable items of every nested host when it rebuilds the key view loop, and with nested hosts that walk roughly doubled with each level.
Now a split's children are either one hosting view for a pane, or a nested NSSplitView that the same SplitContainerView code builds and updates in plain AppKit (makeSplitView / updateSplitView / updateChild). Pane hosts are siblings, never nested, so the walk stays linear.
Credit to @Apptah: their diagnosis in manaflow-ai/cmux#10315 traced the hang to SwiftUI's key-view-loop focus walk over nested hosts, which is what this removes.
What stays the same:
- The
NSSplitViewtree, its delegates, divider drag sessions, entry animations, imposed extents and theBonsplitManagedSplitViewidentity stamps. Hosts that inspect the split views still find them (cmux's terminal and browser portals do). - Panes keep their hosting view across updates and only swap the root view.
What's different:
- Nested splits no longer get SwiftUI updates of their own. The root observes the split state of the whole tree (orientation, children, divider position, imposed extent and epoch, interactivity) and renders it again on the next turn when that state changes. Pane content isn't in that observation scope; it observes itself inside its own host.
- The per-turn
acceptsFirstRespondercache from #283 is removed. The walk callsNSHostingView's getter directly, so the cache never applied: it trimmed about 10%, not the hang.
This branch is based on the commit cmux pins (bd94ed1), not on main, so cmux can pin it without also taking #282. It merges cleanly into main. Please merge with a merge commit so this commit stays reachable from main.
Testing
cmux's tests_v2/test_deep_split_chain_stays_responsive.py times pane.list, a main-actor request, after showing a 16-deep chain, after switching away and after switching back. Each must answer within 1 s. The runs were on capture minis, from cmux fleet builds of manaflow-ai/cmux leo/flat-pane-hosting.
| Build | Result | Main-actor stall after showing the chain |
|---|---|---|
| cmux main equivalent | 3/4 | 28.33 s |
| With this commit | 4/4 | 0.00 s |
Depth scan, main-actor stall after the first show:
| Depth | Before | With this commit |
|---|---|---|
| 12 | 1.1 s | 0.001 s |
| 14 | 6.2 s | 0.001 s |
| 16 | 28–29 s | 0.001–0.007 s |
| 18 | 119 s | 0.009 s |
| 24 | — | 0.02 s |
| 32 | — | 0.02 s |
| 40 | — | 0.26 s |
- Session restore: launching onto a saved session with an 18-deep and four 16-deep chains answers immediately, and each restored workspace shows without a stall. On cmux main the same session left the app unanswering at launch.
- Split behavior: ten existing split e2e tests (nested-split stability, arranged-subview underflow, detach during update, panel routing, flash and layout, frame guard, geometry fuzz, focus by number, break and swap) give identical results with and without this commit. 5 pass, and 5 fail the same way on both builds.
The package wasn't built or tested locally; this PR's CI runs the package tests.
🤖 Generated with Claude Code