From cdf8d367b2677cc71d90bec1e6c1ec9961041ce7 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Fri, 20 Mar 2026 21:11:56 -0700 Subject: [PATCH] Fix browser back navigation history handoff (#1897) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add regression test for browser back history * Fix browser back history handoff * Fix browser tab favicon not updating on navigation Two issues caused stale or missing favicons in browser tabs: 1. KVO race: The isLoading observer read webView.isLoading inside a deferred Task instead of capturing the KVO change value at observation time. For fast navigations (back-forward cache), isLoading flips true→false before the Task runs, so handleWebViewLoadingChanged(true) was never called and the old favicon was never cleared. 2. SPA favicon discovery: Sites that inject via JavaScript (e.g. React apps) had no favicon link in the DOM when didFinish fired. The fallback to /favicon.ico often 404'd, leaving the globe icon permanently. Now retries the JS query after 600ms to give client-side scripts time to add the tag. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- Sources/Panels/BrowserPanel.swift | 173 +++++++++++++++++++++++++---- cmuxTests/BrowserConfigTests.swift | 84 ++++++++++++++ 2 files changed, 237 insertions(+), 20 deletions(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index da61b37e..827b6b28 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -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 (back, forward) + 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[..