Debugging custom Bridge Components in Hotwire Native
I’ve been building a Hotwire Native iOS app with a Rails backend and recently created a custom bridge component called ApproachMapBridgeComponent. The goal was simple: when users click a button in the web view, it should open a native map modal in the iOS app. But instead of a working feature, the button did absolutely nothing. No logs, no crashes, no errors—just silence.
Before diving into how I fixed it, it’s worth explaining what bridge components are. Hotwire Native allows you to build iOS and Android apps where most of the UI is rendered by a web view using standard HTML, CSS, and JavaScript. Bridge components are what let that web code communicate with native device capabilities. They exist in two layers: a Stimulus controller on the JavaScript side, and a Swift class on the iOS side. When they’re working properly, clicking a button in the web view triggers native code that can use any iOS feature such as GPS, camera, or haptic feedback. When they aren’t, things get confusing quickly.
The Setup
The Rails side included a simple Stimulus controller:
// app/javascript/bridge_controllers/approach_map_controller.js
import { BridgeComponent } from "@hotwired/hotwire-native-bridge";
export default class extends BridgeComponent {
static component = "approach-map"
buttonTargetConnected(target) {
this.send("connect", { url }, () => {
target.addEventListener("click", () => {
this.send("open", { url });
});
});
}
}
The HTML markup looked like this:
<div data-controller="bridge--approach-map">
<button data-bridge--approach-map-target="button">
Open Approach Map
</button>
</div>
And the iOS counterpart was equally straightforward:
class ApproachMapBridgeComponent: BridgeComponent {
override class var name: String { "approach-map" }
override func onReceive(message: Message) {
// Present native map modal
}
}
In AppDelegate.swift, I registered the component:
Hotwire.registerBridgeComponents([
ApproachMapBridgeComponent.self,
])
Everything compiled fine, the button appeared, but nothing happened when it was clicked.
Chasing the Wrong Problems
The first distraction came from an unrelated controller that had broken Stimulus code. Fixing it restored unrelated functionality but didn’t fix my bridge issue. Then I experimented with controller naming, assuming maybe the data-controller attribute was wrong. I tried different casing and prefixes, but none worked. Later I learned Hotwire Native bridge controllers must use the bridge-- prefix convention, so my original identifier had been correct all along.
The Double Registration Trap
Eventually, I discovered I was registering the same Stimulus controller twice—once manually and once via eagerLoadControllersFrom(). Stimulus silently refuses to connect a controller that’s registered more than once. Moving my bridge controllers from /bridge/ into a dedicated bridge_controllers/ directory resolved this.
Why a Separate Directory?/
Bridge controllers are manually registered in application.js, not auto-loaded by eagerLoadControllersFrom(). Keeping them in a separate directory makes the architecture cleaner and avoids subtle loading issues. It prevents double-registration because eagerLoadControllersFrom("controllers", ...) won’t automatically pick them up. It also makes the intent clear—any file in bridge_controllers/ is explicitly tied to native bridge behavior—and it reinforces the pattern that bridge components need explicit registration with the bridge-- prefix.
Realization: Bridge Components Don’t Work in the Browser
A major turning point came when I learned that bridge components don’t connect at all in regular web browsers. The Hotwire Native bridge library includes a shouldLoad() check that looks for the component name inside the WebView’s user agent string. This string is only modified by the native iOS app when it registers its components. Because Safari or Chrome never contain that string, Stimulus simply never connects the controller. Testing bridge behavior in a web browser will always fail silently.
The Missing Swift File
Even after testing on the device, my component still didn’t work. It turned out the Swift file existed in the project folder but wasn’t actually part of the Xcode build. Selecting the file and enabling the correct target membership in the File Inspector fixed that. Xcode doesn’t compile files it doesn’t know belong to a target, even if they’re physically inside the project directory.
The Real Culprit: Registration Order
After eliminating all other issues, I discovered the final problem. In AppDelegate, I registered my custom bridge components and then called Hotwire.registerBridgeComponents(Bridgework.coreComponents). The second call overwrote the previous registration, replacing the entire list instead of appending to it. That meant only the core components were included in the user agent string, and my custom ones were never advertised to the JavaScript layer.
Reversing the order solved it:
// Register core components first
Hotwire.registerBridgeComponents(Bridgework.coreComponents)
// Then register custom components
Hotwire.registerBridgeComponents([
ApproachMapBridgeComponent.self,
TestBridgeComponent.self,
NotificationTokenComponent.self,
])
After rebuilding, the DevTools overlay showed my components in the “Bridge” tab, and the button click finally triggered:
ApproachMapBridgeComponent.onReceive called!
The native map modal appeared perfectly.
How Bridge Components Actually Work
When the app launches, Hotwire builds a list of registered component names and adds them to the WebView’s user agent string. The JavaScript side of each BridgeComponent checks whether its name appears there. If not, the controller never connects. Once connected, the controller can send messages like "connect" or "open" through the bridge. The native side receives these messages, performs an action, and can reply back to JavaScript, which triggers callbacks.
Here’s the final working code for the JavaScript side:
export default class extends BridgeComponent {
static component = "approach-map"
static targets = ["button"]
buttonTargetConnected(target) {
const url = new BridgeElement(target).bridgeAttribute("url")
this.send("connect", { url }, () => {
target.addEventListener("click", () => {
this.send("open", { url })
})
})
}
}
And the iOS side:
class ApproachMapBridgeComponent: BridgeComponent {
override class var name: String { "approach-map" }
override func onReceive(message: Message) {
guard let data: MessageData = message.data() else { return }
switch message.event {
case "connect":
reply(to: message.event)
case "open":
presentNativeMap(with: data.url)
default:
break
}
}
}
private struct MessageData: Decodable {
let url: String
}
The Takeaway
What started as a simple “button won’t click” problem turned into a deep dive through Stimulus lifecycles, WebView configuration, user agent inspection, and Xcode build settings. The ultimate fix was the registration order of the bridge components—a small, easily overlooked detail that makes a big difference.
The lesson is clear: in Hotwire Native, the order of component registration determines what appears in the user agent, which in turn determines which bridge controllers can load. Always test in the simulator or on a real device, always check the DevTools “Bridge” tab, and always confirm that your Swift files are part of the target.
Building with Hotwire Native is an adventure. The best part of debugging these kinds of issues is that once you truly understand how the system works under the hood, the next bridge you build will connect on the first try.