diff --git a/Sources/Panels/BrowserPanel+MediaPlayback.swift b/Sources/Panels/BrowserPanel+MediaPlayback.swift index b6674212e5ac..f591b06dba10 100644 --- a/Sources/Panels/BrowserPanel+MediaPlayback.swift +++ b/Sources/Panels/BrowserPanel+MediaPlayback.swift @@ -187,6 +187,19 @@ extension BrowserPanel { ) } + func teardownMediaPlaybackMessageHandler(for webView: WKWebView) { + guard mediaPlaybackMessageHandler != nil else { + resetMediaPlaybackTracking() + return + } + webView.configuration.userContentController.removeScriptMessageHandler( + forName: mediaPlaybackMessageHandlerName, + contentWorld: Self.mediaPlaybackContentWorld + ) + mediaPlaybackMessageHandler = nil + resetMediaPlaybackTracking() + } + /// Applies a per-frame playback report from the injected hook, aggregating /// across the main frame and any iframes. Reports from a superseded webview /// generation are dropped. diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 0f3773065dbb..84cee393d03c 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -3547,6 +3547,10 @@ final class BrowserPanel: Panel, ObservableObject { private var downloadDelegate: BrowserDownloadDelegate? private var webViewObservers: [NSKeyValueObservation] = [] private var activeDownloadCount: Int = 0 + /// Browser close is a one-shot resource teardown: it severs WebKit callbacks + /// and swaps the heavy page web view for an inert placeholder, so repeated + /// close requests from stale SwiftUI/AppKit teardown paths must be ignored. + private var isClosed = false // Avoid flickering the loading indicator for very fast navigations. private let minLoadingIndicatorDuration: TimeInterval = 0.35 @@ -4223,6 +4227,48 @@ final class BrowserPanel: Panel, ObservableObject { applyMuteState(to: webView, reason: "bindWebView") } + private func releaseWebViewForTeardown(_ targetWebView: WKWebView, reason: String) { + if let window = targetWebView.window, + Self.responderChainContains(window.firstResponder, target: targetWebView) { + window.makeFirstResponder(nil) + } + + BrowserWindowPortalRegistry.detach(webView: targetWebView) + BrowserWindowPortalRegistry.discard( + webView: targetWebView, + source: reason, + preserveCurrentSuperview: false + ) + targetWebView.stopLoading() + targetWebView.navigationDelegate = nil + targetWebView.uiDelegate = nil + targetWebView.configuration.userContentController.removeAllUserScripts() + teardownReactGrabMessageHandler(for: targetWebView) + teardownMediaPlaybackMessageHandler(for: targetWebView) + + if let cmuxWebView = targetWebView as? CmuxWebView { + cmuxWebView.onContextMenuDownloadStateChanged = nil + cmuxWebView.onContextMenuOpenLinkInNewTab = nil + cmuxWebView.contextMenuCanMoveTabToNewWorkspace = nil + cmuxWebView.contextMenuMoveTabToNewWorkspace = nil + cmuxWebView.contextMenuLinkURLProvider = nil + cmuxWebView.contextMenuDefaultBrowserOpener = nil + cmuxWebView.allowsFirstResponderAcquisition = false + } + + targetWebView.removeFromSuperview() + } + + private func installClosedPlaceholderWebView() { + let replacement = Self.makeWebView( + profileID: profileID, + websiteDataStore: websiteDataStore + ) + replacement.allowsFirstResponderAcquisition = false + webViewInstanceID = UUID() + webView = replacement + } + private func configureNavigationDelegateCallbacks() { guard let navigationDelegate else { return } let boundWebViewInstanceID = webViewInstanceID @@ -5623,6 +5669,9 @@ final class BrowserPanel: Panel, ObservableObject { } func close() { + guard !isClosed else { return } + isClosed = true + cancelHiddenWebViewDiscard() isClosingWebViewLifecycle = true refreshWebViewLifecycleState() @@ -5645,17 +5694,30 @@ final class BrowserPanel: Panel, ObservableObject { popup.closePopup() } - webView.stopLoading() + let closedWebView = webView + releaseWebViewForTeardown(closedWebView, reason: "panel.close") + installClosedPlaceholderWebView() + isMainFrameProvisionalNavigationActive = false - webView.navigationDelegate = nil - webView.uiDelegate = nil navigationDelegate = nil uiDelegate = nil + downloadDelegate = nil webViewDidRequestClose = nil webViewObservers.removeAll() webViewCancellables.removeAll() faviconTask?.cancel() faviconTask = nil + loadingEndWorkItem?.cancel() + loadingEndWorkItem = nil + activeDownloadCount = 0 + isDownloading = false + isLoading = false + estimatedProgress = 0 + shouldRenderWebView = false + activePortalHostLease = nil + pendingDistinctPortalHostReplacementPaneId = nil + lockedPortalHost = nil + resetReactGrabState(reason: "panel.close") } // MARK: - Popup window management diff --git a/Sources/Panels/ReactGrab.swift b/Sources/Panels/ReactGrab.swift index e4670b36a91a..99c1e024fc14 100644 --- a/Sources/Panels/ReactGrab.swift +++ b/Sources/Panels/ReactGrab.swift @@ -226,6 +226,12 @@ extension BrowserPanel { webView.configuration.userContentController.add(handler, name: reactGrabMessageHandlerName) } + func teardownReactGrabMessageHandler(for webView: WKWebView) { + guard reactGrabMessageHandler != nil else { return } + webView.configuration.userContentController.removeScriptMessageHandler(forName: reactGrabMessageHandlerName) + reactGrabMessageHandler = nil + } + func armReactGrabRoundTrip(returnTo panelId: UUID) { let token = UUID().uuidString #if DEBUG diff --git a/cmuxTests/BrowserPanelTests.swift b/cmuxTests/BrowserPanelTests.swift index 62f7157dc7a9..4a921e256382 100644 --- a/cmuxTests/BrowserPanelTests.swift +++ b/cmuxTests/BrowserPanelTests.swift @@ -645,6 +645,62 @@ final class BrowserPanelInitialNavigationTests: XCTestCase { XCTAssertFalse(panel.shouldRenderWebViewForSessionSnapshot()) } + func testCloseDetachesPortalHostedWebViewImmediately() { + let panel = BrowserPanel(workspaceId: UUID()) + let webView = panel.webView + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + let anchorView = NSView(frame: NSRect(x: 0, y: 0, width: 800, height: 600)) + window.contentView = anchorView + + BrowserWindowPortalRegistry.bind( + webView: webView, + to: anchorView, + visibleInUI: true + ) + XCTAssertNotNil(webView.superview, "Test precondition: portal bind should host the web view") + XCTAssertNotNil( + panel.mediaPlaybackMessageHandler, + "Test precondition: binding should install the media playback handler" + ) + + panel.close() + + XCTAssertNil(webView.superview, "Closing a browser panel must release the old WKWebView immediately") + XCTAssertNil( + panel.mediaPlaybackMessageHandler, + "Closing a browser panel must release the media playback message handler" + ) + } + + func testCloseToleratesAlreadyTornDownScriptHandlers() { + let panel = BrowserPanel(workspaceId: UUID()) + let webView = panel.webView + XCTAssertNotNil( + panel.reactGrabMessageHandler, + "Test precondition: binding should install the ReactGrab handler" + ) + XCTAssertNotNil( + panel.mediaPlaybackMessageHandler, + "Test precondition: binding should install the media playback handler" + ) + + panel.teardownReactGrabMessageHandler(for: webView) + panel.teardownMediaPlaybackMessageHandler(for: webView) + + XCTAssertNil(panel.reactGrabMessageHandler) + XCTAssertNil(panel.mediaPlaybackMessageHandler) + + panel.close() + + XCTAssertNil(panel.reactGrabMessageHandler) + XCTAssertNil(panel.mediaPlaybackMessageHandler) + } + func testDiffViewerURLIsNotPersistedForSessionRestore() throws { let schemeURL = try XCTUnwrap(URL(string: "\(CmuxDiffViewerURLSchemeHandler.scheme)://token/index.html")) let schemePanel = BrowserPanel(