A stale window dismantle stops undoing a fresher attach — the card window keeps its toolbar across a raw-source toggle

Toggling a card window between Edit and Raw Source could leave it with no toolbar at
all, which collapses AppKit's two-line title-and-subtitle chrome down to the single
combined "⟨title⟩ — ⟨board⟩ › ⟨lane⟩" line (the malformed titlebar reported on the
Pipeline card) — that stacked rendering only appears when a toolbar is installed.

Root cause was in HostedWindowController.attach/detach (WindowAccessor.swift), shared
by every window this app hosts. WindowAccessor's own doc comment already recorded that
SwiftUI "dismantles and re-makes the background representable" on macOS 26, and every
slot attach()/detach() manage was made repeat-safe against that (BoardChromeTests
.theSlotReappliesToTheNextWindow pins it for extendsUnderTitlebar) — but that safety
net assumes a dismantle always arrives before its matching attach, and nothing
guarantees that ordering. A content swap deep in the card window's tree (the raw-source
outlet replacing the whole content area, or an edit-mode flush landing a reload) is the
kind of churn that can make SwiftUI recreate the representable mid-session. If the old
view's dismantleNSView lands after the new view's attach has already reinstalled the
toolbar, the old identity-blind detach() had no way to tell — its guards check "is my
state still installed", which is coincidentally true right after a fresh reattach too —
so it tore the toolbar, the titlebar accessory and the delegate proxy right back off a
window a newer attach had just finished configuring, with nothing left to reinstall it.

Fix: attach(to:through:)/detach(through:) track which WindowAccessor view is the
current owner (HostedWindowController.attachedThroughView) and refuse a detach for any
other view outright. WindowAccessor.makeNSView/dismantleNSView pass their own view
through; every existing bare attach(to:)/detach() caller (this file's own tests,
BoardChromeTests, InlineEditWriteTests, HistoryProviderTests) is untouched — the guard
only engages when both sides of a call name a view. State re-asserted at the ownership
point rather than a notification-race band-aid, the same shape a429a7e's
titlebar-transparency fix used.

Could not reproduce live — the screen is locked in this environment (CGSSessionScreen
IsLocked). Established the mechanism from code and verified it with a targeted harness
instead: ToolbarStaleDismantleTests (ToolbarTests.swift) drives HostedWindowController
directly through the exact race (attach view A, attach view B over the same still-live
window, then a stale detach for view A), confirms the toolbar and delegate survive, and
separately confirms the legitimate owner's detach, the ordinary detach-then-attach
order, and every viewless caller all behave exactly as before. Verified the new test
fails without the fix (temporarily disabled the identity guard, reran in isolation, saw
the expected failure) before restoring it.

