Jelajahi Sumber

fix(pwa): don't force-navigate first-install clients in SW activate

  Repro on every fresh demo subdomain: visitor lands on Printers OK, first
  sidebar click sticks on a spinner (Chromium) or trips "Corrupted Content
  Error" with sw.js stuck `activating` (Firefox). Manual reload recovers.

  Root cause is the `client.navigate(client.url)` call added to the activate
  handler in 18d534c9 (intended to force kiosks to pick up new bundles).
  Its guard — `client.url && typeof client.navigate === 'function'` — does
  not distinguish first install from upgrade. On any fresh origin (every
  demo session, every first-time visitor, every cleared profile) it still
  fired: Chromium raced it against the in-flight SPA mount; Firefox
  deadlocked the activate's waitUntil on `await client.navigate(...)`
  because the SW intercepts its own document fetch while still activating.

  Split the lifecycle correctly:
  - sw.js activate handler: just cache cleanup + clients.claim().
  - sw-register.js: capture `hadController = !!serviceWorker.controller`
    at load, listen for `controllerchange`, reload only when hadController
    was true. Returning kiosk hits a new deploy -> had controller -> reloads
    as before; first-install visitor -> no controller -> no forced nav ->
    React mount completes.

  Bump CACHE_NAME v29->v30 and STATIC_CACHE v28->v29 so existing browsers
  fetching the new sw.js drop the old CacheStorage in the same pass.

  SpoolBuddy unregister branch and notificationclick's client.navigate are
  unrelated and unchanged.
maziggy 3 bulan lalu
induk
melakukan
a104ccc95c
5 mengubah file dengan 56 tambahan dan 52 penghapusan
  1. 0 0
      CHANGELOG.md
  2. 15 0
      frontend/public/sw-register.js
  3. 13 26
      frontend/public/sw.js
  4. 15 0
      static/sw-register.js
  5. 13 26
      static/sw.js

File diff ditekan karena terlalu besar
+ 0 - 0
CHANGELOG.md


+ 15 - 0
frontend/public/sw-register.js

