From 5d084a46ce58217bde19302dd1044a693c766177 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Fri, 2 Jan 2026 19:25:47 +0100 Subject: [PATCH 01/18] Switch to turbo-rails version with offline mode support --- Gemfile | 2 +- Gemfile.lock | 14 ++++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/Gemfile b/Gemfile index 17e7496c0..da66e1473 100644 --- a/Gemfile +++ b/Gemfile @@ -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 diff --git a/Gemfile.lock b/Gemfile.lock index 3f8461b39..3a35c35c7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -11,6 +11,15 @@ GIT specs: useragent (0.16.11) +GIT + remote: https://github.com/hotwired/turbo-rails.git + revision: e3795ef6aac6a3d18039751febe6a301cfe71a7b + branch: offline-cache + specs: + turbo-rails (2.0.20) + 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! From ada4688c4e0bc2a6adcdd757821d1779ed4cd570 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Mon, 5 Jan 2026 12:10:57 +0100 Subject: [PATCH 02/18] Take a first stab at offline mode support --- Gemfile.lock | 4 +- Gemfile.saas.lock | 14 +++-- app/javascript/initializers/index.js | 1 + app/javascript/initializers/offline.js | 6 ++ app/views/pwa/service_worker.js | 41 -------------- app/views/pwa/service_worker.js.erb | 76 ++++++++++++++++++++++++++ config/importmap.rb | 1 + 7 files changed, 96 insertions(+), 47 deletions(-) create mode 100644 app/javascript/initializers/offline.js delete mode 100644 app/views/pwa/service_worker.js create mode 100644 app/views/pwa/service_worker.js.erb diff --git a/Gemfile.lock b/Gemfile.lock index 3a35c35c7..a710d0f9e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -13,10 +13,10 @@ GIT GIT remote: https://github.com/hotwired/turbo-rails.git - revision: e3795ef6aac6a3d18039751febe6a301cfe71a7b + revision: 49be3f2d43edeeedad48cb492b35242fdb27af74 branch: offline-cache specs: - turbo-rails (2.0.20) + turbo-rails (2.0.21) actionpack (>= 7.1.0) railties (>= 7.1.0) diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index 152d1889c..cec5be07b 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -60,6 +60,15 @@ GIT rails (>= 6.1) yabeda (~> 0.6) +GIT + remote: https://github.com/hotwired/turbo-rails.git + revision: 49be3f2d43edeeedad48cb492b35242fdb27af74 + branch: offline-cache + specs: + turbo-rails (2.0.21) + 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! diff --git a/app/javascript/initializers/index.js b/app/javascript/initializers/index.js index 90ef36a26..1d16ef205 100644 --- a/app/javascript/initializers/index.js +++ b/app/javascript/initializers/index.js @@ -1,2 +1,3 @@ import "initializers/current" import "initializers/bridge/bridge_element" +import "initializers/offline" diff --git a/app/javascript/initializers/offline.js b/app/javascript/initializers/offline.js new file mode 100644 index 000000000..c5eff824e --- /dev/null +++ b/app/javascript/initializers/offline.js @@ -0,0 +1,6 @@ +import { Turbo } from "@hotwired/turbo-rails" + +Turbo.offline.start("/service-worker.js", { + scope: "/", + native: true +}) diff --git a/app/views/pwa/service_worker.js b/app/views/pwa/service_worker.js deleted file mode 100644 index 91903e735..000000000 --- a/app/views/pwa/service_worker.js +++ /dev/null @@ -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) - } -} diff --git a/app/views/pwa/service_worker.js.erb b/app/views/pwa/service_worker.js.erb new file mode 100644 index 000000000..fb583658c --- /dev/null +++ b/app/views/pwa/service_worker.js.erb @@ -0,0 +1,76 @@ +importScripts("<%= javascript_url("turbo-offline-umd.min") %>") + +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 + }) +}) + +TurboOffline.addRule({ + match: (request) => { + const url = request.url + return /\/(boards|cards|users)\//.test(url) && !/\/(edit|pin|watch|new)$/.test(url) + }, + handler: TurboOffline.handlers.networkFirst({ + cacheName: "cards", + maxAge: 60 * 60 * 24 * 3, + networkTimeout: 3 + }) +}) + +TurboOffline.addRule({ + match: /\/rails\/active_storage\//, + handler: TurboOffline.handlers.networkFirst({ + cacheName: "storage", + maxAge: 60 * 60 * 24 * 7, + networkTimeout: 3 + }) +}) + +// Everything else +TurboOffline.addRule({ + handler: TurboOffline.handlers.networkFirst({ + cacheName: "misc", + maxAge: 60 * 60 * 24, + networkTimeout: 3 + }) +}) + +TurboOffline.start() + +self.addEventListener('activate', (event) => { + event.waitUntil(self.clients.claim()) +}) + +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) + } +} diff --git a/config/importmap.rb b/config/importmap.rb index 5da84683c..f6910c3e1 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -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" From 9fc724572e900953ca500178e3615ec19b7e8602 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Tue, 20 Jan 2026 10:53:40 +0100 Subject: [PATCH 03/18] Use new `except` option for offline rules --- app/views/pwa/service_worker.js.erb | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/app/views/pwa/service_worker.js.erb b/app/views/pwa/service_worker.js.erb index fb583658c..0c4ab6ea5 100644 --- a/app/views/pwa/service_worker.js.erb +++ b/app/views/pwa/service_worker.js.erb @@ -9,14 +9,12 @@ TurboOffline.addRule({ }) TurboOffline.addRule({ - match: (request) => { - const url = request.url - return /\/(boards|cards|users)\//.test(url) && !/\/(edit|pin|watch|new)$/.test(url) - }, + match: /\/(boards|cards|users)\//, + except: /\/(edit|pin|watch|new)$/, handler: TurboOffline.handlers.networkFirst({ cacheName: "cards", maxAge: 60 * 60 * 24 * 3, - networkTimeout: 3 + networkTimeout: 2 }) }) @@ -25,7 +23,7 @@ TurboOffline.addRule({ handler: TurboOffline.handlers.networkFirst({ cacheName: "storage", maxAge: 60 * 60 * 24 * 7, - networkTimeout: 3 + networkTimeout: 2 }) }) From 975968b50f9c8033e278460736e82bbfc2b3dec2 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Tue, 20 Jan 2026 19:16:17 +0100 Subject: [PATCH 04/18] Only enable offline caching for Hotwire Native apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change conditionally renders the TurboOffline caching rules based on whether the request comes from a Hotwire Native app. Web browsers get a minimal service worker that only handles push notifications and a simple document fetch fallback. Why separate behavior for native vs web? ----------------------------------------- We want offline caching for Hotwire Native apps (where users expect app-like offline behavior) but not for regular web browsers. Why use ERB conditional rendering? ---------------------------------- We explored several approaches to detect Hotwire Native requests in the service worker: 1. User-Agent detection in fetch handler: Would be ideal, but Android WebViews override the User-Agent header with the system default when requests are intercepted by service workers, stripping the custom "Hotwire Native" identifier. 2. Custom header (X-Hotwire-Native) from native apps: Would require intercepting ALL requests at the native level using both WebViewClient.shouldInterceptRequest() and ServiceWorkerClient. Complex to implement and still incomplete coverage for all request types (navigation, resources loaded by HTML). 3. In-memory flag with IndexedDB persistence: Service workers can be terminated when idle and restart with fresh state. Reading from IndexedDB is async, but the decision to call respondWith() in a fetch handler must be synchronous. 4. Separate service worker URLs: Same theoretical churn problem as ERB rendering, with more complexity. Why ERB conditional rendering works in practice ----------------------------------------------- The main concern with conditional ERB rendering was "churn" — the service worker constantly updating as different client types fetch it. However, this only happens when web and native share storage on the same device. In practice, this is rare because: - Android native apps use isolated WebView storage - iOS doesn't support service workers in WebViews (yet) - Web browsers have completely separate storage So each context gets its own stable service worker without churn. Co-Authored-By: Claude Opus 4.5 --- app/views/pwa/service_worker.js.erb | 73 +++++++++++++++++------------ 1 file changed, 43 insertions(+), 30 deletions(-) diff --git a/app/views/pwa/service_worker.js.erb b/app/views/pwa/service_worker.js.erb index 0c4ab6ea5..b3f59aa60 100644 --- a/app/views/pwa/service_worker.js.erb +++ b/app/views/pwa/service_worker.js.erb @@ -1,44 +1,57 @@ importScripts("<%= javascript_url("turbo-offline-umd.min") %>") -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 +<% if hotwire_native_app? %> + 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 + }) }) -}) -TurboOffline.addRule({ - match: /\/(boards|cards|users)\//, - except: /\/(edit|pin|watch|new)$/, - handler: TurboOffline.handlers.networkFirst({ - cacheName: "cards", - maxAge: 60 * 60 * 24 * 3, - networkTimeout: 2 + TurboOffline.addRule({ + match: /\/(boards|cards|users)\//, + except: /\/(edit|pin|watch|new)$/, + handler: TurboOffline.handlers.networkFirst({ + cacheName: "cards", + maxAge: 60 * 60 * 24 * 3, + networkTimeout: 2 + }) }) -}) -TurboOffline.addRule({ - match: /\/rails\/active_storage\//, - handler: TurboOffline.handlers.networkFirst({ - cacheName: "storage", - maxAge: 60 * 60 * 24 * 7, - networkTimeout: 2 + TurboOffline.addRule({ + match: /\/rails\/active_storage\//, + handler: TurboOffline.handlers.networkFirst({ + cacheName: "storage", + maxAge: 60 * 60 * 24 * 7, + networkTimeout: 2 + }) }) -}) -// Everything else -TurboOffline.addRule({ - handler: TurboOffline.handlers.networkFirst({ - cacheName: "misc", - maxAge: 60 * 60 * 24, - networkTimeout: 3 + // Everything else + TurboOffline.addRule({ + handler: TurboOffline.handlers.networkFirst({ + cacheName: "misc", + maxAge: 60 * 60 * 24, + networkTimeout: 3 + }) }) -}) -TurboOffline.start() + TurboOffline.start() +<% else %> + self.addEventListener("fetch", (event) => { + if (event.request.method !== "GET") return -self.addEventListener('activate', (event) => { + if (event.request.destination === "document") { + event.respondWith( + fetch(event.request, { cache: "no-cache" }) + .catch(() => caches.match(event.request)) + ) + } + }) +<% end %> + +self.addEventListener("activate", (event) => { event.waitUntil(self.clients.claim()) }) From a949f1e3aed45b89e8a8a9caa6f486c858f87acf Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Tue, 27 Jan 2026 09:32:15 +0100 Subject: [PATCH 05/18] Clear offline cache when logging out Adds a logout Stimulus controller that sends a message to the service worker to clear all cached content when the user logs out. This ensures that cached data from one user isn't accessible after logout. The implementation: - Adds logout_controller.js that posts { action: "clearCache" } to the service worker via postMessage - Updates logout buttons to use the controller on form submission Also fixes data-turbo placement: moved from button to form element where it actually takes effect for disabling Turbo Drive form submissions. Co-Authored-By: Claude Opus 4.5 --- Gemfile.lock | 2 +- Gemfile.saas.lock | 2 +- app/javascript/controllers/logout_controller.js | 8 ++++++++ app/views/my/menus/_settings.html.erb | 2 +- app/views/users/show.html.erb | 2 +- 5 files changed, 12 insertions(+), 4 deletions(-) create mode 100644 app/javascript/controllers/logout_controller.js diff --git a/Gemfile.lock b/Gemfile.lock index a710d0f9e..a17769c07 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -13,7 +13,7 @@ GIT GIT remote: https://github.com/hotwired/turbo-rails.git - revision: 49be3f2d43edeeedad48cb492b35242fdb27af74 + revision: 45a2a40be0a677808a3a269311f1b5abaa3b30ba branch: offline-cache specs: turbo-rails (2.0.21) diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index cec5be07b..03c8e9141 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -62,7 +62,7 @@ GIT GIT remote: https://github.com/hotwired/turbo-rails.git - revision: 49be3f2d43edeeedad48cb492b35242fdb27af74 + revision: 45a2a40be0a677808a3a269311f1b5abaa3b30ba branch: offline-cache specs: turbo-rails (2.0.21) diff --git a/app/javascript/controllers/logout_controller.js b/app/javascript/controllers/logout_controller.js new file mode 100644 index 000000000..bc0511a7f --- /dev/null +++ b/app/javascript/controllers/logout_controller.js @@ -0,0 +1,8 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + async clearCache() { + const registration = await navigator.serviceWorker?.ready + registration?.active?.postMessage({ action: "clearCache" }) + } +} diff --git a/app/views/my/menus/_settings.html.erb b/app/views/my/menus/_settings.html.erb index d1251b7c0..6b711544b 100644 --- a/app/views/my/menus/_settings.html.erb +++ b/app/views/my/menus/_settings.html.erb @@ -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: "logout", action: "submit->logout#clearCache" } } do %> Sign out <% end %> <% end %> diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index 58068d2d6..a285a4f5a 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -41,7 +41,7 @@ <% if Current.user == @user %> - <%= 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: "logout", action: "submit->logout#clearCache" } } do %> Sign out of Fizzy on this device <% end %> <% end %> From cf8d92afeaf031980296da10340674477c339f4f Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Wed, 28 Jan 2026 21:30:50 +0100 Subject: [PATCH 06/18] Enable offline caching for all web users Previously, offline caching was conditionally enabled only for Hotwire Native apps. This removes that restriction and enables offline support for all users, including PWAs and regular browsers. This Uses the new `fetchOptions` support in `TurboOffline` handlers to pass `cache: "no-cache"` for document fetches, which we need to work around a quite annoying Safari PWA bug (see https://github.com/basecamp/fizzy/pull/1014). Also, simplify a bit the cache names and remove the `misc` one. Co-Authored-By: Claude Opus 4.5 --- Gemfile.lock | 2 +- Gemfile.saas.lock | 2 +- app/views/pwa/service_worker.js.erb | 83 +++++++++++++++-------------- 3 files changed, 44 insertions(+), 43 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index a17769c07..3cad12c54 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -13,7 +13,7 @@ GIT GIT remote: https://github.com/hotwired/turbo-rails.git - revision: 45a2a40be0a677808a3a269311f1b5abaa3b30ba + revision: 9bcc244043b16ef0202f8fe93aaeb246d841f9eb branch: offline-cache specs: turbo-rails (2.0.21) diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index 03c8e9141..3517a420e 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -62,7 +62,7 @@ GIT GIT remote: https://github.com/hotwired/turbo-rails.git - revision: 45a2a40be0a677808a3a269311f1b5abaa3b30ba + revision: 9bcc244043b16ef0202f8fe93aaeb246d841f9eb branch: offline-cache specs: turbo-rails (2.0.21) diff --git a/app/views/pwa/service_worker.js.erb b/app/views/pwa/service_worker.js.erb index b3f59aa60..cfdf5468f 100644 --- a/app/views/pwa/service_worker.js.erb +++ b/app/views/pwa/service_worker.js.erb @@ -1,60 +1,61 @@ importScripts("<%= javascript_url("turbo-offline-umd.min") %>") -<% if hotwire_native_app? %> - 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 - }) +// 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", + handler: TurboOffline.handlers.networkFirst({ + cacheName: "main", + maxAge: 60 * 60 * 24 * 3, + networkTimeout: 3, + fetchOptions: { cache: "no-cache" } }) +}) - TurboOffline.addRule({ - match: /\/(boards|cards|users)\//, - except: /\/(edit|pin|watch|new)$/, - handler: TurboOffline.handlers.networkFirst({ - cacheName: "cards", - maxAge: 60 * 60 * 24 * 3, - networkTimeout: 2 - }) +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 }) +}) - TurboOffline.addRule({ - match: /\/rails\/active_storage\//, - handler: TurboOffline.handlers.networkFirst({ - cacheName: "storage", - maxAge: 60 * 60 * 24 * 7, - networkTimeout: 2 - }) +TurboOffline.addRule({ + match: /\/(boards|cards|users)\//, + except: /\/(edit|pin|watch|new)$/, + handler: TurboOffline.handlers.networkFirst({ + cacheName: "main", + maxAge: 60 * 60 * 24 * 3, + networkTimeout: 2 }) +}) - // Everything else - TurboOffline.addRule({ - handler: TurboOffline.handlers.networkFirst({ - cacheName: "misc", - maxAge: 60 * 60 * 24, - networkTimeout: 3 - }) +TurboOffline.addRule({ + match: /\/rails\/active_storage\//, + handler: TurboOffline.handlers.networkFirst({ + cacheName: "storage", + maxAge: 60 * 60 * 24 * 7, + networkTimeout: 2 }) +}) - TurboOffline.start() -<% else %> - 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)) - ) - } +// Everything else +TurboOffline.addRule({ + handler: TurboOffline.handlers.networkFirst({ + cacheName: "main", + maxAge: 60 * 60 * 24, + networkTimeout: 3 }) -<% end %> +}) 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) ])) From 90d5a351f0ef3ab712056710f17ff4df65b33ea0 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Thu, 29 Jan 2026 21:03:50 +0100 Subject: [PATCH 07/18] Bump turbo-rails for Range requests support Cached video and audio files. --- Gemfile.lock | 4 ++-- Gemfile.saas.lock | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 3cad12c54..405a68bac 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -13,10 +13,10 @@ GIT GIT remote: https://github.com/hotwired/turbo-rails.git - revision: 9bcc244043b16ef0202f8fe93aaeb246d841f9eb + revision: 9bc39c9d72f2f78ebcb30dac645eb1261a1574b3 branch: offline-cache specs: - turbo-rails (2.0.21) + turbo-rails (2.0.23) actionpack (>= 7.1.0) railties (>= 7.1.0) diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index 3517a420e..2b9f32fd6 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -62,10 +62,10 @@ GIT GIT remote: https://github.com/hotwired/turbo-rails.git - revision: 9bcc244043b16ef0202f8fe93aaeb246d841f9eb + revision: 9bc39c9d72f2f78ebcb30dac645eb1261a1574b3 branch: offline-cache specs: - turbo-rails (2.0.21) + turbo-rails (2.0.23) actionpack (>= 7.1.0) railties (>= 7.1.0) From 32134be88216b0c7c6e5407171a5be88da6d4253 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Sun, 1 Feb 2026 15:15:34 +0100 Subject: [PATCH 08/18] Bump turbo-rails for network-first strategy fix on network error --- Gemfile.lock | 2 +- Gemfile.saas.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 405a68bac..aa14545ca 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -13,7 +13,7 @@ GIT GIT remote: https://github.com/hotwired/turbo-rails.git - revision: 9bc39c9d72f2f78ebcb30dac645eb1261a1574b3 + revision: cdf9d18a99906c349e01fbe05c6c7cdaa71ce550 branch: offline-cache specs: turbo-rails (2.0.23) diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index 2b9f32fd6..a4033b12b 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -62,7 +62,7 @@ GIT GIT remote: https://github.com/hotwired/turbo-rails.git - revision: 9bc39c9d72f2f78ebcb30dac645eb1261a1574b3 + revision: cdf9d18a99906c349e01fbe05c6c7cdaa71ce550 branch: offline-cache specs: turbo-rails (2.0.23) From 9b264483d11123827ccf556e2eedf91601eae9fe Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Thu, 5 Feb 2026 20:23:36 +0100 Subject: [PATCH 09/18] Bump `turbo-rails` for cache size limits --- Gemfile.lock | 2 +- Gemfile.saas.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index aa14545ca..9dd682dce 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -13,7 +13,7 @@ GIT GIT remote: https://github.com/hotwired/turbo-rails.git - revision: cdf9d18a99906c349e01fbe05c6c7cdaa71ce550 + revision: 0becdbbcdbf607eab6632463bc63f94d64249b7b branch: offline-cache specs: turbo-rails (2.0.23) diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index a4033b12b..8f5c9dd95 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -62,7 +62,7 @@ GIT GIT remote: https://github.com/hotwired/turbo-rails.git - revision: cdf9d18a99906c349e01fbe05c6c7cdaa71ce550 + revision: 0becdbbcdbf607eab6632463bc63f94d64249b7b branch: offline-cache specs: turbo-rails (2.0.23) From b93808235c058c1f2ed422a713f4f97cf09389ab Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Thu, 5 Feb 2026 21:15:22 +0100 Subject: [PATCH 10/18] Limit cached entry sizes 1MB and no limit for number of entries except for attachments, where we limit individual entries to 2MB and total of entries to 500. The number is based on the following percentiles for Active Storage blobs: ``` p50: 97.1044921875 KB p75: 236.9140625 KB p90: 917.7548828125 KB ``` --- app/views/pwa/service_worker.js.erb | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/app/views/pwa/service_worker.js.erb b/app/views/pwa/service_worker.js.erb index cfdf5468f..84f274673 100644 --- a/app/views/pwa/service_worker.js.erb +++ b/app/views/pwa/service_worker.js.erb @@ -9,7 +9,8 @@ TurboOffline.addRule({ cacheName: "main", maxAge: 60 * 60 * 24 * 3, networkTimeout: 3, - fetchOptions: { cache: "no-cache" } + fetchOptions: { cache: "no-cache" }, + maxEntrySize: 1024 * 1024 }) }) @@ -17,7 +18,8 @@ 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 + maxAge: 60 * 60 * 24 * 7, + maxEntrySize: 1024 * 1024 }) }) @@ -27,7 +29,8 @@ TurboOffline.addRule({ handler: TurboOffline.handlers.networkFirst({ cacheName: "main", maxAge: 60 * 60 * 24 * 3, - networkTimeout: 2 + networkTimeout: 2, + maxEntrySize: 1024 * 1024 }) }) @@ -36,7 +39,9 @@ TurboOffline.addRule({ handler: TurboOffline.handlers.networkFirst({ cacheName: "storage", maxAge: 60 * 60 * 24 * 7, - networkTimeout: 2 + networkTimeout: 2, + maxEntrySize: 2 * 1024 * 1024, // 2MB covers about 95% of all Fizzy blobs + maxEntries: 500 }) }) @@ -45,7 +50,8 @@ TurboOffline.addRule({ handler: TurboOffline.handlers.networkFirst({ cacheName: "main", maxAge: 60 * 60 * 24, - networkTimeout: 3 + networkTimeout: 3, + maxEntrySize: 1024 * 1024 }) }) From 089fcf94f8969b432ba113b18a924cd85836ab05 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Fri, 6 Feb 2026 12:37:39 +0100 Subject: [PATCH 11/18] Preload resources after service worker installation When the service worker is registered for the first time, resources loaded before it becomes active won't go through the service worker. These resources may be served from the browser's HTTP cache on subsequent requests, bypassing the service worker cache entirely. The new `preload` option accepts a regex pattern. On first visit (when no controller exists), it waits for the service worker to take control, then sends a message with URLs from `performance.getEntriesByType("resource")` that match the pattern. The service worker fetches and caches these resources. --- Gemfile.lock | 2 +- Gemfile.saas.lock | 2 +- app/javascript/initializers/offline.js | 7 ++++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 9dd682dce..758936188 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -13,7 +13,7 @@ GIT GIT remote: https://github.com/hotwired/turbo-rails.git - revision: 0becdbbcdbf607eab6632463bc63f94d64249b7b + revision: ca62e8bdad88d01914d512ad24424e0670f239c9 branch: offline-cache specs: turbo-rails (2.0.23) diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index 8f5c9dd95..a3552dacd 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -62,7 +62,7 @@ GIT GIT remote: https://github.com/hotwired/turbo-rails.git - revision: 0becdbbcdbf607eab6632463bc63f94d64249b7b + revision: ca62e8bdad88d01914d512ad24424e0670f239c9 branch: offline-cache specs: turbo-rails (2.0.23) diff --git a/app/javascript/initializers/offline.js b/app/javascript/initializers/offline.js index c5eff824e..446a9671e 100644 --- a/app/javascript/initializers/offline.js +++ b/app/javascript/initializers/offline.js @@ -1,6 +1,7 @@ import { Turbo } from "@hotwired/turbo-rails" -Turbo.offline.start("/service-worker.js", { - scope: "/", - native: true +Turbo.offline.start("/service-worker.js", { + scope: "/", + native: true, + preload: /\/assets\// }) From 1c2de6ca79bdd5617e5668c6cb37bf525f3c7085 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Fri, 20 Feb 2026 11:31:36 +0100 Subject: [PATCH 12/18] Clear offline cache on sign-in as well as sign-out Rename logout controller to clear-offline-cache and attach it to the magic link verification form so the service worker cache is cleared when a different user signs in. Co-Authored-By: Claude Opus 4.6 --- .../{logout_controller.js => clear_offline_cache_controller.js} | 0 app/views/my/menus/_settings.html.erb | 2 +- app/views/sessions/magic_links/show.html.erb | 2 +- app/views/users/show.html.erb | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename app/javascript/controllers/{logout_controller.js => clear_offline_cache_controller.js} (100%) diff --git a/app/javascript/controllers/logout_controller.js b/app/javascript/controllers/clear_offline_cache_controller.js similarity index 100% rename from app/javascript/controllers/logout_controller.js rename to app/javascript/controllers/clear_offline_cache_controller.js diff --git a/app/views/my/menus/_settings.html.erb b/app/views/my/menus/_settings.html.erb index 6b711544b..bf34b991d 100644 --- a/app/views/my/menus/_settings.html.erb +++ b/app/views/my/menus/_settings.html.erb @@ -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", form: { data: { turbo: false, controller: "logout", action: "submit->logout#clearCache" } } 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 %> Sign out <% end %> <% end %> diff --git a/app/views/sessions/magic_links/show.html.erb b/app/views/sessions/magic_links/show.html.erb index aba8411f5..968624a10 100644 --- a/app/views/sessions/magic_links/show.html.erb +++ b/app/views/sessions/magic_links/show.html.erb @@ -8,7 +8,7 @@

