fix: SPA mouse back/forward navigation using JS history fallback
- commit
- f105e300ed77829b3840156d6dabf0c67ed8107d
- author
- brice · · 2026-01-08 17:05 UTC
- parent
- 0ae133e
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +29 -42
M CHANGELOG.md +1 -0
| @@ -13,6 +13,7 @@ All notable changes to this project will be documented in this file. | |||
| 13 | 13 | - **Navigation shortcuts**: `Ctrl+Left/Right` now triggers back/forward navigation, `Alt+Left/Right` moves focus between panes. Fixes #98. | |
| 14 | 14 | ||
| 15 | 15 | ### Fixed | |
| 16 | + | - **SPA mouse navigation**: Fixed unreliable back/forward mouse button navigation in Single Page Applications. WebKit's `BackForwardList` doesn't track JavaScript History API (`pushState`/`replaceState`) navigation. Now uses JavaScript `history.back()`/`history.forward()` as fallback when WebKit native navigation is unavailable. | |
| 16 | 17 | - **Accent picker freeze**: Fixed UI freeze when selecting an accent by pressing Enter or Escape. The deadlock was caused by the callback (which calls `Hide()`) being invoked while holding the mutex that `Hide()` also needs to acquire. | |
| 17 | 18 | - **Stacked pane title mismatch**: Fixed title bar showing wrong page title after closing panes in a stack. Refactored stacked pane management to use proper use cases, ensuring domain model stays in sync with UI. | |
| 18 | 19 | - **Flatpak favicons**: Fixed favicons not displaying in omnibox for Flatpak installs by ensuring cache directory exists before saving. Fixes #105. | |
| @@ -13,6 +13,7 @@ All notable changes to this project will be documented in this file. | |||||
| 13 | - **Navigation shortcuts**: `Ctrl+Left/Right` now triggers back/forward navigation, `Alt+Left/Right` moves focus between panes. Fixes #98. | 13 | - **Navigation shortcuts**: `Ctrl+Left/Right` now triggers back/forward navigation, `Alt+Left/Right` moves focus between panes. Fixes #98. | ||
| 14 | 14 | ||||
| 15 | ### Fixed | 15 | ### Fixed | ||
| 16 | + | - **SPA mouse navigation**: Fixed unreliable back/forward mouse button navigation in Single Page Applications. WebKit's `BackForwardList` doesn't track JavaScript History API (`pushState`/`replaceState`) navigation. Now uses JavaScript `history.back()`/`history.forward()` as fallback when WebKit native navigation is unavailable. | |||
| 16 | - **Accent picker freeze**: Fixed UI freeze when selecting an accent by pressing Enter or Escape. The deadlock was caused by the callback (which calls `Hide()`) being invoked while holding the mutex that `Hide()` also needs to acquire. | 17 | - **Accent picker freeze**: Fixed UI freeze when selecting an accent by pressing Enter or Escape. The deadlock was caused by the callback (which calls `Hide()`) being invoked while holding the mutex that `Hide()` also needs to acquire. | ||
| 17 | - **Stacked pane title mismatch**: Fixed title bar showing wrong page title after closing panes in a stack. Refactored stacked pane management to use proper use cases, ensuring domain model stays in sync with UI. | 18 | - **Stacked pane title mismatch**: Fixed title bar showing wrong page title after closing panes in a stack. Refactored stacked pane management to use proper use cases, ensuring domain model stays in sync with UI. | ||
| 18 | - **Flatpak favicons**: Fixed favicons not displaying in omnibox for Flatpak installs by ensuring cache directory exists before saving. Fixes #105. | 19 | - **Flatpak favicons**: Fixed favicons not displaying in omnibox for Flatpak installs by ensuring cache directory exists before saving. Fixes #105. | ||
M internal/application/usecase/navigate.go +0 -26
| @@ -240,32 +240,6 @@ func (uc *NavigateUseCase) Reload(ctx context.Context, webview port.WebView, byp | |||
| 240 | 240 | return webview.Reload(ctx) | |
| 241 | 241 | } | |
| 242 | 242 | ||
| 243 | - | // GoBack navigates back in history. | |
| 244 | - | func (uc *NavigateUseCase) GoBack(ctx context.Context, webview port.WebView) error { | |
| 245 | - | log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger() | |
| 246 | - | ||
| 247 | - | if !webview.CanGoBack() { | |
| 248 | - | log.Debug().Msg("cannot go back - no history") | |
| 249 | - | return nil | |
| 250 | - | } | |
| 251 | - | ||
| 252 | - | log.Debug().Msg("navigating back") | |
| 253 | - | return webview.GoBack(ctx) | |
| 254 | - | } | |
| 255 | - | ||
| 256 | - | // GoForward navigates forward in history. | |
| 257 | - | func (uc *NavigateUseCase) GoForward(ctx context.Context, webview port.WebView) error { | |
| 258 | - | log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger() | |
| 259 | - | ||
| 260 | - | if !webview.CanGoForward() { | |
| 261 | - | log.Debug().Msg("cannot go forward - no history") | |
| 262 | - | return nil | |
| 263 | - | } | |
| 264 | - | ||
| 265 | - | log.Debug().Msg("navigating forward") | |
| 266 | - | return webview.GoForward(ctx) | |
| 267 | - | } | |
| 268 | - | ||
| 269 | 243 | // Stop stops the current page load. | |
| 270 | 244 | func (uc *NavigateUseCase) Stop(ctx context.Context, webview port.WebView) error { | |
| 271 | 245 | log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger() | |
| @@ -240,32 +240,6 @@ func (uc *NavigateUseCase) Reload(ctx context.Context, webview port.WebView, byp | |||||
| 240 | return webview.Reload(ctx) | 240 | return webview.Reload(ctx) | ||
| 241 | } | 241 | } | ||
| 242 | 242 | ||||
| 243 | - | // GoBack navigates back in history. | |||
| 244 | - | func (uc *NavigateUseCase) GoBack(ctx context.Context, webview port.WebView) error { | |||
| 245 | - | log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger() | |||
| 246 | - | ||||
| 247 | - | if !webview.CanGoBack() { | |||
| 248 | - | log.Debug().Msg("cannot go back - no history") | |||
| 249 | - | return nil | |||
| 250 | - | } | |||
| 251 | - | ||||
| 252 | - | log.Debug().Msg("navigating back") | |||
| 253 | - | return webview.GoBack(ctx) | |||
| 254 | - | } | |||
| 255 | - | ||||
| 256 | - | // GoForward navigates forward in history. | |||
| 257 | - | func (uc *NavigateUseCase) GoForward(ctx context.Context, webview port.WebView) error { | |||
| 258 | - | log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger() | |||
| 259 | - | ||||
| 260 | - | if !webview.CanGoForward() { | |||
| 261 | - | log.Debug().Msg("cannot go forward - no history") | |||
| 262 | - | return nil | |||
| 263 | - | } | |||
| 264 | - | ||||
| 265 | - | log.Debug().Msg("navigating forward") | |||
| 266 | - | return webview.GoForward(ctx) | |||
| 267 | - | } | |||
| 268 | - | ||||
| 269 | // Stop stops the current page load. | 243 | // Stop stops the current page load. | ||
| 270 | func (uc *NavigateUseCase) Stop(ctx context.Context, webview port.WebView) error { | 244 | func (uc *NavigateUseCase) Stop(ctx context.Context, webview port.WebView) error { | ||
| 271 | log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger() | 245 | log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger() | ||
M internal/infrastructure/webkit/webview.go +26 -8
| @@ -761,28 +761,46 @@ func (wv *WebView) Stop(ctx context.Context) error { | |||
| 761 | 761 | } | |
| 762 | 762 | ||
| 763 | 763 | // GoBack navigates back in history. | |
| 764 | + | // Uses JavaScript history.back() first for SPA compatibility, falls back to WebKit native. | |
| 764 | 765 | func (wv *WebView) GoBack(ctx context.Context) error { | |
| 765 | 766 | if wv.destroyed.Load() { | |
| 766 | 767 | return fmt.Errorf("webview %d is destroyed", wv.id) | |
| 767 | 768 | } | |
| 768 | - | if !wv.inner.CanGoBack() { | |
| 769 | - | return fmt.Errorf("cannot go back") | |
| 769 | + | log := logging.FromContext(ctx) | |
| 770 | + | ||
| 771 | + | // Try WebKit native navigation first if available (for non-SPA pages) | |
| 772 | + | if wv.inner.CanGoBack() { | |
| 773 | + | wv.inner.GoBack() | |
| 774 | + | log.Debug().Int("webview_id", int(wv.id)).Msg("webview go back (native)") | |
| 775 | + | return nil | |
| 770 | 776 | } | |
| 771 | - | wv.inner.GoBack() | |
| 772 | - | logging.FromContext(ctx).Debug().Int("webview_id", int(wv.id)).Msg("webview go back") | |
| 777 | + | ||
| 778 | + | // Fall back to JavaScript history.back() for SPA navigation | |
| 779 | + | // This handles pushState/replaceState history that WebKit's BackForwardList doesn't track | |
| 780 | + | wv.RunJavaScript(ctx, "history.back()", "") | |
| 781 | + | log.Debug().Int("webview_id", int(wv.id)).Msg("webview go back (js)") | |
| 773 | 782 | return nil | |
| 774 | 783 | } | |
| 775 | 784 | ||
| 776 | 785 | // GoForward navigates forward in history. | |
| 786 | + | // Uses JavaScript history.forward() first for SPA compatibility, falls back to WebKit native. | |
| 777 | 787 | func (wv *WebView) GoForward(ctx context.Context) error { | |
| 778 | 788 | if wv.destroyed.Load() { | |
| 779 | 789 | return fmt.Errorf("webview %d is destroyed", wv.id) | |
| 780 | 790 | } | |
| 781 | - | if !wv.inner.CanGoForward() { | |
| 782 | - | return fmt.Errorf("cannot go forward") | |
| 791 | + | log := logging.FromContext(ctx) | |
| 792 | + | ||
| 793 | + | // Try WebKit native navigation first if available (for non-SPA pages) | |
| 794 | + | if wv.inner.CanGoForward() { | |
| 795 | + | wv.inner.GoForward() | |
| 796 | + | log.Debug().Int("webview_id", int(wv.id)).Msg("webview go forward (native)") | |
| 797 | + | return nil | |
| 783 | 798 | } | |
| 784 | - | wv.inner.GoForward() | |
| 785 | - | logging.FromContext(ctx).Debug().Int("webview_id", int(wv.id)).Msg("webview go forward") | |
| 799 | + | ||
| 800 | + | // Fall back to JavaScript history.forward() for SPA navigation | |
| 801 | + | // This handles pushState/replaceState history that WebKit's BackForwardList doesn't track | |
| 802 | + | wv.RunJavaScript(ctx, "history.forward()", "") | |
| 803 | + | log.Debug().Int("webview_id", int(wv.id)).Msg("webview go forward (js)") | |
| 786 | 804 | return nil | |
| 787 | 805 | } | |
| 788 | 806 | ||
| @@ -761,28 +761,46 @@ func (wv *WebView) Stop(ctx context.Context) error { | |||||
| 761 | } | 761 | } | ||
| 762 | 762 | ||||
| 763 | // GoBack navigates back in history. | 763 | // GoBack navigates back in history. | ||
| 764 | + | // Uses JavaScript history.back() first for SPA compatibility, falls back to WebKit native. | |||
| 764 | func (wv *WebView) GoBack(ctx context.Context) error { | 765 | func (wv *WebView) GoBack(ctx context.Context) error { | ||
| 765 | if wv.destroyed.Load() { | 766 | if wv.destroyed.Load() { | ||
| 766 | return fmt.Errorf("webview %d is destroyed", wv.id) | 767 | return fmt.Errorf("webview %d is destroyed", wv.id) | ||
| 767 | } | 768 | } | ||
| 768 | - | if !wv.inner.CanGoBack() { | 769 | + | log := logging.FromContext(ctx) |
| 769 | - | return fmt.Errorf("cannot go back") | 770 | + | |
| 771 | + | // Try WebKit native navigation first if available (for non-SPA pages) | |||
| 772 | + | if wv.inner.CanGoBack() { | |||
| 773 | + | wv.inner.GoBack() | |||
| 774 | + | log.Debug().Int("webview_id", int(wv.id)).Msg("webview go back (native)") | |||
| 775 | + | return nil | |||
| 770 | } | 776 | } | ||
| 771 | - | wv.inner.GoBack() | 777 | + | |
| 772 | - | logging.FromContext(ctx).Debug().Int("webview_id", int(wv.id)).Msg("webview go back") | 778 | + | // Fall back to JavaScript history.back() for SPA navigation |
| 779 | + | // This handles pushState/replaceState history that WebKit's BackForwardList doesn't track | |||
| 780 | + | wv.RunJavaScript(ctx, "history.back()", "") | |||
| 781 | + | log.Debug().Int("webview_id", int(wv.id)).Msg("webview go back (js)") | |||
| 773 | return nil | 782 | return nil | ||
| 774 | } | 783 | } | ||
| 775 | 784 | ||||
| 776 | // GoForward navigates forward in history. | 785 | // GoForward navigates forward in history. | ||
| 786 | + | // Uses JavaScript history.forward() first for SPA compatibility, falls back to WebKit native. | |||
| 777 | func (wv *WebView) GoForward(ctx context.Context) error { | 787 | func (wv *WebView) GoForward(ctx context.Context) error { | ||
| 778 | if wv.destroyed.Load() { | 788 | if wv.destroyed.Load() { | ||
| 779 | return fmt.Errorf("webview %d is destroyed", wv.id) | 789 | return fmt.Errorf("webview %d is destroyed", wv.id) | ||
| 780 | } | 790 | } | ||
| 781 | - | if !wv.inner.CanGoForward() { | 791 | + | log := logging.FromContext(ctx) |
| 782 | - | return fmt.Errorf("cannot go forward") | 792 | + | |
| 793 | + | // Try WebKit native navigation first if available (for non-SPA pages) | |||
| 794 | + | if wv.inner.CanGoForward() { | |||
| 795 | + | wv.inner.GoForward() | |||
| 796 | + | log.Debug().Int("webview_id", int(wv.id)).Msg("webview go forward (native)") | |||
| 797 | + | return nil | |||
| 783 | } | 798 | } | ||
| 784 | - | wv.inner.GoForward() | 799 | + | |
| 785 | - | logging.FromContext(ctx).Debug().Int("webview_id", int(wv.id)).Msg("webview go forward") | 800 | + | // Fall back to JavaScript history.forward() for SPA navigation |
| 801 | + | // This handles pushState/replaceState history that WebKit's BackForwardList doesn't track | |||
| 802 | + | wv.RunJavaScript(ctx, "history.forward()", "") | |||
| 803 | + | log.Debug().Int("webview_id", int(wv.id)).Msg("webview go forward (js)") | |||
| 786 | return nil | 804 | return nil | ||
| 787 | } | 805 | } | ||
| 788 | 806 | ||||
M internal/ui/coordinator/navigation.go +2 -8
| @@ -133,6 +133,7 @@ func (c *NavigationCoordinator) HardReload(ctx context.Context) error { | |||
| 133 | 133 | } | |
| 134 | 134 | ||
| 135 | 135 | // GoBack navigates back in history. | |
| 136 | + | // Calls webview directly - the webview layer handles SPA navigation via JS fallback. | |
| 136 | 137 | func (c *NavigationCoordinator) GoBack(ctx context.Context) error { | |
| 137 | 138 | log := logging.FromContext(ctx) | |
| 138 | 139 | ||
| @@ -142,14 +143,11 @@ func (c *NavigationCoordinator) GoBack(ctx context.Context) error { | |||
| 142 | 143 | return nil | |
| 143 | 144 | } | |
| 144 | 145 | ||
| 145 | - | if c.navigateUC != nil { | |
| 146 | - | return c.navigateUC.GoBack(ctx, wv) | |
| 147 | - | } | |
| 148 | - | ||
| 149 | 146 | return wv.GoBack(ctx) | |
| 150 | 147 | } | |
| 151 | 148 | ||
| 152 | 149 | // GoForward navigates forward in history. | |
| 150 | + | // Calls webview directly - the webview layer handles SPA navigation via JS fallback. | |
| 153 | 151 | func (c *NavigationCoordinator) GoForward(ctx context.Context) error { | |
| 154 | 152 | log := logging.FromContext(ctx) | |
| 155 | 153 | ||
| @@ -159,10 +157,6 @@ func (c *NavigationCoordinator) GoForward(ctx context.Context) error { | |||
| 159 | 157 | return nil | |
| 160 | 158 | } | |
| 161 | 159 | ||
| 162 | - | if c.navigateUC != nil { | |
| 163 | - | return c.navigateUC.GoForward(ctx, wv) | |
| 164 | - | } | |
| 165 | - | ||
| 166 | 160 | return wv.GoForward(ctx) | |
| 167 | 161 | } | |
| 168 | 162 | ||
| @@ -133,6 +133,7 @@ func (c *NavigationCoordinator) HardReload(ctx context.Context) error { | |||||
| 133 | } | 133 | } | ||
| 134 | 134 | ||||
| 135 | // GoBack navigates back in history. | 135 | // GoBack navigates back in history. | ||
| 136 | + | // Calls webview directly - the webview layer handles SPA navigation via JS fallback. | |||
| 136 | func (c *NavigationCoordinator) GoBack(ctx context.Context) error { | 137 | func (c *NavigationCoordinator) GoBack(ctx context.Context) error { | ||
| 137 | log := logging.FromContext(ctx) | 138 | log := logging.FromContext(ctx) | ||
| 138 | 139 | ||||
| @@ -142,14 +143,11 @@ func (c *NavigationCoordinator) GoBack(ctx context.Context) error { | |||||
| 142 | return nil | 143 | return nil | ||
| 143 | } | 144 | } | ||
| 144 | 145 | ||||
| 145 | - | if c.navigateUC != nil { | |||
| 146 | - | return c.navigateUC.GoBack(ctx, wv) | |||
| 147 | - | } | |||
| 148 | - | ||||
| 149 | return wv.GoBack(ctx) | 146 | return wv.GoBack(ctx) | ||
| 150 | } | 147 | } | ||
| 151 | 148 | ||||
| 152 | // GoForward navigates forward in history. | 149 | // GoForward navigates forward in history. | ||
| 150 | + | // Calls webview directly - the webview layer handles SPA navigation via JS fallback. | |||
| 153 | func (c *NavigationCoordinator) GoForward(ctx context.Context) error { | 151 | func (c *NavigationCoordinator) GoForward(ctx context.Context) error { | ||
| 154 | log := logging.FromContext(ctx) | 152 | log := logging.FromContext(ctx) | ||
| 155 | 153 | ||||
| @@ -159,10 +157,6 @@ func (c *NavigationCoordinator) GoForward(ctx context.Context) error { | |||||
| 159 | return nil | 157 | return nil | ||
| 160 | } | 158 | } | ||
| 161 | 159 | ||||
| 162 | - | if c.navigateUC != nil { | |||
| 163 | - | return c.navigateUC.GoForward(ctx, wv) | |||
| 164 | - | } | |||
| 165 | - | ||||
| 166 | return wv.GoForward(ctx) | 160 | return wv.GoForward(ctx) | ||
| 167 | } | 161 | } | ||
| 168 | 162 | ||||