Skip to content

Capacitor 8 iOS: a same-origin <a target="_blank"> silently does nothing (handed to UIApplication.open as capacitor://)

TL;DR.

In a Capacitor 8 iOS shell, an in-app link with target="_blank" (or window.open to a relative path) never navigates and raises no error: WKWebView asks the WKUIDelegate for a new window, Capacitor's WebViewDelegationHandler answers with UIApplication.shared.open(url) on capacitor://localhost/..., and iOS has no handler for that scheme. SPA routers (SvelteKit) skip _blank anchors on purpose, so nothing in JS sees the tap either. Gate target/rel on the native platform and let the router navigate in place.

Symptom. An in-app link worked on the web (new tab) but tapping it in the iOS app did nothing: no navigation, no console error, no Sentry event.

Mechanism (read from source, @capacitor/ios 8.5.3 and @sveltejs/kit 2.70.3):

  1. SvelteKit's client router bails before beforeNavigate on any anchor whose target is not _self/_parent/_top (runtime/client/client.js, if (target && target !== '_self') return;). So the click reaches WKWebView as a plain new-window request.
  2. WKWebView calls the WKUIDelegate webView(_:createWebViewWith:for:windowFeatures:). Capacitor's WebViewDelegationHandler.swift implements it as
    if let url = navigationAction.request.url {
        UIApplication.shared.open(url, options: [:], completionHandler: nil)
    }
    return nil
  3. A relative href resolves against the shell origin, so the URL is capacitor://localhost/new?.... Nothing registers the capacitor scheme in CFBundleURLSchemes (Capacitor apps only register their deep-link schemes), so open fails and the completion handler is nil. Silent.

The same handler is why target="_blank" to an https URL does work in Capacitor (it opens Safari): the behaviour is per-scheme, not per-attribute.

Fix. Render target/rel only off the native shell, e.g. in Svelte target={IS_APP ? null : '_blank'} rel={IS_APP ? null : 'noopener'} (a null attribute is omitted), and drop the external-link glyph in the app. With no target, the SPA router handles the tap as a same-document navigation.

Audit. grep -rn '_blank' src and classify each hit: external https (fine: Safari), internal path (broken in the shell), already platform-gated. Only the internal ones need the gate.

Verification status: chain read from the installed sources plus Info.plist; the in-place navigation is covered by a Playwright test against the MOBILE bundle, not yet re-run on a device.

No signals yet