- <%= 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], diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index a285a4f5a..eb3e40c64 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -41,7 +41,7 @@ <% if Current.user == @user %> - <%= button_to session_path(script_name: nil), method: :delete, class: "btn txt-x-small center", form: { data: { turbo: false, controller: "logout", action: "submit->logout#clearCache" } } 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 %> Sign out of Fizzy on this device <% end %> <% end %> From de6d70cc2efa1aa3f972d2bb1427a1731c9c2710 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Fri, 20 Feb 2026 11:45:06 +0100 Subject: [PATCH 13/18] Bump turbo-rails for Turbo.offline.clearCache() Simplify the clear-offline-cache controller to use the new Turbo.offline.clearCache() API instead of messaging the service worker directly. Co-Authored-By: Claude Opus 4.6 --- Gemfile.lock | 2 +- Gemfile.saas.lock | 2 +- .../controllers/clear_offline_cache_controller.js | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 758936188..8a49b38a5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -13,7 +13,7 @@ GIT GIT remote: https://github.com/hotwired/turbo-rails.git - revision: ca62e8bdad88d01914d512ad24424e0670f239c9 + revision: 4104a353cfe728232dc0eed6d54c77466a9849f2 branch: offline-cache specs: turbo-rails (2.0.23) diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index a3552dacd..751dc55fb 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -62,7 +62,7 @@ GIT GIT remote: https://github.com/hotwired/turbo-rails.git - revision: ca62e8bdad88d01914d512ad24424e0670f239c9 + revision: 4104a353cfe728232dc0eed6d54c77466a9849f2 branch: offline-cache specs: turbo-rails (2.0.23) diff --git a/app/javascript/controllers/clear_offline_cache_controller.js b/app/javascript/controllers/clear_offline_cache_controller.js index bc0511a7f..7caa2e89b 100644 --- a/app/javascript/controllers/clear_offline_cache_controller.js +++ b/app/javascript/controllers/clear_offline_cache_controller.js @@ -1,8 +1,8 @@ import { Controller } from "@hotwired/stimulus" +import { Turbo } from "@hotwired/turbo-rails" export default class extends Controller { - async clearCache() { - const registration = await navigator.serviceWorker?.ready - registration?.active?.postMessage({ action: "clearCache" }) + clearCache() { + Turbo.offline.clearCache() } } From 0482670fec187f3361531a6b8a4672430b65345f Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Fri, 20 Feb 2026 12:07:38 +0100 Subject: [PATCH 14/18] Exclude service worker and edit/pin/watch/new pages from main cache We don't need the service worker to cache itself, or pages that won't work offline anyway. --- app/javascript/controllers/magic_link_controller.js | 2 +- app/views/pwa/service_worker.js.erb | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/javascript/controllers/magic_link_controller.js b/app/javascript/controllers/magic_link_controller.js index 3e2e4618c..8217af5aa 100644 --- a/app/javascript/controllers/magic_link_controller.js +++ b/app/javascript/controllers/magic_link_controller.js @@ -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 } } diff --git a/app/views/pwa/service_worker.js.erb b/app/views/pwa/service_worker.js.erb index 84f274673..095595f4e 100644 --- a/app/views/pwa/service_worker.js.erb +++ b/app/views/pwa/service_worker.js.erb @@ -5,6 +5,7 @@ importScripts("<%= javascript_url("turbo-offline-umd.min") %>") // 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, @@ -47,6 +48,7 @@ TurboOffline.addRule({ // Everything else TurboOffline.addRule({ + except: /\/(service-worker\.js|edit|pin|watch|new)$/, handler: TurboOffline.handlers.networkFirst({ cacheName: "main", maxAge: 60 * 60 * 24, From 99382db7203f1598c27d71b612a75230cc8b83b6 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Fri, 20 Feb 2026 12:43:18 +0100 Subject: [PATCH 15/18] Only start offline mode when signed in Gate Turbo.offline.start() behind Current.user so the service worker is only registered for authenticated users. This avoids errors on unauthenticated pages where /service-worker.js requires auth. Co-Authored-By: Claude Opus 4.6 --- app/javascript/initializers/offline.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/app/javascript/initializers/offline.js b/app/javascript/initializers/offline.js index 446a9671e..877ffa0ef 100644 --- a/app/javascript/initializers/offline.js +++ b/app/javascript/initializers/offline.js @@ -1,7 +1,9 @@ import { Turbo } from "@hotwired/turbo-rails" -Turbo.offline.start("/service-worker.js", { - scope: "/", - native: true, - preload: /\/assets\// -}) +if (Current.user) { + Turbo.offline.start("/service-worker.js", { + scope: "/", + native: true, + preload: /\/assets\// + }) +} From 67d629f72219b72936127bd12ae7721c4e7a33bb Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Fri, 20 Feb 2026 20:48:01 +0100 Subject: [PATCH 16/18] Use CORS fetch mode for Active Storage to enable `maxEntrySize` checks Otherwise, for resources like images loaded via tags, the browser sets `mode: "no-cors"` (as these aren't CORS requests), so the service worker gets an opaque response even though the server sends CORS headers. We could upgrade all no-cors requests to cors mode, sending a `mode: "cors"` request to a server that doesn't send CORS headers will fail entirely: the `fetch` call throws a `TypeError` (network error), and the browser blocks the response. So the resource wouldn't load at all, not even as opaque. We wouldn't be able to cache it at all. By opting in via `fetchOptions`, we can do it only for rules where we know the server will send CORS headers. --- Gemfile.lock | 2 +- Gemfile.saas.lock | 2 +- app/views/pwa/service_worker.js.erb | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 8a49b38a5..e811a1c2f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -13,7 +13,7 @@ GIT GIT remote: https://github.com/hotwired/turbo-rails.git - revision: 4104a353cfe728232dc0eed6d54c77466a9849f2 + revision: 43bc205155b0eb57e5e016ed0e87a12c29ba2ab2 branch: offline-cache specs: turbo-rails (2.0.23) diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index 751dc55fb..af3c102c1 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -62,7 +62,7 @@ GIT GIT remote: https://github.com/hotwired/turbo-rails.git - revision: 4104a353cfe728232dc0eed6d54c77466a9849f2 + revision: 43bc205155b0eb57e5e016ed0e87a12c29ba2ab2 branch: offline-cache specs: turbo-rails (2.0.23) diff --git a/app/views/pwa/service_worker.js.erb b/app/views/pwa/service_worker.js.erb index 095595f4e..5ab493e22 100644 --- a/app/views/pwa/service_worker.js.erb +++ b/app/views/pwa/service_worker.js.erb @@ -42,7 +42,8 @@ TurboOffline.addRule({ maxAge: 60 * 60 * 24 * 7, networkTimeout: 2, maxEntrySize: 2 * 1024 * 1024, // 2MB covers about 95% of all Fizzy blobs - maxEntries: 500 + maxEntries: 500, + fetchOptions: { mode: "cors", credentials: "omit" } }) }) From 1855490f805477db7bd0eebdca3fc52da1a8f0c7 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Sat, 21 Feb 2026 09:20:47 +0100 Subject: [PATCH 17/18] Revert CORS fetch mode for Active Storage CORS mode doesn't work with redirect-based Active Storage URLs when the storage bucket uses wildcard Access-Control-Allow-Origin. Relying on maxEntries alone until specific origin CORS is available. Co-Authored-By: Claude Opus 4.6 --- Gemfile.lock | 2 +- Gemfile.saas.lock | 2 +- app/views/pwa/service_worker.js.erb | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index e811a1c2f..8db6817dc 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -13,7 +13,7 @@ GIT GIT remote: https://github.com/hotwired/turbo-rails.git - revision: 43bc205155b0eb57e5e016ed0e87a12c29ba2ab2 + revision: 93ce5bc461cd71af1797e79fcd7e09a19242d080 branch: offline-cache specs: turbo-rails (2.0.23) diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index af3c102c1..37ff08294 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -62,7 +62,7 @@ GIT GIT remote: https://github.com/hotwired/turbo-rails.git - revision: 43bc205155b0eb57e5e016ed0e87a12c29ba2ab2 + revision: 93ce5bc461cd71af1797e79fcd7e09a19242d080 branch: offline-cache specs: turbo-rails (2.0.23) diff --git a/app/views/pwa/service_worker.js.erb b/app/views/pwa/service_worker.js.erb index 5ab493e22..095595f4e 100644 --- a/app/views/pwa/service_worker.js.erb +++ b/app/views/pwa/service_worker.js.erb @@ -42,8 +42,7 @@ TurboOffline.addRule({ maxAge: 60 * 60 * 24 * 7, networkTimeout: 2, maxEntrySize: 2 * 1024 * 1024, // 2MB covers about 95% of all Fizzy blobs - maxEntries: 500, - fetchOptions: { mode: "cors", credentials: "omit" } + maxEntries: 500 }) }) From 49a86c7cbf7905d7cb67e1e69e2922040658a279 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Tue, 3 Mar 2026 15:59:25 +0000 Subject: [PATCH 18/18] Enable CORS fetch mode for storage requests The load balancer now returns a specific `Access-Control-Allow-Origin` instead of a wildcard, so credentials can be included by default. This enables `maxEntrySize` enforcement for storage. Co-Authored-By: Claude Opus 4.6 --- app/views/pwa/service_worker.js.erb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/views/pwa/service_worker.js.erb b/app/views/pwa/service_worker.js.erb index 095595f4e..41fac1446 100644 --- a/app/views/pwa/service_worker.js.erb +++ b/app/views/pwa/service_worker.js.erb @@ -42,7 +42,8 @@ TurboOffline.addRule({ maxAge: 60 * 60 * 24 * 7, networkTimeout: 2, maxEntrySize: 2 * 1024 * 1024, // 2MB covers about 95% of all Fizzy blobs - maxEntries: 500 + maxEntries: 500, + fetchOptions: { mode: "cors" } }) })