From 390aad4fcde270e92e67105250253969b6d35abd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=A0vara?= Date: Wed, 28 Feb 2024 19:58:50 +0100 Subject: [PATCH 1/7] Make `BridgeComponent` run on main thread by adopting @MainActor attribute. --- Source/BridgeComponent.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Source/BridgeComponent.swift b/Source/BridgeComponent.swift index 1e48c33..1348add 100644 --- a/Source/BridgeComponent.swift +++ b/Source/BridgeComponent.swift @@ -1,5 +1,6 @@ import Foundation +@MainActor protocol BridgingComponent: AnyObject { static var name: String { get } var delegate: BridgingDelegate { get } @@ -22,6 +23,7 @@ protocol BridgingComponent: AnyObject { func viewDidDisappear() } +@MainActor open class BridgeComponent: BridgingComponent { public typealias ReplyCompletionHandler = (Result) -> Void @@ -30,7 +32,7 @@ open class BridgeComponent: BridgingComponent { /// Subclasses must provide their own implementation of this property. /// /// - Note: This property is used for identifying the component. - open class var name: String { + nonisolated open class var name: String { fatalError("BridgeComponent subclass must provide a unique 'name'") } @@ -149,7 +151,7 @@ open class BridgeComponent: BridgingComponent { @discardableResult /// Replies to the web with the last received message for a given `event`, replacing its `jsonData` - /// with the provided `Encodable` object. + /// with the provided `Encodable` object. /// /// NOTE: If a message has not been received for the given `event`, the reply will be ignored. /// @@ -275,3 +277,4 @@ open class BridgeComponent: BridgingComponent { private var receivedMessages = [String: Message]() } + From 5d58ad4381716df45a310f4cd56ccd982347b933 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=A0vara?= Date: Wed, 28 Feb 2024 19:59:00 +0100 Subject: [PATCH 2/7] Make `BridgeDelegate` run on main thread by adopting @MainActor attribute. --- Source/BridgeDelegate.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Source/BridgeDelegate.swift b/Source/BridgeDelegate.swift index 19e3470..2d42dbc 100644 --- a/Source/BridgeDelegate.swift +++ b/Source/BridgeDelegate.swift @@ -3,6 +3,7 @@ import WebKit public protocol BridgeDestination: AnyObject {} +@MainActor public protocol BridgingDelegate: AnyObject { var location: String { get } var destination: BridgeDestination { get } @@ -24,6 +25,7 @@ public protocol BridgingDelegate: AnyObject { func bridgeDidReceiveMessage(_ message: Message) -> Bool } +@MainActor public final class BridgeDelegate: BridgingDelegate { public let location: String public unowned let destination: BridgeDestination @@ -153,4 +155,3 @@ public final class BridgeDelegate: BridgingDelegate { return component } } - From 2364983c8b9fadf8475e587633f0b011967b32d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=A0vara?= Date: Wed, 28 Feb 2024 20:00:04 +0100 Subject: [PATCH 3/7] Make `Bridge` run on main thread by adopting @MainActor attribute. Fix a crash when using `evaluateJavaScript` function. --- Source/Bridge.swift | 48 +++++++++++++++++++++++++++++++++------------ 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/Source/Bridge.swift b/Source/Bridge.swift index a7cb9b5..c8daa67 100644 --- a/Source/Bridge.swift +++ b/Source/Bridge.swift @@ -5,6 +5,7 @@ public enum BridgeError: Error { case missingWebView } +@MainActor protocol Bridgable: AnyObject { var delegate: BridgeDelegate? { get set } var webView: WKWebView? { get } @@ -17,25 +18,30 @@ protocol Bridgable: AnyObject { /// `Bridge` is the object for configuring a web view and /// the channel for sending/receiving messages +@MainActor public final class Bridge: Bridgable { + public typealias InitializationCompletionHandler = () -> Void weak var delegate: BridgeDelegate? weak var webView: WKWebView? - public static func initialize(_ webView: WKWebView) { + nonisolated public static func initialize(_ webView: WKWebView, completion: InitializationCompletionHandler?) { + Task { @MainActor in + await initialize(webView) + completion?() + } + } + + public static func initialize(_ webView: WKWebView) async { if getBridgeFor(webView) == nil { initialize(Bridge(webView: webView)) } } - + init(webView: WKWebView) { self.webView = webView loadIntoWebView() } - deinit { - webView?.configuration.userContentController.removeScriptMessageHandler(forName: scriptHandlerName) - } - // MARK: - Internal API /// Register a single component @@ -72,15 +78,14 @@ public final class Bridge: Bridgable { // let replyMessage = message.replacing(data: data) // callBridgeFunction("send", arguments: [replyMessage.toJSON()]) // } - @MainActor @discardableResult - func evaluate(javaScript: String) async throws -> Any { + func evaluate(javaScript: String) async throws -> Any? { guard let webView else { throw BridgeError.missingWebView } do { - return try await webView.evaluateJavaScript(javaScript) + return try await webView.evaluateJavaScriptAsync(javaScript) } catch { logger.error("Error evaluating JavaScript: \(error)") throw error @@ -90,7 +95,7 @@ public final class Bridge: Bridgable { /// Evaluates a JavaScript function with optional arguments by encoding the arguments /// Function should not include the parens /// Usage: evaluate(function: "console.log", arguments: ["test"]) - func evaluate(function: String, arguments: [Any] = []) async throws -> Any { + func evaluate(function: String, arguments: [Any] = []) async throws -> Any? { try await evaluate(javaScript: JavaScript(functionName: function, arguments: arguments).toString()) } @@ -151,7 +156,7 @@ public final class Bridge: Bridgable { // MARK: - JavaScript Evaluation @discardableResult - private func evaluate(javaScript: JavaScript) async throws -> Any { + private func evaluate(javaScript: JavaScript) async throws -> Any? { do { return try await evaluate(javaScript: javaScript.toString()) } catch { @@ -168,7 +173,6 @@ public final class Bridge: Bridgable { } extension Bridge: ScriptMessageHandlerDelegate { - @MainActor func scriptMessageHandlerDidReceiveMessage(_ scriptMessage: WKScriptMessage) async throws { if let event = scriptMessage.body as? String, event == "ready" { try await delegate?.bridgeDidInitialize() @@ -183,3 +187,23 @@ extension Bridge: ScriptMessageHandlerDelegate { logger.warning("Unhandled message received: \(String(describing: scriptMessage.body))") } } + +private extension WKWebView { + /// NOTE: The async version crashes the app with `Fatal error: Unexpectedly found nil while implicitly unwrapping an Optional value` + /// in case the function doesn't return anything. + /// This is a workaround. See https://forums.developer.apple.com/forums/thread/701553 for more details. + @discardableResult + func evaluateJavaScriptAsync(_ str: String) async throws -> Any? { + return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + DispatchQueue.main.async { + self.evaluateJavaScript(str) { data, error in + if let error = error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: data) + } + } + } + } + } +} From 894a8f1eb8b5d73a2acb96d6c66ae038ef1a833e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=A0vara?= Date: Wed, 28 Feb 2024 20:05:54 +0100 Subject: [PATCH 4/7] Update tests to run on @MainActor. --- Tests/BridgeComponentTests.swift | 3 +- Tests/BridgeDelegateTests.swift | 1 + Tests/BridgeTests.swift | 53 ++++++++++++++----- .../ComposerComponentTests.swift | 1 + 4 files changed, 45 insertions(+), 13 deletions(-) diff --git a/Tests/BridgeComponentTests.swift b/Tests/BridgeComponentTests.swift index 3460827..8a02804 100644 --- a/Tests/BridgeComponentTests.swift +++ b/Tests/BridgeComponentTests.swift @@ -3,6 +3,7 @@ import XCTest import WebKit import Strada +@MainActor class BridgeComponentTest: XCTestCase { private var delegate: BridgeDelegateSpy! private var destination: AppBridgeDestination! @@ -223,6 +224,6 @@ class BridgeComponentTest: XCTestCase { } } -private extension TimeInterval { +extension TimeInterval { static let expectationTimeout: TimeInterval = 5 } diff --git a/Tests/BridgeDelegateTests.swift b/Tests/BridgeDelegateTests.swift index bb8e56e..78a2ee8 100644 --- a/Tests/BridgeDelegateTests.swift +++ b/Tests/BridgeDelegateTests.swift @@ -3,6 +3,7 @@ import XCTest import WebKit @testable import Strada +@MainActor class BridgeDelegateTests: XCTestCase { private var delegate: BridgeDelegate! private var destination: AppBridgeDestination! diff --git a/Tests/BridgeTests.swift b/Tests/BridgeTests.swift index 661badf..6e00a98 100644 --- a/Tests/BridgeTests.swift +++ b/Tests/BridgeTests.swift @@ -2,32 +2,67 @@ import XCTest import WebKit @testable import Strada +@MainActor class BridgeTests: XCTestCase { - func testInitWithANewWebViewAutomaticallyLoadsIntoWebView() { + func testInitWithANewWebViewAutomaticallyLoadsIntoWebView() async { let webView = WKWebView() let userContentController = webView.configuration.userContentController XCTAssertTrue(userContentController.userScripts.isEmpty) - Bridge.initialize(webView) + await Bridge.initialize(webView) XCTAssertEqual(userContentController.userScripts.count, 1) } - func testInitWithTheSameWebViewDoesNotLoadTwice() { + func testInitWithTheSameWebViewDoesNotLoadTwice() async { let webView = WKWebView() let userContentController = webView.configuration.userContentController XCTAssertTrue(userContentController.userScripts.isEmpty) - Bridge.initialize(webView) + await Bridge.initialize(webView) XCTAssertEqual(userContentController.userScripts.count, 1) - Bridge.initialize(webView) + await Bridge.initialize(webView) XCTAssertEqual(userContentController.userScripts.count, 1) } + + func testInitWithANewWebViewAutomaticallyLoadsIntoWebView() { + let webView = WKWebView() + let userContentController = webView.configuration.userContentController + XCTAssertTrue(userContentController.userScripts.isEmpty) + + let expectation = expectation(description: "Wait for completion.") + Bridge.initialize(webView) { + XCTAssertEqual(userContentController.userScripts.count, 1) + expectation.fulfill() + } + + wait(for: [expectation], timeout: .expectationTimeout) + } + + func testInitWithTheSameWebViewDoesNotLoadTwice() { + let webView = WKWebView() + let userContentController = webView.configuration.userContentController + XCTAssertTrue(userContentController.userScripts.isEmpty) + + let expectation1 = expectation(description: "Wait for completion.") + Bridge.initialize(webView) { + XCTAssertEqual(userContentController.userScripts.count, 1) + expectation1.fulfill() + } + + let expectation2 = expectation(description: "Wait for completion.") + + Bridge.initialize(webView) { + XCTAssertEqual(userContentController.userScripts.count, 1) + expectation2.fulfill() + } + + wait(for: [expectation1, expectation2], timeout: .expectationTimeout) + } /// NOTE: Each call to `webView.evaluateJavaScript(String)` will throw an error. /// We intentionally disregard any thrown errors (`try? await bridge...`) /// because we validate the evaluated JavaScript string ourselves. - @MainActor func testRegisterComponentCallsJavaScriptFunction() async throws { let webView = TestWebView() let bridge = Bridge(webView: webView) @@ -37,7 +72,6 @@ class BridgeTests: XCTestCase { XCTAssertEqual(webView.lastEvaluatedJavaScript, "window.nativeBridge.register(\"test\")") } - @MainActor func testRegisterComponentsCallsJavaScriptFunction() async throws { let webView = TestWebView() let bridge = Bridge(webView: webView) @@ -47,7 +81,6 @@ class BridgeTests: XCTestCase { XCTAssertEqual(webView.lastEvaluatedJavaScript, "window.nativeBridge.register([\"one\",\"two\"])") } - @MainActor func testUnregisterComponentCallsJavaScriptFunction() async throws { let webView = TestWebView() let bridge = Bridge(webView: webView) @@ -57,7 +90,6 @@ class BridgeTests: XCTestCase { XCTAssertEqual(webView.lastEvaluatedJavaScript, "window.nativeBridge.unregister(\"test\")") } - @MainActor func testSendCallsJavaScriptFunction() async throws { let webView = TestWebView() let bridge = Bridge(webView: webView) @@ -78,7 +110,6 @@ class BridgeTests: XCTestCase { XCTAssertEqual(webView.lastEvaluatedJavaScript, "window.nativeBridge.replyWith({\"component\":\"page\",\"event\":\"connect\",\"data\":{\"title\":\"Page-title\"},\"id\":\"1\"})") } - @MainActor func testEvaluateJavaScript() async throws { let webView = TestWebView() let bridge = Bridge(webView: webView) @@ -88,7 +119,6 @@ class BridgeTests: XCTestCase { XCTAssertEqual(webView.lastEvaluatedJavaScript, "test(1,2,3)") } - @MainActor func testEvaluateJavaScriptReturnsErrorForNoWebView() async throws { let bridge = Bridge(webView: WKWebView()) bridge.webView = nil @@ -101,7 +131,6 @@ class BridgeTests: XCTestCase { XCTAssertEqual(bridgeError, BridgeError.missingWebView) } - @MainActor func testEvaluateFunction() async throws { let webView = TestWebView() let bridge = Bridge(webView: webView) diff --git a/Tests/ComponentTestExample/ComposerComponentTests.swift b/Tests/ComponentTestExample/ComposerComponentTests.swift index 1201c40..f25289c 100644 --- a/Tests/ComponentTestExample/ComposerComponentTests.swift +++ b/Tests/ComponentTestExample/ComposerComponentTests.swift @@ -2,6 +2,7 @@ import XCTest import WebKit import Strada +@MainActor final class ComposerComponentTests: XCTestCase { private var delegate: BridgeDelegateSpy! private var destination: AppBridgeDestination! From e83e7b40acb6ff340b6cf4dde546bbe63089fd94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=A0vara?= Date: Thu, 29 Feb 2024 11:28:17 +0100 Subject: [PATCH 5/7] Don't dispatch script messages in an async context. --- Source/Bridge.swift | 19 +++++++++---------- Source/BridgeDelegate.swift | 12 +++++++++--- Source/ScriptMessageHandler.swift | 4 ++-- Tests/BridgeDelegateTests.swift | 7 +++++-- Tests/Spies/BridgeDelegateSpy.swift | 2 +- Tests/Spies/BridgeSpy.swift | 10 +++++++++- 6 files changed, 35 insertions(+), 19 deletions(-) diff --git a/Source/Bridge.swift b/Source/Bridge.swift index c8daa67..2c0a7f3 100644 --- a/Source/Bridge.swift +++ b/Source/Bridge.swift @@ -173,9 +173,9 @@ public final class Bridge: Bridgable { } extension Bridge: ScriptMessageHandlerDelegate { - func scriptMessageHandlerDidReceiveMessage(_ scriptMessage: WKScriptMessage) async throws { + func scriptMessageHandlerDidReceiveMessage(_ scriptMessage: WKScriptMessage) { if let event = scriptMessage.body as? String, event == "ready" { - try await delegate?.bridgeDidInitialize() + delegate?.bridgeDidInitialize() return } @@ -193,15 +193,14 @@ private extension WKWebView { /// in case the function doesn't return anything. /// This is a workaround. See https://forums.developer.apple.com/forums/thread/701553 for more details. @discardableResult - func evaluateJavaScriptAsync(_ str: String) async throws -> Any? { + @MainActor + func evaluateJavaScriptAsync(_ javaScriptString: String) async throws -> Any? { return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - DispatchQueue.main.async { - self.evaluateJavaScript(str) { data, error in - if let error = error { - continuation.resume(throwing: error) - } else { - continuation.resume(returning: data) - } + evaluateJavaScript(javaScriptString) { data, error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: data) } } } diff --git a/Source/BridgeDelegate.swift b/Source/BridgeDelegate.swift index 2d42dbc..2743a86 100644 --- a/Source/BridgeDelegate.swift +++ b/Source/BridgeDelegate.swift @@ -21,7 +21,7 @@ public protocol BridgingDelegate: AnyObject { func component() -> C? - func bridgeDidInitialize() async throws + func bridgeDidInitialize() func bridgeDidReceiveMessage(_ message: Message) -> Bool } @@ -111,9 +111,15 @@ public final class BridgeDelegate: BridgingDelegate { // MARK: Internal use - public func bridgeDidInitialize() async throws { + public func bridgeDidInitialize() { let componentNames = componentTypes.map { $0.name } - try await bridge?.register(components: componentNames) + Task { + do { + try await bridge?.register(components: componentNames) + } catch { + logger.error("bridgeDidFailToRegisterComponents: \(error)") + } + } } @discardableResult diff --git a/Source/ScriptMessageHandler.swift b/Source/ScriptMessageHandler.swift index 222cd2a..1b21d18 100644 --- a/Source/ScriptMessageHandler.swift +++ b/Source/ScriptMessageHandler.swift @@ -1,7 +1,7 @@ import WebKit protocol ScriptMessageHandlerDelegate: AnyObject { - func scriptMessageHandlerDidReceiveMessage(_ scriptMessage: WKScriptMessage) async throws + func scriptMessageHandlerDidReceiveMessage(_ scriptMessage: WKScriptMessage) } // Avoids retain cycle caused by WKUserContentController @@ -13,6 +13,6 @@ final class ScriptMessageHandler: NSObject, WKScriptMessageHandler { } func userContentController(_ userContentController: WKUserContentController, didReceive scriptMessage: WKScriptMessage) { - Task { try await delegate?.scriptMessageHandlerDidReceiveMessage(scriptMessage) } + delegate?.scriptMessageHandlerDidReceiveMessage(scriptMessage) } } diff --git a/Tests/BridgeDelegateTests.swift b/Tests/BridgeDelegateTests.swift index 78a2ee8..daf0638 100644 --- a/Tests/BridgeDelegateTests.swift +++ b/Tests/BridgeDelegateTests.swift @@ -24,8 +24,11 @@ class BridgeDelegateTests: XCTestCase { } func testBridgeDidInitialize() async throws { - try await delegate.bridgeDidInitialize() - + await withCheckedContinuation { continuation in + bridge.registerComponentsContinuation = continuation + delegate.bridgeDidInitialize() + } + XCTAssertTrue(bridge.registerComponentsWasCalled) XCTAssertEqual(bridge.registerComponentsArg, ["one", "two"]) diff --git a/Tests/Spies/BridgeDelegateSpy.swift b/Tests/Spies/BridgeDelegateSpy.swift index 99790a8..d8029f4 100644 --- a/Tests/Spies/BridgeDelegateSpy.swift +++ b/Tests/Spies/BridgeDelegateSpy.swift @@ -49,7 +49,7 @@ final class BridgeDelegateSpy: BridgingDelegate { return nil } - func bridgeDidInitialize() async throws { + func bridgeDidInitialize() { } diff --git a/Tests/Spies/BridgeSpy.swift b/Tests/Spies/BridgeSpy.swift index 9771e34..a8f4371 100644 --- a/Tests/Spies/BridgeSpy.swift +++ b/Tests/Spies/BridgeSpy.swift @@ -9,7 +9,15 @@ final class BridgeSpy: Bridgable { var registerComponentWasCalled = false var registerComponentArg: String? = nil - var registerComponentsWasCalled = false + var registerComponentsWasCalled = false { + didSet { + if registerComponentsWasCalled { + registerComponentsContinuation?.resume() + registerComponentsContinuation = nil + } + } + } + var registerComponentsContinuation: CheckedContinuation? var registerComponentsArg: [String]? = nil var unregisterComponentWasCalled = false From 454d6c067d946d57915ee918f271ac03dfde316c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=A0vara?= Date: Thu, 29 Feb 2024 11:38:33 +0100 Subject: [PATCH 6/7] Move expectation timeout interval to its own file. --- Strada.xcodeproj/project.pbxproj | 12 ++++++++++++ Tests/BridgeComponentTests.swift | 4 ---- .../Extensions/TimeInterval+ExpectationTimeout.swift | 5 +++++ 3 files changed, 17 insertions(+), 4 deletions(-) create mode 100644 Tests/Extensions/TimeInterval+ExpectationTimeout.swift diff --git a/Strada.xcodeproj/project.pbxproj b/Strada.xcodeproj/project.pbxproj index a478a53..5c3ab56 100644 --- a/Strada.xcodeproj/project.pbxproj +++ b/Strada.xcodeproj/project.pbxproj @@ -33,6 +33,7 @@ E2DB15912A7163B0001EE08C /* BridgeDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2DB15902A7163B0001EE08C /* BridgeDelegate.swift */; }; E2DB15932A7282CF001EE08C /* BridgeComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2DB15922A7282CF001EE08C /* BridgeComponent.swift */; }; E2DB15952A72B0A8001EE08C /* BridgeDelegateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2DB15942A72B0A8001EE08C /* BridgeDelegateTests.swift */; }; + E2F4E06B2B9095BC000A3A24 /* TimeInterval+ExpectationTimeout.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F4E06A2B9095BC000A3A24 /* TimeInterval+ExpectationTimeout.swift */; }; E2FDCF982A8297DA003D27AE /* BridgeComponentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FDCF972A8297DA003D27AE /* BridgeComponentTests.swift */; }; E2FDCF9B2A829AEE003D27AE /* BridgeSpy.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FDCF9A2A829AEE003D27AE /* BridgeSpy.swift */; }; E2FDCF9D2A829C6F003D27AE /* TestData.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FDCF9C2A829C6F003D27AE /* TestData.swift */; }; @@ -79,6 +80,7 @@ E2DB15902A7163B0001EE08C /* BridgeDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BridgeDelegate.swift; sourceTree = ""; }; E2DB15922A7282CF001EE08C /* BridgeComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BridgeComponent.swift; sourceTree = ""; }; E2DB15942A72B0A8001EE08C /* BridgeDelegateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BridgeDelegateTests.swift; sourceTree = ""; }; + E2F4E06A2B9095BC000A3A24 /* TimeInterval+ExpectationTimeout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TimeInterval+ExpectationTimeout.swift"; sourceTree = ""; }; E2FDCF972A8297DA003D27AE /* BridgeComponentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BridgeComponentTests.swift; sourceTree = ""; }; E2FDCF9A2A829AEE003D27AE /* BridgeSpy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BridgeSpy.swift; sourceTree = ""; }; E2FDCF9C2A829C6F003D27AE /* TestData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestData.swift; sourceTree = ""; }; @@ -146,6 +148,7 @@ 9274F1F22229963B003E85F4 /* Tests */ = { isa = PBXGroup; children = ( + E2F4E0692B9095A5000A3A24 /* Extensions */, E227FAF12A94D48C00A645E4 /* ComponentTestExample */, E2FDCF9C2A829C6F003D27AE /* TestData.swift */, E2FDCF992A829AD5003D27AE /* Spies */, @@ -181,6 +184,14 @@ path = ComponentTestExample; sourceTree = ""; }; + E2F4E0692B9095A5000A3A24 /* Extensions */ = { + isa = PBXGroup; + children = ( + E2F4E06A2B9095BC000A3A24 /* TimeInterval+ExpectationTimeout.swift */, + ); + path = Extensions; + sourceTree = ""; + }; E2FDCF992A829AD5003D27AE /* Spies */ = { isa = PBXGroup; children = ( @@ -327,6 +338,7 @@ files = ( E2DB15952A72B0A8001EE08C /* BridgeDelegateTests.swift in Sources */, E227FAF02A94D34E00A645E4 /* ComposerComponent.swift in Sources */, + E2F4E06B2B9095BC000A3A24 /* TimeInterval+ExpectationTimeout.swift in Sources */, E227FAEE2A94B35900A645E4 /* BridgeDelegateSpy.swift in Sources */, C11349C2258801F6000A6E56 /* JavaScriptTests.swift in Sources */, E227FAF32A94D57300A645E4 /* ComposerComponentTests.swift in Sources */, diff --git a/Tests/BridgeComponentTests.swift b/Tests/BridgeComponentTests.swift index 8a02804..a6aa1b0 100644 --- a/Tests/BridgeComponentTests.swift +++ b/Tests/BridgeComponentTests.swift @@ -223,7 +223,3 @@ class BridgeComponentTest: XCTestCase { wait(for: [expectation], timeout: .expectationTimeout) } } - -extension TimeInterval { - static let expectationTimeout: TimeInterval = 5 -} diff --git a/Tests/Extensions/TimeInterval+ExpectationTimeout.swift b/Tests/Extensions/TimeInterval+ExpectationTimeout.swift new file mode 100644 index 0000000..101e7ea --- /dev/null +++ b/Tests/Extensions/TimeInterval+ExpectationTimeout.swift @@ -0,0 +1,5 @@ +import Foundation + +extension TimeInterval { + static let expectationTimeout: TimeInterval = 5 +} From 5d1cffb305fc56387ed11e492bc47d8dd7b18b78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=A0vara?= Date: Thu, 29 Feb 2024 15:57:01 +0100 Subject: [PATCH 7/7] Remove @MainActor attribute from the Bridge and add it to relevant functions only. --- Source/Bridge.swift | 20 +++++++++--------- Tests/BridgeTests.swift | 45 +++++------------------------------------ 2 files changed, 15 insertions(+), 50 deletions(-) diff --git a/Source/Bridge.swift b/Source/Bridge.swift index 2c0a7f3..23ccd73 100644 --- a/Source/Bridge.swift +++ b/Source/Bridge.swift @@ -5,7 +5,6 @@ public enum BridgeError: Error { case missingWebView } -@MainActor protocol Bridgable: AnyObject { var delegate: BridgeDelegate? { get set } var webView: WKWebView? { get } @@ -18,20 +17,12 @@ protocol Bridgable: AnyObject { /// `Bridge` is the object for configuring a web view and /// the channel for sending/receiving messages -@MainActor public final class Bridge: Bridgable { public typealias InitializationCompletionHandler = () -> Void weak var delegate: BridgeDelegate? weak var webView: WKWebView? - nonisolated public static func initialize(_ webView: WKWebView, completion: InitializationCompletionHandler?) { - Task { @MainActor in - await initialize(webView) - completion?() - } - } - - public static func initialize(_ webView: WKWebView) async { + public static func initialize(_ webView: WKWebView) { if getBridgeFor(webView) == nil { initialize(Bridge(webView: webView)) } @@ -46,24 +37,28 @@ public final class Bridge: Bridgable { /// Register a single component /// - Parameter component: Name of a component to register support for + @MainActor func register(component: String) async throws { try await callBridgeFunction(.register, arguments: [component]) } /// Register multiple components /// - Parameter components: Array of component names to register + @MainActor func register(components: [String]) async throws { try await callBridgeFunction(.register, arguments: [components]) } /// Unregister support for a single component /// - Parameter component: Component name + @MainActor func unregister(component: String) async throws { try await callBridgeFunction(.unregister, arguments: [component]) } /// Send a message through the bridge to the web application /// - Parameter message: Message to send + @MainActor func reply(with message: Message) async throws { logger.debug("bridgeWillReplyWithMessage: \(String(describing: message))") let internalMessage = InternalMessage(from: message) @@ -79,6 +74,7 @@ public final class Bridge: Bridgable { // callBridgeFunction("send", arguments: [replyMessage.toJSON()]) // } @discardableResult + @MainActor func evaluate(javaScript: String) async throws -> Any? { guard let webView else { throw BridgeError.missingWebView @@ -95,6 +91,7 @@ public final class Bridge: Bridgable { /// Evaluates a JavaScript function with optional arguments by encoding the arguments /// Function should not include the parens /// Usage: evaluate(function: "console.log", arguments: ["test"]) + @MainActor func evaluate(function: String, arguments: [Any] = []) async throws -> Any? { try await evaluate(javaScript: JavaScript(functionName: function, arguments: arguments).toString()) } @@ -117,6 +114,7 @@ public final class Bridge: Bridgable { /// The webkit.messageHandlers name private let scriptHandlerName = "strada" + @MainActor private func callBridgeFunction(_ function: JavaScriptBridgeFunction, arguments: [Any]) async throws { let js = JavaScript(object: bridgeGlobal, functionName: function.rawValue, arguments: arguments) try await evaluate(javaScript: js) @@ -156,6 +154,7 @@ public final class Bridge: Bridgable { // MARK: - JavaScript Evaluation @discardableResult + @MainActor private func evaluate(javaScript: JavaScript) async throws -> Any? { do { return try await evaluate(javaScript: javaScript.toString()) @@ -173,6 +172,7 @@ public final class Bridge: Bridgable { } extension Bridge: ScriptMessageHandlerDelegate { + @MainActor func scriptMessageHandlerDidReceiveMessage(_ scriptMessage: WKScriptMessage) { if let event = scriptMessage.body as? String, event == "ready" { delegate?.bridgeDidInitialize() diff --git a/Tests/BridgeTests.swift b/Tests/BridgeTests.swift index 6e00a98..be6fcfc 100644 --- a/Tests/BridgeTests.swift +++ b/Tests/BridgeTests.swift @@ -4,61 +4,26 @@ import WebKit @MainActor class BridgeTests: XCTestCase { - func testInitWithANewWebViewAutomaticallyLoadsIntoWebView() async { + func testInitWithANewWebViewAutomaticallyLoadsIntoWebView() { let webView = WKWebView() let userContentController = webView.configuration.userContentController XCTAssertTrue(userContentController.userScripts.isEmpty) - await Bridge.initialize(webView) + Bridge.initialize(webView) XCTAssertEqual(userContentController.userScripts.count, 1) } - func testInitWithTheSameWebViewDoesNotLoadTwice() async { + func testInitWithTheSameWebViewDoesNotLoadTwice() { let webView = WKWebView() let userContentController = webView.configuration.userContentController XCTAssertTrue(userContentController.userScripts.isEmpty) - await Bridge.initialize(webView) + Bridge.initialize(webView) XCTAssertEqual(userContentController.userScripts.count, 1) - await Bridge.initialize(webView) + Bridge.initialize(webView) XCTAssertEqual(userContentController.userScripts.count, 1) } - - func testInitWithANewWebViewAutomaticallyLoadsIntoWebView() { - let webView = WKWebView() - let userContentController = webView.configuration.userContentController - XCTAssertTrue(userContentController.userScripts.isEmpty) - - let expectation = expectation(description: "Wait for completion.") - Bridge.initialize(webView) { - XCTAssertEqual(userContentController.userScripts.count, 1) - expectation.fulfill() - } - - wait(for: [expectation], timeout: .expectationTimeout) - } - - func testInitWithTheSameWebViewDoesNotLoadTwice() { - let webView = WKWebView() - let userContentController = webView.configuration.userContentController - XCTAssertTrue(userContentController.userScripts.isEmpty) - - let expectation1 = expectation(description: "Wait for completion.") - Bridge.initialize(webView) { - XCTAssertEqual(userContentController.userScripts.count, 1) - expectation1.fulfill() - } - - let expectation2 = expectation(description: "Wait for completion.") - - Bridge.initialize(webView) { - XCTAssertEqual(userContentController.userScripts.count, 1) - expectation2.fulfill() - } - - wait(for: [expectation1, expectation2], timeout: .expectationTimeout) - } /// NOTE: Each call to `webView.evaluateJavaScript(String)` will throw an error. /// We intentionally disregard any thrown errors (`try? await bridge...`)