From 87423f3d0fd4ec449044f1b9ccdba0161a3a4cd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20S=CC=8Cvara?= Date: Tue, 30 Jun 2026 12:30:07 +0200 Subject: [PATCH 1/7] Introduce Navigating protocol Add a public `Navigating` protocol that abstracts the navigation surface that route and web view policy decision handlers rely on, so handlers can be exercised against a lightweight test double instead of a concrete `Navigator`. It refines the existing `NavigationHandler` (route-only) with pop/clearAll/reload, the root/modal/active navigation controllers, the active web view, and a `currentVisitableURL` convenience whose default implementation reads the active navigation controller's top visitable. --- Source/Turbo/Navigator/Navigating.swift | 50 +++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 Source/Turbo/Navigator/Navigating.swift diff --git a/Source/Turbo/Navigator/Navigating.swift b/Source/Turbo/Navigator/Navigating.swift new file mode 100644 index 00000000..063104eb --- /dev/null +++ b/Source/Turbo/Navigator/Navigating.swift @@ -0,0 +1,50 @@ +import Foundation +import UIKit +import WebKit + +/// A testable abstraction over `Navigator`. +/// +/// Refines `NavigationHandler` (which provides `route(_:)` / `route(_ proposal:)`) +/// with the navigation operations and view controller/web view accessors that +/// `RouteDecisionHandler` and `WebViewPolicyDecisionHandler` implementations rely on. +/// +/// Conform a lightweight fake to `Navigating` to unit test custom decision handlers +/// without instantiating real `Session` or `WKWebView` instances. +public protocol Navigating: NavigationHandler { + /// Routes to the given URL. + /// - Parameters: + /// - url: the URL to visit. + /// - options: passed options will override default `advance` visit options. + /// - parameters: provide context relevant to `url`. + func route(_ url: URL, options: VisitOptions?, parameters: [String: Any]?) + + /// Pops the top controller on the presented navigation stack. + func pop(animated: Bool) + + /// Dismisses a modally presented controller if present, then pops the entire navigation stack. + func clearAll(animated: Bool) + + /// Reloads the main and modal `Session`. + func reload() + + /// The root navigation controller of the main navigation stack. + var rootViewController: UINavigationController { get } + + /// The root navigation controller of the modal navigation stack. + var modalRootViewController: UINavigationController { get } + + /// The navigation controller currently presented to the user. + var activeNavigationController: UINavigationController { get } + + /// The web view backing the active navigation stack. + var activeWebView: WKWebView { get } + + /// The URL currently displayed by the top visitable on the active navigation stack, if any. + var currentVisitableURL: URL? { get } +} + +public extension Navigating { + var currentVisitableURL: URL? { + (activeNavigationController.topViewController as? VisitableViewController)?.currentVisitableURL + } +} From fad710688f567223d07fb62dfda2772b985449b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20S=CC=8Cvara?= Date: Tue, 30 Jun 2026 12:30:16 +0200 Subject: [PATCH 2/7] Conform Navigator to Navigating `Navigator` already implements every member, so the conformance is empty. `currentVisitableURL` is satisfied by the protocol's default implementation. --- Source/Turbo/Navigator/Navigating.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Source/Turbo/Navigator/Navigating.swift b/Source/Turbo/Navigator/Navigating.swift index 063104eb..cfa25dc1 100644 --- a/Source/Turbo/Navigator/Navigating.swift +++ b/Source/Turbo/Navigator/Navigating.swift @@ -48,3 +48,5 @@ public extension Navigating { (activeNavigationController.topViewController as? VisitableViewController)?.currentVisitableURL } } + +extension Navigator: Navigating {} From e1fc3efabb059776a5f41bba47e90fecacf76206 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20S=CC=8Cvara?= Date: Tue, 30 Jun 2026 12:30:30 +0200 Subject: [PATCH 3/7] Pass Navigating to route and web view policy decision handlers Change the `navigator` parameter from the concrete `Navigator` to the `Navigating` protocol in `RouteDecisionHandler` and `WebViewPolicyDecisionHandler`, the `Router`/`WebViewPolicyManager` that drive them, and all of the built-in handlers. Handler bodies are unchanged -- they only use members the protocol exposes. --- .../Routing/Handlers/AppNavigationRouteDecisionHandler.swift | 2 +- .../Handlers/SafariViewControllerRouteDecisionHandler.swift | 2 +- .../Routing/Handlers/SystemNavigationRouteDecisionHandler.swift | 2 +- Source/Turbo/Navigator/Routing/RouteDecisionHandler.swift | 2 +- Source/Turbo/Navigator/Routing/Router.swift | 2 +- .../ExternalNavigationWebViewPolicyDecisionHandler.swift | 2 +- .../Handlers/LinkActivatedWebViewPolicyDecisionHandler.swift | 2 +- .../Handlers/NewWindowWebViewPolicyDecisionHandler.swift | 2 +- .../Handlers/ReloadWebViewPolicyDecisionHandler.swift | 2 +- Source/Turbo/Navigator/WebViewPolicy/WebViewPolicyManager.swift | 2 +- .../Navigator/WebViewPolicy/WebViewRouteDecisionHandler.swift | 2 +- 11 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Source/Turbo/Navigator/Routing/Handlers/AppNavigationRouteDecisionHandler.swift b/Source/Turbo/Navigator/Routing/Handlers/AppNavigationRouteDecisionHandler.swift index b5f92b63..187912e2 100644 --- a/Source/Turbo/Navigator/Routing/Handlers/AppNavigationRouteDecisionHandler.swift +++ b/Source/Turbo/Navigator/Routing/Handlers/AppNavigationRouteDecisionHandler.swift @@ -16,7 +16,7 @@ public final class AppNavigationRouteDecisionHandler: RouteDecisionHandler { public func handle(location: URL, configuration: Navigator.Configuration, - navigator: Navigator) -> Router.Decision { + navigator: Navigating) -> Router.Decision { return .navigate } } diff --git a/Source/Turbo/Navigator/Routing/Handlers/SafariViewControllerRouteDecisionHandler.swift b/Source/Turbo/Navigator/Routing/Handlers/SafariViewControllerRouteDecisionHandler.swift index f2f97dd9..e09459f2 100644 --- a/Source/Turbo/Navigator/Routing/Handlers/SafariViewControllerRouteDecisionHandler.swift +++ b/Source/Turbo/Navigator/Routing/Handlers/SafariViewControllerRouteDecisionHandler.swift @@ -23,7 +23,7 @@ public final class SafariViewControllerRouteDecisionHandler: RouteDecisionHandle public func handle(location: URL, configuration: Navigator.Configuration, - navigator: Navigator) -> HotwireNative.Router.Decision { + navigator: Navigating) -> HotwireNative.Router.Decision { Task { @MainActor in await open(externalURL: location, viewController: navigator.activeNavigationController) diff --git a/Source/Turbo/Navigator/Routing/Handlers/SystemNavigationRouteDecisionHandler.swift b/Source/Turbo/Navigator/Routing/Handlers/SystemNavigationRouteDecisionHandler.swift index d4221da4..7cc5b353 100644 --- a/Source/Turbo/Navigator/Routing/Handlers/SystemNavigationRouteDecisionHandler.swift +++ b/Source/Turbo/Navigator/Routing/Handlers/SystemNavigationRouteDecisionHandler.swift @@ -18,7 +18,7 @@ public final class SystemNavigationRouteDecisionHandler: RouteDecisionHandler { public func handle(location: URL, configuration: Navigator.Configuration, - navigator: Navigator) -> Router.Decision { + navigator: Navigating) -> Router.Decision { UIApplication.shared.open(location) return .cancel diff --git a/Source/Turbo/Navigator/Routing/RouteDecisionHandler.swift b/Source/Turbo/Navigator/Routing/RouteDecisionHandler.swift index 65812b46..877eb031 100644 --- a/Source/Turbo/Navigator/Routing/RouteDecisionHandler.swift +++ b/Source/Turbo/Navigator/Routing/RouteDecisionHandler.swift @@ -24,5 +24,5 @@ public protocol RouteDecisionHandler { /// - navigator: The navigator instance responsible for the navigation. func handle(location: URL, configuration: Navigator.Configuration, - navigator: Navigator) -> Router.Decision + navigator: Navigating) -> Router.Decision } diff --git a/Source/Turbo/Navigator/Routing/Router.swift b/Source/Turbo/Navigator/Routing/Router.swift index 786cd928..c8290aca 100644 --- a/Source/Turbo/Navigator/Routing/Router.swift +++ b/Source/Turbo/Navigator/Routing/Router.swift @@ -11,7 +11,7 @@ public final class Router { func decideRoute(for location: URL, configuration: Navigator.Configuration, - navigator: Navigator) -> Router.Decision { + navigator: Navigating) -> Router.Decision { for handler in decisionHandlers { if handler.matches(location: location, configuration: configuration) { logger.debug("[Router] handler match found handler: \(handler.name) location: \(location)") diff --git a/Source/Turbo/Navigator/WebViewPolicy/Handlers/ExternalNavigationWebViewPolicyDecisionHandler.swift b/Source/Turbo/Navigator/WebViewPolicy/Handlers/ExternalNavigationWebViewPolicyDecisionHandler.swift index f9ba73a8..bf60f7a3 100644 --- a/Source/Turbo/Navigator/WebViewPolicy/Handlers/ExternalNavigationWebViewPolicyDecisionHandler.swift +++ b/Source/Turbo/Navigator/WebViewPolicy/Handlers/ExternalNavigationWebViewPolicyDecisionHandler.swift @@ -19,7 +19,7 @@ public struct ExternalNavigationWebViewPolicyDecisionHandler: WebViewPolicyDecis public func handle(navigationAction: WKNavigationAction, configuration: Navigator.Configuration, - navigator: Navigator) -> WebViewPolicyManager.Decision { + navigator: Navigating) -> WebViewPolicyManager.Decision { if let url = navigationAction.request.url { navigator.route(url) } diff --git a/Source/Turbo/Navigator/WebViewPolicy/Handlers/LinkActivatedWebViewPolicyDecisionHandler.swift b/Source/Turbo/Navigator/WebViewPolicy/Handlers/LinkActivatedWebViewPolicyDecisionHandler.swift index 89a2ca62..fa18453d 100644 --- a/Source/Turbo/Navigator/WebViewPolicy/Handlers/LinkActivatedWebViewPolicyDecisionHandler.swift +++ b/Source/Turbo/Navigator/WebViewPolicy/Handlers/LinkActivatedWebViewPolicyDecisionHandler.swift @@ -19,7 +19,7 @@ public struct LinkActivatedWebViewPolicyDecisionHandler: WebViewPolicyDecisionHa public func handle(navigationAction: WKNavigationAction, configuration: Navigator.Configuration, - navigator: Navigator) -> WebViewPolicyManager.Decision { + navigator: Navigating) -> WebViewPolicyManager.Decision { return .cancel } } diff --git a/Source/Turbo/Navigator/WebViewPolicy/Handlers/NewWindowWebViewPolicyDecisionHandler.swift b/Source/Turbo/Navigator/WebViewPolicy/Handlers/NewWindowWebViewPolicyDecisionHandler.swift index 235e25e8..14e55bde 100644 --- a/Source/Turbo/Navigator/WebViewPolicy/Handlers/NewWindowWebViewPolicyDecisionHandler.swift +++ b/Source/Turbo/Navigator/WebViewPolicy/Handlers/NewWindowWebViewPolicyDecisionHandler.swift @@ -19,7 +19,7 @@ public struct NewWindowWebViewPolicyDecisionHandler: WebViewPolicyDecisionHandle public func handle(navigationAction: WKNavigationAction, configuration: Navigator.Configuration, - navigator: Navigator) -> WebViewPolicyManager.Decision { + navigator: Navigating) -> WebViewPolicyManager.Decision { if let url = navigationAction.request.url { navigator.route(url) } diff --git a/Source/Turbo/Navigator/WebViewPolicy/Handlers/ReloadWebViewPolicyDecisionHandler.swift b/Source/Turbo/Navigator/WebViewPolicy/Handlers/ReloadWebViewPolicyDecisionHandler.swift index 4d77491e..54000a49 100644 --- a/Source/Turbo/Navigator/WebViewPolicy/Handlers/ReloadWebViewPolicyDecisionHandler.swift +++ b/Source/Turbo/Navigator/WebViewPolicy/Handlers/ReloadWebViewPolicyDecisionHandler.swift @@ -17,7 +17,7 @@ public struct ReloadWebViewPolicyDecisionHandler: WebViewPolicyDecisionHandler { public func handle(navigationAction: WKNavigationAction, configuration: Navigator.Configuration, - navigator: Navigator) -> WebViewPolicyManager.Decision { + navigator: Navigating) -> WebViewPolicyManager.Decision { navigator.reload() return .cancel diff --git a/Source/Turbo/Navigator/WebViewPolicy/WebViewPolicyManager.swift b/Source/Turbo/Navigator/WebViewPolicy/WebViewPolicyManager.swift index 10a8c62d..4572aac2 100644 --- a/Source/Turbo/Navigator/WebViewPolicy/WebViewPolicyManager.swift +++ b/Source/Turbo/Navigator/WebViewPolicy/WebViewPolicyManager.swift @@ -12,7 +12,7 @@ public final class WebViewPolicyManager { func decidePolicy(for navigationAction: WKNavigationAction, configuration: Navigator.Configuration, - navigator: Navigator) -> WebViewPolicyManager.Decision { + navigator: Navigating) -> WebViewPolicyManager.Decision { for handler in policyDecisionHandlers { if handler.matches(navigationAction: navigationAction, configuration: configuration) { logger.debug("[WebViewPolicyManager] handler match found handler: \(handler.name) navigation action:\(navigationAction)") diff --git a/Source/Turbo/Navigator/WebViewPolicy/WebViewRouteDecisionHandler.swift b/Source/Turbo/Navigator/WebViewPolicy/WebViewRouteDecisionHandler.swift index dab6cb50..dc8eb0dd 100644 --- a/Source/Turbo/Navigator/WebViewPolicy/WebViewRouteDecisionHandler.swift +++ b/Source/Turbo/Navigator/WebViewPolicy/WebViewRouteDecisionHandler.swift @@ -26,5 +26,5 @@ public protocol WebViewPolicyDecisionHandler { /// whether to allow or cancel the WebView navigation. func handle(navigationAction: WKNavigationAction, configuration: Navigator.Configuration, - navigator: Navigator) -> WebViewPolicyManager.Decision + navigator: Navigating) -> WebViewPolicyManager.Decision } From 1d11fbb9091d28d86f0e16603013bc12492577c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20S=CC=8Cvara?= Date: Tue, 30 Jun 2026 12:30:37 +0200 Subject: [PATCH 4/7] Make NavigationSpy a lightweight Navigating conformer Rewrite `NavigationSpy` as a standalone `Navigating` conformer that records route/reload calls and exposes a settable `currentVisitableURL`, instead of subclassing `Navigator` and creating real `Session` and `WKWebView` instances. Update the in-test decision handler spies to the new `Navigating` signature. --- Tests/Turbo/Routing/RouterTests.swift | 4 +-- ...aseWebViewPolicyDecisionHandlerTests.swift | 2 +- .../WebViewPolicy/Spies/NavigationSpy.swift | 36 ++++++++++++++----- .../WebViewPolicyManagerTests.swift | 4 +-- 4 files changed, 32 insertions(+), 14 deletions(-) diff --git a/Tests/Turbo/Routing/RouterTests.swift b/Tests/Turbo/Routing/RouterTests.swift index 8034d82c..608912c2 100644 --- a/Tests/Turbo/Routing/RouterTests.swift +++ b/Tests/Turbo/Routing/RouterTests.swift @@ -89,7 +89,7 @@ final class NoMatchRouteDecisionHandlerSpy: RouteDecisionHandler { return false } - func handle(location: URL, configuration: HotwireNative.Navigator.Configuration, navigator: HotwireNative.Navigator) -> HotwireNative.Router.Decision { + func handle(location: URL, configuration: HotwireNative.Navigator.Configuration, navigator: HotwireNative.Navigating) -> HotwireNative.Router.Decision { handleWasCalled = true return .cancel } @@ -105,7 +105,7 @@ final class MatchRouteDecisionHandlerSpy: RouteDecisionHandler { return true } - func handle(location: URL, configuration: HotwireNative.Navigator.Configuration, navigator: HotwireNative.Navigator) -> HotwireNative.Router.Decision { + func handle(location: URL, configuration: HotwireNative.Navigator.Configuration, navigator: HotwireNative.Navigating) -> HotwireNative.Router.Decision { handleWasCalled = true return .navigate } diff --git a/Tests/Turbo/WebViewPolicy/BaseWebViewPolicyDecisionHandlerTests.swift b/Tests/Turbo/WebViewPolicy/BaseWebViewPolicyDecisionHandlerTests.swift index 1df3c2bc..799b1b0e 100644 --- a/Tests/Turbo/WebViewPolicy/BaseWebViewPolicyDecisionHandlerTests.swift +++ b/Tests/Turbo/WebViewPolicy/BaseWebViewPolicyDecisionHandlerTests.swift @@ -12,7 +12,7 @@ class BaseWebViewPolicyDecisionHandlerTests: XCTestCase { ) override func setUp() async throws { - navigatorSpy = NavigationSpy(configuration: navigatorConfiguration) + navigatorSpy = NavigationSpy() webNavigationSimulator = WebViewNavigationSimulator() } diff --git a/Tests/Turbo/WebViewPolicy/Spies/NavigationSpy.swift b/Tests/Turbo/WebViewPolicy/Spies/NavigationSpy.swift index cd923163..c40a7546 100644 --- a/Tests/Turbo/WebViewPolicy/Spies/NavigationSpy.swift +++ b/Tests/Turbo/WebViewPolicy/Spies/NavigationSpy.swift @@ -1,25 +1,43 @@ @testable import HotwireNative import Foundation +import UIKit +import WebKit -final class NavigationSpy: Navigator { +/// A lightweight `Navigating` test double that records navigation calls without +/// instantiating real `Session` or `WKWebView` instances. +final class NavigationSpy: Navigating { var routeWasCalled = false var routeURL: URL? var reloadWasCalled = false - init(configuration: Navigator.Configuration) { - super.init( - session: Session(webView: Hotwire.config.makeWebView()), - modalSession: Session(webView: Hotwire.config.makeWebView()), - configuration: configuration - ) + /// Stub for the currently displayed URL, overriding the `Navigating` default. + var currentVisitableURL: URL? + + lazy var rootViewController = UINavigationController() + lazy var modalRootViewController = UINavigationController() + lazy var activeNavigationController = UINavigationController() + lazy var activeWebView = WKWebView() + + func route(_ url: URL) { + routeWasCalled = true + routeURL = url } - override func route(_ url: URL, options: VisitOptions? = VisitOptions(action: .advance), parameters: [String : Any]? = nil) { + func route(_ proposal: VisitProposal) { + routeWasCalled = true + routeURL = proposal.url + } + + func route(_ url: URL, options: VisitOptions?, parameters: [String: Any]?) { routeWasCalled = true routeURL = url } - override func reload() { + func pop(animated: Bool) {} + + func clearAll(animated: Bool) {} + + func reload() { reloadWasCalled = true } } diff --git a/Tests/Turbo/WebViewPolicy/WebViewPolicyManagerTests.swift b/Tests/Turbo/WebViewPolicy/WebViewPolicyManagerTests.swift index 2857049b..ad8382eb 100644 --- a/Tests/Turbo/WebViewPolicy/WebViewPolicyManagerTests.swift +++ b/Tests/Turbo/WebViewPolicy/WebViewPolicyManagerTests.swift @@ -112,7 +112,7 @@ final class NoMatchWebViewPolicyDecisionHandler: WebViewPolicyDecisionHandler { func handle(navigationAction: WKNavigationAction, configuration: Navigator.Configuration, - navigator: Navigator) -> WebViewPolicyManager.Decision { + navigator: Navigating) -> WebViewPolicyManager.Decision { handleWasCalled = true return .cancel } @@ -131,7 +131,7 @@ final class MatchWebViewPolicyDecisionHandler: WebViewPolicyDecisionHandler { func handle(navigationAction: WKNavigationAction, configuration: Navigator.Configuration, - navigator: Navigator) -> WebViewPolicyManager.Decision { + navigator: Navigating) -> WebViewPolicyManager.Decision { handleWasCalled = true return .cancel } From ed2b97a1c3134b2530846344ec736887f480f77b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20S=CC=8Cvara?= Date: Tue, 30 Jun 2026 12:46:34 +0200 Subject: [PATCH 5/7] Use NavigationSpy in decision handler tests The router, web view policy manager, and individual route decision handler tests only pass the navigator through to the code under test; they never rely on real navigation. Swap the concrete `Navigator` for the lightweight `NavigationSpy`, which avoids creating real `Session` and `WKWebView` instances. The Navigator-behavior tests (NavigationHierarchyController*, NavigationDelegateTests) keep the real `Navigator`, since they exercise its actual navigation behavior. --- .../Turbo/Routing/AppNavigationRouteDecisionHandlerTest.swift | 4 ++-- Tests/Turbo/Routing/RouterTests.swift | 4 ++-- .../SafariViewControllerRouteDecisionHandlerTests.swift | 4 ++-- .../Routing/SystemNavigationRouteDecisionHandlerTest.swift | 4 ++-- Tests/Turbo/WebViewPolicy/WebViewPolicyManagerTests.swift | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Tests/Turbo/Routing/AppNavigationRouteDecisionHandlerTest.swift b/Tests/Turbo/Routing/AppNavigationRouteDecisionHandlerTest.swift index af68abdf..4c228e32 100644 --- a/Tests/Turbo/Routing/AppNavigationRouteDecisionHandlerTest.swift +++ b/Tests/Turbo/Routing/AppNavigationRouteDecisionHandlerTest.swift @@ -7,11 +7,11 @@ final class AppNavigationRouteDecisionHandlerTest: XCTestCase { startLocation: URL(string: "https://my.app.com")! ) var route: AppNavigationRouteDecisionHandler! - var navigator: Navigator! + var navigator: NavigationSpy! override func setUp() { route = AppNavigationRouteDecisionHandler() - navigator = Navigator(configuration: navigatorConfiguration) + navigator = NavigationSpy() } func test_handling_matching_result_navigates() { diff --git a/Tests/Turbo/Routing/RouterTests.swift b/Tests/Turbo/Routing/RouterTests.swift index 608912c2..41ccebe7 100644 --- a/Tests/Turbo/Routing/RouterTests.swift +++ b/Tests/Turbo/Routing/RouterTests.swift @@ -8,10 +8,10 @@ final class RouterTests: XCTestCase { ) let url = URL(string: "https://my.app.com/page")! var router: Router! - var navigator: Navigator! + var navigator: NavigationSpy! override func setUp() { - navigator = Navigator(configuration: navigatorConfiguration) + navigator = NavigationSpy() } func test_no_handlers_stops_navigation() { diff --git a/Tests/Turbo/Routing/SafariViewControllerRouteDecisionHandlerTests.swift b/Tests/Turbo/Routing/SafariViewControllerRouteDecisionHandlerTests.swift index bc143523..fda403ab 100644 --- a/Tests/Turbo/Routing/SafariViewControllerRouteDecisionHandlerTests.swift +++ b/Tests/Turbo/Routing/SafariViewControllerRouteDecisionHandlerTests.swift @@ -6,12 +6,12 @@ final class SafariViewControllerRouteDecisionHandlerTests: XCTestCase { name: "test", startLocation: URL(string: "https://my.app.com")! ) - var navigator: Navigator! + var navigator: NavigationSpy! var route: SafariViewControllerRouteDecisionHandler! override func setUp() { route = SafariViewControllerRouteDecisionHandler() - navigator = Navigator(configuration: navigatorConfiguration) + navigator = NavigationSpy() } func test_handling_matching_result_stops_navigation() { diff --git a/Tests/Turbo/Routing/SystemNavigationRouteDecisionHandlerTest.swift b/Tests/Turbo/Routing/SystemNavigationRouteDecisionHandlerTest.swift index 8b896d0c..ee87a33d 100644 --- a/Tests/Turbo/Routing/SystemNavigationRouteDecisionHandlerTest.swift +++ b/Tests/Turbo/Routing/SystemNavigationRouteDecisionHandlerTest.swift @@ -6,12 +6,12 @@ final class SystemNavigationRouteDecisionHandlerTests: XCTestCase { name: "test", startLocation: URL(string: "https://my.app.com")! ) - var navigator: Navigator! + var navigator: NavigationSpy! var route: SystemNavigationRouteDecisionHandler! override func setUp() { route = SystemNavigationRouteDecisionHandler() - navigator = Navigator(configuration: navigatorConfiguration) + navigator = NavigationSpy() } func test_handling_matching_result_stops_navigation() { diff --git a/Tests/Turbo/WebViewPolicy/WebViewPolicyManagerTests.swift b/Tests/Turbo/WebViewPolicy/WebViewPolicyManagerTests.swift index ad8382eb..998a6682 100644 --- a/Tests/Turbo/WebViewPolicy/WebViewPolicyManagerTests.swift +++ b/Tests/Turbo/WebViewPolicy/WebViewPolicyManagerTests.swift @@ -11,11 +11,11 @@ final class WebViewPolicyManagerTests: XCTestCase { let url = URL(string: "https://my.app.com/page")! var policyManager: WebViewPolicyManager! var webNavigationSimulator: WebViewNavigationSimulator! - var navigator: Navigator! + var navigator: NavigationSpy! static var navigationAction: WKNavigationAction! override func setUp() async throws { - navigator = Navigator(configuration: navigatorConfiguration) + navigator = NavigationSpy() // We can't instantiate a `WKNavigationAction`, so we load HTML and retrieve one. // In these tests, the navigation action is not actually evaluated, but we need it as an argument From aef0a37e976a7941757471e1cd41863268d6f901 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20S=CC=8Cvara?= Date: Tue, 30 Jun 2026 13:05:55 +0200 Subject: [PATCH 6/7] Drop activeWebView from Navigating Neither the built-in decision handlers nor the bc3-ios/hey-ios route and web view policy handlers ever read the navigator's web view; the only navigator-level web view/session access in those apps lives in coordinators that hold a concrete `Navigator`. Keeping `activeWebView` on the protocol forced every conformer (and test double) to fabricate a `WKWebView` for no benefit, so remove it. `Navigator` keeps its own public `activeWebView` for concrete callers. --- Source/Turbo/Navigator/Navigating.swift | 9 +-------- Tests/Turbo/WebViewPolicy/Spies/NavigationSpy.swift | 2 -- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/Source/Turbo/Navigator/Navigating.swift b/Source/Turbo/Navigator/Navigating.swift index cfa25dc1..2c56b319 100644 --- a/Source/Turbo/Navigator/Navigating.swift +++ b/Source/Turbo/Navigator/Navigating.swift @@ -1,15 +1,11 @@ import Foundation import UIKit -import WebKit /// A testable abstraction over `Navigator`. /// /// Refines `NavigationHandler` (which provides `route(_:)` / `route(_ proposal:)`) -/// with the navigation operations and view controller/web view accessors that +/// with the navigation operations and view controller accessors that /// `RouteDecisionHandler` and `WebViewPolicyDecisionHandler` implementations rely on. -/// -/// Conform a lightweight fake to `Navigating` to unit test custom decision handlers -/// without instantiating real `Session` or `WKWebView` instances. public protocol Navigating: NavigationHandler { /// Routes to the given URL. /// - Parameters: @@ -36,9 +32,6 @@ public protocol Navigating: NavigationHandler { /// The navigation controller currently presented to the user. var activeNavigationController: UINavigationController { get } - /// The web view backing the active navigation stack. - var activeWebView: WKWebView { get } - /// The URL currently displayed by the top visitable on the active navigation stack, if any. var currentVisitableURL: URL? { get } } diff --git a/Tests/Turbo/WebViewPolicy/Spies/NavigationSpy.swift b/Tests/Turbo/WebViewPolicy/Spies/NavigationSpy.swift index c40a7546..50427174 100644 --- a/Tests/Turbo/WebViewPolicy/Spies/NavigationSpy.swift +++ b/Tests/Turbo/WebViewPolicy/Spies/NavigationSpy.swift @@ -1,7 +1,6 @@ @testable import HotwireNative import Foundation import UIKit -import WebKit /// A lightweight `Navigating` test double that records navigation calls without /// instantiating real `Session` or `WKWebView` instances. @@ -16,7 +15,6 @@ final class NavigationSpy: Navigating { lazy var rootViewController = UINavigationController() lazy var modalRootViewController = UINavigationController() lazy var activeNavigationController = UINavigationController() - lazy var activeWebView = WKWebView() func route(_ url: URL) { routeWasCalled = true From b66c48b577a54a122de62972f78e0b58ed4e5179 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20S=CC=8Cvara?= Date: Tue, 30 Jun 2026 13:17:12 +0200 Subject: [PATCH 7/7] Move route(_:options:parameters:) to NavigationHandler `NavigationHandler` is the "trigger a visit" contract, so all of the `route` overloads belong together there. Move the full-control `route(_:options:parameters:)` down from `Navigating`, and provide `route(_:)` as a default implementation in terms of it. Conformers now only implement `route(_:options:parameters:)` and `route(_ proposal:)`: `Navigator` satisfies both with its own methods (empty conformance), and `HotwireTabBarController` forwards the full-control overload to its active navigator and drops its hand-written `route(_:)`. `Navigating` still exposes the overloads through protocol inheritance, so nothing changes for decision handlers. --- Source/NavigationHandler.swift | 14 +++++++++++--- Source/Turbo/Navigator/Navigating.swift | 11 ++--------- .../ViewControllers/HotwireTabBarController.swift | 4 ++-- .../Turbo/WebViewPolicy/Spies/NavigationSpy.swift | 5 ----- 4 files changed, 15 insertions(+), 19 deletions(-) diff --git a/Source/NavigationHandler.swift b/Source/NavigationHandler.swift index 59a7d4da..9f57220b 100644 --- a/Source/NavigationHandler.swift +++ b/Source/NavigationHandler.swift @@ -10,13 +10,21 @@ import Foundation /// /// - Note: See `NumbersViewController` in the demo app for an example. public protocol NavigationHandler: AnyObject { - func route(_ url: URL) + /// Routes to the given URL. + /// - Parameters: + /// - url: the URL to visit. + /// - options: passed options will override default `advance` visit options. + /// - parameters: provide context relevant to `url`. + func route(_ url: URL, options: VisitOptions?, parameters: [String: Any]?) func route(_ proposal: VisitProposal) } -extension Navigator: NavigationHandler { - public func route(_ url: URL) { +public extension NavigationHandler { + /// Routes to the given URL using the default `advance` visit options. + func route(_ url: URL) { route(url, options: VisitOptions(action: .advance), parameters: nil) } } + +extension Navigator: NavigationHandler {} diff --git a/Source/Turbo/Navigator/Navigating.swift b/Source/Turbo/Navigator/Navigating.swift index 2c56b319..514b4099 100644 --- a/Source/Turbo/Navigator/Navigating.swift +++ b/Source/Turbo/Navigator/Navigating.swift @@ -3,17 +3,10 @@ import UIKit /// A testable abstraction over `Navigator`. /// -/// Refines `NavigationHandler` (which provides `route(_:)` / `route(_ proposal:)`) -/// with the navigation operations and view controller accessors that +/// Refines `NavigationHandler` (which provides the `route` overloads) with the +/// navigation operations and view controller accessors that /// `RouteDecisionHandler` and `WebViewPolicyDecisionHandler` implementations rely on. public protocol Navigating: NavigationHandler { - /// Routes to the given URL. - /// - Parameters: - /// - url: the URL to visit. - /// - options: passed options will override default `advance` visit options. - /// - parameters: provide context relevant to `url`. - func route(_ url: URL, options: VisitOptions?, parameters: [String: Any]?) - /// Pops the top controller on the presented navigation stack. func pop(animated: Bool) diff --git a/Source/Turbo/ViewControllers/HotwireTabBarController.swift b/Source/Turbo/ViewControllers/HotwireTabBarController.swift index eaa05551..b0371558 100644 --- a/Source/Turbo/ViewControllers/HotwireTabBarController.swift +++ b/Source/Turbo/ViewControllers/HotwireTabBarController.swift @@ -56,8 +56,8 @@ open class HotwireTabBarController: UITabBarController, NavigationHandler { // MARK: NavigationHandler - open func route(_ url: URL) { - activeNavigator.route(url) + open func route(_ url: URL, options: VisitOptions?, parameters: [String: Any]?) { + activeNavigator.route(url, options: options, parameters: parameters) } open func route(_ proposal: VisitProposal) { diff --git a/Tests/Turbo/WebViewPolicy/Spies/NavigationSpy.swift b/Tests/Turbo/WebViewPolicy/Spies/NavigationSpy.swift index 50427174..633f1ce1 100644 --- a/Tests/Turbo/WebViewPolicy/Spies/NavigationSpy.swift +++ b/Tests/Turbo/WebViewPolicy/Spies/NavigationSpy.swift @@ -16,11 +16,6 @@ final class NavigationSpy: Navigating { lazy var modalRootViewController = UINavigationController() lazy var activeNavigationController = UINavigationController() - func route(_ url: URL) { - routeWasCalled = true - routeURL = url - } - func route(_ proposal: VisitProposal) { routeWasCalled = true routeURL = proposal.url