Here are the problems encountered when switching from a single Navigator to the HotwireTabBarController pattern while wrapping a Rails app with Hotwire Native. Bugs that were invisible in the simulator surfaced on TestFlight, and local dev environment settings got tangled – multiple points where time was wasted.


1. HotwireTabBarController Basic Structure

Instead of a single Navigator, each tab has its own independent Navigator and WKWebView.

// AppTab.swift
enum AppTab: String, CaseIterable {
    case home, ai, request

    var systemImage: String {
        switch self {
        case .home:    return "house"
        case .ai:      return "message"
        case .request: return "checkmark.circle"
        }
    }

    var selectedSystemImage: String {
        switch self {
        case .home:    return "house.fill"
        case .ai:      return "message.fill"
        case .request: return "checkmark.circle.fill"
        }
    }

    var url: URL {
        let base = AppDelegate.baseURL
        switch self {
        case .home:    return base.appendingPathComponent("dashboard")
        case .ai:      return base.appendingPathComponent("conversations")
        case .request: return base.appendingPathComponent("service_requests")
        }
    }

    var hotwireTab: HotwireTab {
        HotwireTab(
            title: "",
            image: UIImage(systemName: systemImage)!,
            selectedImage: UIImage(systemName: selectedSystemImage)!,
            url: url
        )
    }
}
// SceneController.swift core part
private lazy var tabBarController: HotwireTabBarController = {
    let controller = HotwireTabBarController(navigatorDelegate: self)
    controller.load(AppTab.allCases.map(\.hotwireTab))

    // Show only tab icons, remove text
    controller.viewControllers?.forEach { vc in
        vc.tabBarItem.title = nil
        vc.tabBarItem.imageInsets = UIEdgeInsets(top: 6, left: 0, bottom: -6, right: 0)
        (vc as? UINavigationController)?.delegate = self
    }
    return controller
}()

To remove tab titles and keep only icons, both tabBarItem.title = nil and imageInsets adjustment are needed. Setting only title to nil leaves the icon position unchanged, looking awkward.


2. Pinning a Notification Button to the Navigation Bar

To maintain a bell icon in the top right on every screen transition, use UINavigationControllerDelegate.

extension SceneController: UINavigationControllerDelegate {
    func navigationController(
        _ navigationController: UINavigationController,
        didShow viewController: UIViewController,
        animated: Bool
    ) {
        addNavBarButtons(to: viewController)
    }

    private func addNavBarButtons(to viewController: UIViewController) {
        viewController.navigationItem.title = ""

        let notificationButton = UIBarButtonItem(
            image: UIImage(systemName: "bell"),
            style: .plain,
            target: self,
            action: #selector(openNotifications)
        )
        notificationButton.tintColor = UIColor.secondaryLabel
        viewController.navigationItem.rightBarButtonItem = notificationButton
    }

    @objc private func openNotifications() {
        tabBarController.activeNavigator.route(
            AppDelegate.baseURL.appendingPathComponent("notifications")
        )
    }
}

didShow is called after all transitions (push/pop/replace), so the button persists on every screen.


3. Authentication Screen Modal Handling

extension SceneController: NavigatorDelegate {
    func handle(proposal: VisitProposal) -> ProposalResult {
        let path = proposal.url.path()

        if path.hasPrefix("/sign_in") || path.hasPrefix("/sign_up") {
            guard tabBarController.presentedViewController == nil else {
                return .reject
            }
            let authVC = AuthViewController(url: proposal.url)
            tabBarController.present(authVC, animated: true)
            return .reject
        }

        if !isAppURL(proposal.url) {
            let safariVC = SFSafariViewController(url: proposal.url)
            tabBarController.activeNavigator.rootViewController.present(safariVC, animated: true)
            return .reject
        }

        return .accept
    }
}

The presentedViewController != nil check is important. If all 3 tabs redirect to /sign_in simultaneously, the modal tries to appear 3 times. Only allow the first and reject the rest.


4. Background Tab WebView Suspend -> NSURLErrorCancelled (-999)

Symptoms

On first app launch, a “Network error occurred” dialog appears. The server is fine and curl returns 200, but only the app shows an error.

Cause

HotwireTabBarController loads all tabs simultaneously. The active tab (tab 1) WebView loads normally in the foreground, but inactive tabs (tab 2, 3) have their WebProcess immediately suspended by iOS. HTTP requests in progress get cancelled, producing NSURLErrorCancelled (-999) and triggering visitableDidFailRequest.

Simulator log confirmation:

WebProcessProxy::didChangeThrottleState(Foreground)
WebProcessProxy::didChangeThrottleState(Suspended)  <- Immediately suspended

Fix

func visitableDidFailRequest(
    _ visitable: any Visitable,
    error: Error,
    retryHandler: RetryBlock?
) {
    let nsError = error as NSError
    // -999: Request cancelled due to background tab WebView suspend
    // HotwireTabBarController auto-reloads on tab switch, so ignore
    guard nsError.code != NSURLErrorCancelled else { return }

    let alert = UIAlertController(
        title: "Connection Error",
        message: "A network error occurred. Please try again.",
        preferredStyle: .alert
    )
    if let retryHandler {
        alert.addAction(UIAlertAction(title: "Retry", style: .default) { _ in retryHandler() })
    }
    alert.addAction(UIAlertAction(title: "OK", style: .cancel))
    tabBarController.activeNavigator.rootViewController.present(alert, animated: true)
}

