Prevent stale host visibility thrash after tab move

This commit is contained in:
Lawrence Chen 2026-02-23 19:22:11 -08:00
parent 5c584dd7f7
commit b0b73e8878
3 changed files with 91 additions and 3 deletions

View file

@ -5061,6 +5061,16 @@ struct GhosttyTerminalView: NSViewRepresentable {
Coordinator()
}
static func shouldApplyImmediateHostedStateUpdate(
hostWindowAttached: Bool,
hostedViewHasSuperview: Bool,
isBoundToCurrentHost: Bool
) -> Bool {
if !hostWindowAttached { return true }
if isBoundToCurrentHost { return true }
return !hostedViewHasSuperview
}
func makeNSView(context: Context) -> NSView {
let container = HostContainerView()
container.wantsLayer = false
@ -5103,8 +5113,6 @@ struct GhosttyTerminalView: NSViewRepresentable {
// Keep the surface lifecycle and handlers updated even if we defer re-parenting.
hostedView.attachSurface(terminalSurface)
hostedView.setVisibleInUI(isVisibleInUI)
hostedView.setActive(isActive)
hostedView.setInactiveOverlay(
color: inactiveOverlayColor,
opacity: CGFloat(inactiveOverlayOpacity),
@ -5139,7 +5147,8 @@ struct GhosttyTerminalView: NSViewRepresentable {
coordinator.attachGeneration += 1
let generation = coordinator.attachGeneration
if let host = nsView as? HostContainerView {
let hostContainer = nsView as? HostContainerView
if let host = hostContainer {
host.onDidMoveToWindow = { [weak host, weak hostedView, weak coordinator] in
guard let host, let hostedView, let coordinator else { return }
guard coordinator.attachGeneration == generation else { return }
@ -5190,6 +5199,28 @@ struct GhosttyTerminalView: NSViewRepresentable {
)
}
}
let hostWindowAttached = hostContainer?.window != nil
let isBoundToCurrentHost = hostContainer.map { host in
TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host)
} ?? true
let shouldApplyImmediateHostedState = Self.shouldApplyImmediateHostedStateUpdate(
hostWindowAttached: hostWindowAttached,
hostedViewHasSuperview: hostedView.superview != nil,
isBoundToCurrentHost: isBoundToCurrentHost
)
if shouldApplyImmediateHostedState {
hostedView.setVisibleInUI(isVisibleInUI)
hostedView.setActive(isActive)
} else {
// Preserve portal entry visibility while a stale host is still receiving SwiftUI updates.
// The currently bound host remains authoritative for immediate visible/active state.
TerminalWindowPortalRegistry.updateEntryVisibility(
for: hostedView,
visibleInUI: isVisibleInUI
)
}
}
static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) {

View file

@ -934,6 +934,12 @@ final class WindowTerminalPortal: NSObject {
entriesByHostedId[hostedId] = entry
}
func isHostedViewBoundToAnchor(withId hostedId: ObjectIdentifier, anchorView: NSView) -> Bool {
guard let entry = entriesByHostedId[hostedId],
let boundAnchor = entry.anchorView else { return false }
return boundAnchor === anchorView
}
func bind(hostedView: GhosttySurfaceScrollView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0) {
guard ensureInstalled() else { return }
@ -1462,6 +1468,15 @@ enum TerminalWindowPortalRegistry {
portal.updateEntryVisibility(forHostedId: hostedId, visibleInUI: visibleInUI)
}
static func isHostedView(_ hostedView: GhosttySurfaceScrollView, boundTo anchorView: NSView) -> Bool {
let hostedId = ObjectIdentifier(hostedView)
guard let window = anchorView.window else { return false }
let windowId = ObjectIdentifier(window)
guard hostedToWindowId[hostedId] == windowId,
let portal = portalsByWindowId[windowId] else { return false }
return portal.isHostedViewBoundToAnchor(withId: hostedId, anchorView: anchorView)
}
static func viewAtWindowPoint(_ windowPoint: NSPoint, in window: NSWindow) -> NSView? {
let portal = portal(for: window)
return portal.viewAtWindowPoint(windowPoint)

View file

@ -6289,3 +6289,45 @@ final class BrowserOmnibarFocusPolicyTests: XCTestCase {
)
}
}
final class GhosttyTerminalViewVisibilityPolicyTests: XCTestCase {
func testImmediateStateUpdateAllowedWhenHostNotInWindow() {
XCTAssertTrue(
GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate(
hostWindowAttached: false,
hostedViewHasSuperview: true,
isBoundToCurrentHost: false
)
)
}
func testImmediateStateUpdateAllowedWhenBoundToCurrentHost() {
XCTAssertTrue(
GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate(
hostWindowAttached: true,
hostedViewHasSuperview: true,
isBoundToCurrentHost: true
)
)
}
func testImmediateStateUpdateSkippedForStaleHostBoundElsewhere() {
XCTAssertFalse(
GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate(
hostWindowAttached: true,
hostedViewHasSuperview: true,
isBoundToCurrentHost: false
)
)
}
func testImmediateStateUpdateAllowedWhenUnboundAndNotAttachedAnywhere() {
XCTAssertTrue(
GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate(
hostWindowAttached: true,
hostedViewHasSuperview: false,
isBoundToCurrentHost: false
)
)
}
}