fix(pwa): keep a subscribed worker registered behind a gateway

A same-origin auth gateway that answers an expired session with a 200
sign-in page returns HTML for both the navigation and the /api/version
probe, which read as another app. The worker then unregistered and
destroyed the Web Push subscription, which nothing re-creates, so
reminders stopped without notice.

The worker no longer unregisters while the registration holds a push
subscription, or when the lookup is unavailable. It deletes its caches
and passes requests to the network for the rest of its life instead.
The probe now sends same-origin credentials, so a valid gateway cookie
reaches HealthLog.

Refs #847
This commit is contained in:
Marc-André Bombeck committed 2026-09-13 14:45:31 +02:00
1 parent b1331de130
commit 873e9cb4fb
2 files changed
+101 -5

No files matched your search

+63 -1
View File
@@ -136,6 +136,8 @@ interface SwHarness {
cacheStorage: FakeCacheStorage;
navPreload: { enabled: boolean; enable: () => Promise<void> };
unregisterCalls: { count: number };
/** What `pushManager.getSubscription()` does; default: no subscription. */
push: { getSubscription: () => Promise<unknown> };
lifetimePromises: Promise<unknown>[];
context: Record<string, unknown>;
}
@@ -145,6 +147,7 @@ function bootServiceWorker(): SwHarness {
const cacheStorage = new FakeCacheStorage();
const lifetimePromises: Promise<unknown>[] = [];
const unregisterCalls = { count: 0 };
const push = { getSubscription: async (): Promise<unknown> => null };
const navPreload = {
enabled: false,
enable: async () => {
@@ -167,6 +170,9 @@ function bootServiceWorker(): SwHarness {
unregisterCalls.count += 1;
return true;
},
pushManager: {
getSubscription: () => push.getSubscription(),
},
showNotification: async (title: string, opts: { tag?: string }) => {
shown.push({ title, tag: opts?.tag, closed: false });
},
@@ -219,6 +225,7 @@ function bootServiceWorker(): SwHarness {
cacheStorage,
navPreload,
unregisterCalls,
push,
lifetimePromises,
context,
};
@@ -803,7 +810,7 @@ describe("sw.js — foreign origin (#847)", () => {
expect(harness.unregisterCalls.count).toBe(1);
});
it("probes with no-store and a manual redirect", async () => {
it("probes with no-store, a manual redirect and same-origin credentials", async () => {
const harness = bootServiceWorker();
let probeInit: RequestInit | undefined;
harness.context.fetch = async (
@@ -826,6 +833,8 @@ describe("sw.js — foreign origin (#847)", () => {
expect(probeInit?.cache).toBe("no-store");
expect(probeInit?.redirect).toBe("manual");
// A valid same-origin gateway cookie must reach /api/version.
expect(probeInit?.credentials).toBe("same-origin");
});
it("does not retire when a header-stripping proxy hides the marker from a real HealthLog", async () => {
@@ -924,6 +933,59 @@ describe("sw.js — foreign origin (#847)", () => {
expect(harness.unregisterCalls.count).toBe(1);
});
it("keeps a worker that holds a push subscription: caches deleted, no unregister, pass-through", async () => {
const harness = bootServiceWorker();
await seedHealthlogCaches(harness);
harness.push.getSubscription = async () => ({
endpoint: "https://push.example/abc",
});
// A gateway's 200 sign-in page for both the navigation and the probe.
routeFetch(harness, foreignHtml, foreignHtml);
await dispatchNavigationFetch(harness, "/");
await Promise.all(harness.lifetimePromises);
expect(harness.unregisterCalls.count).toBe(0);
expect([...harness.cacheStorage.stores.keys()]).toEqual([
"next-app-runtime",
]);
expect(
dispatchAnyFetch(harness, "/_next/static/chunks/app.js").responded,
).toBe(false);
expect(
dispatchAnyFetch(harness, "/about", { accept: "text/html" }).responded,
).toBe(false);
});
it.each([
[
"the subscription lookup throws",
(h: SwHarness) => {
h.push.getSubscription = async () => {
throw new Error("push service unavailable");
};
},
],
[
"pushManager is missing",
(h: SwHarness) => {
delete (h.context.self as { registration: { pushManager?: unknown } })
.registration.pushManager;
},
],
])("does not unregister when %s", async (_label, arrange) => {
const harness = bootServiceWorker();
await seedHealthlogCaches(harness);
arrange(harness);
routeFetch(harness, foreignHtml, foreignHtml);
await dispatchNavigationFetch(harness, "/");
await Promise.all(harness.lifetimePromises);
expect(harness.unregisterCalls.count).toBe(0);
expect(harness.cacheStorage.stores.has(CURRENT_STATIC_CACHE)).toBe(false);
});
it("still unregisters when deleting the caches fails", async () => {
const harness = bootServiceWorker();
harness.cacheStorage.keys = async () => {