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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user