Skip to content

Fix browser back navigation history handoff - #1897

Merged
austinywang merged 3 commits into
mainfrom
issue-1892-browser-back-button
Mar 21, 2026
Merged

austinywang merged 3 commits into
mainfrom
issue-1892-browser-back-button

Conversation

@austinywang

@austinywang austinywang commented Mar 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • prefer live WKWebView back/forward history before falling back to restored session history
  • realign restored history when native WebKit navigation lands on an entry that already exists in the restored stack and clear stale forward history after branching away
  • add a regression test covering live navigation after session-history restore

Fixes #1892

Verification

  • ./scripts/setup.sh
  • ./scripts/reload.sh --tag issue-1892-back-history (xcodebuild reached ** BUILD SUCCEEDED ** in /tmp/cmux-xcodebuild-issue-1892-back-history.log; the later Ghostty CLI helper step then failed because the machine ran out of disk space)
  • xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/cmux-issue-1892-back-history build (failed to rerun once the volume hit other(28) / out-of-space during package resolution)

Summary by cubic

Fixes back/forward history handoff and stabilizes tab favicon updates after navigation. The app now prefers live web history and updates icons reliably on fast navigations and SPAs.

  • Bug Fixes
    • Prefer live WebKit history; fall back to restored stacks only when needed.
    • Realign restored history to the live current URL and clear stale forward entries after branching.
    • Keep back/forward controls enabled when either native or restored entries exist.
    • Use native goBack/goForward when possible; otherwise navigate via restored stacks without extra prompts.
    • Fix favicon updates by capturing the KVO isLoading value at observation time and retrying SPA favicon discovery once after 600ms.
    • Add a regression test ensuring live history is used before restored fallback.

Fixes #1892

Written for commit 8846e45. Summary will update on new commits.

Summary by CodeRabbit

  • Bug Fixes

    • Better alignment of restored session history with the live browsing session to avoid mismatches.
    • Back/forward actions now prefer and use the live web view history when possible, improving navigation fidelity.
    • Corrected navigation availability reporting so back/forward buttons reflect both native and restored stacks.
  • Tests

    • Added tests and helpers validating restored-session history realignment and back/forward behavior.

@vercel

vercel Bot commented Mar 21, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Mar 21, 2026 4:12am

@coderabbitai

coderabbitai Bot commented Mar 21, 2026 •

Copy link
Copy Markdown

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 19ea2a96-7f88-46b5-b8ad-f48abe462cb9

📥 Commits

Reviewing files that changed from the base of the PR and between 9063f52 and 8846e45.

📒 Files selected for processing (1)
  • Sources/Panels/BrowserPanel.swift

📝 Walkthrough

Walkthrough

Synchronizes restored session history with the live WKWebView state, adds URL resolution and alignment checks, updates session snapshot logic, and changes restored-mode goBack/goForward to prefer native WKWebView navigation when available. Includes a test validating live-history preference for back navigation.

Changes

Cohort / File(s) Summary
Session History Synchronization
Sources/Panels/BrowserPanel.swift
Added URL resolution helpers (resolvedLiveSessionHistoryURL, isLiveSessionHistoryAlignedWithRestoredCurrent) and realignRestoredSessionHistoryToLiveCurrentIfPossible(). Updated sessionNavigationHistorySnapshot() to merge native and restored stacks conditionally. Modified restored-mode goBack()/goForward() to realign first and prefer native webView.goBack()/goForward(); updated availability (canGoBack/canGoForward).
Navigation History Tests
cmuxTests/BrowserConfigTests.swift
Added test helpers writeBrowserFixturePage() and waitForBrowserPanel(). Added testGoBackPrefersLiveWKWebViewHistoryBeforeRestoredFallback() to assert live WKWebView history is preferred over restored fallback.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant User as "User / UI"
  participant Panel as "BrowserPanel"
  participant WebView as "WKWebView"
  participant Restored as "RestoredHistoryStore"

  User->>Panel: press Back
  Panel->>Panel: realignRestoredSessionHistoryToLiveCurrentIfPossible()
  Panel->>WebView: check nativeCanGoBack / backForwardList
  alt nativeCanGoBack == true
    Panel->>WebView: webView.goBack()
    WebView-->>Panel: didFinish navigation (realign call)
  else nativeCanGoBack == false
    Panel->>Restored: pop restoredBack
    Restored-->>Panel: targetURL
    Panel->>Panel: set restoredHistoryCurrentURL, update stacks
    Panel->>Panel: navigate(to: targetURL, preserveRestoredSessionHistory: true)
    Panel->>WebView: load targetURL
    WebView-->>Panel: didFinish navigation (realign call)
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰
I hop through stacks of back and forward,
Aligning paths where stray links soared,
Now live and restored walk hand in paw,
The back button returns the page it saw,
Hooray — no more mystery detours! 🥕✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Fix browser back navigation history handoff' clearly and directly describes the main change of fixing back navigation behavior between live and restored history.
Description check ✅ Passed The description includes a clear summary of changes and links to the fixed issue; verification steps are documented, though testing details are somewhat limited due to environment constraints.
Linked Issues check ✅ Passed The PR successfully addresses issue #1892 by preferring live WebKit history over restored stacks, realigning restored history when needed, and adding regression tests covering the fixed behavior.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the back/forward history handoff behavior and adding supporting tests; no unrelated modifications are present.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-1892-browser-back-button

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 2 files