When the tab is switched, HotwireTabBarController automatically reloads that tab’s page, so it is safe to simply ignore the error.


5. Debug/Release URL Separation

A TestFlight build crashed with a log pointing to UINavigationController.init(rootViewController:). It turned out baseURL was hardcoded to localhost:3001, causing connection failure on real devices and a crash during initialization.

// AppDelegate.swift
static let baseURL: URL = {
    if let envURL = ProcessInfo.processInfo.environment["KRX_AI_BASE_URL"] {
        return URL(string: envURL)!
    }
    #if DEBUG
    return URL(string: "http://localhost:3001")!
    #else
    return URL(string: "https://your-production-server.com")!
    #endif
}()

Use #if DEBUG / #else to separate Debug (simulator) and Release (TestFlight/App Store). Making environment variable injection possible also adds flexibility for CI/CD.


6. Duplicate Back Button (Web + Native)

When a Rails view has a back link and the native navigation bar also has a back arrow, it causes user confusion.

Solution – A Combination of Approaches

1. Hide web back button with CSS

/* application.css */
.native-app .native-back { display: none !important; }

2. Add native-app class in Rails layout

<%# application.html.erb %>
<% native_app = hotwire_native_app? %>
<body class="<%= 'native-app' if native_app %>">

hotwire_native_app? is a helper provided by turbo-rails. Returns true if “Turbo Native” is in the User-Agent.

3. Add class to each view’s back button

<%= link_to "<- Back", some_path, class: "native-back" %>

4. Set tab roots to replace in path-configuration.json

{
  "patterns": ["^/dashboard$", "^/conversations$", "^/service_requests$"],
  "properties": {
    "context": "default",
    "presentation": "replace"
  }
}

Setting tab root URLs to presentation: replace prevents them from stacking in the navigation stack. This means the native back arrow does not appear on tab roots.

5. Remove back button text

// AppDelegate.swift
Hotwire.config.backButtonDisplayMode = .minimal

Shows only the arrow and hides the previous page title text.


7. Puma Thread Settings – Preparing for Simultaneous Tab Loading

HotwireTabBarController sends as many requests simultaneously as there are tabs. If the default Puma threads (2) are insufficient, requests queue up.

# config/puma.rb
threads_count = ENV.fetch("RAILS_MAX_THREADS", 5)
threads threads_count, threads_count

With 3 tabs, set at least 3, with some margin at 5.

Explicitly specifying the local development port also makes it easy to match the iOS app’s baseURL:

port ENV.fetch("PORT", 3001)
# Procfile.dev
web: bin/rails server -p 3001

8. Missing Export Compliance Documentation (ITSAppUsesNonExemptEncryption)

When uploading TestFlight/App Store builds, the “Missing Export Compliance Documentation” warning keeps appearing. For apps that only use HTTPS without implementing separate encryption, add the following to Info.plist.

If using XcodeGen, in project.yml:

info:
  properties:
    ITSAppUsesNonExemptEncryption: false

If adding directly to Info.plist:

<key>ITSAppUsesNonExemptEncryption</key>
<false/>

This eliminates the hassle of manually answering in App Store Connect for every build.


9. make sim – Local Simulator Build Automation

make testflight always builds Release, connecting to the production server without a local server. For local development with Debug builds on the simulator, a separate target is needed.

SIM_DEVICE_ID = <your-simulator-udid>

sim: gen-ios
	@echo "Building for Simulator (Debug)..."
	xcodebuild build \
		-project ios/$(SCHEME).xcodeproj \
		-scheme $(SCHEME) \
		-configuration Debug \
		-destination "platform=iOS Simulator,id=$(SIM_DEVICE_ID)" \
		-derivedDataPath ios/build/sim \
		| xcpretty 2>/dev/null || true
	xcrun simctl boot $(SIM_DEVICE_ID) 2>/dev/null || true
	xcrun simctl install $(SIM_DEVICE_ID) \
		"ios/build/sim/Build/Products/Debug-iphonesimulator/$(SCHEME).app"
	xcrun simctl launch --console-pty $(SIM_DEVICE_ID) com.your.bundle.id
	open -a Simulator

Local workflow:

# Terminal 1
make dev       # Rails server (localhost:3001)

# Terminal 2
make sim       # Simulator Debug build + launch

Summary

ProblemCauseSolution
“Connection error” on app launchBackground tab WebView suspend -> NSURLErrorCancelledIgnore -999 error
TestFlight crashlocalhost hardcoded in Release build#if DEBUG / #else branching
Duplicate back buttonWeb back button + native navigation barCSS .native-back hide + path-config replace
Export compliance warningITSAppUsesNonExemptEncryption not declaredAdd false to project.yml
Simulator connection failureProcfile port not specified (3000) + app expects 3001bin/rails server -p 3001
Simultaneous request failurePuma threads 2 < 3 tabs loading simultaneouslyIncrease threads to 5