Offline mode for Hotwire Native apps
How a small service worker unlocks offline mode for your Rails-powered mobile app.
Way back in 2022 I asked if Turbo Native supports offline mode or caching on GitHub.
Jay, the mobile team lead at 37signals, responded with an overview of how they implemented caching for HEY. It involved spinning up a proxy server inside the iOS app. Sadly, “this was a pretty large effort of the team and a substantial amount of work” and was never abstracted.
My answer to “does Hotwire Native support offline mode” has been a solid “NO” since then. But recently there has been a lot of activity on the issue. And a more recent discussion on the Hotwire Native repo.
Rosa already has an open PR (and a Rails World talk!) to add cached-on-visit support to Turbo.js. The change introduces a lightweight offline bundle that takes care of service worker registration, Hotwire Native integration, and caching rules.
This will offer a rules API to configure what and how things should be cached:
TurboOffline.addRule({
match: /\/topics\/\d+/,
handler: Turbo.handlers.networkFirst({
cacheName: "topics",
maxAge: 60 * 60 * 24 * 7,
networkTimeout: 3
})
})
TurboOffline.start()
And once merged, adding offline support to Hotwire Native apps will be as quick as a single line of configuration:
Turbo.offline.start("/service-worker.js", {
scope: "/",
type: "module",
native: true
})
But I’m impatient. And I want cached-on visit support in my Hotwire Native app now! So I spent a few days experimenting. And it turns out, we can do it.
Here’s how you can add basic offline caching to your Hotwire Native app today.
Add a service worker
First up is adding a service worker to our Rails application. We’ll use this to intercept tapped links and provide cached content when the user is offline.
Start by adding the route to config/routes.rb
:
Rails.application.routes.draw do
get "service-worker" => "rails/pwa#service_worker"
end
Then add a basic service worker at app/views/pwa/service-worker.js
:
self.addEventListener("fetch", (event) => {
event.respondWith(
caches.open("pages").then(async (cache) => {
const cached = await cache.match(event.request)
if (cached) return cached
try {
const response = await fetch(event.request)
if (event.request.method === "GET" && response.ok) {
cache.put(event.request, response.clone())
}
return response
} catch {
return cached
}
})
)
})
Every time a network request is made from the browser this function fires. If the page is available in the cache it is returned to the browser to render. If the page hasn’t been cached yet, the response is cached for future use.
A heads up that this is a very naive implementation of offline caching. This cache will never expire, meaning that assets and even some pages can become stale quickly. Make sure to update your code if you plan on using this outside of a proof-of-concept.
Wrap up the Rails code by registering the service worker at the bottom of app/javascript/application.js
:
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/service-worker.js")
})
}
Up next we’ll configure our Hotwire Native iOS app to work with the service worker.
Enable service worker support on iOS
iOS blocks service workers in WKWebView
by default. To allow access we need to configure the web view with limitsNavigationsToAppBoundDomains set to true
.
In Hotwire Native, this can be done with a Hotwire.config
helper:
Hotwire.config.makeCustomWebView = { config in
config.limitsNavigationsToAppBoundDomains = true
return WKWebView(frame: .zero, configuration: config)
}
But when this flag is enabled then WKWebView
will block all requests to domains that aren’t explicitly defined. Ugh… Luckily the fix is quick!
Open Info.plist
and add the WKAppBoundDomains
key as an array. Add localhost
and any other domain your app will access hosting a service worker. For example, your staging and production URLs:
<key>WKAppBoundDomains</key>
<array>
<string>localhost</string>
<string>example.com</string>
</array>
And that’s it! Your Hotwire Native iOS app now has cached-on visit support!
What about Android?
Android is both easier and harder in a sense. By default, Android already works with service workers! …but only for SSL requests. So if you’re running the app locally you’ll need to proxy your localhost
requests through ngrok or similar.