RailsFast Native - iOS
RailsFast iOS is a thin UIKit + Hotwire Native shell around your RailsFast Base app.
Its job is not to rebuild the product in Swift. Its job is to:
- bootstrap Hotwire Native correctly
- present the right native shell
- keep iOS-specific behavior polished
- consume the Rails server contract cleanly
- stay template-generic so downstream apps can brand and extend it
The iOS starter follows the same Rails contract as RailsFast Android: Rails owns product semantics, and the native repo owns iOS container details.
Rails owns product semantics. The native repo owns iOS container details.
If you are new to the native layer, read Native Overview first for the big picture, then RailsFast Base for Native for the server contract. This page is the iOS-specific deep dive.
Run It On A Simulator
Get the shell running before you change anything. You only need a running RailsFast server and a couple of tools.
The Xcode project is generated by XcodeGen from project.yml. There is no committed .xcodeproj to fight with β you regenerate it from the YAML.
# from the railsfast-ios repo root
brew install xcodegen # one-time
xcodegen generate # writes RailsFast.xcodeproj from project.yml
open RailsFast.xcodeproj # open in Xcode
Then:
- Start your RailsFast Base server so the shell has something to render (
bin/dev, servinghttp://localhost:3000). - In Xcode, pick an iOS Simulator and press Run.
- The app launches into
/native/entryand Rails decides what to show next.
The base URL defaults to http://localhost:3000. To point at a different server, edit RAILSFAST_BASE_URL in project.yml and run xcodegen generate again (or override it as an Xcode build setting).
π NSAppTransportSecurity.NSAllowsLocalNetworking = true is already set in Info.plist so the simulator can talk to your local server over plain HTTP. You do not need to configure anything for localhost dev.
Toolchain And Version Pins
A few pins are load-bearing. Know them before you upgrade anything.
- Hotwire Native iOS is pinned to exactly
1.3.0-betavia Swift Package Manager (project.yml). This is an exact tag, not a semver range. - Deployment target: iOS 14.0. Swift 5.9.
xcodeVersion: 16.0. - Bundle id default
com.railsfast.ios, device family1,2(iPhone + iPad).
The 1.3.0-beta pin is not cosmetic. Several APIs in this template are version-specific: hidesTabBarWhenPushed is the plural spelling at this tag (main has used a different one), HotwireTabBarController exposes activeNavigator and load(_:) but not navigator(for:), and HotwireTab has no id. Bumping the pin can silently break the shell. If you upgrade, re-audit AppDelegate, RailsFastTabBarController, and Tabs.swift against the new API.
Release Versioning
RailsFast iOS stores version/build settings in project.yml:
CURRENT_PROJECT_VERSION: 1
MARKETING_VERSION: "1.0.0"
Info.plist then derives:
CFBundleShortVersionString = $(MARKETING_VERSION)CFBundleVersion = $(CURRENT_PROJECT_VERSION)
Apple treats CFBundleShortVersionString as the user-facing release version and
CFBundleVersion as the uploaded build iteration. The template keeps those
values in project.yml because XcodeGen regenerates RailsFast.xcodeproj from
that YAML. Do not edit Version or Build only in Xcode on the signing Mac:
those edits live in the generated project and are easy to lose.
Use the repo helper before archiving:
# Normal new public release: 1.0.0 -> 1.0.1, build 1 -> 2
ruby scripts/ios_release_version
# Another TestFlight build for the same public version: build only
ruby scripts/ios_release_version --bump none
# Exact sync after an emergency manual edit
ruby scripts/ios_release_version --version 1.0.1 --build 2
The helper updates project.yml, runs xcodegen generate, and leaves both
project.yml and RailsFast.xcodeproj/project.pbxproj ready to commit and
push. The signing Mac should then pull the committed version before archiving.
Why not Apple's agvtool directly? agvtool is built for projects where
project.pbxproj is the source of truth. It searches the Xcode project for
CURRENT_PROJECT_VERSION and writes version values from there, while RailsFast
iOS intentionally treats project.yml as the source of truth.
If you later move signing and upload to CI, the network-aware versioning path is
App Store Connect or fastlane. Apple exposes uploaded builds through the App
Store Connect API, and fastlane's latest_testflight_build_number can derive
the next build number from already-uploaded TestFlight builds. RailsFast does
not add that dependency by default because deterministic source-controlled
bumps are enough for local or remote-Mac signing.
The Main Files
App bootstrap
RailsFast/App/AppDelegate.swiftRailsFast/App/SceneDelegate.swiftRailsFast/Core/AppConfiguration.swiftRailsFast/Resources/Info.plistRailsFast/RailsFast.entitlementsproject.yml
AppDelegate.didFinishLaunching does two things, in order:
configureAppearance()β nav bar and tab bar appearanceconfigureHotwire()β path config, user agent, custom web view, bridge components, route handlers
Keep that order. Appearance is applied first so the chrome is correct before the first navigation. The window and root view controller are not created here β that happens in SceneDelegate.scene(_:willConnectTo:), which is where the actual root shell gets set.
configureHotwire() is the single registration point. It should stay boring and explicit. Important defaults:
Hotwire.config.pathConfiguration.matchQueryStrings = false- bundled path configuration first, then the remote Rails config
- tests use only the bundled path configuration (deterministic)
Hotwire.config.applicationUserAgentPrefix = "RailsFast iOS; RailsFast Native iOS;"Hotwire.config.defaultViewController = { WebViewController(url: $0) }Hotwire.config.makeCustomWebView = { RailsFastWebView(...) }Hotwire.config.hidesTabBarWhenPushed = true
Register bridge components and route handlers before any navigation starts.
User Agent
AppConfiguration.userAgentPrefix builds:
{CFBundleDisplayName} iOS; RailsFast Native iOS;
Hotwire Native appends the rest:
Hotwire Native iOS; Turbo Native iOS; bridge-components: [...]
That keeps the downstream app name, the RailsFast template identity, the Hotwire framework identity, and the registered bridge capabilities in one user agent without duplicating framework strings. This mirrors the Android layering exactly, so Rails' hotwire_native_app? and railsfast_native_app? detection behaves identically on both platforms.
RailsFast Base gates bridge markup by the advertised bridge-components segment, not by platform. iOS only receives a toast/menu/overflow tag when it advertises that component in its UA. See RailsFast Base for Native for the rule: gate bridge output by component support, not by platform.
The iOS Architecture
The iOS starter is a thin UIKit shell over Hotwire Native with two roots that swap on window.rootViewController:
- a signed-out / onboarding
Navigator(configuration nameauth, starting at/native/entry) - a signed-in
RailsFastTabBarController(aHotwireTabBarControllersubclass)
SceneDelegate starts in the auth navigator. Rails then publishes shell state through the native-shell bridge component, using the same three-state vocabulary as Android and Rails:
authβ signed out, no tabsonboardingβ signed in but setup runs without tabstabsβ signed in, native tab shell
When Rails sends tabs, iOS swaps the window root to RailsFastTabBarController and loads the current tab catalog. The auth navigator is recreated fresh on each transition back to .auth / .onboarding for a clean slate β this mirrors Android's finish() + startActivity(AuthActivity) root swap.
Cookies survive the root swap because Hotwire's web views share the default WKWebsiteDataStore and process pool β not because the Navigator instance persists. The session is the same shared Devise cookie the web app uses.
This is the same two-shell model as Android. Auth, welcome, and onboarding do not live inside the signed-in tab bar β that is deliberate, and it avoids the hybrid-app smell where sign-in screens look trapped under app tabs.
The two-shell auth/tabs split is a RailsFast addition, not an official Hotwire Native pattern. The official iOS demo uses a single HotwireTabBarController root with no auth shell.
Tab routing has one extra iOS-specific guard. The framework's HotwireTabBarController.route(_:) only ever routes through the currently selected navigator, so RailsFast iOS selects the owning tab first β and then must not race that tab's root load. The shell tracks, per tab root, whether the root's cold boot is still in flight, no matter who started it: the framework starting the selected tab at launch, a user tapping a cold tab, or shell routing itself. While a root is booting, any target other than the exact root URL already loading waits for requestDidFinish(at:) before routing β child routes and query-bearing roots such as /settings?return_to=/dashboard alike β and a newer route into the same booting tab replaces the waiting one rather than cancelling the root load. The finished signal fires for failed roots too, so a deep link is delivered (or shows the standard error screen) instead of being dropped. Tabs with a fully native root, like Account's /native/me, never issue a web request for their root, so nothing waits on them: routes go out immediately, which is equally race-free. Net effect: queries survive cold starts, and there are never two overlapping cold WKWebView.load() calls on one navigator.
Key files:
RailsFast/App/SceneDelegate.swiftRailsFast/Bridge/NativeShellComponent.swiftRailsFast/Core/ShellState.swiftRailsFast/Shell/RailsFastTabBarController.swift
Shell Handoff Rules
The native-shell bridge is page-scoped, but shell changes are scene/root-controller state. So the bridge component posts a NotificationCenter event instead of reaching directly into SceneDelegate. That keeps the component decoupled from UIKit scene plumbing.
Two details are load-bearing:
- Shell transition notifications are dispatched on the next main-queue turn (
DispatchQueue.main.async), not synchronously. - Modal bridge messages are ignored unless Rails marks the message as a handoff (
handoff == true || !isModalPresentation).
The async boundary gives sibling bridge components in the same HTML document a chance to fire before the root controller swaps. This matters for login success: the handoff document carries both a toast message and a native-shell message, and you want the verified-login toast to fire before the root swap deactivates the destination. This mirrors Android's runOnUiThread async boundary.
The modal guard prevents a background modal page from reshaping the root shell just because its bridge connects while the presenting tab should stay in control.
Removing the async boundary loses the auth-handoff toast. Do not make the notification synchronous.
The Four Bridge Components
iOS registers exactly four bridge components, in this order: NativeShellComponent, ToastComponent, MenuComponent, OverflowMenuComponent. Each one has a web BridgeComponent controller in RailsFast Base and an Android counterpart β the names, event names, and JSON payloads must match across all three.
| Component | Web controller | Event in | Payload in | Reply |
|---|---|---|---|---|
native-shell |
railsfast--native--shell |
connect |
{state, handoff?, location?} |
β |
toast |
railsfast--native--toast |
show |
{message, severity} |
β |
menu |
railsfast--native--menu |
display |
{title, items: [{title, index}], source} |
{selectedIndex} |
overflow-menu |
railsfast--native--overflow-menu |
connect |
{label} |
connect (on tap) |
severityis one ofsuccess | error | warning | info.stateis one ofauth | onboarding | tabs.
Bridge components are for progressive native enhancement of a mostly-web screen β a native menu, a native toast, a native share affordance. They are not for root tabs. Tabs are shell infrastructure, not a bridge concern.
Toasts
RailsFast uses bridge-driven native toasts for nonblocking Rails flash feedback. The toast component receives show with a {message, severity} data object β message and severity are fields of the message data, not siblings of an event key.
iOS has no first-party UIKit toast control, so RailsFast renders a custom UIKit material capsule with:
- an SF Symbol severity icon
- an adaptive blur material background
- haptic feedback (success / warning / error notification haptics; a light impact for info)
- a VoiceOver announcement
- two-line support for validation messages
- swipe-down dismissal, plus auto-dismiss
Presentation rules:
- normal visible pages render the toast in their source destination
/native/handofflogin-success toasts render on the active foreground window after a short delay, so the verified-login toast survives the auth β tabs root swap/native/entrylogout transitions intentionally drop stale detached toasts
Why not a global SwiftUI toast root? The iOS shell is UIKit + Hotwire Native with an iOS 14 deployment target. A SwiftUI overlay root would only fully pay off on iOS 26+ and would wrap extra state around the navigator. Keeping toast ownership in the bridge component is simpler and aligned with the Hotwire page lifecycle.
Menu and overflow menu (iOS 26 subtlety)
These two components are small, but the iOS 26 details are where the work is.
The overflow menu creates an image-only bar button (UIImage(systemName: "ellipsis.circle"), no title):
// RailsFast/Bridge/OverflowMenuComponent.swift
let button = UIBarButtonItem(image: UIImage(systemName: "ellipsis.circle"), primaryAction: action)
button.accessibilityLabel = data.label
if #available(iOS 16.0, *) {
viewController.navigationItem.trailingItemGroups = [
UIBarButtonItemGroup(barButtonItems: [button], representativeItem: nil)
]
} else {
viewController.navigationItem.rightBarButtonItem = button
}
The overflow button must be image-only. On iOS 26 Liquid Glass, a bar button with a title is classified as a text button, gets its own glass background, and renders as a detached pill on the leading side. Image-only items group naturally on the trailing edge. It is also placed via trailingItemGroups on iOS 16+ rather than rightBarButtonItem, which is more reliable across iOS 26 glass layouts.
The menu component builds a UIAlertController(.actionSheet) and anchors its popover to the native bar button, not the web source rect:
On iOS 26, action sheets present as popovers even on iPhone. The overflow menu's web source rect points at the hidden HTML trigger, whose coordinates do not match the native ellipsis button β so anchoring to it pins the popover to the wrong side of the screen. MenuComponent prefers navigationItem.trailingItemGroups.first?.barButtonItems.first (iOS 16+), falls back to rightBarButtonItem, then to the source rect, then to view.bounds with no arrow.
The web stays the source of truth for what each menu item does β iOS replies with the server-provided selectedIndex.
Key files:
RailsFast/Bridge/NativeShellComponent.swiftRailsFast/Bridge/ToastComponent.swiftRailsFast/Bridge/MenuComponent.swiftRailsFast/Bridge/OverflowMenuComponent.swift
Route Decision Handlers
Route handlers are registered in this order β first match wins:
// RailsFast/App/AppDelegate.swift
Hotwire.registerRouteDecisionHandlers([
TabRouteDecisionHandler(), // custom β cross-tab routing
AppNavigationRouteDecisionHandler(), // stock β in-tab pushes
SafariViewControllerRouteDecisionHandler(), // stock β external links in Safari
SystemNavigationRouteDecisionHandler() // stock β mailto:, tel:, etc.
])
registerRouteDecisionHandlers([...]) replaces the entire default handler list β it does not append. You MUST re-list all three stock handlers (AppNavigation, SafariViewController, SystemNavigation) after your custom one, or Safari links, system navigation, and normal in-tab pushes silently break. The custom TabRouteDecisionHandler must come first so cross-tab routing happens before the generic same-host handler says "navigate in the current navigator."
Native Tabs And Cross-Tab Routing
RailsFast iOS uses a custom RailsFastTabBarController on top of Hotwire's HotwireTabBarController.
Hotwire's stock tab controller routes through the currently selected tab's active navigator. RailsFast needs Android-equivalent ownership routing: before routing, the shell selects the tab that owns the URL.
TabCatalog is the native model for the Rails settings.shell.tabs contract. The default RailsFast tabs are:
homestarts at/dashboardaccountstarts at/native/mesettingsstarts at/settings
The Settings tab owns a broader route family, not just its start URL:
^/settings(?:/.*)?$^/organizations(?:/.*)?$^/memberships(?:/.*)?$^/invitations(?:/.*)?$^/billing(?:/.*)?$
Tabs are not owned only by their start URL. They are owned by route families.
TabCatalog accepts server-defined tabs only when they are safe for iOS: known RailsFast keys reuse native title/icon defaults, while unknown keys need an explicit title and a valid SF Symbol name (otherwise they are skipped with a warning). That keeps downstream apps flexible without letting arbitrary remote strings crash the native tab bar.
Cross-tab routing lives in TabRouteDecisionHandler + TabRoutingCoordinator. It matches only when the source navigator's tab differs from the tab that owns the target URL. Unclaimed routes are tab-agnostic: TabCatalog.tab(for:) returns nil, the custom handler does not match, and Hotwire's stock handler pushes the URL onto the current tab.
tab(for:) returning nil means "stack on the current tab", not "use the default tab". Only tabOrDefault(for:) falls back to the default tab (used by cold launch). Mixing these up silently reassigns unclaimed URLs to Home. This mirrors Android's nullable MainTabs.Catalog.tabFor behavior exactly.
Key files:
RailsFast/Shell/Tabs.swiftRailsFast/Shell/RailsFastTabBarController.swiftRailsFast/Shell/TabRoutingCoordinator.swift
Path Configuration
Rails is the source of truth for route policy. iOS loads path configuration from two sources, in order:
- bundled fallback:
RailsFast/Resources/path-configuration.json - Rails server:
/native/configurations/ios/v1.json
The bundled file is a safety net, not a second product contract. Keep it aligned with the Rails server config and with the Android catalog.
matchQueryStrings = false is intentional. Path-config rules and tab ownership are path-family rules, so /native/me?source=settings resolves to the same rule and the same native screen as /native/me. Query strings are preserved on the URL but never change which rule, tab, or screen applies.
The iOS rules use platform-idiomatic presentation properties (view_controller, modal_style, presentation) that Android does not β Android uses uri deeplinks instead. The shared part is settings.shell (the tab catalog); the rules[] arrays differ by platform. Do not copy iOS modal_style into Android.
Auth sheet detents (iOS-only)
/users/sign_upopens as amodalwithmodal_style: large(full-height detent). The signup form is taller β email, password, password confirmation, terms β and a medium detent forced users to swipe up before they could see and accept the terms./users/sign_inopens as amodalwithmodal_style: medium(half-height). It is just email + password.
Sign-up is the tall one. Android ignores modal_style entirely and opens both as one combined expanded auth sheet, so this large-vs-medium distinction is iOS-specific.
Native HTTP And The CSRF Posture
Fully native screens (like the Account tab) reuse the shared Rails session instead of inventing a second auth system. Here is how that works on iOS:
- Hotwire Native syncs
WKWebViewcookies intoHTTPCookieStorage.sharedafter every page load (Navigator.sessionDidFinishRequest). NativeHttpClientconfigures itsURLSessionwithhttpCookieStorage = .shared, so its requests automatically carry the Rails session cookie.- It sends
Accept: application/jsonand decodes typed responses.
NativeHttpClient is GET-only by design. URLSession does not carry Rails' X-CSRF-Token from the WebView DOM, so native writes would fail CSRF checks. Cookie sync solves authentication continuity, not CSRF. Adding native write endpoints requires an explicit server contract β the blessed pattern is skip_forgery_protection scoped to JSON for authenticated native-only endpoints. See RailsFast Base for Native.
Key file: RailsFast/Core/NativeHttpClient.swift
Session Recovery: 401 β Auth Shell
When a Rails session expires, web and native should converge back on /native/entry rather than getting stuck.
SceneDelegate.visitableDidFailRequesttreatsTurboError.http(401)as "the session is gone" and callstransitionToShell(.auth)instead of recovering in-stack.NativeMeViewModelseparately maps a401JSON response to its.unauthorizedstate and invokesonUnauthorized, whichSceneDelegatewires totransitionToShell(.auth).
Both paths rebuild the signed-out shell so the user lands cleanly on the welcome flow.
Key file: RailsFast/App/SceneDelegate.swift
Add A Native Screen
The reference recipe is the Account tab (Features/Me), selected by the Rails path-config view_controller: "me" rule. Copy this four-part split instead of putting networking or route parsing inside SwiftUI views:
NativeMeViewControllerβ aUIViewControllerconforming toPathConfigurationIdentifiable, hosting aUIHostingController<NativeMeView>pinned to all edges. This is the Hotwire destination.NativeMeViewModelβ a@MainActor ObservableObjectowning theloading / loaded / unauthorized / errorstate machine, dedup, retry, and the fetch throughNativeHttpClient.NativeMeViewβ pure SwiftUI presentation, no networking.NativeMePayloadβ theDecodableshape (with a flexible decoder forcredits, since Rails may serialize counters as numbers or strings).
Keep the canonical Rails URL as the screen identity.
To wire a new screen: add the view controller, give it a pathConfigurationIdentifier, map it in SceneDelegate.handle(proposal:), and add a view_controller rule in the Rails path configuration.
The Custom Web View
RailsFast installs RailsFastWebView through Hotwire.config.makeCustomWebView. Use that hook instead of forking Hotwire Native.
Its configure() currently owns:
backgroundColor = .systemBackgroundandisOpaque = falseβ together these let the system background show through, which is what keeps the chrome consistent during loads and transitions- disabling the horizontal scroll indicator and horizontal bounce
isInspectable = trueunder DEBUG (iOS 16.4+) to preserve Safari Web Inspector after replacing Hotwire's default factory- removing the iPhone HTML form accessory bar (the prev/next/done toolbar)
The form-accessory removal dynamically subclasses WebKit's private content view to override inputAccessoryView, because there is no public WKWebView switch for it. Keep that workaround centralized and easy to delete if WebKit ever exposes a public API.
Outer-scroll lock for fullscreen surfaces
WebViewController.locksOuterScroll(for:) disables the outer WKScrollView bounce for /native/entry, /native/auth/welcome, and any /onboarding prefix. These are fixed-chrome screens that own their layout in CSS, so the whole surface should not rubber-band away from the device edges β only the inner page body scrolls. This is part of the same "CSS owns the safe area" story as the Liquid Glass work below.
iOS 26 Liquid Glass
This is the standout work in the iOS shell, so it is worth understanding the problem before the fixes.
iOS 26 changed navigation bars and tab bars to Liquid Glass β a translucent material that samples and blurs whatever is behind it. That is beautiful for native content, but a Hotwire app paints its content with a web view, and getting glass to look right over a web view took care. RailsFast iOS handles three page families differently, all driven by a small pure value type, NativeChromePolicy, which resolves chrome from two inputs: hidesNavigationBar and supportsLiquidGlass.
Three page families
1. Fullscreen web chrome (tab roots, onboarding, /native/entry, /native/auth/welcome):
- hide the UIKit nav bar
- extend the web view to all edges
contentInsetAdjustmentBehavior = .never- let web CSS own the safe area through
env(safe-area-inset-*)
2. Visible native nav bar on iOS < 26:
- opaque native nav bar, web view does not extend under it
contentInsetAdjustmentBehavior = .automatic
3. Visible native nav bar on iOS 26+:
- let UIKit use Liquid Glass; extend the web view under the bar
contentInsetAdjustmentBehavior = .automatic- nudge the scroll offset to
-adjustedContentInset.topso content starts below the bar
Extending the web view under the glass gives it one continuous adaptive surface to sample, which avoids the two-tone artifact where the glass and the page resolve against different backing colors.
Do not force any of these on an iOS 26 navigation bar: isTranslucent = false, backgroundColor = .systemBackground, or configureWithOpaqueBackground(). All three were tried and reverted. isTranslucent = false makes bar buttons detach into floating glass pills; the other two double-layer the glass material into a gray tint. On iOS 26, let the default glass material own the bar surface.
Why the shared WKWebView makes this hard
Hotwire reuses one WKWebView per Session across visits. So scroll-view state β contentInsetAdjustmentBehavior, contentOffset β leaks between tab roots, pushed pages, and modal pages.
configureNativeWebViewChrome runs on every visitableDidActivateWebView (push and pop), and must explicitly set both .never (fullscreen) and .automatic (visible-nav). Never assume the UIKit default still holds β a previous screen may have left the opposite value on the shared scroll view.
Keep the WKWebView opaque
RailsFast paints the shared WKWebView with .systemBackground so cold loads,
overscroll, dark mode, and Liquid Glass all see the same adaptive surface. Keep
the WKWebView itself opaque while doing so.
This is a correctness contract, not just a drawing optimization. A standalone, dependency-free iOS 26.3.1 reduction naturally reproduced an intermittent native-content collapse only when a compact-sheet WKWebView combined these two public appearance settings:
webView.isOpaque = false
webView.backgroundColor = .systemBackground
The DOM, WKWebView.bounds, and every public zoom value remained at the correct
device width / 1.0, while the visible document and
scrollView.contentSize.width fell to approximately deviceWidth / 980
(402 -> 164.6667, or 390 -> 155). Either appearance property alone completed
100-cycle controls without a qualifying collapse. The reduced case needs no
Hotwire, Rails, network request, navigation controller, detent change, form, or
representative app CSS.
RailsFast therefore keeps .systemBackground and explicitly sets
isOpaque = true. Removing only the initial background assignment is not a
valid fix: configureNativeWebViewChrome reapplies the route's adaptive backing
on every activation. Keeping the view opaque preserves all intended visuals and
removes the known trigger.
The shell still retains its public-API recovery guard. Prevention cannot update
already-installed binaries, and a future WebKit version may reach the same
inconsistent postcondition through another path. For an attached, loaded,
non-zoomable document whose native content width is below 80% of its viewport,
RailsFastWebView uses a visually indistinguishable
setZoomScale(1.000001) nudge under a temporary 1.01 maximum, restores the
original zoom contract and logical scroll position, and never reloads or mutates
the DOM. Checks cover render, bounds settling, window attachment, appearance,
and app resume.
The natural stress loop intentionally lives outside product CI; RailsFast's unit suite injects the captured public/native postcondition deterministically to test recovery without making ordinary builds flaky.
Exact sample, screenshots, property controls, and raw public-API JSONL evidence:
- https://github.com/rameerez/WKWebViewSheetViewportRepro/tree/dc68650bacffc75c9d07f4ef3c18cc30c0333333
- https://github.com/rameerez/WKWebViewSheetViewportRepro/blob/dc68650bacffc75c9d07f4ef3c18cc30c0333333/evidence/README.md#property-level-controls
The exact upstream report is listed first, followed by neighboring legacy-viewport and resize states that do not describe this precise healthy-DOM/native-only divergence:
- Exact reduced report: https://bugs.webkit.org/show_bug.cgi?id=319180
- https://bugs.webkit.org/show_bug.cgi?id=262207
- https://bugs.webkit.org/show_bug.cgi?id=170595
- https://bugs.webkit.org/show_bug.cgi?id=267545
- https://bugs.webkit.org/show_bug.cgi?id=191872
WebKit's animated-resize implementation is a plausible source-level mechanism, not a proven root causeβthe reduced case needs no detent transition after presentation: https://github.com/WebKit/WebKit/blob/3489b26dd844b8115f10e23f7d194fcd9055d6e2/Source/WebKit/UIProcess/API/ios/WKWebViewIOS.mm#L3417-L3435
The cold-modal first-paint bug
This one was subtle enough to keep documented.
- Symptom: the first open of
/users/sign_upor/users/sign_inpainted web content behind the native nav bar. Second and later opens were fine. Pushed screens like/settingswere always fine. - Root cause: a modal
Navigatorhas its own modal Session. On first open that session cold-boots a newWKWebView(aColdBootVisit). The pre-render nudge correctly set the offset, then the cold-page render resetcontentOffset.yback to0. On second open the modal web view already holds the page, so activation has real size and the early nudge sticks β which is why the bug was first-open-only. - The fix that worked: keep the normal lifecycle nudge for pushed/cached screens, then add a second render-phase nudge in
visitableDidRender(). That is the first native hook after the cold-modal render reset, so it repairs without observers, timers, forced layout, or page JS. It only moves the scroll view when it is still effectively at the native top (contentOffset.y <= 1β an inclusive 1pt tolerance for fractional offsets during the sheet animation); real Turbo scroll restoration (offset > 1) is left alone.
The nudge cascade runs across visitableDidActivateWebView β viewDidLayoutSubviews β viewDidAppear, plus the visitableDidRender render-phase correction. adjustedContentInset.top is still 0 during visitableDidActivateWebView (view not laid out yet), so the nudge cannot happen there alone. A forced-layout / KVO experiment regressed modal geometry β do not force layout during the sheet animation.
The iOS < 26 dock gap
Fullscreen surfaces own their bottom safe area in CSS (env(safe-area-inset-bottom), position: fixed; bottom: 0). On iOS < 26 the default .automatic adds a bottom safe-area inset, so the CSS viewport ends up inset and bottom: 0 lands at the safe-area edge, not the device edge β and env() returns 0 on the very first paint before viewport-fit=cover settles, so the page's compensation produces a visible gap above the home indicator.
The fix is order-dependent: fullscreen surfaces are checked first, regardless of iOS version, and forced to .never. This was previously gated inside #available(iOS 26.0, *) and never ran on iOS 18, which caused the onboarding dock gap. Because the WebView is shared, the visible-nav iOS < 26 branch must also restore .automatic so .never does not leak into the next page.
Key file: RailsFast/Shell/WebViewController.swift
Deep Links
RailsFast iOS supports two deep-link classes:
- Custom scheme (
railsfast://) for development and automation β no AASA needed, registered inInfo.plist. - Universal Links (production HTTPS) β declared via the Associated Domains entitlement.
SceneDelegate canonicalizes both forms into internal app URLs (relative paths become absolute, localhost aliases are normalized in dev, external hosts are rejected, query strings are preserved) and then routes through RailsFastTabBarController.routeDeepLink β or stashes the link as a pending deep link and replays it once the shell reaches .tabs.
Test the custom scheme on a booted simulator:
xcrun simctl openurl booted "railsfast:///dashboard?deeplink_smoke=1"
xcrun simctl openurl booted "railsfast:///settings?return_to=/dashboard"
xcrun simctl openurl booted "railsfast:///native/me?source=settings"
For tab-root links, expect: the correct tab selected, the tab bar visible, the query string preserved, and no duplicate pushed root page.
A tab-root deep link with a query string must not be treated like a normal push. A normal .advance visit to /dashboard?x pushes a duplicate /dashboard above the real tab root, and because hidesTabBarWhenPushed is on, that hides the native tab bar. The fix: detect tab-start paths (ignoring query string), dismiss any modal, pop to root, and route with VisitOptions(action: .replace) + resetToRoot β preserving the query without a duplicate root.
Universal Links require a real HTTPS domain and a valid AASA file. Localhost can validate the custom scheme and the server payload, but not Apple's full domain trust flow.
Universal Links And AASA
Universal Links are a two-sided trust relationship:
- The iOS app declares an Associated Domains entitlement, e.g.
applinks:yourdomain.com. - Rails serves an Apple App Site Association file listing the app ID and the claimed paths.
On the iOS side, the entitlement uses the $(RAILSFAST_ASSOCIATED_DOMAIN) build variable, which defaults to the placeholder example.com. Replace it with your real production host before shipping.
RAILSFAST_ASSOCIATED_DOMAIN ships as the placeholder example.com. It is a value you must replace, not a host that does anything. Set it to your real domain in project.yml and run xcodegen generate.
The Rails side serves /.well-known/apple-app-site-association (and a byte-identical alias at /apple-app-site-association). The path list is server-owned β it lives in config/railsfast/railsfast.yml under native.ios.app_link_paths. With no Team ID / bundle / paths configured, the endpoint fails closed and returns a valid non-claiming payload, so the template never publishes a fake Apple app claim.
Keep path lists explicit. Do not claim /* unless the native app should open every product, marketing, legal, support, and auth URL on the domain. The endpoint must stay public, cacheable JSON with a no-transform cache directive β a CDN that rewrites the body breaks verification. See RailsFast Base for Native for the full app-link contract.
Testing The iOS Template
RailsFastTests/AppConfigurationTests.swift is the single test file, and it encodes the whole contract. If you add a behavior worth protecting, add an assertion here. It covers:
- base URL stays template-generic; user-agent identity; custom scheme registered
- entitlement keeps
applinks:$(RAILSFAST_ASSOCIATED_DOMAIN)and theexample.comdefault - URL canonicalization (localhost aliases, IPv6
::1, rejecting wrong port / scheme / external / protocol-relative) jsonURLbehavior; default + remote tab catalog parsing;matchQueryStrings = false- the bundled path-config contract (account tab,
meview controller, onboarding replace + hide nav bar) NativeMePayloadflexible credits decoder- every toast
PresentationStrategybranch and its delays - the full
NativeChromePolicymatrix and the glass nudge decision tables (including the inclusive 1pt top tolerance) - the opaque adaptive WKWebView prevention contract plus deterministic collapsed-content detection/recovery, including detached-session rejection
Run it:
cd ~/GitHub/railsfast-ios
xcodebuild test -project RailsFast.xcodeproj -scheme RailsFast -destination 'platform=iOS Simulator,name=iPhone 16 Pro'
When investigating layout bugs, run on both an iOS 18 and an iOS 26 simulator. They exercise different nav bar and safe-area paths, and the Liquid Glass code is injected with a supportsLiquidGlass flag so the policy can be tested both ways on one simulator.
What To Customize In iOS
Change app identity
Edit project.yml, then xcodegen generate. Use it for: app display name, bundle ID, base URL, custom URL scheme, associated domain, navigation title font hook.
Change the app icon and launch screen
Use App Icons and Launch Screens for the
complete asset-generation and cold-launch verification workflow. iOS derives
installed icon sizes from one opaque 1024x1024 source, while the static
UILaunchScreen and runtime StartupLoadingView must share the same
LaunchBackground and optional LaunchLogo assets to avoid a white WebView
interval.
Change native navigation title fonts
Native nav titles are UIKit text, not Rails HTML β CSS or Google Fonts loaded by the Rails app do not touch UINavigationBar, native tab labels, or SwiftUI-native screens. RailsFast iOS has one narrow hook in AppDelegate (BrandAppearance.navigationTitleFontName) that reads NAVIGATION_TITLE_FONT_NAME from Info.plist (a PostScript name), falling back to the system font if blank or unresolved.
To add a title font: add the .ttf/.otf files to a resource folder, register them under UIAppFonts in Info.plist, then set NAVIGATION_TITLE_FONT_NAME to the font's PostScript name.
# project.yml
settings:
base:
RAILSFAST_NAVIGATION_TITLE_FONT_NAME: Inter-SemiBold
info:
properties:
NAVIGATION_TITLE_FONT_NAME: "$(RAILSFAST_NAVIGATION_TITLE_FONT_NAME)"
UIAppFonts:
- Fonts/Inter-SemiBold.ttf
Use UIFont.familyNames / UIFont.fontNames(forFamilyName:) to confirm the real PostScript name β it is not the file name, and UIFont(name:size:) needs the internal name. For SwiftUI-native screens, add a shared SwiftUI font/theme layer rather than styling each screen ad hoc.
Change supported native tabs
Edit RailsFast/Shell/Tabs.swift, the bundled RailsFast/Resources/path-configuration.json, and the Rails path configuration in RailsFast Base. The server can hide, show, and route supported tabs, but the iOS binary still needs a safe native representation (title + SF Symbol) for every supported tab.
Change shell behavior
Edit SceneDelegate.swift / NativeShellComponent.swift β only when the actual shell architecture changes.
Change page chrome or visual polish
Edit WebViewController.swift / AppDelegate.swift for nav bar behavior, Liquid Glass handling, safe-area behavior, and modal/pushed page chrome.
When To Put Something In iOS Instead Of Rails
Ask the same question as the rest of the native docs:
Is this a product rule or a native container detail?
Put it in iOS when it is a true container concern: scene/window setup, view controllers, the tab bar, iOS-only transitions, glass/safe-area chrome, native UI affordances.
Keep it in RailsFast Base when it is really a route-ownership rule, a tab-policy rule, an onboarding rule, or a billing/pricing policy. Those belong on the server so they can change without an App Store review.
Production Readiness Checklist
Before shipping a downstream iOS app:
- Set a real bundle ID, Apple Team ID, and associated domain.
- Bump version/build with
ruby scripts/ios_release_version, commit the regenerated Xcode project, and pull that commit on the signing Mac before archiving. - Keep
RAILSFAST_ASSOCIATED_DOMAINaligned with the production host. - Configure Rails AASA app IDs and paths in
config/railsfast/railsfast.yml. - Verify
/.well-known/apple-app-site-associationover HTTPS with no redirects or HTML. - Verify the custom scheme still works in development.
- Verify Universal Links on a simulator or real device.
- Test login, logout, onboarding, tabs, pushed pages, modal sheets, and app relaunch.
- Test iOS 18 and iOS 26 if the deployment target spans both visual systems.
Related Docs
- Native Overview
- RailsFast Base for Native
- RailsFast Android
- Customization Workflow
- App Icons and Launch Screens
- Notifications
References
- Apple Info.plist version keys: https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
- Apple
agvtoolversioning reference: https://developer.apple.com/library/archive/qa/qa1827/_index.html - App Store Connect API builds: https://developer.apple.com/documentation/appstoreconnectapi/builds
- fastlane latest TestFlight build number: https://docs.fastlane.tools/actions/latest_testflight_build_number/
- XcodeGen project spec: https://yonaskolb.github.io/XcodeGen/Docs/ProjectSpec.html