WKScriptMessageHandlerWithReply and strict concurrency checking

Hi, I'm trying to implement a type conforming to WKScriptMessageHandlerWithReply while having Swift's strict concurrency checking enabled. It's not been fun.

The protocol contains the following method (there's also one with a callback, but we're in 2024):

func userContentController(
    controller: WKUserContentController,
    didReceive message: WKScriptMessage
) async -> (Any?, String?)

WKScriptMessage's properties like body must be accessed on the main thread. But since WKScriptMessageHandlerWithReply is not @MainActor, neither can this method be so marked (same for the conforming type).

At the same time WKScriptMessage is not Sendable, so I can't handle it in Task { @MainActor in this method, because that leads to

Capture of 'message' with non-sendable type 'WKScriptMessage' in a `@Sendable` closure

That leaves me with @preconcurrency import - is that the way to go? Should I file a feedback for this or is it somehow working as intended?

Replies

But since WKScriptMessageHandlerWithReply is not @MainActor, neither can this method be so marked

My WebKit is kinda rusty but I believe that WKScriptMessageHandlerWithReply should be as main-actor isolated, right? That is, all of its methods are expected to be called on the main thread?

If so, this devolves into the question “How do I tell the Swift compiler about that fact?” There are various tricks for that but I tend to reach for MainActor.assumeIsolated(_:file:line:):

@MainActor
class MyClass: NSObject, WKScriptMessageHandlerWithReply {

    var lastMessage: WKScriptMessage? = nil
    var counter: Int = 0

    nonisolated func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage, replyHandler: @escaping (Any?, String?) -> Void) {
        MainActor.assumeIsolated {
            self.lastMessage = message
            self.counter += 1
            replyHandler(["counter": self.counter], nil)
        }
    }
}

This compiles without warnings in Xcode 15.3 with strict concurrency enabled. Now how it’s accessing counter, which is main actor isolated, and using message

The docs for MainActor.assumeIsolated(…) say:

This API should only be used as last resort, when it is not possible to express the current execution context definitely belongs to the main actor in other ways [for example] one may need to use this in a delegate style API, where a synchronous method is guaranteed to be called by the main actor

which is exactly the situation you’re in.

Note I believe that Swift 6 will have a shorthand syntax for this, but I don’t have a Swift 6 setup handy to test that right now (and I wasn’t able to quickly find the SE proposal that describes it).

Should I file a feedback for this … ?

Yes. The flip side of the doc comment above is that the framework vendor should be correctly annotating their delegate protocols. In that case that vendor is Apple, so filing a bug is appropriate.

Please post your bug number, just for the record.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Hey Quinn,

thanks for responding. I was about to write that your suggestion will crash, but it turns out that only true for the async version. The version with the replyHandler that you posted does get called from the main thread, while the async one seems to be called from arbitrary threads (and thus crashing).

I submitted feedback FB13774556.

while the async one seems to be called from arbitrary threads (and thus crashing).

Hmmmm, I’m not sure what to make of that. If I had more time I’d dig into it to confirm my understanding, but the months leading up to WWDC are kinda busy O-:

I submitted feedback FB13774556.

Thanks. Regardless of your workaround, getting this annotated correctly in the headers is the best long-term solution.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"