# Talking to JavaScript from a SwiftUI WebView (and the iOS 26 Catch)

Table of Contents

In Readeck the reader is a WebView. Readeck strips every article down to clean HTML, no ads, no clutter, so a WebView is the obvious place to show it, and it ended up being the central reader view of the whole app.

But a WebView left alone feels like a website sitting inside your app, not a part of it. I wanted it to feel native, like the web page under there does not exist. For that, Swift and the page have to talk: Swift measures the content height, the page reports when the user highlights a sentence. So you end up with two directions of communication, and iOS 26 quietly broke one of them.

Two directions, and iOS 26 broke oneLink to heading

Swift into the page is the easy direction, and it works on every version. With the new iOS 26 WebPage model it is even async/await:

let result = try? await webPage.callJavaScript("return document.body.scrollHeight")
if let height = result as? Double { onHeightChange(height) }

You call, the page answers, you move on.

The page into Swift is the one that got worse. On WKWebView this was solved: you get a real push channel, register a named handler, the page calls postMessage, your coordinator receives it.

// Swift: register
webView.configuration.userContentController.add(context.coordinator, name: "annotationCreated")
// Page: push
window.webkit.messageHandlers.annotationCreated.postMessage(payload);

Clean, instant, event-driven. Then the native iOS 26 WebView shows up and there is no equivalent. No message handler, no push channel, nothing for data coming from the page back into Swift.

So I fake the push with a pollLink to heading

For values Swift wants on its own schedule, like the height after loading, none of this matters: you just pull them when you care.

The trouble is what the page sends on its own, like a fresh highlight. Nobody asked Swift to check right now, but the page has something to say.

Apple’s answer is basically “then keep a WKWebView for that part.” I could, but that means two web engines side by side to catch one event, built on the legacy view I want to delete, not keep as a crutch. So I stay in the native model and pay for it with a small loop.

My fix is not clever, and that is on purpose. JavaScript drops the payload in a global variable, and a Swift Task reads and clears it every 100ms:

annotationPollingTask = Task { @MainActor in
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 100_000_000)
if let data = try? await webPage.callJavaScript(
"const d = window.__pendingAnnotation; window.__pendingAnnotation = null; return d;"
) as? [String: Any] {
// handle the annotation
}
}
}
// Page: no postMessage, just leave it for Swift to find
window.__pendingAnnotation = payload;

Is polling gross? A little.Link to heading

Is it elegant? No. Is it reliable and easy to reason about? Yes. For this app that trade is easy to make:

  • A 100ms interval is invisible for something like highlighting, the user never feels it.
  • The Readeck page is clean, lightweight HTML, so a callJavaScript round trip every 100ms is cheap, more like polling a lightweight page than wrestling a full website.
  • A boring loop that works beats an elegant channel that does not exist.
  • One rule keeps it safe: cancel the task in onDisappear, or it outlives the view and keeps polling a page nobody is reading, quietly burning battery.

None of this is a dogma. If Apple ships a real push channel, or polling ever starts to hurt, WKWebView is not off the table for good. For now the small loop wins.

My avatar

Thanks for reading my blog post! Feel free to check out my other posts or contact me via the social links in the footer.


More Posts