From 4f4af7accd606a626da75040befb3be29f251c38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zo=C3=AB=20Smith?= Date: Fri, 30 Jan 2026 14:09:08 -0500 Subject: [PATCH 1/6] Handle requests for new windows by proposing a new visit Changes default Hotwire Native behaviour to automatically start a new visit to the requested url when the WKUIDelegate receives a request to create a new web view for a navigation. The motivation here was to handle links requesting new windows that are embedded in an iframe seamlessly through HotwireNative navigation, rather than discarding them. Previously iOS clients would have to subclass WKUIController and implement their own handling of the WKUIDelegate method func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? The new behaviour proposed here matches the implementation in hotwire-native-android in HotwireWebChromeClient https://github.com/hotwired/hotwire-native-android/blob/588d69c124f2baf7fe605c91fd96d67896e6b534/core/src/main/kotlin/dev/hotwire/core/turbo/webview/HotwireWebChromeClient.kt#L74 We attempt to filter out common blank urls before calling the delegate method. The Android implementation avoids this step by using requestFocusNodeHref. If Navigator is not used, the default implementation of the new delegate method does nothing, so this is a non-breaking change. --- Source/Turbo/Navigator/Navigator.swift | 4 +++ Source/Turbo/Navigator/WKUIController.swift | 33 +++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/Source/Turbo/Navigator/Navigator.swift b/Source/Turbo/Navigator/Navigator.swift index 9c5b0655..51d6daf8 100644 --- a/Source/Turbo/Navigator/Navigator.swift +++ b/Source/Turbo/Navigator/Navigator.swift @@ -264,6 +264,10 @@ extension Navigator: WKUIControllerDelegate { public func present(_ alert: UIAlertController, animated: Bool) { hierarchyController.activeNavigationController.present(alert, animated: animated) } + + public func newWindowRequested(for url: URL) { + session.visitDidProposeVisitToLocation(url) + } } // MARK: - Session and web view reloading diff --git a/Source/Turbo/Navigator/WKUIController.swift b/Source/Turbo/Navigator/WKUIController.swift index 0cfb4254..faa0e435 100644 --- a/Source/Turbo/Navigator/WKUIController.swift +++ b/Source/Turbo/Navigator/WKUIController.swift @@ -3,6 +3,11 @@ import WebKit public protocol WKUIControllerDelegate: AnyObject { func present(_ alert: UIAlertController, animated: Bool) + func newWindowRequested(for url: URL) +} + +public extension WKUIControllerDelegate { + func newWindowRequested(for url: URL) {} } open class WKUIController: NSObject, WKUIDelegate { @@ -30,4 +35,32 @@ open class WKUIController: NSObject, WKUIDelegate { }) delegate?.present(alert, animated: true) } + + open func webView(_ webView: WKWebView, + createWebViewWith configuration: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, + windowFeatures: WKWindowFeatures) -> WKWebView? { + guard navigationAction.requestsNewWindow, + let url = navigationAction.request.url, + url.isRoutableNewWindowURL else { + return nil + } + delegate?.newWindowRequested(for: url) + return nil + } +} + +private extension URL { + var isRoutableNewWindowURL: Bool { + guard !absoluteString.isEmpty else { + return false + } + + switch absoluteString { + case "about:blank", "about:srcdoc": + return false + default: + return true + } + } } From 8dff7f5cf6109250eb994548039342417ad227de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=A0vara?= Date: Wed, 1 Jul 2026 13:58:25 +0200 Subject: [PATCH 2/6] Revert "Handle requests for new windows by proposing a new visit" This reverts commit 4f4af7accd606a626da75040befb3be29f251c38. --- Source/Turbo/Navigator/Navigator.swift | 4 --- Source/Turbo/Navigator/WKUIController.swift | 33 --------------------- 2 files changed, 37 deletions(-) diff --git a/Source/Turbo/Navigator/Navigator.swift b/Source/Turbo/Navigator/Navigator.swift index 51d6daf8..9c5b0655 100644 --- a/Source/Turbo/Navigator/Navigator.swift +++ b/Source/Turbo/Navigator/Navigator.swift @@ -264,10 +264,6 @@ extension Navigator: WKUIControllerDelegate { public func present(_ alert: UIAlertController, animated: Bool) { hierarchyController.activeNavigationController.present(alert, animated: animated) } - - public func newWindowRequested(for url: URL) { - session.visitDidProposeVisitToLocation(url) - } } // MARK: - Session and web view reloading diff --git a/Source/Turbo/Navigator/WKUIController.swift b/Source/Turbo/Navigator/WKUIController.swift index faa0e435..0cfb4254 100644 --- a/Source/Turbo/Navigator/WKUIController.swift +++ b/Source/Turbo/Navigator/WKUIController.swift @@ -3,11 +3,6 @@ import WebKit public protocol WKUIControllerDelegate: AnyObject { func present(_ alert: UIAlertController, animated: Bool) - func newWindowRequested(for url: URL) -} - -public extension WKUIControllerDelegate { - func newWindowRequested(for url: URL) {} } open class WKUIController: NSObject, WKUIDelegate { @@ -35,32 +30,4 @@ open class WKUIController: NSObject, WKUIDelegate { }) delegate?.present(alert, animated: true) } - - open func webView(_ webView: WKWebView, - createWebViewWith configuration: WKWebViewConfiguration, - for navigationAction: WKNavigationAction, - windowFeatures: WKWindowFeatures) -> WKWebView? { - guard navigationAction.requestsNewWindow, - let url = navigationAction.request.url, - url.isRoutableNewWindowURL else { - return nil - } - delegate?.newWindowRequested(for: url) - return nil - } -} - -private extension URL { - var isRoutableNewWindowURL: Bool { - guard !absoluteString.isEmpty else { - return false - } - - switch absoluteString { - case "about:blank", "about:srcdoc": - return false - default: - return true - } - } } From ebae57a8217006c40791c57e550867a41e47f07f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=A0vara?= Date: Wed, 1 Jul 2026 14:52:29 +0200 Subject: [PATCH 3/6] Handle taps inside iframes that request a new window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the WKUIDelegate createWebViewWith method so that a new-window request originating from a non-main frame (e.g. a link inside an iframe embed) is loaded into the web view instead of being discarded. This lets taps inside an embed route out of the iframe — for example launching the YouTube app from an embedded video. Requests with no URL, or those targeting the main frame, are ignored. --- Source/Turbo/Navigator/WKUIController.swift | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/Source/Turbo/Navigator/WKUIController.swift b/Source/Turbo/Navigator/WKUIController.swift index 0cfb4254..65c088b5 100644 --- a/Source/Turbo/Navigator/WKUIController.swift +++ b/Source/Turbo/Navigator/WKUIController.swift @@ -30,4 +30,24 @@ open class WKUIController: NSObject, WKUIDelegate { }) delegate?.present(alert, animated: true) } + + /// Ensures that taps inside an embed that should route out of the iframe are handled. + open func webView( + _ webView: WKWebView, + createWebViewWith configuration: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, + windowFeatures: WKWindowFeatures + ) -> WKWebView? { + guard navigationAction.request.url != nil else { + return nil + } + + if let targetFrame = navigationAction.targetFrame, + targetFrame.isMainFrame { + return nil + } + + webView.load(navigationAction.request) + return nil + } } From e39dbc1bd7b7ccb1e14ada610482e30603e66294 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=A0vara?= Date: Wed, 1 Jul 2026 14:52:35 +0200 Subject: [PATCH 4/6] Cancel external navigation to unsafe URL schemes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a configurable set of invalid schemes (about, data, file, javascript) to ExternalNavigationWebViewPolicyDecisionHandler. When a matching navigation carries one of these schemes, cancel it without routing rather than proposing an external visit. This guards against routing blank or unsafe URLs — such as those produced when a new-window request from an iframe is loaded into the web view. --- ...avigationWebViewPolicyDecisionHandler.swift | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/Source/Turbo/Navigator/WebViewPolicy/Handlers/ExternalNavigationWebViewPolicyDecisionHandler.swift b/Source/Turbo/Navigator/WebViewPolicy/Handlers/ExternalNavigationWebViewPolicyDecisionHandler.swift index f9ba73a8..28aa957e 100644 --- a/Source/Turbo/Navigator/WebViewPolicy/Handlers/ExternalNavigationWebViewPolicyDecisionHandler.swift +++ b/Source/Turbo/Navigator/WebViewPolicy/Handlers/ExternalNavigationWebViewPolicyDecisionHandler.swift @@ -6,10 +6,21 @@ import WebKit /// /// When such an action is detected, it routes the URL using the provided navigator /// and cancels the web view's default navigation. +/// +/// If the URL's scheme is considered invalid (e.g. `about`, `data`, `file`, +/// `javascript`), the navigation is simply cancelled without routing. This guards +/// against routing blank or unsafe URLs, such as those produced when a new window +/// request from an iframe is loaded into the web view. public struct ExternalNavigationWebViewPolicyDecisionHandler: WebViewPolicyDecisionHandler { public let name: String = "external-navigation-policy" - public init() {} + /// URL schemes that should never be routed externally. Matching navigation + /// actions are cancelled without routing. + public let invalidSchemes: Set + + public init(invalidSchemes: Set = ["about", "data", "file", "javascript"]) { + self.invalidSchemes = invalidSchemes + } public func matches(navigationAction: WKNavigationAction, configuration: Navigator.Configuration) -> Bool { @@ -20,6 +31,11 @@ public struct ExternalNavigationWebViewPolicyDecisionHandler: WebViewPolicyDecis public func handle(navigationAction: WKNavigationAction, configuration: Navigator.Configuration, navigator: Navigator) -> WebViewPolicyManager.Decision { + if let scheme = navigationAction.request.url?.scheme, + invalidSchemes.contains(scheme) { + return .cancel + } + if let url = navigationAction.request.url { navigator.route(url) } From f6d630cc2814bcba387b37dbc2ff160961205dd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=A0vara?= Date: Wed, 1 Jul 2026 14:53:00 +0200 Subject: [PATCH 5/6] Test unsafe-scheme handling. Add tests covering the new external-navigation behaviour: about: and data: schemes cancel without routing, and a form submission is not treated as external navigation. --- ...aseWebViewPolicyDecisionHandlerTests.swift | 4 +- ...ionWebViewPolicyDecisionHandlerTests.swift | 51 +++++++++- ...tedWebViewPolicyDecisionHandlerTests.swift | 4 +- .../WebViewPolicy/NavigationPolicyHTML.swift | 29 ++++++ ...dowWebViewPolicyDecisionHandlerTests.swift | 4 +- ...oadWebViewPolicyDecisionHandlerTests.swift | 4 +- .../WebViewNavigationSimulator.swift | 60 ------------ .../WebViewPolicyManagerTests.swift | 6 +- .../WebViewPolicyNavigationSimulator.swift | 97 +++++++++++++++++++ 9 files changed, 184 insertions(+), 75 deletions(-) delete mode 100644 Tests/Turbo/WebViewPolicy/WebViewNavigationSimulator.swift create mode 100644 Tests/Turbo/WebViewPolicy/WebViewPolicyNavigationSimulator.swift diff --git a/Tests/Turbo/WebViewPolicy/BaseWebViewPolicyDecisionHandlerTests.swift b/Tests/Turbo/WebViewPolicy/BaseWebViewPolicyDecisionHandlerTests.swift index 7981afb5..0ec11d03 100644 --- a/Tests/Turbo/WebViewPolicy/BaseWebViewPolicyDecisionHandlerTests.swift +++ b/Tests/Turbo/WebViewPolicy/BaseWebViewPolicyDecisionHandlerTests.swift @@ -4,7 +4,7 @@ import XCTest @MainActor class BaseWebViewPolicyDecisionHandlerTests: XCTestCase { - var webNavigationSimulator: WebViewNavigationSimulator! + var webNavigationSimulator: WebViewPolicyNavigationSimulator! var navigatorSpy: NavigationSpy! let navigatorConfiguration = Navigator.Configuration( name: "test", @@ -13,7 +13,7 @@ class BaseWebViewPolicyDecisionHandlerTests: XCTestCase { override func setUp() async throws { navigatorSpy = NavigationSpy(configuration: navigatorConfiguration) - webNavigationSimulator = WebViewNavigationSimulator() + webNavigationSimulator = WebViewPolicyNavigationSimulator() } override func tearDown() async throws { diff --git a/Tests/Turbo/WebViewPolicy/ExternalNavigationWebViewPolicyDecisionHandlerTests.swift b/Tests/Turbo/WebViewPolicy/ExternalNavigationWebViewPolicyDecisionHandlerTests.swift index 865b4a00..1fbba054 100644 --- a/Tests/Turbo/WebViewPolicy/ExternalNavigationWebViewPolicyDecisionHandlerTests.swift +++ b/Tests/Turbo/WebViewPolicy/ExternalNavigationWebViewPolicyDecisionHandlerTests.swift @@ -14,7 +14,7 @@ final class ExternalNavigationWebViewPolicyDecisionHandlerTests: BaseWebViewPoli func test_link_activated_matches() async throws { guard let action = try await webNavigationSimulator.simulateNavigation( withHTML: .simpleLink, - simulateLinkClickElementId: "link") else { + simulateAction: .click("link")) else { XCTFail("No navigation action captured") return } @@ -25,7 +25,7 @@ final class ExternalNavigationWebViewPolicyDecisionHandlerTests: BaseWebViewPoli func test_handling_link_activated_cancels_web_navigation_and_routes_internally() async throws { guard let action = try await webNavigationSimulator.simulateNavigation( withHTML: .simpleLink, - simulateLinkClickElementId: "link") else { + simulateAction: .click("link")) else { XCTFail("No navigation action captured") return } @@ -42,7 +42,7 @@ final class ExternalNavigationWebViewPolicyDecisionHandlerTests: BaseWebViewPoli func test_js_click_matches() async throws { guard let action = try await webNavigationSimulator.simulateNavigation( withHTML: .jsClick, - simulateLinkClickElementId: nil) else { + simulateAction: .click(nil)) else { XCTFail("No navigation action captured") return } @@ -53,7 +53,7 @@ final class ExternalNavigationWebViewPolicyDecisionHandlerTests: BaseWebViewPoli func test_handling_js_click_cancels_web_navigation_and_routes_internally() async throws { guard let action = try await webNavigationSimulator.simulateNavigation( withHTML: .jsClick, - simulateLinkClickElementId: nil) else { + simulateAction: .click(nil)) else { XCTFail("No navigation action captured") return } @@ -66,4 +66,47 @@ final class ExternalNavigationWebViewPolicyDecisionHandlerTests: BaseWebViewPoli XCTAssertTrue(navigatorSpy.routeWasCalled) XCTAssertEqual(action.request.url, navigatorSpy.routeURL) } + + func test_form_submit_does_not_match() async throws { + guard let action = try await webNavigationSimulator.simulateNavigation( + withHTML: .externalForm, + simulateAction: .submit("form")) else { + XCTFail("No navigation action captured") + return + } + let result = policyHandler.matches(navigationAction: action, configuration: navigatorConfiguration) + XCTAssertFalse(result) + } + + func test_handling_about_scheme_cancels_without_routing() async throws { + guard let action = try await webNavigationSimulator.simulateNavigation( + withHTML: .aboutLink, + simulateAction: .click("link")) else { + XCTFail("No navigation action captured") + return + } + let result = policyHandler.handle( + navigationAction: action, + configuration: navigatorConfiguration, + navigator: navigatorSpy + ) + XCTAssertEqual(result, WebViewPolicyManager.Decision.cancel) + XCTAssertFalse(navigatorSpy.routeWasCalled) + } + + func test_handling_data_scheme_cancels_without_routing() async throws { + guard let action = try await webNavigationSimulator.simulateNavigation( + withHTML: .dataLink, + simulateAction: .click("link")) else { + XCTFail("No navigation action captured") + return + } + let result = policyHandler.handle( + navigationAction: action, + configuration: navigatorConfiguration, + navigator: navigatorSpy + ) + XCTAssertEqual(result, WebViewPolicyManager.Decision.cancel) + XCTAssertFalse(navigatorSpy.routeWasCalled) + } } diff --git a/Tests/Turbo/WebViewPolicy/LinkActivatedWebViewPolicyDecisionHandlerTests.swift b/Tests/Turbo/WebViewPolicy/LinkActivatedWebViewPolicyDecisionHandlerTests.swift index 7903193d..1f1a8bd5 100644 --- a/Tests/Turbo/WebViewPolicy/LinkActivatedWebViewPolicyDecisionHandlerTests.swift +++ b/Tests/Turbo/WebViewPolicy/LinkActivatedWebViewPolicyDecisionHandlerTests.swift @@ -14,7 +14,7 @@ final class LinkActivatedWebViewPolicyDecisionHandlerTests: BaseWebViewPolicyDec func test_link_activated_matches() async throws { guard let action = try await webNavigationSimulator.simulateNavigation( withHTML: .simpleLink, - simulateLinkClickElementId: "link") else { + simulateAction: .click("link")) else { XCTFail("No navigation action captured") return } @@ -30,7 +30,7 @@ final class LinkActivatedWebViewPolicyDecisionHandlerTests: BaseWebViewPolicyDec func test_handling_matching_result_cancels_web_navigation() async throws { guard let action = try await webNavigationSimulator.simulateNavigation( withHTML: .simpleLink, - simulateLinkClickElementId: "link") else { + simulateAction: .click("link")) else { XCTFail("No navigation action captured") return } diff --git a/Tests/Turbo/WebViewPolicy/NavigationPolicyHTML.swift b/Tests/Turbo/WebViewPolicy/NavigationPolicyHTML.swift index fbe36a06..638a6dbd 100644 --- a/Tests/Turbo/WebViewPolicy/NavigationPolicyHTML.swift +++ b/Tests/Turbo/WebViewPolicy/NavigationPolicyHTML.swift @@ -31,6 +31,35 @@ extension String { """ + /// A link to an `about:` URL, which should never be routed externally. + static var aboutLink = """ + + + About Link + + + """ + + /// A link to a `data:` URL, which should never be routed externally. + static var dataLink = """ + + + Data Link + + + """ + + /// A form that submits to an external URL. + static var externalForm = """ + + +
+ +
+ + + """ + /// A JavaScript-initiated reload via a button click. static var reload = """ diff --git a/Tests/Turbo/WebViewPolicy/NewWindowWebViewPolicyDecisionHandlerTests.swift b/Tests/Turbo/WebViewPolicy/NewWindowWebViewPolicyDecisionHandlerTests.swift index ad67aeb8..041fb6e9 100644 --- a/Tests/Turbo/WebViewPolicy/NewWindowWebViewPolicyDecisionHandlerTests.swift +++ b/Tests/Turbo/WebViewPolicy/NewWindowWebViewPolicyDecisionHandlerTests.swift @@ -14,7 +14,7 @@ final class NewWindowWebViewPolicyDecisionHandlerTests: BaseWebViewPolicyDecisio func test_target_blank_matches() async throws { guard let action = try await webNavigationSimulator.simulateNavigation( withHTML: .targetBlank, - simulateLinkClickElementId: "externalLink") else { + simulateAction: .click("externalLink")) else { XCTFail("No navigation action captured") return } @@ -30,7 +30,7 @@ final class NewWindowWebViewPolicyDecisionHandlerTests: BaseWebViewPolicyDecisio func test_handling_matching_result_cancels_web_navigation_and_routes_internally() async throws { guard let action = try await webNavigationSimulator.simulateNavigation( withHTML: .targetBlank, - simulateLinkClickElementId: "externalLink") else { + simulateAction: .click("externalLink")) else { XCTFail("No navigation action captured") return } diff --git a/Tests/Turbo/WebViewPolicy/ReloadWebViewPolicyDecisionHandlerTests.swift b/Tests/Turbo/WebViewPolicy/ReloadWebViewPolicyDecisionHandlerTests.swift index 71d1c12c..d7db3e9e 100644 --- a/Tests/Turbo/WebViewPolicy/ReloadWebViewPolicyDecisionHandlerTests.swift +++ b/Tests/Turbo/WebViewPolicy/ReloadWebViewPolicyDecisionHandlerTests.swift @@ -14,7 +14,7 @@ final class ReloadWebViewPolicyDecisionHandlerTests: BaseWebViewPolicyDecisionHa func test_reload_matches() async throws { guard let action = try await webNavigationSimulator.simulateNavigation( withHTML: .reload, - simulateLinkClickElementId: "reloadButton") else { + simulateAction: .click("reloadButton")) else { XCTFail("No navigation action captured") return } @@ -30,7 +30,7 @@ final class ReloadWebViewPolicyDecisionHandlerTests: BaseWebViewPolicyDecisionHa func test_handling_matching_result_cancels_web_navigation_and_reloads() async throws { guard let action = try await webNavigationSimulator.simulateNavigation( withHTML: .reload, - simulateLinkClickElementId: "reloadButton") else { + simulateAction: .click("reloadButton")) else { XCTFail("No navigation action captured") return } diff --git a/Tests/Turbo/WebViewPolicy/WebViewNavigationSimulator.swift b/Tests/Turbo/WebViewPolicy/WebViewNavigationSimulator.swift deleted file mode 100644 index 2978c29b..00000000 --- a/Tests/Turbo/WebViewPolicy/WebViewNavigationSimulator.swift +++ /dev/null @@ -1,60 +0,0 @@ -import Foundation -import XCTest -@preconcurrency import WebKit - -@MainActor -class WebViewNavigationSimulator: NSObject, WKNavigationDelegate { - var capturedNavigationAction: WKNavigationAction? - var simulateLinkClickElementId: String? - - private var didSimulateLinkClick: Bool = false - private var continuation: CheckedContinuation? - - let webView: WKWebView - - override init() { - webView = WKWebView() - super.init() - webView.navigationDelegate = self - } - - /// Loads the given HTML into the web view and awaits the resulting navigation action. - /// - Parameters: - /// - html: The HTML content to load. - /// - simulateLinkClickElementId: If provided, once the page loads, a simulated click will be triggered on the element with this ID. - /// - Returns: The captured `WKNavigationAction`. - func simulateNavigation(withHTML html: String, simulateLinkClickElementId: String? = nil) async throws -> WKNavigationAction? { - self.simulateLinkClickElementId = simulateLinkClickElementId - webView.loadHTMLString(html, baseURL: nil) - return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - self.continuation = continuation - } - } - - // MARK: - WKNavigationDelegate - - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - guard let elementId = simulateLinkClickElementId else { return } - didSimulateLinkClick = true - let js = "document.getElementById('\(elementId)').click();" - webView.evaluateJavaScript(js) { [weak self] (_, error) in - if let error = error { - self?.continuation?.resume(throwing: error) - self?.continuation = nil - } - } - } - - func webView(_ webView: WKWebView, - decidePolicyFor navigationAction: WKNavigationAction, - decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { - capturedNavigationAction = navigationAction - decisionHandler(.allow) - - // When there is no simulated click, or after a simulated click is performed, resume the continuation. - if simulateLinkClickElementId == nil || didSimulateLinkClick { - continuation?.resume(returning: navigationAction) - continuation = nil - } - } -} diff --git a/Tests/Turbo/WebViewPolicy/WebViewPolicyManagerTests.swift b/Tests/Turbo/WebViewPolicy/WebViewPolicyManagerTests.swift index 5991608b..121cb1ef 100644 --- a/Tests/Turbo/WebViewPolicy/WebViewPolicyManagerTests.swift +++ b/Tests/Turbo/WebViewPolicy/WebViewPolicyManagerTests.swift @@ -10,7 +10,7 @@ final class WebViewPolicyManagerTests: XCTestCase { ) let url = URL(string: "https://my.app.com/page")! var policyManager: WebViewPolicyManager! - var webNavigationSimulator: WebViewNavigationSimulator! + var webNavigationSimulator: WebViewPolicyNavigationSimulator! var navigator: Navigator! static var navigationAction: WKNavigationAction! @@ -21,10 +21,10 @@ final class WebViewPolicyManagerTests: XCTestCase { // In these tests, the navigation action is not actually evaluated, but we need it as an argument // when calling `decidePolicy` on the handlers. if Self.navigationAction == nil { - webNavigationSimulator = WebViewNavigationSimulator() + webNavigationSimulator = WebViewPolicyNavigationSimulator() Self.navigationAction = try await webNavigationSimulator.simulateNavigation( withHTML: .simpleLink, - simulateLinkClickElementId: "link" + simulateAction: .click("link") )! } } diff --git a/Tests/Turbo/WebViewPolicy/WebViewPolicyNavigationSimulator.swift b/Tests/Turbo/WebViewPolicy/WebViewPolicyNavigationSimulator.swift new file mode 100644 index 00000000..aebc8ff0 --- /dev/null +++ b/Tests/Turbo/WebViewPolicy/WebViewPolicyNavigationSimulator.swift @@ -0,0 +1,97 @@ +import Foundation +import XCTest +@preconcurrency import WebKit + +@MainActor +class WebViewPolicyNavigationSimulator: NSObject, WKNavigationDelegate { + enum SimulateAction { + case click(String?) + case submit(String?) + + var elementId: String? { + switch self { + case .click(let string), .submit(let string): + return string + } + } + } + + var capturedNavigationAction: WKNavigationAction? + var simulateAction: SimulateAction = .click(nil) + + private var didSimulateInteraction: Bool = false + private var continuation: CheckedContinuation? + + let webView: WKWebView + + override init() { + webView = WKWebView() + super.init() + webView.navigationDelegate = self + } + + /// Loads the given HTML into the web view and awaits the resulting navigation action. + /// - Parameters: + /// - html: The HTML content to load. + /// - simulateAction: The interaction to simulate once the page loads: `.click` or + /// `.submit` on the element with the given ID. Pass a `nil` element ID to await a + /// navigation initiated by the page itself (e.g. via an inline script). + /// - Returns: The captured `WKNavigationAction`. + func simulateNavigation( + withHTML html: String, + simulateAction: SimulateAction + ) async throws -> WKNavigationAction? { + self.simulateAction = simulateAction + webView.loadHTMLString(html, baseURL: nil) + return try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + } + } + + // MARK: - WKNavigationDelegate + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + guard let elementId = simulateAction.elementId else { return } + didSimulateInteraction = true + + let js: String + switch simulateAction { + case .click: + js = "document.getElementById('\(elementId)').click();" + case .submit: + js = "document.getElementById('\(elementId)').submit();" + } + + webView.evaluateJavaScript(js) { [weak self] _, error in + if let error = error { + self?.continuation?.resume(throwing: error) + self?.continuation = nil + } + } + } + + func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void + ) { + decisionHandler(.allow) + + // When awaiting a navigation initiated by the page itself (no simulated interaction), + // ignore the initial about:blank document load produced by `loadHTMLString(_:baseURL:)` + // and wait for the page-initiated navigation. + if simulateAction.elementId == nil, + navigationAction.navigationType == .other, + navigationAction.request.url?.absoluteString == "about:blank" { + return + } + + capturedNavigationAction = navigationAction + + // When there is no simulated interaction, or after one is performed, resume the continuation. + if simulateAction.elementId == nil || didSimulateInteraction { + continuation?.resume(returning: navigationAction) + continuation = nil + } + } +} From a5ff35dee6fee8892da5956f835403cf282cc015 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=A0vara?= Date: Wed, 1 Jul 2026 15:01:17 +0200 Subject: [PATCH 6/6] Restore navigation simulator leak fix lost in merge The port to WebViewPolicyNavigationSimulator dropped changes that main had made to WebViewNavigationSimulator, because the file was renamed. Reinstate them: - Cancel the captured navigation (decisionHandler(.cancel)) once the continuation resumes, rather than allowing it. Allowing it lets the web view navigate to external URLs, blocking the shared WKProcessPool and stalling subsequent tests. Intermediate loads are still allowed so the page can finish loading and trigger the simulated interaction. - Add stopLoading() to stop the web view and detach its delegate, now called from test tearDown. The initial about:blank document load is allowed (not cancelled) so page-initiated navigations can still be captured. --- .../WebViewPolicyNavigationSimulator.swift | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/Tests/Turbo/WebViewPolicy/WebViewPolicyNavigationSimulator.swift b/Tests/Turbo/WebViewPolicy/WebViewPolicyNavigationSimulator.swift index aebc8ff0..16ae3498 100644 --- a/Tests/Turbo/WebViewPolicy/WebViewPolicyNavigationSimulator.swift +++ b/Tests/Turbo/WebViewPolicy/WebViewPolicyNavigationSimulator.swift @@ -75,23 +75,37 @@ class WebViewPolicyNavigationSimulator: NSObject, WKNavigationDelegate { decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void ) { - decisionHandler(.allow) - // When awaiting a navigation initiated by the page itself (no simulated interaction), - // ignore the initial about:blank document load produced by `loadHTMLString(_:baseURL:)` - // and wait for the page-initiated navigation. + // allow the initial about:blank document load produced by `loadHTMLString(_:baseURL:)` + // so the page can load and run its script, then wait for the page-initiated navigation. if simulateAction.elementId == nil, navigationAction.navigationType == .other, navigationAction.request.url?.absoluteString == "about:blank" { + decisionHandler(.allow) return } capturedNavigationAction = navigationAction // When there is no simulated interaction, or after one is performed, resume the continuation. - if simulateAction.elementId == nil || didSimulateInteraction { + let shouldResumeContinuation = simulateAction.elementId == nil || didSimulateInteraction + + if shouldResumeContinuation { + // Cancel the actual navigation — we only need the `WKNavigationAction` object. + // Allowing it would let WKWebView navigate to external URLs (e.g., https://example.com), + // blocking the shared WKProcessPool and stalling subsequent tests for minutes. + decisionHandler(.cancel) continuation?.resume(returning: navigationAction) continuation = nil + } else { + // Allow intermediate navigations (e.g., the initial loadHTMLString page load) + // so the page fully loads and didFinish can trigger the simulated interaction. + decisionHandler(.allow) } } + + func stopLoading() { + webView.stopLoading() + webView.navigationDelegate = nil + } }