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.
This commit is contained in:
Marc-André Bombeck committed 2026-11-12 15:28:37 +01:00
1 parent 45a5d4e353
commit 80ee228899
5 files changed
+61 -18

No files matched your search

-13
View File
@@ -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;
+4 -2
View File
@@ -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);
});
});
@@ -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();
});
});
@@ -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<void>;
};
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);
});
};
@@ -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();
});