Commit44fe49bunknown_key
fix(pwa): rip out service workers entirely + kill-switch for trapped browsers
fix(pwa): rip out service workers entirely + kill-switch for trapped browsers
This bug class — two service workers fighting over scope `/` causing an
infinite reload loop — has now hit gluecron.com four separate times:
the AA admin-dashboard loop, the deploy-pill flash, the admin-screen
flash, and the first-install PWA reload. Patching it in place keeps
not sticking. PWA was the wrong tool for a git host: nobody installs
gluecron.com as a phone app, and "offline mode for a git host" was
never going to be useful. Remove it permanently.
Changes:
1. src/views/layout.tsx
- Drop the `<link rel="manifest">` tag from <head>.
- Replace `pwaRegisterScript` + `pwaInstallBannerScript` with a
single `pwaKillSwitchScript` that calls
`getRegistrations().then(regs => regs.forEach(unregister))` on
every page load. Also purges gluecron-* caches.
- Net result: every browser that hits the new layout unregisters
any leftover /sw.js or /sw-push.js it had cached.
2. src/routes/pwa.ts — buildVersionedServiceWorker
- The /sw.js body still installs + activates + claims clients +
purges gluecron-* caches, but now also calls
`self.registration.unregister()` on activate. Browsers that
don't make it to the new layout (because the old loop reloads
too fast) STILL recover, because SW update checks fire every
navigation (Cache-Control: no-store on /sw.js itself) and the
new body nukes itself on activate.
- SW_VERSION literal is preserved; cache-bust tests still assert
against it.
3. Tests
- src/__tests__/pwa.test.ts rewritten: pins the new contract
(layout does NOT include manifest, does NOT call
serviceWorker.register, DOES include getRegistrations +
reg.unregister).
- src/__tests__/pwa-cache-bust.test.ts adds an assertion that the
served /sw.js body contains self.registration.unregister.
- src/__tests__/push.test.ts already updated in 904927d.
Push notifications and "Install Gluecron" banner are gone. Both were
optional, both were broken in practice, and both can be re-added as a
single SW (handling push + notificationclick only, no fetch handler)
later if and when there's actual demand.
Tests: 42/42 pass on pwa + pwa-cache-bust + push suites. tsc clean.4 files changed+107−16944fe49b06a7d35a13ed947234199a6353c0712c5
4 changed files+107−169
Modifiedsrc/__tests__/pwa-cache-bust.test.ts+8−0View fileUnifiedSplit
@@ -69,6 +69,14 @@ describe("pwa cache-bust (S2) — GET /sw.js", () => {
6969 expect(body).toContain('"gluecron-"');
7070 });
7171
72 // 2026-05-16 — PWA rip-out. /sw.js now self-unregisters on activate so
73 // any browser with the old SW installed recovers automatically.
74 it("body unregisters itself on activate", async () => {
75 const res = await app.request("/sw.js");
76 const body = await res.text();
77 expect(body).toContain("self.registration.unregister");
78 });
79
7280 it("when BUILD_SHA is set, that exact value appears as SW_VERSION", async () => {
7381 process.env.BUILD_SHA = "abc123deadbeef";
7482 const res = await app.request("/sw.js");
Modifiedsrc/__tests__/pwa.test.ts+35−24View fileUnifiedSplit
@@ -1,15 +1,17 @@
11/**
2 * Block G1 — PWA route smoke tests.
2 * PWA route smoke tests — post-rip-out (2026-05-16).
33 *
4 * Verifies manifest/icon/service-worker endpoints serve the right content
5 * types + the manifest parses as JSON with the required install-prompt fields.
4 * The PWA layer was removed because it produced recurring reload-loop
5 * bugs (admin dashboard, deploy pill, admin-screen flash). The routes
6 * still exist but serve self-unregister bodies so any browser with the
7 * old SW installed auto-recovers. The layout no longer registers a SW.
68 */
79
810import { describe, it, expect } from "bun:test";
911import app from "../app";
1012import { MANIFEST, SERVICE_WORKER_SRC, PWA_REGISTER_SNIPPET } from "../routes/pwa";
1113
12describe("pwa — manifest", () => {
14describe("pwa — manifest (kept for any pre-existing install)", () => {
1315 it("GET /manifest.webmanifest → 200 JSON", async () => {
1416 const res = await app.request("/manifest.webmanifest");
1517 expect(res.status).toBe(200);
@@ -32,7 +34,7 @@ describe("pwa — manifest", () => {
3234 });
3335});
3436
35describe("pwa — service worker", () => {
37describe("pwa — service worker (self-unregister edition)", () => {
3638 it("GET /sw.js → 200 JavaScript", async () => {
3739 const res = await app.request("/sw.js");
3840 expect(res.status).toBe(200);
@@ -42,14 +44,27 @@ describe("pwa — service worker", () => {
4244 expect(res.headers.get("service-worker-allowed")).toBe("/");
4345 });
4446
45 it("v4 self-nuke service worker installs + activates + unregisters", () => {
47 it("served SW body unregisters itself on activate", async () => {
48 const res = await app.request("/sw.js");
49 const body = await res.text();
50 expect(body).toContain("self.registration.unregister");
51 });
52
53 it("served SW body has no fetch handler", async () => {
54 const res = await app.request("/sw.js");
55 const body = await res.text();
56 expect(body).not.toContain('addEventListener("fetch"');
57 expect(body).not.toContain("addEventListener('fetch'");
58 });
59
60 it("locked SERVICE_WORKER_SRC constant kept for back-compat tests", () => {
4661 expect(SERVICE_WORKER_SRC).toContain("addEventListener('install'");
4762 expect(SERVICE_WORKER_SRC).toContain("addEventListener('activate'");
4863 expect(SERVICE_WORKER_SRC).toContain("self.registration.unregister");
4964 expect(SERVICE_WORKER_SRC).toContain("caches.delete");
5065 });
5166
52 it("v4 service worker has no fetch handler — every request hits network", () => {
67 it("locked SERVICE_WORKER_SRC has no fetch handler", () => {
5368 expect(SERVICE_WORKER_SRC).not.toContain("addEventListener('fetch'");
5469 });
5570});
@@ -65,37 +80,33 @@ describe("pwa — icon", () => {
6580 });
6681});
6782
68describe("pwa — register snippet", () => {
69 it("registers a service worker when available", () => {
83describe("pwa — register snippet (legacy, no longer used by layout)", () => {
84 it("snippet still references serviceWorker for legacy callers", () => {
7085 expect(PWA_REGISTER_SNIPPET).toContain("serviceWorker");
7186 expect(PWA_REGISTER_SNIPPET).toContain("'/sw.js'");
7287 });
7388});
7489
75describe("pwa — layout wiring", () => {
76 it("home page includes the manifest link", async () => {
90describe("pwa — layout no longer registers a service worker", () => {
91 // 2026-05-16 — PWA ripped out. The layout used to inject manifest +
92 // SW registration scripts; now it injects a kill-switch that
93 // unregisters any pre-existing SW. These tests pin the new contract.
94 it("home page does NOT include the manifest link", async () => {
7795 const res = await app.request("/");
7896 const body = await res.text();
79 expect(body).toContain('rel="manifest"');
80 expect(body).toContain("/manifest.webmanifest");
97 expect(body).not.toContain('rel="manifest"');
8198 });
8299
83 it("home page registers the service worker", async () => {
100 it("home page does NOT call serviceWorker.register", async () => {
84101 const res = await app.request("/");
85102 const body = await res.text();
86 // JSX entity-escapes quotes inside <script>; just check the SW path is wired.
87 expect(body).toContain("serviceWorker.register");
88 expect(body).toContain("/sw.js");
103 expect(body).not.toContain("serviceWorker.register");
89104 });
90105
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 () => {
106 it("home page includes the kill-switch (unregisters legacy SWs)", async () => {
96107 const res = await app.request("/");
97108 const body = await res.text();
98 expect(body).not.toContain("register('/sw-push.js'");
99 expect(body).not.toContain('register("/sw-push.js"');
109 expect(body).toContain("getRegistrations");
110 expect(body).toContain("reg.unregister");
100111 });
101112});
Modifiedsrc/routes/pwa.ts+25−19View fileUnifiedSplit
@@ -150,37 +150,43 @@ export function buildSwVersion(): string {
150150}
151151
152152export function buildVersionedServiceWorker(version: string): string {
153 // Escape backslashes + double-quotes so the version is safe inside a
154 // double-quoted JS string literal. Real SHAs are hex, but the dev
155 // fallback could in principle contain anything — belt + braces.
153 // PWA removed 2026-05-16. The body served at /sw.js is now a self-
154 // unregister script. Browsers with the old SW installed fetch this
155 // body on their next SW update check (every navigation, since
156 // Cache-Control: no-store on the response) and the SW unregisters
157 // itself. Combined with the layout-side kill-switch script, every
158 // browser self-recovers within one page load.
159 //
160 // SW_VERSION is still pinned + preserved in the body so the existing
161 // cache-bust tests keep asserting against a non-empty literal. The
162 // cache-prefix delete + clients.claim are also preserved (they nuke
163 // any caches the old SW left behind). Final step: unregister.
156164 const safe = version.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
157 return `// gluecron service worker — Block S2 (deploy-SHA-pinned cache bust)
165 return `// gluecron service worker — self-unregister edition (2026-05-16)
158166const SW_VERSION = "${safe}";
159167const CACHE_PREFIX = "gluecron-";
160168const CURRENT_CACHE = CACHE_PREFIX + SW_VERSION;
161169
162170self.addEventListener("install", (e) => {
163 // Activate immediately — don't wait for every tab to close. Pairs with
164 // the layout's updatefound→reload hook so the user sees the new HTML
165 // on the very next page load instead of "forever until DevTools".
166171 self.skipWaiting();
167172});
168173
169174self.addEventListener("activate", (e) => {
170 e.waitUntil(
171 caches.keys().then((names) =>
172 Promise.all(
173 names
174 .filter((n) => n.startsWith(CACHE_PREFIX) && n !== CURRENT_CACHE)
175 .map((n) => caches.delete(n))
176 )
177 ).then(() => self.clients.claim())
178 );
175 e.waitUntil((async () => {
176 try {
177 const names = await caches.keys();
178 await Promise.all(
179 names.filter((n) => n.startsWith(CACHE_PREFIX)).map((n) => caches.delete(n))
180 );
181 } catch (_) {}
182 try { await self.clients.claim(); } catch (_) {}
183 try { await self.registration.unregister(); } catch (_) {}
184 })());
179185});
180186
181// No fetch handler — every request goes straight to the network. The
182// version-pinned cache machinery is in place for future opt-in caching
183// without re-introducing the stale-HTML bug.
187// No fetch handler — once this SW activates and unregisters itself the
188// browser never invokes it again. Every request goes straight to the
189// network.
184190`;
185191}
186192
Modifiedsrc/views/layout.tsx+39−126View fileUnifiedSplit
@@ -58,7 +58,10 @@ export const Layout: FC<
5858 <meta charset="UTF-8" />
5959 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6060 <meta name="theme-color" content="#0d1117" />
61 <link rel="manifest" href="/manifest.webmanifest" />
61 {/* PWA removed 2026-05-16 — repeated reload-loop bugs (admin
62 dashboard, deploy pill, admin-screen flash). A git host has
63 no use for service workers or install-as-app. Manifest link
64 and SW registrations are gone permanently. */}
6265 <link rel="icon" type="image/svg+xml" href="/icon.svg" />
6366 <title>{renderedTitle}</title>
6467 {description && <meta name="description" content={description} />}
@@ -335,17 +338,11 @@ export const Layout: FC<
335338 <div id="cmdk-list" style="max-height:60vh;overflow-y:auto" />
336339 </div>
337340 <script dangerouslySetInnerHTML={{ __html: clientJs }} />
338 <script dangerouslySetInnerHTML={{ __html: pwaRegisterScript }} />
341 {/* PWA-kill script: actively unregisters any service worker
342 previously installed under gluecron.com. Recovers any browser
343 still trapped in the SW reload loop from the legacy registrations. */}
344 <script dangerouslySetInnerHTML={{ __html: pwaKillSwitchScript }} />
339345
340 {/* Block M2 — smart install banner + push-SW bootstrap. Authed
341 users only; the banner respects a 3+ visits + 14-day cooldown
342 heuristic. The push SW is registered on the same gate so we
343 don't ask anonymous visitors to opt into anything. */}
344 {user && (
345
346 dangerouslySetInnerHTML={{ __html: pwaInstallBannerScript }}
347 />
348 )}
349346
350347 </body>
351348 </html>
@@ -620,128 +617,44 @@ const toastScript = `
620617 })();
621618`;
622619
623// Block S2 extension — when an UPDATED SW activates we force a single
624// reload so the user picks up the fresh HTML immediately instead of
625// being stuck on the previous deploy's cached page.
626//
627// Critical: snapshot \`navigator.serviceWorker.controller\` BEFORE
628// registration. The SW's activate handler calls clients.claim() which
629// sets the controller during first install — so checking the controller
630// at statechange-time was always truthy and produced an infinite reload
631// loop on every first visit (incognito tab, fresh browser, etc).
632//
633// New guard: only reload if there was a previously-controlling SW at
634// page load. First-install path stays a single page render.
635const pwaRegisterScript = `
636 if ('serviceWorker' in navigator) {
637 var hadControllerAtLoad = !!navigator.serviceWorker.controller;
638 window.addEventListener('load', function(){
639 navigator.serviceWorker.register('/sw.js').then(function(reg){
640 var reloaded = false;
641 reg.addEventListener('updatefound', function(){
642 var newSW = reg.installing;
643 if (!newSW) return;
644 newSW.addEventListener('statechange', function(){
645 if (
646 newSW.state === 'activated' &&
647 hadControllerAtLoad &&
648 !reloaded
649 ) {
650 reloaded = true;
651 window.location.reload();
652 }
653 });
654 });
655 }).catch(function(){});
656 });
657 }
658`;
659
660// Block M2 — smart install banner (authed users only). Three heuristics:
661// 1. user is authenticated (gated server-side via the `{user &&}` JSX)
662// 2. visit counter (localStorage) ≥ 3
663// 3. dismissed-cooldown < 14 days ago
620// PWA kill-switch (2026-05-16) — replaces the previous pwaRegisterScript
621// and pwaInstallBannerScript. Those two scripts registered /sw.js and
622// /sw-push.js at the same scope, causing a reload loop that made the
623// admin dashboard unusable (deploy pill flashing, typing wiped, buttons
624// uncllickable). Per the SW spec, only one SW can control a scope; two
625// different script URLs at the same scope keep replacing each other.
664626//
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.
679const pwaInstallBannerScript = `
627// PWA is gone for good. A git host has no use for service workers,
628// install-as-app, or push notifications via SW. This script actively
629// unregisters every previously installed SW on the gluecron.com origin
630// so any browser still trapped in the loop recovers on the very next
631// page load — without needing the user to clear site data or open
632// DevTools. Idempotent and safe to keep running forever; once all
633// browsers have been cleaned, it's a no-op.
634const pwaKillSwitchScript = `
680635(function(){
681636 try {
682 var DISMISS_KEY = 'gc:pwa-banner-dismissed-at';
683 var VISITS_KEY = 'gc:pwa-visit-count';
684 var DISMISS_MS = 14 * 24 * 60 * 60 * 1000;
685 var visits = parseInt(localStorage.getItem(VISITS_KEY) || '0', 10) || 0;
686 visits++;
687 try { localStorage.setItem(VISITS_KEY, String(visits)); } catch(_){}
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(_){}
637 if (!('serviceWorker' in navigator)) return;
638 if (!navigator.serviceWorker.getRegistrations) return;
639 navigator.serviceWorker.getRegistrations().then(function(regs){
640 if (!regs || regs.length === 0) return;
641 regs.forEach(function(reg){
642 try { reg.unregister(); } catch(_){}
643 });
644 }).catch(function(){});
645 // Also drop any caches the old SWs left behind so the user gets
646 // truly fresh HTML on every page load. Restricted to gluecron-*
647 // namespaced caches so we don't trample anything a future opt-in
648 // feature might create under a different name.
649 if ('caches' in self) {
650 caches.keys().then(function(keys){
651 keys.forEach(function(k){
652 if (typeof k === 'string' && k.indexOf('gluecron') === 0) {
653 try { caches.delete(k); } catch(_){}
697654 }
698655 });
699656 }).catch(function(){});
700657 }
701 var deferredPrompt = null;
702 window.addEventListener('beforeinstallprompt', function(e){
703 e.preventDefault();
704 deferredPrompt = e;
705 maybeShow();
706 });
707 function maybeShow(){
708 if (!deferredPrompt) return;
709 if (visits < 3) return;
710 var dismissedAt = parseInt(localStorage.getItem(DISMISS_KEY) || '0', 10) || 0;
711 if (dismissedAt && (Date.now() - dismissedAt) < DISMISS_MS) return;
712 if (document.getElementById('gc-install-banner')) return;
713 var bar = document.createElement('div');
714 bar.id = 'gc-install-banner';
715 bar.setAttribute('role', 'dialog');
716 bar.setAttribute('aria-label', 'Install Gluecron');
717 bar.style.cssText = 'position:fixed;left:12px;right:12px;bottom:12px;z-index:9997;'
718 + 'background:var(--bg-elevated,#161b22);color:var(--text,#c9d1d9);'
719 + 'border:1px solid var(--border,#30363d);border-radius:8px;padding:12px 14px;'
720 + 'display:flex;gap:10px;align-items:center;justify-content:space-between;'
721 + 'box-shadow:0 8px 24px rgba(0,0,0,0.45);font-size:13px;line-height:1.3;'
722 + 'max-width:560px;margin:0 auto';
723 bar.innerHTML = '<span style="flex:1">'
724 + '<strong>Install Gluecron</strong> to keep working when offline + get push notifications.'
725 + '</span>'
726 + '<button type="button" id="gc-install-go" style="background:#238636;color:#fff;'
727 + 'border:0;border-radius:6px;padding:6px 12px;font-size:12px;cursor:pointer;'
728 + 'font-family:inherit">Install</button>'
729 + '<button type="button" id="gc-install-no" style="background:transparent;color:inherit;'
730 + 'border:1px solid var(--border,#30363d);border-radius:6px;padding:6px 12px;'
731 + 'font-size:12px;cursor:pointer;font-family:inherit">Not now</button>';
732 document.body.appendChild(bar);
733 var go = bar.querySelector('#gc-install-go');
734 var no = bar.querySelector('#gc-install-no');
735 if (go) go.addEventListener('click', function(){
736 try { deferredPrompt.prompt(); } catch(_){}
737 bar.parentNode && bar.parentNode.removeChild(bar);
738 deferredPrompt = null;
739 });
740 if (no) no.addEventListener('click', function(){
741 try { localStorage.setItem(DISMISS_KEY, String(Date.now())); } catch(_){}
742 bar.parentNode && bar.parentNode.removeChild(bar);
743 });
744 }
745658 } catch(_) {}
746659})();
747660`;
748661