@greptile-apps

greptile-apps Bot commented Mar 21, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes browser back/forward navigation when a WKWebView session is restored from a saved history snapshot. Previously, the back button only traversed the manually-restored stack and ignored any live navigation the user performed after the restore. The fix introduces three new helpers — resolvedLiveSessionHistoryURL(), isLiveSessionHistoryAlignedWithRestoredCurrent, and realignRestoredSessionHistoryToLiveCurrentIfPossible() — that reconcile the in-memory restored stacks with whatever WKWebView's backForwardList actually contains before every navigation action.

Key changes:

  • sessionNavigationHistorySnapshot() now merges restored and live back lists when the two histories have diverged, and prefers the live forward list when the restored forward stack is empty.
  • goBack() calls webView.goBack() (using WKWebView's native history) when the live position has drifted past the restored current entry, and falls back to popping the restored stack only when they are in sync or no native back entry exists.
  • goForward() similarly prefers webView.goForward() when native forward history is available.
  • refreshNavigationAvailability() now ORs nativeCanGoBack/nativeCanGoForward into the resolved flags while usesRestoredSessionHistory is active.
  • A regression test navigates B→C after a restore of [A]→B, then asserts two consecutive goBack() calls land on B then A.

Notable concern: Once every restored back entry has been popped via navigateWithoutInsecureHTTPPrompt, WKWebView's back list still contains the pages visited during that restored navigation (because navigateWithoutInsecureHTTPPrompt creates new history entries). With the updated canGoBack = nativeCanGoBack || !restoredBackHistoryStack.isEmpty, canGoBack stays true and the next press falls through to webView.goBack(), which takes the user back to the page they just came from — effectively reversing the last restored navigation.

Confidence Score: 3/5

  • The primary regression scenario is fixed, but a secondary regression (unexpected back navigation after exhausting the restored stack) was introduced by including native back state in canGoBack while navigateWithoutInsecureHTTPPrompt silently grows that same native state.
  • The core logic for preferring live WKWebView history and realigning restored stacks is sound and the commit structure correctly follows the project's two-commit regression-test policy. However, the interaction between navigateWithoutInsecureHTTPPrompt growing the native back list and the new canGoBack = nativeCanGoBack || !restoredBackHistoryStack.isEmpty computation can produce a ghost back navigation after restored history is fully consumed. This is a behavioral regression that warrants a fix or explicit guard before merging.
  • Sources/Panels/BrowserPanel.swift — specifically the goBack() native-fallback path (lines 3989–3994) and refreshNavigationAvailability() (lines 5277–5278).

Important Files Changed

Filename Overview
Sources/Panels/BrowserPanel.swift Adds live-history preference and restored-history realignment logic. The approach is sound for the primary scenario (live navigation after restore), but the updated canGoBack computation (`nativeCanGoBack
cmuxTests/BrowserConfigTests.swift Adds a well-structured integration test using real WKWebView navigation with temp HTML files; correctly follows the two-commit policy (test first, fix second). The test covers the primary scenario but does not assert the boundary condition after restored history is exhausted.

Sequence Diagram

sequenceDiagram
    participant User
    participant BrowserPanel
    participant RestoredStack as Restored History Stack
    participant WKWebView

    Note over BrowserPanel,RestoredStack: After session restore: back=[A], current=B
    User->>BrowserPanel: navigates to C (live)
    BrowserPanel->>WKWebView: load C
    WKWebView-->>BrowserPanel: didFinish(C)
    BrowserPanel->>RestoredStack: realignRestoredSessionHistoryToLiveCurrentIfPossible()
    Note over RestoredStack: C not in restored stack → clear forward

    User->>BrowserPanel: goBack()
    BrowserPanel->>RestoredStack: realignRestoredSessionHistoryToLiveCurrentIfPossible()
    Note over BrowserPanel: isAligned=false, nativeCanGoBack=true
    BrowserPanel->>WKWebView: webView.goBack() → B
    WKWebView-->>BrowserPanel: didFinish(B)
    BrowserPanel->>RestoredStack: realign → live=B == restored=B, aligned ✓

    User->>BrowserPanel: goBack()
    BrowserPanel->>RestoredStack: realignRestoredSessionHistoryToLiveCurrentIfPossible()
    Note over BrowserPanel: isAligned=true, nativeCanGoBack=false
    BrowserPanel->>RestoredStack: popLast() → A
    BrowserPanel->>WKWebView: navigateWithoutInsecureHTTPPrompt(A)
    Note over WKWebView: native back now has [B] again ⚠️
    WKWebView-->>BrowserPanel: didFinish(A)

    Note over BrowserPanel: restored back=[], nativeCanGoBack=true → canGoBack=true ⚠️
    User->>BrowserPanel: goBack() [unexpected extra press]
    Note over BrowserPanel: restored pop=nil, fallthrough to webView.goBack()
    BrowserPanel->>WKWebView: webView.goBack() → B (unexpected!)
Loading

Last reviewed commit: "Fix browser back his..."

Comment on lines +3989 to 3994
if nativeCanGoBack {
webView.goBack()
return
}
restoredHistoryCurrentURL = targetURL

refreshNavigationAvailability()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Potential extra back navigation after exhausting restored stack

After navigateWithoutInsecureHTTPPrompt is used to pop a restored-history entry (e.g. navigating to page A from B), WKWebView records a new forward-history entry for B in its back list — so nativeCanGoBack remains true even after the restored stack is fully consumed.

At that point refreshNavigationAvailability() keeps canGoBack = true (because nativeCanGoBack || !restoredBackHistoryStack.isEmpty = true). The next goBack() call finds:

  1. isLiveSessionHistoryAlignedWithRestoredCurrent = true (live A == restored A).
  2. restoredBackHistoryStack.popLast() → nil (stack is empty) → the if block is skipped.
  3. Falls through to if nativeCanGoBack { webView.goBack() } → jumps back to B.

The user ends up at B after explicitly having backed out to A — an unexpected reversal. In the old code this was hidden because canGoBack was computed as !restoredBackHistoryStack.isEmpty only, so it went false once the stack was drained.

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 nativeCanGoBack entries that were created by navigateWithoutInsecureHTTPPrompt should contribute to canGoBack at all while usesRestoredSessionHistory is true.

Comment on lines +2965 to +2973
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()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Stale restoredHistoryCurrentURL causes repeated work on every call

When the live current URL is not found in either the restored back or forward stack, the function clears restoredForwardHistoryStack but does not update restoredHistoryCurrentURL. On every subsequent call the function will:

  1. Recompute restoredBack, restoredForward, and restoredCurrent arrays via compactMap.
  2. Fail both lastIndex/firstIndex searches.
  3. Hit the 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 = liveCurrent here (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 and isLiveSessionHistoryAlignedWithRestoredCurrent queries accurate immediately.

Comment on lines +1658 to +1662
panel.goBack()
waitForBrowserPanel(panel, url: pageB)

panel.goBack()
waitForBrowserPanel(panel, url: pageA)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Test does not exercise the "extra back" regression path

The test verifies the two happy-path goBack() calls (C→B via native, B→A via restored), which both pass. However, as described in the comment on BrowserPanel.swift lines 3989–3994, calling goBack() a third time from A would unexpectedly navigate back to B because nativeCanGoBack is still true (WKWebView recorded B when navigateWithoutInsecureHTTPPrompt loaded A). Adding a third goBack() + assertion that canGoBack is false after reaching the start of history would catch that regression:

// After reaching A, no more history should be available
XCTAssertFalse(panel.canGoBack, "canGoBack should be false after reaching history start")

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
cmuxTests/BrowserConfigTests.swift (2)

1539-1559: Fail fast when the wait times out.

XCTFail records the timeout, but this helper then returns and lets the caller keep running through the remaining navigation/assertion steps. One missed wait will usually turn into several secondary failures, which makes the root cause harder to spot. Consider making this helper throws (or returning a Bool and asserting at the call site) so the test stops at the first timeout.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxTests/BrowserConfigTests.swift` around lines 1539 - 1559, The helper
waitForBrowserPanel currently calls XCTFail on timeout but returns, allowing
callers to continue; change its signature to throw (e.g., make
waitForBrowserPanel(...) throws) and on timeout throw a descriptive error (or a
custom WaitError) instead of just calling XCTFail, then update all callers to
use try/try? so the test stops immediately when the wait fails; reference the
waitForBrowserPanel function and
panel.preferredURLStringForOmnibar()/panel.isLoading checks and replace the
XCTFail branch with throwing the error so failures fail fast.

1624-1657: Exercise the stale-forward-history branch too.

The restored forward stack is empty in this setup, so the test never proves that branching to live pageC clears stale restored forward history. If that cleanup is part of the fix, seed one forward entry and assert it disappears after the live navigation.

💡 Possible test strengthening
+        let pageD = tempDir.appendingPathComponent("d.html")
+        try writeBrowserFixturePage(at: pageD, title: "D")
+
         panel.restoreSessionNavigationHistory(
             backHistoryURLStrings: [pageA.absoluteString],
-            forwardHistoryURLStrings: [],
+            forwardHistoryURLStrings: [pageD.absoluteString],
             currentURLString: pageB.absoluteString
         )
@@
         XCTAssertEqual(
             snapshot.backHistoryURLStrings,
             [pageA.absoluteString, pageB.absoluteString]
         )
+        XCTAssertTrue(snapshot.forwardHistoryURLStrings.isEmpty)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxTests/BrowserConfigTests.swift` around lines 1624 - 1657, The test
testGoBackPrefersLiveWKWebViewHistoryBeforeRestoredFallback never seeds a
restored forward stack so it doesn't verify that navigating to a live page
clears stale restored forward history; update the
restoreSessionNavigationHistory call to include a non-empty
forwardHistoryURLStrings (e.g. a fourth fixture page or pageC/pageD string)
before calling browserLoadRequest, then after waitForBrowserPanel(panel, url:
pageC) call sessionNavigationHistorySnapshot() and assert that
snapshot.forwardHistoryURLStrings is empty and the backHistoryURLStrings still
include the restored entries (e.g. [pageA.absoluteString,
pageB.absoluteString]); this exercises the stale-forward-history branch in
restoreSessionNavigationHistory and sessionNavigationHistorySnapshot.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@cmuxTests/BrowserConfigTests.swift`:
- Around line 1539-1559: The helper waitForBrowserPanel currently calls XCTFail
on timeout but returns, allowing callers to continue; change its signature to
throw (e.g., make waitForBrowserPanel(...) throws) and on timeout throw a
descriptive error (or a custom WaitError) instead of just calling XCTFail, then
update all callers to use try/try? so the test stops immediately when the wait
fails; reference the waitForBrowserPanel function and
panel.preferredURLStringForOmnibar()/panel.isLoading checks and replace the
XCTFail branch with throwing the error so failures fail fast.
- Around line 1624-1657: The test
testGoBackPrefersLiveWKWebViewHistoryBeforeRestoredFallback never seeds a
restored forward stack so it doesn't verify that navigating to a live page
clears stale restored forward history; update the
restoreSessionNavigationHistory call to include a non-empty
forwardHistoryURLStrings (e.g. a fourth fixture page or pageC/pageD string)
before calling browserLoadRequest, then after waitForBrowserPanel(panel, url:
pageC) call sessionNavigationHistorySnapshot() and assert that
snapshot.forwardHistoryURLStrings is empty and the backHistoryURLStrings still
include the restored entries (e.g. [pageA.absoluteString,
pageB.absoluteString]); this exercises the stale-forward-history branch in
restoreSessionNavigationHistory and sessionNavigationHistorySnapshot.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d91e69d5-62f2-49c2-b745-9dbd539a8ca9

📥 Commits

Reviewing files that changed from the base of the PR and between 39c03c9 and 9063f52.

📒 Files selected for processing (2)
  • Sources/Panels/BrowserPanel.swift
  • cmuxTests/BrowserConfigTests.swift

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 <link rel="icon"> 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 <noreply@anthropic.com>
@austinywang
austinywang merged commit cdf8d36 into main Mar 21, 2026
10 of 12 checks passed
bn-l pushed a commit to bn-l/cmux that referenced this pull request Apr 3, 2026
* 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 <link rel="icon"> 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview — 8846e45b Deployed Mar 21, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Browser pane back button navigates to wrong page instead of previous page in history

1 participant