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
+44 -5
View File
@@ -127,11 +127,38 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
/// directions.
private var extendsUnderTitlebar: Bool?
/// **Which `WindowAccessor` view this controller is currently attached through** the identity
/// `detach(through:)` checks before it tears anything down.
///
/// SwiftUI's dismantle-then-make pair for the background representable is not guaranteed to
/// arrive in the order it logically pairs in (`detach()`'s own doc comment: "observed on macOS
/// 26" is an empirical note, not a documented ordering). A content swap deep in a window's tree
/// the raw-source outlet replacing the whole card-window content area is the one this was traced
/// to can make SwiftUI recreate this representable, and if the *old* instance's `dismantleNSView`
/// is delivered **after** the *new* instance has already attached, an identity-blind `detach()`
/// would tear down the toolbar, the titlebar accessory and the delegate proxy that the newer
/// attach just installed leaving the window with no toolbar at all, which collapses a two-line
/// title-and-subtitle down to AppKit's single combined line (the malformed titlebar this guards
/// against). Tracking *which* view is the current owner is what lets a stale teardown recognize
/// itself as stale and refuse, rather than winning a race it does not know it is in.
///
/// `nil` for every caller that attaches without naming a view every direct call in this file's
/// own tests which keeps their `attach`/`detach` pair exactly as unconditional as it always was;
/// the guard only ever engages when both sides of a call name one.
private weak var attachedThroughView: NSView?
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "window")
// MARK: Attachment
func attach(to window: NSWindow) {
/// - Parameter view: The `WindowAccessor`'s own sensing view, when the caller is one `nil` for
/// a caller with no view of its own (every direct call this file's tests make), which attaches
/// exactly as before. Recorded **before** the same-window early return, so a fresh view mounted
/// over an unchanged window still updates who owns the next `detach(through:)`.
func attach(to window: NSWindow, through view: NSView? = nil) {
if let view {
attachedThroughView = view
}
guard self.window !== window else { return }
self.window = window
if window.delegate !== self {
@@ -161,7 +188,15 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
/// window, a dismantle follows, and only *then* does the real window attach) so chrome
/// discarded here would never reach the window it was made for. `attach` reinstalls whatever is
/// held; a controller that is genuinely done takes its slots down with it.
func detach() {
///
/// - Parameter view: The `WindowAccessor` view being dismantled, when the caller is one `nil`
/// for a caller with no view (every direct call this file's tests make), which detaches exactly
/// as before, unconditionally. A named view that is **not** the one `attachedThroughView` last
/// recorded is refused outright: a fresher `attach(to:through:)` already owns this window's
/// chrome, and this call is a stale teardown arriving for a view that lost the race see
/// `attachedThroughView`.
func detach(through view: NSView? = nil) {
if let view, view !== attachedThroughView { return }
removeTitlebarAccessory()
removeToolbar()
guard let window, window.delegate === self else { return }
@@ -429,8 +464,12 @@ struct WindowAccessor: NSViewRepresentable {
func makeNSView(context: Context) -> NSView {
let view = WindowSensingView()
view.onWindow = { [controller] window in
controller.attach(to: window)
// **Named**, not bare this instance is the token `attach(to:through:)`/`detach(through:)`
// race-guard on (`HostedWindowController.attachedThroughView`), so a stale dismantle for a
// view a later `makeNSView` has already superseded refuses to undo that newer attach's work.
view.onWindow = { [controller, weak view] window in
guard let view else { return }
controller.attach(to: window, through: view)
}
return view
}
@@ -438,7 +477,7 @@ struct WindowAccessor: NSViewRepresentable {
func updateNSView(_ nsView: NSView, context: Context) {}
static func dismantleNSView(_ nsView: NSView, coordinator: HostedWindowController) {
coordinator.detach()
coordinator.detach(through: nsView)
}
}