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
2 changes: 1 addition & 1 deletion Sources/Panels/BrowserHiddenWebViewDiscardPolicy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ nonisolated enum BrowserHiddenWebViewDiscardPolicy {

static let enabledKey = "browserHiddenWebViewDiscardEnabled"
static let hiddenDelayKey = "browserHiddenWebViewDiscardDelaySeconds"
static let defaultEnabled = true
static let defaultEnabled = false
static let defaultHiddenDelay: TimeInterval = 300
static let minimumHiddenDelay: TimeInterval = 0
static let maximumHiddenDelay: TimeInterval = 3600
Expand Down
235 changes: 171 additions & 64 deletions cmuxTests/GhosttyConfigTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1364,6 +1364,66 @@ final class BrowserPanelPopupContextTests: XCTestCase {

@MainActor
final class BrowserPanelWebViewLifecycleTests: XCTestCase {
private static var hasHiddenDiscardEnabledEnvironmentOverride: Bool {
ProcessInfo.processInfo.environment["CMUX_BROWSER_HIDDEN_WEBVIEW_DISCARD_ENABLED"] != nil
}

private static var hiddenDiscardEnvironmentOverrideDisablesPolicy: Bool {
guard let value = ProcessInfo.processInfo.environment["CMUX_BROWSER_HIDDEN_WEBVIEW_DISCARD_ENABLED"]?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased() else {
return false
}
return ["0", "false", "no", "off"].contains(value)
}

private func withHiddenWebViewDiscardDefaults(
enabled: Bool?,
delay: TimeInterval? = nil,
_ body: () throws -> Void
) rethrows {
let defaults = UserDefaults.standard
let previousEnabled = defaults.object(forKey: BrowserHiddenWebViewDiscardPolicy.enabledKey)
let previousDelay = defaults.object(forKey: BrowserHiddenWebViewDiscardPolicy.hiddenDelayKey)
defer {
if let previousEnabled {
defaults.set(previousEnabled, forKey: BrowserHiddenWebViewDiscardPolicy.enabledKey)
} else {
defaults.removeObject(forKey: BrowserHiddenWebViewDiscardPolicy.enabledKey)
}
if let previousDelay {
defaults.set(previousDelay, forKey: BrowserHiddenWebViewDiscardPolicy.hiddenDelayKey)
} else {
defaults.removeObject(forKey: BrowserHiddenWebViewDiscardPolicy.hiddenDelayKey)
}
}

if let enabled {
defaults.set(enabled, forKey: BrowserHiddenWebViewDiscardPolicy.enabledKey)
} else {
defaults.removeObject(forKey: BrowserHiddenWebViewDiscardPolicy.enabledKey)
}
if let delay {
defaults.set(delay, forKey: BrowserHiddenWebViewDiscardPolicy.hiddenDelayKey)
} else {
defaults.removeObject(forKey: BrowserHiddenWebViewDiscardPolicy.hiddenDelayKey)
}

try body()
}

private func waitForBrowserPanelWebViewToFinishLoading(
_ panel: BrowserPanel,
file: StaticString = #filePath,
line: UInt = #line
) {
let deadline = Date().addingTimeInterval(1.0)
while panel.webView.isLoading,
RunLoop.main.run(mode: .default, before: deadline),
Date() < deadline {}
XCTAssertFalse(panel.webView.isLoading, "Timed out waiting for browser panel WebView to finish loading", file: file, line: line)
}

func testHiddenDiscardPolicyReadsUserDefaults() throws {
let suiteName = "cmux.browserHiddenDiscardPolicyTests.\(UUID().uuidString)"
let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
Expand Down Expand Up @@ -1412,6 +1472,47 @@ final class BrowserPanelWebViewLifecycleTests: XCTestCase {
}
}

func testDefaultWorkspaceVisibilityHidePreservesWebViewIdentityPastDiscardDelay() throws {
try XCTSkipIf(
Self.hasHiddenDiscardEnabledEnvironmentOverride,
"Environment override makes the default hidden-discard policy unobservable."
)

try withHiddenWebViewDiscardDefaults(enabled: nil, delay: 0) {
XCTAssertFalse(BrowserHiddenWebViewDiscardPolicy.isEnabled)

let hiddenAt = Date().addingTimeInterval(-1)
let panel = BrowserPanel(
workspaceId: UUID(),
initialURL: URL(string: "about:blank")!,
isRemoteWorkspace: false
)
defer { panel.close() }
waitForBrowserPanelWebViewToFinishLoading(panel)

panel.noteWebViewVisibility(true, reason: "test.workspace.visible")
XCTAssertEqual(panel.webViewLifecycleState, .liveVisible)
let originalWebView = panel.webView

panel.noteWebViewVisibility(false, reason: "test.workspace.hidden", now: hiddenAt)

XCTAssertTrue(
panel.webView === originalWebView,
"Workspace visibility hides must preserve the live WKWebView unless Browser Memory Saver is explicitly enabled"
)
XCTAssertTrue(panel.shouldRenderWebView)
XCTAssertEqual(panel.webViewLifecycleState, .liveHidden)

panel.noteWebViewVisibility(true, reason: "test.workspace.revisible")

XCTAssertTrue(
panel.webView === originalWebView,
"Re-showing a workspace-hidden browser should rebind the same WKWebView instead of navigating a replacement"
)
XCTAssertEqual(panel.webViewLifecycleState, .liveVisible)
}
}

func testLifecycleStartsAsNewTabUntilRenderable() {
let panel = BrowserPanel(
workspaceId: UUID(),
Expand Down Expand Up @@ -1551,87 +1652,93 @@ final class BrowserPanelWebViewLifecycleTests: XCTestCase {
XCTAssertEqual(panel.webViewLifecycleState, .closing)
}

func testDiscardReplacesHiddenWebViewAndRestoresOnDemand() {
let discardedAt = Date(timeIntervalSince1970: 200)
let panel = BrowserPanel(
workspaceId: UUID(),
initialURL: URL(string: "about:blank")!,
isRemoteWorkspace: false
func testDiscardReplacesHiddenWebViewAndRestoresOnDemand() throws {
try XCTSkipIf(
Self.hiddenDiscardEnvironmentOverrideDisablesPolicy,
"Environment override disables Browser Memory Saver."
)
defer { panel.close() }

let deadline = Date().addingTimeInterval(1.0)
while panel.webView.isLoading,
RunLoop.main.run(mode: .default, before: deadline),
Date() < deadline {}
XCTAssertFalse(panel.webView.isLoading, "Timed out waiting for about:blank to finish loading")
try withHiddenWebViewDiscardDefaults(enabled: true) {
let discardedAt = Date(timeIntervalSince1970: 200)
let panel = BrowserPanel(
workspaceId: UUID(),
initialURL: URL(string: "about:blank")!,
isRemoteWorkspace: false
)
defer { panel.close() }

panel.noteWebViewVisibility(false, reason: "test.hidden", now: discardedAt)
let originalWebView = panel.webView
waitForBrowserPanelWebViewToFinishLoading(panel)

XCTAssertTrue(panel.discardHiddenWebViewForMemory(reason: "test.discard", now: discardedAt))
XCTAssertFalse(panel.webView === originalWebView)
XCTAssertFalse(panel.shouldRenderWebView)
XCTAssertEqual(panel.webViewLifecycleState, .discarded)
panel.noteWebViewVisibility(false, reason: "test.hidden", now: discardedAt)
let originalWebView = panel.webView

let discardedPayload = panel.webViewLifecycleTopPayload(now: discardedAt)
XCTAssertEqual(discardedPayload["state"] as? String, "discarded")
XCTAssertEqual(discardedPayload["last_discard_reason"] as? String, "test.discard")
XCTAssertNotNil(discardedPayload["discarded_at"] as? String)
XCTAssertTrue(panel.discardHiddenWebViewForMemory(reason: "test.discard", now: discardedAt))
XCTAssertFalse(panel.webView === originalWebView)
XCTAssertFalse(panel.shouldRenderWebView)
XCTAssertEqual(panel.webViewLifecycleState, .discarded)

var observedStates: [BrowserWebViewLifecycleState] = []
var cancellable: AnyCancellable?
cancellable = panel.$webViewLifecycleState.sink { state in
observedStates.append(state)
}
defer { cancellable?.cancel() }
let discardedPayload = panel.webViewLifecycleTopPayload(now: discardedAt)
XCTAssertEqual(discardedPayload["state"] as? String, "discarded")
XCTAssertEqual(discardedPayload["last_discard_reason"] as? String, "test.discard")
XCTAssertNotNil(discardedPayload["discarded_at"] as? String)

XCTAssertTrue(panel.restoreDiscardedWebViewIfNeeded(reason: "test.restore"))
XCTAssertTrue(panel.shouldRenderWebView)
XCTAssertEqual(panel.webViewLifecycleState, .liveHidden)
XCTAssertFalse(observedStates.contains(.newTab), "Restore emitted unexpected states: \(observedStates)")
var observedStates: [BrowserWebViewLifecycleState] = []
var cancellable: AnyCancellable?
cancellable = panel.$webViewLifecycleState.sink { state in
observedStates.append(state)
}
defer { cancellable?.cancel() }

panel.noteWebViewVisibility(true, reason: "test.visible")
XCTAssertEqual(panel.webViewLifecycleState, .liveVisible)
XCTAssertTrue(panel.restoreDiscardedWebViewIfNeeded(reason: "test.restore"))
XCTAssertTrue(panel.shouldRenderWebView)
XCTAssertEqual(panel.webViewLifecycleState, .liveHidden)
XCTAssertFalse(observedStates.contains(.newTab), "Restore emitted unexpected states: \(observedStates)")

panel.noteWebViewVisibility(true, reason: "test.visible")
XCTAssertEqual(panel.webViewLifecycleState, .liveVisible)
}
}

func testRestoredHistoryBackDoesNotEmitNewTabLifecycleState() {
let discardedAt = Date(timeIntervalSince1970: 300)
let panel = BrowserPanel(
workspaceId: UUID(),
initialURL: URL(string: "about:blank")!,
isRemoteWorkspace: false
func testRestoredHistoryBackDoesNotEmitNewTabLifecycleState() throws {
try XCTSkipIf(
Self.hiddenDiscardEnvironmentOverrideDisablesPolicy,
"Environment override disables Browser Memory Saver."
)
defer { panel.close() }

let deadline = Date().addingTimeInterval(1.0)
while panel.webView.isLoading,
RunLoop.main.run(mode: .default, before: deadline),
Date() < deadline {}
XCTAssertFalse(panel.webView.isLoading, "Timed out waiting for about:blank to finish loading")
try withHiddenWebViewDiscardDefaults(enabled: true) {
let discardedAt = Date(timeIntervalSince1970: 300)
let panel = BrowserPanel(
workspaceId: UUID(),
initialURL: URL(string: "about:blank")!,
isRemoteWorkspace: false
)
defer { panel.close() }

panel.restoreSessionNavigationHistory(
backHistoryURLStrings: ["https://example.test/back"],
forwardHistoryURLStrings: [],
currentURLString: "https://example.test/current"
)
XCTAssertTrue(panel.canGoBack)
waitForBrowserPanelWebViewToFinishLoading(panel)

panel.noteWebViewVisibility(false, reason: "test.hidden", now: discardedAt)
XCTAssertTrue(panel.discardHiddenWebViewForMemory(reason: "test.discard", now: discardedAt))
XCTAssertEqual(panel.webViewLifecycleState, .discarded)
panel.restoreSessionNavigationHistory(
backHistoryURLStrings: ["https://example.test/back"],
forwardHistoryURLStrings: [],
currentURLString: "https://example.test/current"
)
XCTAssertTrue(panel.canGoBack)

var observedStates: [BrowserWebViewLifecycleState] = []
var cancellable: AnyCancellable?
cancellable = panel.$webViewLifecycleState.sink { state in
observedStates.append(state)
}
defer { cancellable?.cancel() }
panel.noteWebViewVisibility(false, reason: "test.hidden", now: discardedAt)
XCTAssertTrue(panel.discardHiddenWebViewForMemory(reason: "test.discard", now: discardedAt))
XCTAssertEqual(panel.webViewLifecycleState, .discarded)

panel.goBack()
var observedStates: [BrowserWebViewLifecycleState] = []
var cancellable: AnyCancellable?
cancellable = panel.$webViewLifecycleState.sink { state in
observedStates.append(state)
}
defer { cancellable?.cancel() }

XCTAssertFalse(observedStates.contains(.newTab), "Back restore emitted unexpected states: \(observedStates)")
XCTAssertEqual(panel.webViewLifecycleState, .liveHidden)
panel.goBack()

XCTAssertFalse(observedStates.contains(.newTab), "Back restore emitted unexpected states: \(observedStates)")
XCTAssertEqual(panel.webViewLifecycleState, .liveHidden)
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion web/data/cmux.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -709,7 +709,7 @@
},
"discardHiddenWebViews": {
"type": "boolean",
"default": true,
"default": false,
"description": "Allow hidden browser tabs to release page memory and restore when shown again."
},
"hiddenWebViewDiscardDelaySeconds": {
Expand Down
Loading