Add restart-to-update banner: waiting SW detected via watchForWaitingUpdate, explicit user opt-in applies SKIP_WAITING + reload (no auto mid-session claim, ARCH §8)
Some checks failed
ci / check (push) Has been cancelled

This commit is contained in:
Lumen Stage1 2026-10-03 13:12:25 -05:00
commit e46b14ff07
6 changed files with 266 additions and 5 deletions

View file

@ -7,7 +7,12 @@
*/ */
import "./styles.css"; import "./styles.css";
import { deleteShellCaches } from "../platform/cache/shell-cache.js"; import { deleteShellCaches } from "../platform/cache/shell-cache.js";
import { registerSW, unregisterAllServiceWorkers } from "../platform/sw/register.js"; import {
registerSW,
skipWaitingForUpdate,
unregisterAllServiceWorkers,
watchForWaitingUpdate,
} from "../platform/sw/register.js";
import { evaluateBootReadiness } from "./readiness.js"; import { evaluateBootReadiness } from "./readiness.js";
import type { BootReadinessReport } from "../domain/readiness/types.js"; import type { BootReadinessReport } from "../domain/readiness/types.js";
import { chipForState } from "../domain/readiness/logic.js"; import { chipForState } from "../domain/readiness/logic.js";
@ -30,6 +35,7 @@ import { Router, routeForPath, normalizePath } from "../ui/router/router.js";
import { createHomeView } from "../ui/views/home/home.js"; import { createHomeView } from "../ui/views/home/home.js";
import { createStatusView, createPrepGuidanceView } from "../ui/views/status/status.js"; import { createStatusView, createPrepGuidanceView } from "../ui/views/status/status.js";
import { createEmergencyView } from "../ui/views/emergency/emergency.js"; import { createEmergencyView } from "../ui/views/emergency/emergency.js";
import { showUpdateBanner } from "../ui/components/update-banner.js";
import { createMapView } from "../ui/views/map/map.js"; import { createMapView } from "../ui/views/map/map.js";
import { loadMapData, type MapHandle } from "./map.js"; import { loadMapData, type MapHandle } from "./map.js";
import type { MapViewData } from "../domain/map/types.js"; import type { MapViewData } from "../domain/map/types.js";
@ -378,9 +384,31 @@ function mount(): { router: Router; cleanup: () => void } {
void deleteShellCaches(); void deleteShellCaches();
} else { } else {
// Stage 3: register SW after boot — never blocks rendering (offline-safe) // Stage 3: register SW after boot — never blocks rendering (offline-safe)
void registerSW().catch(() => { void registerSW()
// registration failure is non-blocking — shell remains usable .then((result) => {
}); // "Restart to update": when a newer shell has installed and waits
// behind the current one, offer an explicit restart. The SW never
// claims clients mid-session on its own (ARCH §8) — this is the
// sanctioned opt-in.
if (result.status === "registered") {
watchForWaitingUpdate(result.registration, (reg) => {
showUpdateBanner(root, () => {
// One reload per controllerchange; the swap lands right after
// SKIP_WAITING wakes the waiting worker.
let reloaded = false;
navigator.serviceWorker.addEventListener("controllerchange", () => {
if (reloaded) return;
reloaded = true;
location.reload();
});
skipWaitingForUpdate(reg);
});
});
}
})
.catch(() => {
// registration failure is non-blocking — shell remains usable
});
} }
// Ensure the deterministic deep-link set exists: all four destinations must be reachable // Ensure the deterministic deep-link set exists: all four destinations must be reachable

View file

@ -921,3 +921,44 @@ html.dark .app-main__icon {
background: var(--chip-amber-bg); background: var(--chip-amber-bg);
color: var(--chip-amber-fg); color: var(--chip-amber-fg);
} }
/* Restart-to-update banner — appears only when a newer shell is installed
and waiting; tap applies (SKIP_WAITING + reload), Later dismisses. */
.update-banner {
position: fixed;
inset: auto 0 0 0;
z-index: 20;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem max(1rem, env(safe-area-inset-right)) max(0.75rem, env(safe-area-inset-bottom))
max(1rem, env(safe-area-inset-left));
background: color-mix(in srgb, var(--bg) 92%, transparent);
-webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
border-top: 1px solid var(--border-accent);
}
.update-banner__text {
flex: 1;
font-size: 0.9375rem;
color: var(--fg);
}
.update-banner__apply {
min-height: 44px;
padding: 0.5rem 1rem;
border: 1px solid var(--accent);
border-radius: var(--radius-sm);
background: var(--accent);
color: var(--accent-contrast);
font-weight: 600;
font-size: 0.875rem;
}
.update-banner__dismiss {
min-height: 44px;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--muted);
font-size: 0.875rem;
}

View file

@ -32,6 +32,37 @@ export function skipWaitingForUpdate(registration?: ServiceWorkerRegistration):
if (waiting) waiting.postMessage({ type: "SKIP_WAITING" }); if (waiting) waiting.postMessage({ type: "SKIP_WAITING" });
} }
/**
* Notify `onWaiting` when a newer shell has installed and is waiting behind
* the current one (never mid-session by itself — the user opts in via
* skipWaitingForUpdate). Covers both a registration that already has a
* `waiting` worker and one discovered later through `updatefound`.
* Only fires when a controller exists (a true update, not first install).
*/
export function watchForWaitingUpdate(
registration: ServiceWorkerRegistration,
onWaiting: (registration: ServiceWorkerRegistration) => void,
): void {
const hasController =
typeof navigator !== "undefined" &&
"serviceWorker" in navigator &&
navigator.serviceWorker.controller !== null;
if (!hasController) return; // first install activates on its own — nothing to offer
if (registration.waiting) {
onWaiting(registration);
return;
}
registration.addEventListener("updatefound", () => {
const installing = registration.installing;
if (!installing) return;
installing.addEventListener("statechange", () => {
if (installing.state === "installed" && registration.waiting) {
onWaiting(registration);
}
});
});
}
/** /**
* Unregister every service worker (dev-mode scrub only — production never * Unregister every service worker (dev-mode scrub only — production never
* calls this). Resolves true when a controlling SW was removed and the * calls this). Resolves true when a controlling SW was removed and the

View file

@ -0,0 +1,41 @@
/**
* "Restart to update" banner — shown when a newer shell is installed and
* waiting behind the current one. The SW never claims clients mid-session
* (ARCH §8: no auto skipWaiting/clients.claim), so activating an update is
* an explicit user action wired by the app layer via onApply. Dismiss hides
* it; the next full start picks the update up anyway. Text-only DOM via
* textContent (B-7); no storage, no network, no platform imports (B-1).
*/
export function showUpdateBanner(root: HTMLElement, onApply: () => void): void {
if (document.querySelector(".update-banner")) return; // idempotent
const banner = document.createElement("div");
banner.className = "update-banner";
banner.setAttribute("role", "status");
const label = document.createElement("span");
label.className = "update-banner__text";
label.textContent = "A new version of Lumen is ready.";
banner.append(label);
const apply = document.createElement("button");
apply.type = "button";
apply.className = "update-banner__apply";
apply.textContent = "Restart to update";
apply.addEventListener("click", () => {
onApply();
});
banner.append(apply);
const dismiss = document.createElement("button");
dismiss.type = "button";
dismiss.className = "update-banner__dismiss";
dismiss.textContent = "Later";
dismiss.setAttribute("aria-label", "Dismiss update notice");
dismiss.addEventListener("click", () => {
banner.remove();
});
banner.append(dismiss);
root.append(banner);
}

View file

@ -6,7 +6,11 @@
*/ */
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { readFileSync, existsSync } from "node:fs"; import { readFileSync, existsSync } from "node:fs";
import { registerSW, skipWaitingForUpdate } from "../../src/platform/sw/register.js"; import {
registerSW,
skipWaitingForUpdate,
watchForWaitingUpdate,
} from "../../src/platform/sw/register.js";
const SW_PATH = "public/sw.js"; const SW_PATH = "public/sw.js";
@ -148,6 +152,68 @@ describe("SW registration — page bridge", () => {
}); });
}); });
describe("watchForWaitingUpdate — restart-to-update discovery", () => {
function withController(controller: unknown): void {
Object.defineProperty(globalThis, "navigator", {
value: { serviceWorker: { controller } },
writable: true,
configurable: true,
});
}
it("no controller (first install) → never fires", () => {
withController(null);
const onWaiting = vi.fn();
const listeners: Record<string, () => void> = {};
const reg = {
waiting: { postMessage: vi.fn() },
addEventListener: (ev: string, cb: () => void) => {
listeners[ev] = cb;
},
} as unknown as ServiceWorkerRegistration;
watchForWaitingUpdate(reg, onWaiting);
expect(onWaiting).not.toHaveBeenCalled();
expect(listeners.updatefound).toBeUndefined();
});
it("already-waiting worker fires immediately", () => {
withController({ scriptURL: "/sw.js" });
const onWaiting = vi.fn();
const reg = {
waiting: { postMessage: vi.fn() },
addEventListener: vi.fn(),
} as unknown as ServiceWorkerRegistration;
watchForWaitingUpdate(reg, onWaiting);
expect(onWaiting).toHaveBeenCalledWith(reg);
});
it("updatefound → installed waiting worker fires", () => {
withController({ scriptURL: "/sw.js" });
const onWaiting = vi.fn();
let foundCb: (() => void) | undefined;
const stateCbs: (() => void)[] = [];
const installed = {
state: "installed",
addEventListener: (_ev: string, cb: () => void) => {
stateCbs.push(cb);
},
};
const reg = {
waiting: null as unknown,
installing: installed,
addEventListener: (ev: string, cb: () => void) => {
if (ev === "updatefound") foundCb = cb;
},
} as unknown as ServiceWorkerRegistration;
watchForWaitingUpdate(reg, onWaiting);
expect(onWaiting).not.toHaveBeenCalled();
foundCb?.();
(reg as { waiting: unknown }).waiting = { postMessage: vi.fn() };
for (const cb of stateCbs) cb();
expect(onWaiting).toHaveBeenCalledTimes(1);
});
});
describe("Cache safety — versioning and shell assets", () => { describe("Cache safety — versioning and shell assets", () => {
it("vite build injects hashed assets into precache manifest", () => { it("vite build injects hashed assets into precache manifest", () => {
const viteConfig = readFileSync("vite.config.ts", "utf8"); const viteConfig = readFileSync("vite.config.ts", "utf8");

View file

@ -0,0 +1,54 @@
/**
* @vitest-environment jsdom
* "Restart to update" banner — appears once, apply fires the opt-in,
* Later removes it. Pure DOM: no SW, no storage, no network.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { showUpdateBanner } from "../../src/ui/components/update-banner.js";
beforeEach(() => {
document.body.replaceChildren();
});
function root(): HTMLElement {
const el = document.createElement("div");
document.body.append(el);
return el;
}
describe("update banner", () => {
it("renders banner with apply and dismiss once (idempotent)", () => {
const host = root();
showUpdateBanner(host, () => undefined);
showUpdateBanner(host, () => undefined);
expect(document.querySelectorAll(".update-banner").length).toBe(1);
expect(host.textContent).toContain("new version");
expect(host.querySelector(".update-banner__apply")?.textContent).toBe("Restart to update");
});
it("apply invokes the opt-in callback exactly once per click", () => {
const host = root();
const onApply = vi.fn();
showUpdateBanner(host, onApply);
host.querySelector(".update-banner__apply")?.dispatchEvent(new MouseEvent("click"));
expect(onApply).toHaveBeenCalledTimes(1);
});
it("Later removes the banner without calling apply", () => {
const host = root();
const onApply = vi.fn();
showUpdateBanner(host, onApply);
host.querySelector(".update-banner__dismiss")?.dispatchEvent(new MouseEvent("click"));
expect(document.querySelector(".update-banner")).toBeNull();
expect(onApply).not.toHaveBeenCalled();
// Re-show after dismiss is allowed (a later update can re-offer).
showUpdateBanner(host, onApply);
expect(document.querySelectorAll(".update-banner").length).toBe(1);
});
it("renders no raw HTML from anywhere (textContent-only)", () => {
const host = root();
showUpdateBanner(host, () => undefined);
expect(host.querySelector(".update-banner")?.innerHTML).not.toMatch(/<img|<script/);
});
});