Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions Sources/BrowserWindowPortal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2801,7 +2801,10 @@ final class WindowBrowserPortal: NSObject {
guard entry.webView != nil else { return webViewId }
guard let container = entry.containerView else { return webViewId }
guard let anchor = entry.anchorView else {
return entry.visibleInUI ? nil : webViewId
// Workspace switching hides retiring browser portals before SwiftUI unmounts
// their anchor views. Keep the hidden WKWebView/slot alive so switching back
// can rebind the existing view instead of forcing a full WebKit reload.
return nil
}
if container.superview == nil || !container.isDescendant(of: hostView) {
return webViewId
Expand All @@ -2811,7 +2814,10 @@ final class WindowBrowserPortal: NSObject {
anchor.superview == nil ||
(installedReferenceView.map { !anchor.isDescendant(of: $0) } ?? false)
if anchorInvalidForCurrentHost {
return entry.visibleInUI ? nil : webViewId
// Hidden browser portals can legitimately be off-tree between workspace
// deactivation and the next rebind. Preserve them until an explicit detach
// (panel close, window teardown, or web view replacement) says otherwise.
return nil
}
Comment on lines 2816 to 2821

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multi-window anchor migration may leak entries permanently

The anchorInvalidForCurrentHost condition covers three sub-cases: anchor.window !== currentWindow, anchor.superview == nil, and the reference-view check. The first sub-case — anchor migrated to a different NSWindow — is not the same as the "workspace switch on the same window" scenario the comment describes.

In a multi-window configuration, if a hidden portal's anchorView ends up on a different window and the corresponding workspace is later closed without going back to that window, the entry will never be cleaned up unless a direct detachWebView call is made. The previous code at least pruned hidden entries in that state.

Consider narrowing the "always-preserve" logic to the specific conditions that workspace switching triggers:

if anchorInvalidForCurrentHost {
    // Hidden portals whose anchor is just off-tree (superview == nil or outside the reference
    // view) are legitimately mid-transition during workspace deactivation; preserve them.
    // If the anchor has moved to a different NSWindow, the portal is truly orphaned and
    // should still be pruned while hidden.
    let anchorOnDifferentWindow = anchor.window !== currentWindow
    if anchorOnDifferentWindow && !entry.visibleInUI {
        return webViewId
    }
    return nil
}

Same consideration applies to the guard let anchor = entry.anchorView else path above — a fully-deallocated anchor (weak ref went nil) is an equally strong signal that the entry is orphaned.

return nil
}
Expand Down
66 changes: 66 additions & 0 deletions cmuxTests/CmuxWebViewKeyEquivalentTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10915,6 +10915,72 @@ final class BrowserWindowPortalLifecycleTests: XCTestCase {
XCTAssertTrue(webView.superview === slot, "Hiding should preserve the hosted WKWebView attachment")
XCTAssertTrue(slot.isHidden, "Hiding should immediately hide the existing portal slot")
}

func testHiddenPortalEntrySurvivesAnchorRemovalUntilWorkspaceRebind() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 500, height: 320),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }
realizeWindowLayout(window)
let portal = WindowBrowserPortal(window: window)

guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}

let anchorFrame = NSRect(x: 40, y: 24, width: 220, height: 160)
let oldAnchor = NSView(frame: anchorFrame)
contentView.addSubview(oldAnchor)

let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration())
portal.bind(webView: webView, to: oldAnchor, visibleInUI: true)
portal.synchronizeWebViewForAnchor(oldAnchor)
advanceAnimations()

guard let slot = webView.superview as? WindowBrowserSlotView else {
XCTFail("Expected browser slot")
return
}

portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: false, zPriority: 0)
portal.synchronizeWebViewForAnchor(oldAnchor)
advanceAnimations()
XCTAssertTrue(slot.isHidden, "Workspace handoff should hide the retiring browser before unmount")

oldAnchor.removeFromSuperview()
portal.synchronizeWebViewForAnchor(oldAnchor)
advanceAnimations()

XCTAssertTrue(
webView.superview === slot,
"Hidden workspace browsers should stay attached while their SwiftUI anchor is temporarily unmounted"
)
XCTAssertTrue(slot.isHidden, "Unmounted hidden workspace browser should remain hidden until rebound")
XCTAssertEqual(portal.debugEntryCount(), 1, "Workspace handoff should keep the hidden browser portal entry alive")

let displayCountBeforeRebind = webView.displayIfNeededCount
let newAnchor = NSView(frame: anchorFrame)
contentView.addSubview(newAnchor)
portal.bind(webView: webView, to: newAnchor, visibleInUI: true)
portal.synchronizeWebViewForAnchor(newAnchor)
advanceAnimations()

XCTAssertTrue(
webView.superview === slot,
"Selecting the workspace again should reuse the existing hidden browser portal slot"
)
XCTAssertFalse(slot.isHidden, "Rebinding the workspace browser should reveal the existing portal slot")
XCTAssertEqual(portal.debugEntryCount(), 1)
XCTAssertGreaterThan(
webView.displayIfNeededCount,
displayCountBeforeRebind,
"Workspace rebind should refresh the preserved browser without recreating its portal slot"
)
}
}

@MainActor
Expand Down