Tests: 3223 KanbanTests, 3220 passing. The only 3 failures are PointerLatencyTests'
documented locked-screen environmental mode (CGEvent-driven clicks need a live screen)
— reran that suite alone and got the identical 3 failures, none of which touch this
window-attachment code.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-09 12:06:01 -04:00
parent 98bbe551ea
commit fda19881de
2 changed files with 167 additions and 5 deletions
+123
View File
@@ -828,6 +828,129 @@ struct CardToolbarTests {
}
}
// MARK: - The toolbar survives a stale dismantle
/// **The malformed-titlebar bug**: toggling a card window between Edit and Raw Source could leave it
/// with no toolbar at all and losing the toolbar is what collapses AppKit's two-line title-and-
/// subtitle down to its single combined line, the "title board lane" strip a user reported
/// seeing after exactly that toggle.
///
/// The root cause lived in `HostedWindowController.attach`/`detach`, not in the card window's own
/// chrome code: `WindowAccessor`'s own doc comment already recorded that SwiftUI "dismantles and
/// re-makes the background representable" on macOS 26, and a content swap deep in the window's tree
/// (raw source replacing the whole content area is the one this traces to) can trigger exactly that
/// but the dismantle for the *old* representable and the attach for the *new* one were never
/// guaranteed to arrive in the order they logically pair in. When the stale dismantle landed **after**
/// the fresh attach, an identity-blind `detach()` tore the toolbar (and the titlebar accessory, and the
/// delegate proxy) right back off a window a newer attach had just finished configuring silently,
/// with nothing to put it back until something else attached again.
///
/// `attach(to:through:)`/`detach(through:)` fix this by tracking *which* `WindowAccessor` view is the
/// current owner and refusing a detach for any other see `HostedWindowController.attachedThroughView`.
/// These tests drive that seam directly, standing in for the two views SwiftUI would otherwise recreate.
@MainActor
@Suite("Toolbar ▸ survives a stale dismantle")
struct ToolbarStaleDismantleTests {
private func makeWindow() -> NSWindow {
NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 600, height: 400),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered,
defer: true
)
}
/// A real, installable card toolbar the exact shape `CardWindowHost.configureWindow` hands
/// `installToolbar`, built fresh rather than borrowed from `CardToolbarTests` (`private` there,
/// on purpose: a struct's own fixtures are not a second suite's to reach into).
private func makeToolbar() -> WindowToolbarController {
CardToolbar.controller(
body: CardBodyPresentation(),
rawSource: CardRawSourceSession(),
attachments: CardAttachments(),
actions: CardWindowActions()
)
}
@Test("A dismantle for a view a newer attach has already superseded does not remove the toolbar")
func aStaleDismantleIsRefused() {
let window = makeWindow()
let controller = HostedWindowController()
let toolbarController = makeToolbar()
controller.installToolbar(toolbarController)
let firstView = NSView()
let secondView = NSView()
// The normal-order half: the first mount installs the toolbar.
controller.attach(to: window, through: firstView)
#expect(window.toolbar === toolbarController.toolbar)
// A second `WindowAccessor` instance mounts over the *same* window the racy content-swap
// case, standing in for SwiftUI recreating the representable without the window itself
// changing. It becomes the new owner.
controller.attach(to: window, through: secondView)
#expect(window.toolbar === toolbarController.toolbar, "the still-current window keeps its toolbar")
// The first view's teardown arrives *after* the second view's attach the ordering
// `WindowAccessor`'s own doc comment says is not guaranteed. A stale detach for a superseded
// view must not undo what the fresher attach just did.
controller.detach(through: firstView)
#expect(window.toolbar === toolbarController.toolbar, "a stale dismantle must not remove the toolbar")
#expect(window.delegate === controller, "nor hand the delegate back to SwiftUI's own")
}
@Test("The legitimate owner's dismantle still tears the toolbar down")
func theCurrentOwnersDismantleStillWorks() {
let window = makeWindow()
let controller = HostedWindowController()
let toolbarController = makeToolbar()
controller.installToolbar(toolbarController)
let view = NSView()
controller.attach(to: window, through: view)
#expect(window.toolbar === toolbarController.toolbar)
controller.detach(through: view)
#expect(window.toolbar == nil, "the view that actually owns the attachment can still tear it down")
}
@Test("The normal order — detach, then attach — reinstalls the toolbar exactly as before")
func theOrdinaryOrderStillSelfHeals() {
let window = makeWindow()
let controller = HostedWindowController()
let toolbarController = makeToolbar()
controller.installToolbar(toolbarController)
let firstView = NSView()
controller.attach(to: window, through: firstView)
controller.detach(through: firstView)
#expect(window.toolbar == nil)
let secondView = NSView()
controller.attach(to: window, through: secondView)
#expect(window.toolbar === toolbarController.toolbar, "the next attach reinstalls it")
}
@Test("A caller that names no view keeps the original, unconditional attach/detach")
func viewlessCallsAreUnaffected() {
// `BoardChromeTests` and every other direct caller in this test target attaches and detaches
// without a view the shape `attach(to:)`/`detach()` always had. That pair must keep working
// exactly as before: the view-identity guard only ever engages when both sides name one.
let window = makeWindow()
let controller = HostedWindowController()
let toolbarController = makeToolbar()
controller.installToolbar(toolbarController)
controller.attach(to: window)
#expect(window.toolbar === toolbarController.toolbar)
controller.detach()
#expect(window.toolbar == nil)
}
}
// MARK: - F and the search field's two homes
/// "F always summons search: with the field removed from the toolbar, invoking it surfaces the