Merge pull request #2582 from basecamp/offline-mode

Offline mode for visited/loaded resources
This commit is contained in:
Rosa Gutierrez
2026-03-10 10:18:22 +00:00
committed by GitHub
13 changed files with 141 additions and 54 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ gem "rails", github: "rails/rails", branch: "main"
gem "importmap-rails"
gem "propshaft"
gem "stimulus-rails"
gem "turbo-rails"
gem "turbo-rails", github: "hotwired/turbo-rails", branch: "offline-cache"
# Deployment and drivers
gem "bootsnap", require: false
+10 -4
View File
@@ -11,6 +11,15 @@ GIT
specs:
useragent (0.16.11)
GIT
remote: https://github.com/hotwired/turbo-rails.git
revision: 93ce5bc461cd71af1797e79fcd7e09a19242d080
branch: offline-cache
specs:
turbo-rails (2.0.23)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
GIT
remote: https://github.com/rails/rails.git
revision: 12e24eaf2f0a9613e015653f013dd131317d9bf5
@@ -461,9 +470,6 @@ GEM
trilogy (2.10.0)
bigdecimal
tsort (0.2.0)
turbo-rails (2.0.23)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (3.2.0)
@@ -539,7 +545,7 @@ DEPENDENCIES
stimulus-rails
thruster
trilogy (~> 2.10)
turbo-rails
turbo-rails!
useragent!
vcr
web-console!
+10 -4
View File
@@ -60,6 +60,15 @@ GIT
rails (>= 6.1)
yabeda (~> 0.6)
GIT
remote: https://github.com/hotwired/turbo-rails.git
revision: 93ce5bc461cd71af1797e79fcd7e09a19242d080
branch: offline-cache
specs:
turbo-rails (2.0.23)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
GIT
remote: https://github.com/rails/rails.git
revision: 12e24eaf2f0a9613e015653f013dd131317d9bf5
@@ -633,9 +642,6 @@ GEM
trilogy (2.10.0)
bigdecimal
tsort (0.2.0)
turbo-rails (2.0.23)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (3.2.0)
@@ -752,7 +758,7 @@ DEPENDENCIES
stripe (~> 18.0)
thruster
trilogy (~> 2.10)
turbo-rails
turbo-rails!
useragent!
vcr
web-console!
@@ -0,0 +1,8 @@
import { Controller } from "@hotwired/stimulus"
import { Turbo } from "@hotwired/turbo-rails"
export default class extends Controller {
clearCache() {
Turbo.offline.clearCache()
}
}
@@ -15,7 +15,7 @@ export default class extends Controller {
submit() {
if (this.inputTarget.disabled) return
this.element.submit()
this.element.requestSubmit()
this.inputTarget.disabled = true
}
}
+1
View File
@@ -1,2 +1,3 @@
import "initializers/current"
import "initializers/bridge/bridge_element"
import "initializers/offline"
+9
View File
@@ -0,0 +1,9 @@
import { Turbo } from "@hotwired/turbo-rails"
if (Current.user) {
Turbo.offline.start("/service-worker.js", {
scope: "/",
native: true,
preload: /\/assets\//
})
}
+1 -1
View File
@@ -6,7 +6,7 @@
<%= tag.li class: "popup__item", data: { filter_target: "item", navigable_list_target: "item" } do %>
<%= icon_tag "logout", class: "popup__icon" %>
<%= button_to session_path(script_name: nil), method: :delete, class: "popup__btn btn", data: { turbo: false } do %>
<%= button_to session_path(script_name: nil), method: :delete, class: "popup__btn btn", form: { data: { turbo: false, controller: "clear-offline-cache", action: "submit->clear-offline-cache#clearCache" } } do %>
<span>Sign out</span>
<% end %>
<% end %>
-41
View File
@@ -1,41 +0,0 @@
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return
if (event.request.destination === 'document') {
event.respondWith(
fetch(event.request, { cache: 'no-cache' })
.catch(() => caches.match(event.request))
)
}
})
self.addEventListener("push", (event) => {
const data = event.data.json()
event.waitUntil(Promise.all([ showNotification(data), updateBadgeCount(data.options) ]))
})
async function showNotification({ title, options }) {
return self.registration.showNotification(title, options)
}
async function updateBadgeCount({ data: { badge } }) {
return self.navigator.setAppBadge?.(badge || 0)
}
self.addEventListener("notificationclick", (event) => {
event.notification.close()
const url = new URL(event.notification.data.url, self.location.origin).href
event.waitUntil(openURL(url))
})
async function openURL(url) {
const clients = await self.clients.matchAll({ type: "window" })
const focused = clients.find((client) => client.focused)
if (focused) {
await focused.navigate(url)
} else {
await self.clients.openWindow(url)
}
}
+97
View File
@@ -0,0 +1,97 @@
importScripts("<%= javascript_url("turbo-offline-umd.min") %>")
// Documents - top-level navigation requests only
// We need `cache: "no-cache"` here to work around
// an annoying Safari PWA bug that predates offline mode
TurboOffline.addRule({
match: (request) => request.destination === "document",
except: /\/(edit|pin|watch|new)$/,
handler: TurboOffline.handlers.networkFirst({
cacheName: "main",
maxAge: 60 * 60 * 24 * 3,
networkTimeout: 3,
fetchOptions: { cache: "no-cache" },
maxEntrySize: 1024 * 1024
})
})
TurboOffline.addRule({
match: /\/assets\/.+[-\.][0-9a-f]+\.(js|css|svg|png|jpg|webp|woff2?|ico)$/,
handler: TurboOffline.handlers.cacheFirst({
cacheName: "assets",
maxAge: 60 * 60 * 24 * 7,
maxEntrySize: 1024 * 1024
})
})
TurboOffline.addRule({
match: /\/(boards|cards|users)\//,
except: /\/(edit|pin|watch|new)$/,
handler: TurboOffline.handlers.networkFirst({
cacheName: "main",
maxAge: 60 * 60 * 24 * 3,
networkTimeout: 2,
maxEntrySize: 1024 * 1024
})
})
TurboOffline.addRule({
match: /\/rails\/active_storage\//,
handler: TurboOffline.handlers.networkFirst({
cacheName: "storage",
maxAge: 60 * 60 * 24 * 7,
networkTimeout: 2,
maxEntrySize: 2 * 1024 * 1024, // 2MB covers about 95% of all Fizzy blobs
maxEntries: 500,
fetchOptions: { mode: "cors" }
})
})
// Everything else
TurboOffline.addRule({
except: /\/(service-worker\.js|edit|pin|watch|new)$/,
handler: TurboOffline.handlers.networkFirst({
cacheName: "main",
maxAge: 60 * 60 * 24,
networkTimeout: 3,
maxEntrySize: 1024 * 1024
})
})
self.addEventListener("activate", (event) => {
event.waitUntil(self.clients.claim())
})
TurboOffline.start()
self.addEventListener("push", (event) => {
const data = event.data.json()
event.waitUntil(Promise.all([ showNotification(data), updateBadgeCount(data.options) ]))
})
async function showNotification({ title, options }) {
return self.registration.showNotification(title, options)
}
async function updateBadgeCount({ data: { badge } }) {
return self.navigator.setAppBadge?.(badge || 0)
}
self.addEventListener("notificationclick", (event) => {
event.notification.close()
const url = new URL(event.notification.data.url, self.location.origin).href
event.waitUntil(openURL(url))
})
async function openURL(url) {
const clients = await self.clients.matchAll({ type: "window" })
const focused = clients.find((client) => client.focused)
if (focused) {
await focused.navigate(url)
} else {
await self.clients.openWindow(url)
}
}
+1 -1
View File
@@ -8,7 +8,7 @@
</p>
</header>
<%= form_with url: session_magic_link_path, method: :post, html: { data: { controller: "magic-link" } } do |form| %>
<%= form_with url: session_magic_link_path, method: :post, html: { data: { controller: "magic-link clear-offline-cache", action: "submit->clear-offline-cache#clearCache" } } do |form| %>
<%= form.text_field :code, required: true, class: "input center txt-align-enter txt-large txt-uppercase",
autofocus: true, autocorrect: "off", autocapitalize: "off", spellcheck: "false", "data-1p-ignore": true,
autocomplete: "one-time-code", maxlength: "6", placeholder: "••••••", value: params[:code],
+1 -1
View File
@@ -41,7 +41,7 @@
<% if Current.user == @user %>
<hr class="separator--horizontal full-width flex-item-grow margin-block-start-double" style="--border-color: var(--color-ink-light);" aria-hidden="true">
<%= button_to session_path(script_name: nil), method: :delete, class: "btn txt-x-small center", data: { turbo: false } do %>
<%= button_to session_path(script_name: nil), method: :delete, class: "btn txt-x-small center", form: { data: { turbo: false, controller: "clear-offline-cache", action: "submit->clear-offline-cache#clearCache" } } do %>
<span>Sign out of Fizzy on this device</span>
<% end %>
<% end %>
+1
View File
@@ -2,6 +2,7 @@
pin "application"
pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/turbo/offline", to: "turbo-offline.min.js"
pin "@hotwired/stimulus", to: "stimulus.min.js"
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin "@hotwired/hotwire-native-bridge", to: "@hotwired--hotwire-native-bridge.js"