@@ -9,6 +9,21 @@ if ('serviceWorker' in navigator) {
       }
     });
   } else {
+    // Capture controller state at script-load. Used to decide whether a
+    // subsequent `controllerchange` is a deploy-pickup (had a prior SW →
+    // reload so the new bundle takes over) or a first install (no prior SW →
+    // skip the reload; the in-flight React mount would otherwise race the
+    // forced navigation, leaving the page wedged on a spinner. The previous
+    // approach — `client.navigate(client.url)` from the SW's activate
+    // handler — exhibited that race in Chromium and a waitUntil hang in
+    // Firefox, both surfaced on every fresh demo subdomain).
+    const hadController = !!navigator.serviceWorker.controller;
+    let reloading = false;
+    navigator.serviceWorker.addEventListener('controllerchange', () => {
+      if (!hadController || reloading) return;
+      reloading = true;
+      location.reload();
+    });
     window.addEventListener('load', () => {
       navigator.serviceWorker.register('/sw.js')
         .then((registration) => {

+ 13 - 26
frontend/public/sw.js

@@ -1,6 +1,6 @@
 // Bambuddy Service Worker
-const CACHE_NAME = 'bambuddy-v29';
-const STATIC_CACHE = 'bambuddy-static-v28';
+const CACHE_NAME = 'bambuddy-v30';
+const STATIC_CACHE = 'bambuddy-static-v29';
 
 // Static assets to cache on install
 const STATIC_ASSETS = [
@@ -31,12 +31,17 @@ self.addEventListener('install', (event) => {
   self.skipWaiting();
 });
 
-// Activate event - clean up old caches, then force-reload any controlled
-// windows so they pick up the new bundle. Important for the SpoolBuddy kiosk
-// (Pi + Chromium-in-kiosk-mode, no devtools, no manual reload control):
-// without this hop, restarting Chromium installs the new SW but the existing
-// document keeps running the previously-cached bundle until a navigation
-// happens — which on a locked kiosk never occurs.
+// Activate event - clean up old caches and claim existing clients.
+//
+// The forced reload that picks up a new bundle on already-open clients (the
+// kiosk deploy-pickup scenario) lives in sw-register.js via a
+// `controllerchange` listener, gated on whether the page already had a SW
+// controller at load time. That gate distinguishes first-install (where a
+// reload would race the in-flight React mount — observed on every fresh
+// *.demo.bambuddy.cool subdomain, and in Firefox the activate's waitUntil
+// hung on `client.navigate` until the document load was aborted with a
+// Corrupted-Content error) from upgrade-on-existing-client (where the reload
+// is wanted).
 self.addEventListener('activate', (event) => {
   console.log('[SW] Activating service worker...');
   event.waitUntil(
@@ -50,25 +55,7 @@ self.addEventListener('activate', (event) => {
             return caches.delete(name);
           }),
       );
-      // Take control immediately.
       await self.clients.claim();
-      // Force a fresh navigation in any window that this SW now controls.
-      // ``client.navigate(client.url)`` re-requests the page through the
-      // network-first fetch handler, picking up the new index.html + the
-      // new content-hashed JS bundle. Guarded so the very first install on
-      // a never-controlled client doesn't trigger an unwanted reload.
-      const clients = await self.clients.matchAll({ type: 'window' });
-      for (const client of clients) {
-        try {
-          if (client.url && typeof client.navigate === 'function') {
-            await client.navigate(client.url);
-          }
-        } catch (e) {
-          // Some browsers reject navigate on cross-origin or detached
-          // clients — swallow so one bad client doesn't break the rest.
-          console.warn('[SW] Forced reload skipped for client:', client.url, e);
-        }
-      }
     })(),
   );
 });

+ 15 - 0
static/sw-register.js

@@ -9,6 +9,21 @@ if ('serviceWorker' in navigator) {
       }
     });
   } else {
+    // Capture controller state at script-load. Used to decide whether a
+    // subsequent `controllerchange` is a deploy-pickup (had a prior SW →
+    // reload so the new bundle takes over) or a first install (no prior SW →
+    // skip the reload; the in-flight React mount would otherwise race the
+    // forced navigation, leaving the page wedged on a spinner. The previous
+    // approach — `client.navigate(client.url)` from the SW's activate
+    // handler — exhibited that race in Chromium and a waitUntil hang in
+    // Firefox, both surfaced on every fresh demo subdomain).
+    const hadController = !!navigator.serviceWorker.controller;
+    let reloading = false;
+    navigator.serviceWorker.addEventListener('controllerchange', () => {
+      if (!hadController || reloading) return;
+      reloading = true;
+      location.reload();
+    });
     window.addEventListener('load', () => {
       navigator.serviceWorker.register('/sw.js')
         .then((registration) => {

+ 13 - 26
static/sw.js

@@ -1,6 +1,6 @@
 // Bambuddy Service Worker
-const CACHE_NAME = 'bambuddy-v29';
-const STATIC_CACHE = 'bambuddy-static-v28';
+const CACHE_NAME = 'bambuddy-v30';
+const STATIC_CACHE = 'bambuddy-static-v29';
 
 // Static assets to cache on install
 const STATIC_ASSETS = [
@@ -31,12 +31,17 @@ self.addEventListener('install', (event) => {
   self.skipWaiting();
 });
 
-// Activate event - clean up old caches, then force-reload any controlled
-// windows so they pick up the new bundle. Important for the SpoolBuddy kiosk
-// (Pi + Chromium-in-kiosk-mode, no devtools, no manual reload control):
-// without this hop, restarting Chromium installs the new SW but the existing
-// document keeps running the previously-cached bundle until a navigation
-// happens — which on a locked kiosk never occurs.
+// Activate event - clean up old caches and claim existing clients.
+//
+// The forced reload that picks up a new bundle on already-open clients (the
+// kiosk deploy-pickup scenario) lives in sw-register.js via a
+// `controllerchange` listener, gated on whether the page already had a SW
+// controller at load time. That gate distinguishes first-install (where a
+// reload would race the in-flight React mount — observed on every fresh
+// *.demo.bambuddy.cool subdomain, and in Firefox the activate's waitUntil
+// hung on `client.navigate` until the document load was aborted with a
+// Corrupted-Content error) from upgrade-on-existing-client (where the reload
+// is wanted).
 self.addEventListener('activate', (event) => {
   console.log('[SW] Activating service worker...');
   event.waitUntil(
@@ -50,25 +55,7 @@ self.addEventListener('activate', (event) => {
             return caches.delete(name);
           }),
       );
-      // Take control immediately.
       await self.clients.claim();
-      // Force a fresh navigation in any window that this SW now controls.
-      // ``client.navigate(client.url)`` re-requests the page through the
-      // network-first fetch handler, picking up the new index.html + the
-      // new content-hashed JS bundle. Guarded so the very first install on
-      // a never-controlled client doesn't trigger an unwanted reload.
-      const clients = await self.clients.matchAll({ type: 'window' });
-      for (const client of clients) {
-        try {
-          if (client.url && typeof client.navigate === 'function') {
-            await client.navigate(client.url);
-          }
-        } catch (e) {
-          // Some browsers reject navigate on cross-origin or detached
-          // clients — swallow so one bad client doesn't break the rest.
-          console.warn('[SW] Forced reload skipped for client:', client.url, e);
-        }
-      }
     })(),
   );
 });

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini