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-namebridgeAttribute("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.rbresources :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

This site uses Just the Docs, a documentation theme for Jekyll.