mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
fix(pwa): update the worker on Reload instead of unregistering it
The "new version" Reload action unregistered every service worker before reloading. Unregistering a registration ends its Web Push subscription and nothing in the app re-creates one, so each update silently turned push reminders off in the installed app: the server kept the dead endpoint and the settings card showed push as off. Reload now asks the registration to update (the new worker activates at once and drops the previous release's caches), deletes only HealthLog's own caches, and reloads. The cache-name contract moves into sw-cache-policy.ts so the session-end cleanup and the poller share it.
This commit is contained in:
1 parent
ee8044b379
commit
503802e363
4 files changed
+129
-13
No files matched your search
@@ -52,3 +52,95 @@ describe("resolveVersionPollDecision", () => {
|
||||
).toBe("reload");
|
||||
});
|
||||
});
|
||||
|
||||
describe("evictAndReload", () => {
|
||||
/**
|
||||
* The Reload action used to unregister every service worker. Unregistering
|
||||
* a registration ends its Web Push subscription, and nothing re-creates it,
|
||||
* so each update silently switched push reminders off in the installed app.
|
||||
* The action now updates the registration and deletes only HealthLog's
|
||||
* caches.
|
||||
*/
|
||||
function stubBrowser(cacheNames: string[]) {
|
||||
const calls = { update: 0, unregister: 0, reload: 0 };
|
||||
const deleted: string[] = [];
|
||||
const registration = {
|
||||
update: async () => {
|
||||
calls.update += 1;
|
||||
},
|
||||
unregister: async () => {
|
||||
calls.unregister += 1;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
const saved = {
|
||||
navigator: Object.getOwnPropertyDescriptor(globalThis, "navigator"),
|
||||
caches: g.caches,
|
||||
window: g.window,
|
||||
sessionStorage: g.sessionStorage,
|
||||
};
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
configurable: true,
|
||||
value: {
|
||||
serviceWorker: { getRegistrations: async () => [registration] },
|
||||
},
|
||||
});
|
||||
g.caches = {
|
||||
keys: async () => cacheNames,
|
||||
delete: async (name: string) => {
|
||||
deleted.push(name);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
g.window = {
|
||||
location: {
|
||||
reload: () => {
|
||||
calls.reload += 1;
|
||||
},
|
||||
},
|
||||
};
|
||||
g.sessionStorage = { setItem: () => {}, getItem: () => null };
|
||||
const restore = () => {
|
||||
if (saved.navigator)
|
||||
Object.defineProperty(globalThis, "navigator", saved.navigator);
|
||||
g.caches = saved.caches;
|
||||
g.window = saved.window;
|
||||
g.sessionStorage = saved.sessionStorage;
|
||||
};
|
||||
return { calls, deleted, restore };
|
||||
}
|
||||
|
||||
it("updates the worker instead of unregistering it, so the push subscription survives", async () => {
|
||||
const { evictAndReload } = await import("../version-poller");
|
||||
const browser = stubBrowser([]);
|
||||
try {
|
||||
await evictAndReload("9.9.9");
|
||||
} finally {
|
||||
browser.restore();
|
||||
}
|
||||
expect(browser.calls.unregister).toBe(0);
|
||||
expect(browser.calls.update).toBe(1);
|
||||
expect(browser.calls.reload).toBe(1);
|
||||
});
|
||||
|
||||
it("deletes HealthLog's caches and leaves another app's on the same origin", async () => {
|
||||
const { evictAndReload } = await import("../version-poller");
|
||||
const browser = stubBrowser([
|
||||
"healthlog-static-v1.41.2",
|
||||
"healthlog-pages-v1.41.2",
|
||||
"healthlog-data-v1.41.2",
|
||||
"other-app-runtime",
|
||||
]);
|
||||
try {
|
||||
await evictAndReload("9.9.9");
|
||||
} finally {
|
||||
browser.restore();
|
||||
}
|
||||
expect(browser.deleted.sort()).toEqual([
|
||||
"healthlog-data-v1.41.2",
|
||||
"healthlog-pages-v1.41.2",
|
||||
"healthlog-static-v1.41.2",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { toast } from "sonner";
|
||||
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { HEALTHLOG_CACHE_NAME_RE } from "@/lib/pwa/sw-cache-policy";
|
||||
|
||||
/**
|
||||
* v1.4.38.4 — runtime self-heal for the stale-shell post-deploy
|
||||
@@ -27,14 +28,24 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
* Reload — an in-progress form or chat draft is never destroyed by a
|
||||
* silent mid-session reload. On that click:
|
||||
*
|
||||
* 1. Unregister every active service worker so the next page load
|
||||
* doesn't reinstall the old SW.
|
||||
* 2. Delete every CacheStorage entry so the precached root HTML
|
||||
* and the `/_next/static/*` chunks from the previous deploy
|
||||
* can't be served back.
|
||||
* 1. Ask the service worker registration to update, so the new
|
||||
* deploy's worker (its `sw-version.js` changed) installs and takes
|
||||
* over; it activates at once (`skipWaiting` + `clients.claim`) and
|
||||
* drops the previous release's caches itself.
|
||||
* 2. Delete HealthLog's CacheStorage entries so the `/_next/static/*`
|
||||
* chunks and page shells from the previous deploy can't be served
|
||||
* back while that happens.
|
||||
* 3. `window.location.reload()` — fetches the fresh shell + new
|
||||
* chunk graph.
|
||||
*
|
||||
* v1.42 — the worker is updated, never unregistered. Unregistering a
|
||||
* registration ends its Web Push subscription, and nothing re-creates one:
|
||||
* every click on Reload silently switched off push reminders in the
|
||||
* installed app (the server kept the dead endpoint, the settings card
|
||||
* showed push as off). An update keeps the registration and with it the
|
||||
* subscription. The page navigation is network-first in the worker, so the
|
||||
* reload reaches the new shell whichever worker answers it.
|
||||
*
|
||||
* The toast is shown once per detected version (the poll keeps
|
||||
* running, but a repeated mismatch against a version we already
|
||||
* surfaced doesn't re-spam every 60 s). The reload guard is keyed on
|
||||
@@ -90,7 +101,7 @@ async function fetchLiveVersion(signal: AbortSignal): Promise<string | null> {
|
||||
// to break the loop within one document lifetime.
|
||||
let inMemoryReloadGuard: string | null = null;
|
||||
|
||||
async function evictAndReload(targetVersion: string): Promise<void> {
|
||||
export async function evictAndReload(targetVersion: string): Promise<void> {
|
||||
inMemoryReloadGuard = targetVersion;
|
||||
try {
|
||||
sessionStorage.setItem(SESSION_GUARD_KEY, targetVersion);
|
||||
@@ -102,16 +113,20 @@ async function evictAndReload(targetVersion: string): Promise<void> {
|
||||
if ("serviceWorker" in navigator) {
|
||||
try {
|
||||
const regs = await navigator.serviceWorker.getRegistrations();
|
||||
await Promise.all(regs.map((r) => r.unregister()));
|
||||
await Promise.all(regs.map((r) => r.update()));
|
||||
} catch {
|
||||
/* best effort */
|
||||
/* best effort — the reload below still fetches the new shell */
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof caches !== "undefined") {
|
||||
try {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.map((k) => caches.delete(k)));
|
||||
await Promise.all(
|
||||
keys
|
||||
.filter((k) => HEALTHLOG_CACHE_NAME_RE.test(k))
|
||||
.map((k) => caches.delete(k)),
|
||||
);
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
|
||||
@@ -35,16 +35,16 @@ import {
|
||||
import { trackBackgroundTask } from "@/lib/logging/background-tasks";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { isReadingSharedRecord } from "@/lib/query-keys/record-scope";
|
||||
// Session-end cleanup removes every current/legacy HealthLog cache family
|
||||
// while leaving unrelated CacheStorage namespaces on the same origin
|
||||
// untouched; the name contract is shared with `public/sw.js`.
|
||||
import { HEALTHLOG_CACHE_NAME_RE } from "@/lib/pwa/sw-cache-policy";
|
||||
|
||||
const DB_NAME = "healthlog-query-cache";
|
||||
const STORE = "kv";
|
||||
const KEY = "react-query";
|
||||
const VERSION_KEY = "react-query-version";
|
||||
const MAX_AGE_MS = 24 * 60 * 60 * 1000; // discard anything older than a day
|
||||
// Keep this ownership contract aligned with `public/sw.js`. Session-end
|
||||
// cleanup removes every current/legacy HealthLog cache family while leaving
|
||||
// unrelated CacheStorage namespaces on the same origin untouched.
|
||||
const HEALTHLOG_CACHE_NAME_RE = /^healthlog-(?:static|pages|data)-/;
|
||||
|
||||
/**
|
||||
* Upper bound on the serialized snapshot. The allowlist already caps WHAT is
|
||||
|
||||
@@ -14,6 +14,15 @@
|
||||
* allowlisted by mistake — mirroring the idempotency cache's refusal.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The worker's own cache names: `healthlog-{static,pages,data}-<version>`.
|
||||
* Everything the app deletes from CacheStorage (session end, the "new
|
||||
* version" reload) matches against this and nothing else, so another app's
|
||||
* caches on the same origin are left alone. Keep in lockstep with
|
||||
* `HEALTHLOG_CACHE_NAME_RE` in `public/sw.js`.
|
||||
*/
|
||||
export const HEALTHLOG_CACHE_NAME_RE = /^healthlog-(?:static|pages|data)-/;
|
||||
|
||||
/**
|
||||
* Path prefixes whose safe GET reads are eligible for the offline data cache.
|
||||
*
|
||||
|
||||
Reference in new issue
Block a user