From 80ee2288998a180cf0cb246bbc4940f87af998f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 12 Nov 2026 15:28:37 +0100 Subject: [PATCH] fix(pwa): clear the leftover app badge from the page, not the worker A badge call from the service worker stalled Chromium's worker thread while it activated, awaited or not: the worker reached "activated" but never claimed its pages, so no page was ever controlled and every offline path was gone. The worker no longer touches the badge. The installed app clears a leftover count from the page once the worker is registered; a browser tab leaves it alone. The filter-sync test no longer reads `history.length`, which the browser caps; the recorded push without a replace already proves the new entry. --- public/sw.js | 13 ------- src/__tests__/service-worker.test.ts | 6 ++- .../service-worker-registrar.test.ts | 39 ++++++++++++++++++- src/components/service-worker-registrar.tsx | 17 ++++++++ .../use-url-filter-sync.browser.test.tsx | 4 +- 5 files changed, 61 insertions(+), 18 deletions(-) diff --git a/public/sw.js b/public/sw.js index e7bdd02e2..bf37050ea 100644 --- a/public/sw.js +++ b/public/sw.js @@ -70,9 +70,6 @@ self.addEventListener("activate", (event) => { : Promise.resolve(), ]).then(() => self.clients.claim()), ); - // Not part of the activation: a browser that never settles the badge call - // must not keep the worker from taking over its pages. - clearLeftoverAppBadge(); }); self.addEventListener("fetch", (event) => { @@ -355,16 +352,6 @@ async function trimCache(cacheName, maxEntries) { } } -// Earlier releases set a dose count on the app icon; nothing sends one now, -// so clear what they left. Best effort, outside the activation. -async function clearLeftoverAppBadge() { - try { - await self.navigator?.clearAppBadge?.(); - } catch { - // Optional. - } -} - self.addEventListener("push", (event) => { if (!event.data) return; diff --git a/src/__tests__/service-worker.test.ts b/src/__tests__/service-worker.test.ts index 6fc4a074e..76dff4bd5 100644 --- a/src/__tests__/service-worker.test.ts +++ b/src/__tests__/service-worker.test.ts @@ -482,13 +482,15 @@ describe("sw.js — activate", () => { expect(harness.navPreload.enabled).toBe(true); }); - it("clears the dose count an earlier release left on the app icon", async () => { + it("leaves the app icon badge to the page, never touches it while activating", async () => { + // A badge call from the worker can stall Chromium's worker thread, so the + // activation never claims its pages. The page clears a leftover count. const harness = bootServiceWorker(); const { badge } = swFakes(harness); await dispatchActivate(harness); - expect(badge.cleared).toBe(true); + expect(badge.cleared).toBe(false); }); }); diff --git a/src/components/__tests__/service-worker-registrar.test.ts b/src/components/__tests__/service-worker-registrar.test.ts index d4ece3ff7..9ffb644f6 100644 --- a/src/components/__tests__/service-worker-registrar.test.ts +++ b/src/components/__tests__/service-worker-registrar.test.ts @@ -8,7 +8,10 @@ */ import { describe, expect, it } from "vitest"; -import { shouldRegisterServiceWorker } from "../service-worker-registrar"; +import { + clearLeftoverAppBadge, + shouldRegisterServiceWorker, +} from "../service-worker-registrar"; describe("shouldRegisterServiceWorker", () => { it("registers in production with window + serviceWorker support", () => { @@ -29,3 +32,37 @@ describe("shouldRegisterServiceWorker", () => { expect(shouldRegisterServiceWorker("production", true, false)).toBe(false); }); }); + +describe("clearLeftoverAppBadge", () => { + function fakeWindow(standalone: boolean, hasApi = true) { + const calls = { cleared: 0 }; + const win = { + matchMedia: () => ({ matches: standalone }), + navigator: hasApi + ? { + clearAppBadge: async () => { + calls.cleared += 1; + }, + } + : {}, + } as unknown as Window; + return { win, calls }; + } + + it("clears the icon badge in the installed app", () => { + const { win, calls } = fakeWindow(true); + clearLeftoverAppBadge(win); + expect(calls.cleared).toBe(1); + }); + + it("leaves a browser tab alone", () => { + const { win, calls } = fakeWindow(false); + clearLeftoverAppBadge(win); + expect(calls.cleared).toBe(0); + }); + + it("does nothing where the badge API does not exist", () => { + const { win } = fakeWindow(true, false); + expect(() => clearLeftoverAppBadge(win)).not.toThrow(); + }); +}); diff --git a/src/components/service-worker-registrar.tsx b/src/components/service-worker-registrar.tsx index fb33f5fea..a9b60cc21 100644 --- a/src/components/service-worker-registrar.tsx +++ b/src/components/service-worker-registrar.tsx @@ -19,6 +19,22 @@ export function shouldRegisterServiceWorker( return true; } +/** + * Earlier releases set a dose count on the installed app's icon, and nothing + * sets one now, so a leftover count would stay for good. Cleared from the page + * of the installed app only, where the badge exists; never from the worker, + * where the call can stall Chromium's worker thread. + */ +export function clearLeftoverAppBadge(win: Window): void { + const standalone = + win.matchMedia?.("(display-mode: standalone)").matches ?? false; + const nav = win.navigator as Navigator & { + clearAppBadge?: () => Promise; + }; + if (!standalone || typeof nav.clearAppBadge !== "function") return; + nav.clearAppBadge().catch(() => undefined); +} + /** * App-wide service-worker registration. * @@ -64,6 +80,7 @@ export function ServiceWorkerRegistrar(): null { .catch(() => { /* best effort — registration failure must never break the app */ }); + clearLeftoverAppBadge(window); }); }; diff --git a/src/hooks/__tests__/use-url-filter-sync.browser.test.tsx b/src/hooks/__tests__/use-url-filter-sync.browser.test.tsx index 7d9c1bef0..a9f3cdb19 100644 --- a/src/hooks/__tests__/use-url-filter-sync.browser.test.tsx +++ b/src/hooks/__tests__/use-url-filter-sync.browser.test.tsx @@ -164,12 +164,12 @@ describe("useUrlFilterSync with FilterBar", () => { it("writes a facet picked from the open menu as a new history entry", async () => { const { pill, history, pick } = await mount(); - const entries = window.history.length; await pick("Type", "Pulse"); await expectSearch("?type=PULSE"); + // A push and no replace is the new entry. `history.length` is no proof: + // the browser caps it, and other files in this frame push entries too. expect(history.pushes()).toEqual(["?type=PULSE"]); expect(history.replaces()).toEqual([]); - expect(window.history.length).toBe(entries + 1); await expect.element(pill("Type: Pulse")).toBeInTheDocument(); });