Repository navigation
Fix browser back navigation history handoff #1897
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2525,6 +2525,7 @@ final class BrowserPanel: Panel, ObservableObject { | |
| navigationDelegate.didFinish = { [weak self] webView in | ||
| Task { @MainActor [weak self] in | ||
| guard let self, self.isCurrentWebView(webView, instanceID: boundWebViewInstanceID) else { return } | ||
| self.realignRestoredSessionHistoryToLiveCurrentIfPossible() | ||
| boundHistoryStore.recordVisit(url: webView.url, title: webView.title) | ||
| self.refreshFavicon(from: webView) | ||
| self.applyBrowserThemeModeIfNeeded() | ||
|
|
@@ -2867,20 +2868,109 @@ final class BrowserPanel: Panel, ObservableObject { | |
| backHistoryURLStrings: [String], | ||
| forwardHistoryURLStrings: [String] | ||
| ) { | ||
| realignRestoredSessionHistoryToLiveCurrentIfPossible() | ||
|
|
||
| let nativeBack = webView.backForwardList.backList.compactMap { | ||
| Self.serializableSessionHistoryURLString($0.url) | ||
| } | ||
| let nativeForward = webView.backForwardList.forwardList.compactMap { | ||
| Self.serializableSessionHistoryURLString($0.url) | ||
| } | ||
|
|
||
| if usesRestoredSessionHistory { | ||
| let back = restoredBackHistoryStack.compactMap { Self.serializableSessionHistoryURLString($0) } | ||
| // `restoredForwardHistoryStack` stores nearest-forward entries at the end. | ||
| let forward = restoredForwardHistoryStack.reversed().compactMap { Self.serializableSessionHistoryURLString($0) } | ||
| return (back, forward) | ||
| let restoredForward = restoredForwardHistoryStack.reversed().compactMap { | ||
| Self.serializableSessionHistoryURLString($0) | ||
| } | ||
|
|
||
| if isLiveSessionHistoryAlignedWithRestoredCurrent { | ||
| return ( | ||
| back, | ||
| restoredForward.isEmpty ? nativeForward : restoredForward | ||
| ) | ||
| } | ||
|
|
||
| return (back + nativeBack, nativeForward) | ||
| } | ||
|
|
||
| let back = webView.backForwardList.backList.compactMap { | ||
| Self.serializableSessionHistoryURLString($0.url) | ||
| return (nativeBack, nativeForward) | ||
| } | ||
|
|
||
| private func resolvedLiveSessionHistoryURL() -> URL? { | ||
| if let webViewURL = Self.remoteProxyDisplayURL(for: webView.url), | ||
| Self.serializableSessionHistoryURLString(webViewURL) != nil { | ||
| return webViewURL | ||
| } | ||
| let forward = webView.backForwardList.forwardList.compactMap { | ||
| Self.serializableSessionHistoryURLString($0.url) | ||
| if let currentURL, | ||
| Self.serializableSessionHistoryURLString(currentURL) != nil { | ||
| return currentURL | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| private var isLiveSessionHistoryAlignedWithRestoredCurrent: Bool { | ||
| let liveCurrent = Self.serializableSessionHistoryURLString(resolvedLiveSessionHistoryURL()) | ||
| let restoredCurrent = Self.serializableSessionHistoryURLString(restoredHistoryCurrentURL) | ||
| guard let liveCurrent, let restoredCurrent else { return true } | ||
| return liveCurrent == restoredCurrent | ||
| } | ||
|
|
||
| private func realignRestoredSessionHistoryToLiveCurrentIfPossible() { | ||
| guard usesRestoredSessionHistory else { return } | ||
| guard let liveCurrent = resolvedLiveSessionHistoryURL(), | ||
| let liveCurrentString = Self.serializableSessionHistoryURLString(liveCurrent) else { | ||
| return | ||
| } | ||
| guard Self.serializableSessionHistoryURLString(restoredHistoryCurrentURL) != liveCurrentString else { | ||
| return | ||
| } | ||
|
|
||
| let restoredBack = restoredBackHistoryStack.compactMap { Self.serializableSessionHistoryURLString($0) } | ||
| let restoredForward = restoredForwardHistoryStack.reversed().compactMap { | ||
| Self.serializableSessionHistoryURLString($0) | ||
| } | ||
| let restoredCurrent = Self.serializableSessionHistoryURLString(restoredHistoryCurrentURL) | ||
|
|
||
| if let backIndex = restoredBack.lastIndex(of: liveCurrentString) { | ||
| let newBack = Array(restoredBack[..<backIndex]) | ||
| var newForward = Array(restoredBack[(backIndex + 1)...]) | ||
| if let restoredCurrent { | ||
| newForward.append(restoredCurrent) | ||
| } | ||
| newForward.append(contentsOf: restoredForward) | ||
|
|
||
| restoredBackHistoryStack = Self.sanitizedSessionHistoryURLs(newBack) | ||
| restoredForwardHistoryStack = Array(Self.sanitizedSessionHistoryURLs(newForward).reversed()) | ||
| restoredHistoryCurrentURL = liveCurrent | ||
| refreshNavigationAvailability() | ||
| return | ||
| } | ||
|
|
||
| if let forwardIndex = restoredForward.firstIndex(of: liveCurrentString) { | ||
| var newBack = restoredBack | ||
| if let restoredCurrent { | ||
| newBack.append(restoredCurrent) | ||
| } | ||
| newBack.append(contentsOf: restoredForward[..<forwardIndex]) | ||
| let newForward = Array(restoredForward[(forwardIndex + 1)...]) | ||
|
|
||
| restoredBackHistoryStack = Self.sanitizedSessionHistoryURLs(newBack) | ||
| restoredForwardHistoryStack = Array(Self.sanitizedSessionHistoryURLs(newForward).reversed()) | ||
| restoredHistoryCurrentURL = liveCurrent | ||
| refreshNavigationAvailability() | ||
| return | ||
| } | ||
| return (back, forward) | ||
|
|
||
| guard !restoredForwardHistoryStack.isEmpty else { return } | ||
| #if DEBUG | ||
| dlog( | ||
| "browser.history.restore.forward.clear panel=\(id.uuidString.prefix(5)) " + | ||
| "current=\(liveCurrentString)" | ||
| ) | ||
| #endif | ||
| restoredForwardHistoryStack.removeAll(keepingCapacity: false) | ||
| refreshNavigationAvailability() | ||
| } | ||
|
|
||
| func restoreSessionNavigationHistory( | ||
|
|
@@ -2927,10 +3017,16 @@ final class BrowserPanel: Panel, ObservableObject { | |
| webViewObservers.append(titleObserver) | ||
|
|
||
| // Loading state | ||
| let loadingObserver = webView.observe(\.isLoading, options: [.new]) { [weak self] webView, _ in | ||
| // Capture the KVO-provided value at observation time rather than reading | ||
| // webView.isLoading inside the deferred Task. For fast navigations (e.g. | ||
| // back-forward cache), isLoading can flip true→false before the first Task | ||
| // runs, causing handleWebViewLoadingChanged(true) to be missed entirely. | ||
| // That skips favicon/loading-state cleanup and leaves stale icons visible. | ||
| let loadingObserver = webView.observe(\.isLoading, options: [.new]) { [weak self] webView, change in | ||
| let newValue = change.newValue ?? webView.isLoading | ||
| Task { @MainActor in | ||
| guard let self, self.isCurrentWebView(webView, instanceID: observedWebViewInstanceID) else { return } | ||
| self.handleWebViewLoadingChanged(webView.isLoading) | ||
| self.handleWebViewLoadingChanged(newValue) | ||
| } | ||
| } | ||
| webViewObservers.append(loadingObserver) | ||
|
|
@@ -3209,6 +3305,27 @@ final class BrowserPanel: Panel, ObservableObject { | |
| guard self.isCurrentWebView(webView, instanceID: refreshWebViewInstanceID) else { return } | ||
| guard self.isCurrentFaviconRefresh(generation: refreshGeneration) else { return } | ||
|
|
||
| // SPAs often inject <link rel="icon"> via JavaScript after the initial | ||
| // HTML loads. If no link tag was found, wait briefly and retry once to | ||
| // give client-side scripts time to add the tag. | ||
| if discoveredURL == nil { | ||
| try? await Task.sleep(nanoseconds: 600_000_000) | ||
| guard self.isCurrentWebView(webView, instanceID: refreshWebViewInstanceID) else { return } | ||
| guard self.isCurrentFaviconRefresh(generation: refreshGeneration) else { return } | ||
| if let href = await self.evaluateJavaScriptString( | ||
| js, | ||
| in: webView, | ||
| timeoutNanoseconds: 400_000_000 | ||
| ) { | ||
| let trimmed = href.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| if !trimmed.isEmpty, let u = URL(string: trimmed) { | ||
| discoveredURL = u | ||
| } | ||
| } | ||
| guard self.isCurrentWebView(webView, instanceID: refreshWebViewInstanceID) else { return } | ||
| guard self.isCurrentFaviconRefresh(generation: refreshGeneration) else { return } | ||
| } | ||
|
|
||
| let fallbackURL = URL(string: "/favicon.ico", relativeTo: pageURL) | ||
| let iconURL = discoveredURL ?? fallbackURL | ||
| guard let iconURL else { return } | ||
|
|
@@ -3879,20 +3996,29 @@ extension BrowserPanel { | |
| func goBack() { | ||
| guard canGoBack else { return } | ||
| if usesRestoredSessionHistory { | ||
| guard let targetURL = restoredBackHistoryStack.popLast() else { | ||
| realignRestoredSessionHistoryToLiveCurrentIfPossible() | ||
|
|
||
| if (isLiveSessionHistoryAlignedWithRestoredCurrent || !nativeCanGoBack), | ||
| let targetURL = restoredBackHistoryStack.popLast() { | ||
| if let current = resolvedCurrentSessionHistoryURL() { | ||
| restoredForwardHistoryStack.append(current) | ||
| } | ||
| restoredHistoryCurrentURL = targetURL | ||
| refreshNavigationAvailability() | ||
| navigateWithoutInsecureHTTPPrompt( | ||
| to: targetURL, | ||
| recordTypedNavigation: false, | ||
| preserveRestoredSessionHistory: true | ||
| ) | ||
| return | ||
| } | ||
| if let current = resolvedCurrentSessionHistoryURL() { | ||
| restoredForwardHistoryStack.append(current) | ||
|
|
||
| if nativeCanGoBack { | ||
| webView.goBack() | ||
| return | ||
| } | ||
| restoredHistoryCurrentURL = targetURL | ||
|
|
||
| refreshNavigationAvailability() | ||
|
Comment on lines
+4016
to
4021
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
After At that point
The user ends up at B after explicitly having backed out to A — an unexpected reversal. In the old code this was hidden because A minimal guard would be to skip the native fallback here when the restored-session is still active and the stack is empty: if nativeCanGoBack {
webView.goBack()
return
}
// restored stack is empty and no useful native back → let canGoBack settle to false
refreshNavigationAvailability()But this requires also re-evaluating whether |
||
| navigateWithoutInsecureHTTPPrompt( | ||
| to: targetURL, | ||
| recordTypedNavigation: false, | ||
| preserveRestoredSessionHistory: true | ||
| ) | ||
| return | ||
| } | ||
|
|
||
|
|
@@ -3903,6 +4029,13 @@ extension BrowserPanel { | |
| func goForward() { | ||
| guard canGoForward else { return } | ||
| if usesRestoredSessionHistory { | ||
| realignRestoredSessionHistoryToLiveCurrentIfPossible() | ||
|
|
||
| if nativeCanGoForward { | ||
| webView.goForward() | ||
| return | ||
| } | ||
|
|
||
| guard let targetURL = restoredForwardHistoryStack.popLast() else { | ||
| refreshNavigationAvailability() | ||
| return | ||
|
|
@@ -5168,8 +5301,8 @@ extension BrowserPanel { | |
| let resolvedCanGoBack: Bool | ||
| let resolvedCanGoForward: Bool | ||
| if usesRestoredSessionHistory { | ||
| resolvedCanGoBack = !restoredBackHistoryStack.isEmpty | ||
| resolvedCanGoForward = !restoredForwardHistoryStack.isEmpty | ||
| resolvedCanGoBack = nativeCanGoBack || !restoredBackHistoryStack.isEmpty | ||
| resolvedCanGoForward = nativeCanGoForward || !restoredForwardHistoryStack.isEmpty | ||
| } else { | ||
| resolvedCanGoBack = nativeCanGoBack | ||
| resolvedCanGoForward = nativeCanGoForward | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1515,6 +1515,49 @@ final class BrowserJavaScriptDialogDelegateTests: XCTestCase { | |
|
|
||
| @MainActor | ||
| final class BrowserSessionHistoryRestoreTests: XCTestCase { | ||
| private func writeBrowserFixturePage( | ||
| at url: URL, | ||
| title: String, | ||
| file: StaticString = #filePath, | ||
| line: UInt = #line | ||
| ) throws { | ||
| let html = """ | ||
| <html> | ||
| <head><title>\(title)</title></head> | ||
| <body>\(title)</body> | ||
| </html> | ||
| """ | ||
|
|
||
| do { | ||
| try html.write(to: url, atomically: true, encoding: .utf8) | ||
| } catch { | ||
| XCTFail("Failed to write browser fixture page: \(error)", file: file, line: line) | ||
| throw error | ||
| } | ||
| } | ||
|
|
||
| private func waitForBrowserPanel( | ||
| _ panel: BrowserPanel, | ||
| url: URL, | ||
| timeout: TimeInterval = 5.0, | ||
| file: StaticString = #filePath, | ||
| line: UInt = #line | ||
| ) { | ||
| let deadline = Date().addingTimeInterval(timeout) | ||
| while Date() < deadline { | ||
| RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01)) | ||
| if panel.preferredURLStringForOmnibar() == url.absoluteString && !panel.isLoading { | ||
| return | ||
| } | ||
| } | ||
|
|
||
| XCTFail( | ||
| "Timed out waiting for browser panel to load \(url.absoluteString). Current=\(panel.preferredURLStringForOmnibar() ?? "nil") loading=\(panel.isLoading)", | ||
| file: file, | ||
| line: line | ||
| ) | ||
| } | ||
|
|
||
| func testSessionNavigationHistorySnapshotUsesRestoredStacks() { | ||
| let panel = BrowserPanel(workspaceId: UUID()) | ||
|
|
||
|
|
@@ -1578,6 +1621,47 @@ final class BrowserSessionHistoryRestoreTests: XCTestCase { | |
| XCTAssertTrue(panel.canGoForward) | ||
| } | ||
|
|
||
| func testGoBackPrefersLiveWKWebViewHistoryBeforeRestoredFallback() throws { | ||
| let tempDir = FileManager.default.temporaryDirectory | ||
| .appendingPathComponent("cmux-browser-history-\(UUID().uuidString)", isDirectory: true) | ||
| try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) | ||
| defer { try? FileManager.default.removeItem(at: tempDir) } | ||
|
|
||
| let pageA = tempDir.appendingPathComponent("a.html") | ||
| let pageB = tempDir.appendingPathComponent("b.html") | ||
| let pageC = tempDir.appendingPathComponent("c.html") | ||
| try writeBrowserFixturePage(at: pageA, title: "A") | ||
| try writeBrowserFixturePage(at: pageB, title: "B") | ||
| try writeBrowserFixturePage(at: pageC, title: "C") | ||
|
|
||
| let panel = BrowserPanel( | ||
| workspaceId: UUID(), | ||
| initialURL: pageB | ||
| ) | ||
| waitForBrowserPanel(panel, url: pageB) | ||
|
|
||
| panel.restoreSessionNavigationHistory( | ||
| backHistoryURLStrings: [pageA.absoluteString], | ||
| forwardHistoryURLStrings: [], | ||
| currentURLString: pageB.absoluteString | ||
| ) | ||
|
|
||
| _ = browserLoadRequest(URLRequest(url: pageC), in: panel.webView) | ||
| waitForBrowserPanel(panel, url: pageC) | ||
|
|
||
| let snapshot = panel.sessionNavigationHistorySnapshot() | ||
| XCTAssertEqual( | ||
| snapshot.backHistoryURLStrings, | ||
| [pageA.absoluteString, pageB.absoluteString] | ||
| ) | ||
|
|
||
| panel.goBack() | ||
| waitForBrowserPanel(panel, url: pageB) | ||
|
|
||
| panel.goBack() | ||
| waitForBrowserPanel(panel, url: pageA) | ||
|
Comment on lines
+1658
to
+1662
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The test verifies the two happy-path // After reaching A, no more history should be available
XCTAssertFalse(panel.canGoBack, "canGoBack should be false after reaching history start") |
||
| } | ||
|
|
||
| func testWebViewReplacementAfterProcessTerminationUpdatesInstanceIdentity() { | ||
| let panel = BrowserPanel( | ||
| workspaceId: UUID(), | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
restoredHistoryCurrentURLcauses repeated work on every callWhen the live current URL is not found in either the restored back or forward stack, the function clears
restoredForwardHistoryStackbut does not updaterestoredHistoryCurrentURL. On every subsequent call the function will:restoredBack,restoredForward, andrestoredCurrentarrays viacompactMap.lastIndex/firstIndexsearches.guard !restoredForwardHistoryStack.isEmpty(succeeds on first call, then fails on repeat calls — so no crash, but still allocates the arrays above every time).Setting
restoredHistoryCurrentURL = liveCurrenthere (alongside the forward-clear) would let the early-out on line 2925 short-circuit all future calls until the URL changes again, and would make subsequent snapshots andisLiveSessionHistoryAlignedWithRestoredCurrentqueries accurate immediately.