Commitd7ba05dunknown_key
fix(pwa): kill admin-dashboard AA loop — stop registering two SWs at scope /
fix(pwa): kill admin-dashboard AA loop — stop registering two SWs at scope /
Root cause: layout.tsx registers `/sw.js` via `pwaRegisterScript` AND
`/sw-push.js` via `pwaInstallBannerScript`, both at scope `/`. Per the SW
spec, a scope can have only one active SW — each registration replaces
the other. Then `pwaRegisterScript`'s `updatefound → reload` hook fires
on every subsequent page load: infinite reload. On the admin dashboard
the user reports the deploy pill flashing, typing being wiped, and the
page unusable. The pill flashing is the page reloading mid-render
before SSE / latest.json can paint anything.
Fix:
1. Remove the `/sw-push.js` registration from pwaInstallBannerScript.
The install banner UI is unchanged.
2. Proactively unregister any existing `/sw-push.js` SW so tabs already
stuck in the loop recover on next refresh (no manual cache wipe).
3. Regression test in pwa.test.ts asserts the rendered home page never
contains `register('/sw-push.js'` or its double-quoted form.
Push notifications opted in via /settings still work for users who
click Subscribe — `getReg()` in settings.tsx returns the existing
`/sw.js` registration (no new register call, no scope fight). The
push-event handler folding into /sw.js is a separate cleanup tracked
for a follow-up; the AA loop is P0 and ships now.
BIBLE §4 lock notes: `/sw.js` (Block G1, locked) untouched. The
`/sw-push.js` route + tests untouched. Only the layout-level
auto-registration is removed.
Tests: pwa.test.ts + pwa-cache-bust.test.ts + push.test.ts → 37/37
pass. layout-user-prop.test.ts → 8/8. tsc clean.2 files changed+38−8d7ba05d21f6b6b31cf5fd811b266128a5329d19d
2 changed files+38−8
Modifiedsrc/__tests__/pwa.test.ts+11−0View fileUnifiedSplit
@@ -87,4 +87,15 @@ describe("pwa — layout wiring", () => {
8787 expect(body).toContain("serviceWorker.register");
8888 expect(body).toContain("/sw.js");
8989 });
90
91 // AA-LOOP REGRESSION (2026-05-16): two SWs at the same scope = infinite
92 // reload. Layout MUST NOT auto-register `/sw-push.js`. The /sw-push.js
93 // route still exists for explicit-opt-in push subscriptions (gated to
94 // /settings) but no anonymous or default page may register it.
95 it("layout never auto-registers /sw-push.js at scope /", async () => {
96 const res = await app.request("/");
97 const body = await res.text();
98 expect(body).not.toContain("register('/sw-push.js'");
99 expect(body).not.toContain('register("/sw-push.js"');
100 });
90101});
Modifiedsrc/views/layout.tsx+27−8View fileUnifiedSplit
@@ -661,8 +661,21 @@ const pwaRegisterScript = `
661661// 1. user is authenticated (gated server-side via the `{user &&}` JSX)
662662// 2. visit counter (localStorage) ≥ 3
663663// 3. dismissed-cooldown < 14 days ago
664// Also bootstraps the push + offline SW at /sw-push.js. Idempotent — safe
665// to load on every page; only inserts DOM nodes if all conditions match.
664//
665// AA-LOOP FIX (2026-05-16): this script previously also registered
666// `/sw-push.js` at scope `/`. Combined with `pwaRegisterScript` registering
667// `/sw.js` at the same scope, every page load alternated which SW
668// controlled the scope. `pwaRegisterScript`'s `updatefound → reload` hook
669// then fired on every subsequent load — infinite reload loop on the admin
670// dashboard (input wiped, deploy pill flashing). Per the SW spec, only one
671// SW can be active per scope; registering two different script URLs at the
672// same scope makes each replace the other.
673//
674// Fix: stop registering `/sw-push.js` from the banner script entirely, and
675// proactively unregister any existing `/sw-push.js` SW so users already
676// caught in the loop recover on next refresh. Push notifications opted in
677// via /settings still register the SW from settings.tsx when (and only
678// when) the user clicks Subscribe.
666679const pwaInstallBannerScript = `
667680(function(){
668681 try {
@@ -672,12 +685,18 @@ const pwaInstallBannerScript = `
672685 var visits = parseInt(localStorage.getItem(VISITS_KEY) || '0', 10) || 0;
673686 visits++;
674687 try { localStorage.setItem(VISITS_KEY, String(visits)); } catch(_){}
675 // Bootstrap the push + offline SW (separate from /sw.js's locked behaviour).
676 if ('serviceWorker' in navigator) {
677 window.addEventListener('load', function(){
678 navigator.serviceWorker.register('/sw-push.js', { scope: '/' })
679 .catch(function(){});
680 });
688 // Recover stuck tabs: if a previous build registered /sw-push.js at
689 // scope '/', it is still fighting /sw.js for that scope. Unregister
690 // only the /sw-push.js registration; leave /sw.js intact.
691 if ('serviceWorker' in navigator && navigator.serviceWorker.getRegistrations) {
692 navigator.serviceWorker.getRegistrations().then(function(regs){
693 regs.forEach(function(reg){
694 var url = (reg.active && reg.active.scriptURL) || '';
695 if (url.indexOf('/sw-push.js') !== -1) {
696 try { reg.unregister(); } catch(_){}
697 }
698 });
699 }).catch(function(){});
681700 }
682701 var deferredPrompt = null;
683702 window.addEventListener('beforeinstallprompt', function(e){
684703