Hotwire Native Reference
Lookup tables for while you are coding. Concepts and rationale live in the guide. API signatures match hotwire-native-ios 1.3.1 (the version pinned in ios/project.yml).
Library coordinates
| Platform | Coordinate |
|---|---|
| iOS | Swift Package https://github.com/hotwired/hotwire-native-ios, product HotwireNative, from 1.3.1 |
| Android (when built) | dev.hotwire:core + dev.hotwire:navigation-fragments |
| Web bridge JS | @hotwired/hotwire-native-bridge (importmap) |
| Rails | turbo-rails (already in Gemfile) |
| Official docs | https://native.hotwired.dev |
Rails helpers
| Helper | Returns | Notes |
|---|---|---|
hotwire_native_app? |
true if UA matches /(Turbo\|Hotwire) Native/ |
Preferred in new code |
turbo_native_app? |
alias | Exposed as helper in ApplicationController |
Simulate the native client: curl -A "Immersive/1.0 Hotwire Native iOS" http://localhost:3000/decks
CSS visibility classes (app/assets/stylesheets/native.css)
All gated on [data-native="true"], which the layouts set from turbo_native_app?. Web clients never match.
| Class | Behavior in native |
|---|---|
d-hotwire-native-none |
display: none !important |
d-hotwire-native-block |
display: block !important — pair with display-none (utility.css) for native-only elements |
d-hotwire-native-inline |
display: inline !important |
d-hotwire-native-flex |
display: flex !important |
There is no d-none in this codebase; the always-hidden class is display-none.
Path configuration
Served by ConfigurationsController#ios_v1 at GET /configurations/ios_v1.json. Versioned per platform; add ios_v2 on schema changes and never delete a version a shipped build still uses.
{
"settings": {},
"rules": [
{ "patterns": ["regex"], "properties": { "...": "..." } }
]
}
Rules accumulate top to bottom; later rules override earlier ones. settings must be present even if empty.
Rule properties
| Property | Platform | Values | Notes |
|---|---|---|---|
context |
both | "default", "modal" |
Modal slides up, X to dismiss |
presentation |
both | "default", "replace", "replace-root", "pop", "clear-all", "refresh" |
Stack manipulation |
view_controller |
iOS | identifier string | Matched in NavigatorDelegate.handle(proposal:) |
uri |
Android | hotwire://fragment/<name> |
Matched by @HotwireDestinationDeepLink |
pull_to_refresh_enabled |
both | bool | Disable on modals |
title |
both | string | Overrides the page <title> |
Common patterns
{ "patterns": ["/.+/new$", "/.+/edit$"], "properties": { "context": "modal" } }
{ "patterns": ["/users/sign_in$"], "properties": { "context": "modal", "presentation": "replace-root" } }
{ "patterns": [".*"], "properties": { "uri": "hotwire://fragment/web" } }
Always anchor with $ (/new$, not /new — which also matches /news). The wildcard ".*" rule is mandatory first rule on Android, omitted on iOS.
iOS API at a glance (1.3.1)
| Symbol | Use for |
|---|---|
Hotwire.loadPathConfiguration(from: [.server(URL)]) |
App boot, in AppDelegate — call exactly once |
Hotwire.registerBridgeComponents([Type.self]) |
App boot, registering bridge components |
Navigator(configuration: .init(name:startLocation:)) |
Per-tab navigator; name is stable identity (we use the path), startLocation the tab’s URL |
Navigator(configuration:delegate:) |
Pass a delegate when custom routing is needed — without one, handle(proposal:) is never called |
navigator.start() |
Visit the configured startLocation (we call it on first tab selection) |
navigator.route(url) |
Visit an arbitrary URL (deep links, push taps) |
NavigatorDelegate.handle(proposal: VisitProposal) -> ProposalResult |
Route to custom view controllers |
ProposalResult |
.accept (default web view), .acceptCustom(UIViewController), .reject |
VisitProposal |
.url, .viewController (the rule’s view_controller), .context, .properties |
BridgeComponent |
Subclass; override class var name and onReceive(message:) |
Message |
.event; .data() generic decode (let data: MessageData = message.data()); reply via reply(to:) |
delegate.destination as? UIViewController |
Hosting view controller inside a bridge component |
Bridge component triple
The name string must be identical in all three layers:
| Layer | File | Identifier |
|---|---|---|
| HTML | any view | data-controller="bridge--button", values via data-bridge-* |
| Stimulus | app/javascript/controllers/bridge/button_controller.js |
static component = "button" |
| Swift | ios/Immersive/App/Components/ButtonComponent.swift |
override class var name: String { "button" } |
Attribute naming: data-bridge-image-name ⇄ bridgeAttribute("image-name") ⇄ data.imageName (camelCased in Swift). Hide the HTML control only when the component is registered:
[data-bridge-components~="button"] [data-controller~="bridge--button"] { display: none !important; }
File paths
| Concern | Path |
|---|---|
| Native CSS | app/assets/stylesheets/native.css |
| Layout switch | app/views/layouts/{application,two_column,public,devise}.html.erb |
| Path config controller | app/controllers/configurations_controller.rb (inherits PublicController) |
| Path config route | config/routes.rb — resources :configurations, only: [] { get :ios_v1, on: :collection } |
| Bridge Stimulus controllers | app/javascript/controllers/bridge/*_controller.js (none yet) |
| iOS project spec | ios/project.yml (XcodeGen; .xcodeproj is git-ignored) |
| iOS environment | ios/Immersive/App/Configuration.swift (Debug → localhost, Release → https://immersive-app.com) |
| iOS delegates | ios/Immersive/App/Delegates/{App,Scene}Delegate.swift |
| Tabs | ios/Immersive/App/Models/Tab.swift, ios/Immersive/App/Controllers/TabBarController.swift |
| Tab title strings | ios/Immersive/App/Resources/<locale>.lproj/Localizable.strings (ca, en, es, fr, it, pt) |
| iOS bridge components (future) | ios/Immersive/App/Components/ |
Commands
cd ios && xcodegen generate # regenerate Immersive.xcodeproj (after any file add/move)
open ios/Immersive.xcodeproj # then pick a simulator, cmd-R (Rails must be on :3000)
curl http://localhost:3000/configurations/ios_v1.json
curl -A "Immersive/1.0 Hotwire Native iOS" http://localhost:3000/decks | grep data-native
Dev loop: change Rails, pull-to-refresh in the simulator. Force-quit the app to refresh a cached path configuration.
Error reference
| Symptom | Likely cause |
|---|---|
| Custom view controller never loads (iOS) | Navigator created without a delegate |
| Bridge component silently does nothing | Name mismatch across HTML / JS / Swift, or missing super.connect() |
| Native nav-bar button survives onto the next page | Missing disconnect handler in the bridge component |
| User signed out on every cold start | Auth cookie not permanent (Devise :rememberable covers us) |
| Fragment cache thrash / HTML differs per client | Conditional Ruby used instead of d-hotwire-native-* CSS |
Modal opens on wrong URLs (e.g. /news/new) |
Pattern not anchored — use /new$ |
| Blank screen after a denied action | Controller responded with head/JSON instead of navigable HTML |
| Path-config change not picked up in dev | Cached on device — force-quit the app |
| Every URL crashes (Android, later) | Missing wildcard .* rule or HotwireWebFragment not registered |
| Emulator can’t reach Rails (Android, later) | Use http://10.0.2.2:3000, not localhost |