Skip to content

Open same-host non-HTTP(S) URLs (webcal:, mailto:, ...) via the system#255

Open
pardeyke wants to merge 1 commit into
hotwired:mainfrom
pardeyke:system-navigation-same-host-external-schemes
Open

Open same-host non-HTTP(S) URLs (webcal:, mailto:, ...) via the system#255
pardeyke wants to merge 1 commit into
hotwired:mainfrom
pardeyke:system-navigation-same-host-external-schemes

Conversation

@pardeyke

@pardeyke pardeyke commented Jul 9, 2026

Copy link
Copy Markdown

Same-host URLs with a non-HTTP(S) scheme are currently routed as in-app Turbo visits, which always fail. This PR routes them through SystemNavigationRouteDecisionHandler instead, so the system opens them — matching how external-scheme links on other hosts already behave.

The problem

A link like webcal://my.app.com/calendars/subscribe.ics (calendar subscription served by the app's own backend) matches AppNavigationRouteDecisionHandler, because it compares hosts without looking at the scheme. The resulting Turbo visit can't work — Turbo.js can't even record it in history:

Error evaluating JavaScript function `window.turboNative.visitLocationWithOptionsAndRestorationIdentifier':
SecurityError: Blocked attempt to use history.pushState() to change session history URL from
http://my.app.com/calendars to webcal://my.app.com/calendars/subscribe.ics.
Protocols, domains, ports, usernames, and passwords must match.

The visit then fails with status code 0 and falls into the JS fetch recovery path, which also fails (unsupported URL), ending in an error screen.

SystemNavigationRouteDecisionHandler doesn't catch this case either: it only matches when hosts differ. Its existing tests (test_sms_urls_match, test_non_http_urls_match) show the intent is for external schemes to be opened by the system — those URLs just happen to have a nil host, so they pass the host comparison by accident. As soon as the URL carries the app's own host, the intent breaks down.

The fix

  • AppNavigationRouteDecisionHandler only matches http/https URLs — it can only ever route those in-app.
  • SystemNavigationRouteDecisionHandler matches any non-HTTP(S) URL regardless of host, so UIApplication.shared.open hands it to the owning app (Calendar for webcal:, Mail for mailto:, ...).

Behavior for HTTP(S) URLs is unchanged, so this shouldn't affect SafariViewControllerRouteDecisionHandler or the universal-link handling from #191 / #195. Tests added for the same-host webcal: case on both handlers; the full suite passes.

🤖 Generated with Claude Code

AppNavigationRouteDecisionHandler matched on host alone, so a same-host
URL with a custom scheme (e.g. webcal://my.app.com/calendar.ics) was
routed as an in-app Turbo visit, which fails: Turbo.js cannot
history.pushState() across protocols and the visit ends with error 0.

Require an HTTP(S) scheme in AppNavigationRouteDecisionHandler and make
SystemNavigationRouteDecisionHandler claim non-HTTP(S) URLs regardless
of host, so the system opens them like it already does for sms: and
mailto: links on other hosts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant