diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 545cfafa..5ed71a7b 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1430,6 +1430,13 @@ extension BrowserPanel { return true } + /// During split/layout transitions SwiftUI can briefly mark the browser surface hidden + /// while its container is off-window. Avoid detaching in that transient phase if + /// DevTools is intended to remain open, because detach/reattach can blank inspector content. + func shouldPreserveWebViewAttachmentDuringTransientHide() -> Bool { + preferredDeveloperToolsVisible + } + @discardableResult func zoomIn() -> Bool { applyPageZoom(webView.pageZoom + pageZoomStep) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 7b3c3ea8..c0aeee61 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -2554,6 +2554,7 @@ struct WebViewRepresentable: NSViewRepresentable { let isPanelFocused: Bool final class Coordinator { + weak var panel: BrowserPanel? weak var webView: WKWebView? var attachRetryWorkItem: DispatchWorkItem? var attachRetryCount: Int = 0 @@ -2585,7 +2586,9 @@ struct WebViewRepresentable: NSViewRepresentable { } func makeCoordinator() -> Coordinator { - Coordinator() + let coordinator = Coordinator() + coordinator.panel = panel + return coordinator } func makeNSView(context: Context) -> NSView { @@ -2685,12 +2688,29 @@ struct WebViewRepresentable: NSViewRepresentable { func updateNSView(_ nsView: NSView, context: Context) { let webView = panel.webView + context.coordinator.panel = panel context.coordinator.webView = webView // Bonsplit keepAllAlive keeps hidden tabs alive (opacity 0). WKWebView is fragile when left // in the window hierarchy while hidden and rapidly switching focus between tabs. To reduce // WebKit crashes, detach the WKWebView when this surface is not the selected tab in its pane. if !shouldAttachWebView { + // Split/layout churn can briefly create an off-window phase while DevTools is open. + // Detaching here can blank inspector content even when visibility preference stays true. + if nsView.window == nil, + webView.superview != nil, + panel.shouldPreserveWebViewAttachmentDuringTransientHide() { + #if DEBUG + Self.logDevToolsState( + panel, + event: "detach.skipped.offWindowDevTools", + generation: context.coordinator.attachGeneration, + retryCount: context.coordinator.attachRetryCount + ) + #endif + return + } + #if DEBUG Self.logDevToolsState( panel, @@ -2814,6 +2834,7 @@ struct WebViewRepresentable: NSViewRepresentable { coordinator.attachGeneration += 1 guard let webView = coordinator.webView else { return } + let panel = coordinator.panel // If we're being torn down while the WKWebView (or one of its subviews) is first responder, // resign it before detaching. @@ -2821,8 +2842,35 @@ struct WebViewRepresentable: NSViewRepresentable { if let window, responderChainContains(window.firstResponder, target: webView) { window.makeFirstResponder(nil) } + + // During split/layout churn, SwiftUI may tear down a host view while a new one is still + // coming online. When DevTools is intended open, avoid eagerly detaching here. + if let panel, + panel.shouldPreserveWebViewAttachmentDuringTransientHide(), + webView.superview === nsView { + #if DEBUG + logDevToolsState( + panel, + event: "dismantle.skipDetach.devTools", + generation: coordinator.attachGeneration, + retryCount: coordinator.attachRetryCount + ) + #endif + return + } + if webView.superview === nsView { webView.removeFromSuperview() + #if DEBUG + if let panel { + logDevToolsState( + panel, + event: "dismantle.detached", + generation: coordinator.attachGeneration, + retryCount: coordinator.attachRetryCount + ) + } + #endif } } } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 5913fd79..a51ba07b 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -296,6 +296,56 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { XCTAssertTrue(panel.isDeveloperToolsVisible()) XCTAssertEqual(inspector.showCount, 2) } + + func testTransientHideAttachmentPreserveFollowsDeveloperToolsIntent() { + let (panel, _) = makePanelWithInspector() + + XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide()) + XCTAssertTrue(panel.showDeveloperTools()) + XCTAssertTrue(panel.shouldPreserveWebViewAttachmentDuringTransientHide()) + XCTAssertTrue(panel.hideDeveloperTools()) + XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide()) + } + + func testWebViewDismantleSkipsDetachWhenDeveloperToolsIntentIsVisible() { + let (panel, _) = makePanelWithInspector() + XCTAssertTrue(panel.showDeveloperTools()) + + let representable = WebViewRepresentable( + panel: panel, + shouldAttachWebView: true, + shouldFocusWebView: false, + isPanelFocused: true + ) + let coordinator = representable.makeCoordinator() + coordinator.webView = panel.webView + let host = NSView(frame: NSRect(x: 0, y: 0, width: 100, height: 100)) + host.addSubview(panel.webView) + + WebViewRepresentable.dismantleNSView(host, coordinator: coordinator) + + XCTAssertTrue(panel.webView.superview === host) + } + + func testWebViewDismantleDetachesWhenDeveloperToolsIntentIsHidden() { + let (panel, _) = makePanelWithInspector() + XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide()) + + let representable = WebViewRepresentable( + panel: panel, + shouldAttachWebView: true, + shouldFocusWebView: false, + isPanelFocused: true + ) + let coordinator = representable.makeCoordinator() + coordinator.webView = panel.webView + let host = NSView(frame: NSRect(x: 0, y: 0, width: 100, height: 100)) + host.addSubview(panel.webView) + + WebViewRepresentable.dismantleNSView(host, coordinator: coordinator) + + XCTAssertNil(panel.webView.superview) + } } final class WorkspaceShortcutMapperTests: XCTestCase {