feat(pwa): improve install and update lifecycle
- commit
- adbae75db3476675cec34e57e40ef6095f7dbe9a
- parent
- 416731131178674230c72242a605b529d2ef5032
- parent
- a42d44c553ec74db43c673904a25ae0c21f50de6
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | docs/ | 18 | ++ |
| M | package/ | 1 | + |
| M | package/ | 19 | ++- |
| M | package/ | 52 | +++++- |
| M | package/ | 1 | + |
| M | package/ | 2 | + |
| M | package/ | 253 | +++++++++++++++++++++++++++--- |
| M | package/ | 163 | +++++++++++++++++++- |
| M | tools/ | 58 | +++++++ |
@@ -135,6 +135,24 @@ Navigation preload remains disabled: generated routes and their assets are preca
135135 parallel network request before the normal cache-first lookup would spend bandwidth on the expected136136 offline-ready path. Jazz sync, OPFS storage, background sync, and push remain outside the worker.137137 138+### Install and update lifecycle139+140+The optional `PwaActions` UI uses a browser prompt only while `beforeinstallprompt` is actually141+available. iOS receives its Share-menu steps. Other secure browsers with service-worker support get142+generic browser-menu guidance that says to use **Install app** or **Add to Home Screen** only if the143+browser offers it; an insecure or unsupported context is reported separately.144+145+When the app returns to the foreground, restores from the back-forward cache, or reconnects, Lofi146+asks the active service-worker registration to check for an update. Checks share one in-flight147+request, time out, and are rate-limited. Update state moves through `checking`, `installing`,148+`ready`, and `applying`; a waiting worker activates only after the user chooses **Update app**. Only149+that explicit action permits the following controller change to reload the page, so ordinary worker150+changes cannot create a reload loop.151+152+A runtime-cache write error is best-effort and leaves the active worker ready. Registration,153+required precache, and activation failures remain worker failures; update-check failures leave the154+current worker running and retry on a later foreground signal.155+138156 Do not run a server-side Jazz credential in the static host or expose `JAZZ_ADMIN_SECRET` or139157 `BACKEND_SECRET` as public environment variables.140158 @@ -145,6 +145,7 @@ export default function DeviceStatus(): VNode {
145145 <Row label="Display mode" value={report.displayMode} />146146 <Row label="Install" value={pwa.install} />147147 <Row label="Service worker" value={pwa.worker} />148+ <Row label="Update" value={pwa.update} />148149 </dl>149150 <PwaActions title="Install & updates" />150151 </div>@@ -8,7 +8,7 @@ import {
88 } from "../runtime/pwa.ts";99 1010 export { pwaFailureMessage } from "../runtime/pwa.ts";11-export type { PwaController, PwaFailureCode, PwaState } from "../runtime/pwa.ts";11+export type { PwaController, PwaFailureCode, PwaState, PwaUpdateState } from "../runtime/pwa.ts";1212 1313 /** Subscribes a Preact component to an isolated or shared PWA controller. */1414 export function usePwaState(controller: PwaController = pwaController): PwaState {@@ -32,8 +32,9 @@ export function PwaActions({
3232 }: PwaActionsProps): VNode | null {3333 const state = usePwaState(controller);3434 const visible = state.install === "available" || state.install === "manual-ios" ||35+ state.install === "manual-browser" || state.install === "unsupported" ||3536 state.install === "accepted" || state.install === "dismissed" ||36- state.worker === "update-available" || Boolean(state.failure);37+ state.update !== "idle" || Boolean(state.failure);3738 if (!visible) return null;3839 3940 return (@@ -63,7 +64,18 @@ export function PwaActions({
6364 </ol>6465 </div>6566 )}66- {state.worker === "update-available" && (67+ {state.install === "manual-browser" && (68+ <div class="pwa-browser-guidance">69+ <p>No in-page install button is available right now.</p>70+ <p>Look in its menu for Install app or Add to Home Screen, if offered.</p>71+ </div>72+ )}73+ {state.install === "unsupported" && (74+ <p>Installation is not supported in this browser or browsing context.</p>75+ )}76+ {state.update === "checking" && <p role="status">Checking for updates…</p>}77+ {state.update === "installing" && <p role="status">Installing update…</p>}78+ {state.update === "ready" && (6779 <button6880 type="button"6981 onClick={() => controller.applyUpdate()}@@ -71,6 +83,7 @@ export function PwaActions({
7183 Update app7284 </button>7385 )}86+ {state.update === "applying" && <p role="status">Applying update…</p>}7487 {state.failure && (7588 <p class="pwa-error" role="alert">{pwaFailureMessage(state.failure.code)}</p>7689 )}@@ -10,6 +10,7 @@ function controller(state: PwaState): PwaController {
1010 return () => undefined;1111 },1212 requestInstall: () => Promise.resolve(state.install),13+ checkForUpdate: () => Promise.resolve(false),1314 applyUpdate: () => true,1415 initialize: () => undefined,1516 };@@ -20,8 +21,8 @@ function renderState(state: PwaState): string {
2021 }2122 2223 Deno.test("PwaActions renders a Chromium action only while the prompt is available", () => {23- const available = renderState({ worker: "ready", install: "available" });24- const dismissed = renderState({ worker: "ready", install: "dismissed" });24+ const available = renderState({ worker: "ready", install: "available", update: "idle" });25+ const dismissed = renderState({ worker: "ready", install: "dismissed", update: "idle" });2526 if (!available.includes("Install app")) throw new Error("install action was omitted");2627 if (dismissed.includes("Install app")) throw new Error("install action outlived its prompt");2728 if (!dismissed.includes("Installation dismissed")) {@@ -30,25 +31,58 @@ Deno.test("PwaActions renders a Chromium action only while the prompt is availab
3031 });3132 3233 Deno.test("PwaActions renders complete iOS Add to Home Screen guidance", () => {33- const html = renderState({ worker: "ready", install: "manual-ios" });34+ const html = renderState({ worker: "ready", install: "manual-ios", update: "idle" });3435 for (const step of ["Open the Share menu.", "Choose Add to Home Screen.", "Confirm Add."]) {3536 if (!html.includes(step)) throw new Error(`iOS guidance omitted: ${step}`);3637 }3738 if (/unavailable in (?:the )?EU/i.test(html)) throw new Error("obsolete EU guidance returned");3839 });3940 40-Deno.test("PwaActions renders the waiting-worker update action", () => {41- const html = renderState({ worker: "update-available", install: "unavailable" });42- if (!html.includes("Update app")) throw new Error("update action was omitted");41+Deno.test("PwaActions renders truthful generic manual-install guidance", () => {42+ const html = renderState({ worker: "ready", install: "manual-browser", update: "idle" });43+ if (!html.includes("No in-page install button is available right now")) {44+ throw new Error("missing prompt limitation");45+ }46+ if (!html.includes("if offered")) throw new Error("manual guidance invented availability");47+});48+49+Deno.test("PwaActions renders every active update phase", () => {50+ const checking = renderState({ worker: "ready", install: "installed", update: "checking" });51+ const installing = renderState({ worker: "ready", install: "installed", update: "installing" });52+ const ready = renderState({ worker: "ready", install: "installed", update: "ready" });53+ const applying = renderState({ worker: "ready", install: "installed", update: "applying" });54+ if (!checking.includes("Checking for updates")) throw new Error("checking state was omitted");55+ if (!installing.includes("Installing update")) throw new Error("installing state was omitted");56+ if (!ready.includes("Update app")) throw new Error("waiting-worker action was omitted");57+ if (!applying.includes("Applying update")) throw new Error("applying state was omitted");58+});59+60+Deno.test("PwaActions distinguishes unsupported installation", () => {61+ const html = renderState({ worker: "unsupported", install: "unsupported", update: "idle" });62+ if (!html.includes("not supported in this browser or browsing context")) {63+ throw new Error("unsupported environment was hidden");64+ }4365 });4466 4567 Deno.test("actionable PWA failure messages are fixed, useful, and non-secret", () => {46- for (const code of ["registration", "installation", "precache", "runtime-cache"] as const) {68+ for (69+ const code of [70+ "registration",71+ "installation",72+ "install-prompt",73+ "update-check",74+ "precache",75+ "runtime-cache",76+ ] as const77+ ) {4778 const message = pwaFailureMessage(code);48- if (!/(Reload|Reconnect)/.test(message)) throw new Error(`${code} lacks an action`);79+ if (!/(Reload|Reconnect|browser menu)/i.test(message)) {80+ throw new Error(`${code} lacks an action`);81+ }4982 const html = renderState({5083 worker: "failed",51- install: "unavailable",84+ install: "unsupported",85+ update: code === "update-check" ? "failed" : "idle",5286 failure: { code, message: "secret-value-must-not-render" },5387 });5488 if (!html.includes(message) || html.includes("secret-value")) {@@ -42,6 +42,7 @@ export {
4242 } from "./device-capabilities.ts";4343 export {4444 applyPwaUpdate,45+ checkPwaUpdate,4546 createPwaController,4647 getPwaState,4748 type InstallEnvironment,@@ -53,6 +54,7 @@ export {
5354 pwaFailureMessage,5455 type PwaInstallState,5556 type PwaState,57+ type PwaUpdateState,5658 type PwaWorkerState,5759 requestPwaInstall,5860 subscribePwaState,@@ -6,7 +6,6 @@ export type PwaWorkerState =
66 | "unsupported"77 | "registering"88 | "ready"9- | "update-available"109 | "failed";1110 1211 /** Browser installation states exposed to application UI. */@@ -17,15 +16,32 @@ export type PwaInstallState =
1716 | "accepted"1817 | "dismissed"1918 | "manual-ios"20- | "unavailable";19+ | "manual-browser"20+ | "unsupported";21+22+/** Foreground update-check and waiting-worker states exposed to application UI. */23+export type PwaUpdateState =24+ | "idle"25+ | "checking"26+ | "installing"27+ | "ready"28+ | "applying"29+ | "failed";2130 2231 /** Stable categories for recoverable offline/PWA failures. */23-export type PwaFailureCode = "registration" | "installation" | "precache" | "runtime-cache";32+export type PwaFailureCode =33+ | "registration"34+ | "installation"35+ | "install-prompt"36+ | "update-check"37+ | "precache"38+ | "runtime-cache";2439 2540 /** Current install, service-worker, and offline-cache state. */2641 export type PwaState = {2742 worker: PwaWorkerState;2843 install: PwaInstallState;44+ update: PwaUpdateState;2945 failure?: {3046 code: PwaFailureCode;3147 message: string;@@ -44,6 +60,8 @@ export type InstallEnvironment = {
4460 navigatorStandalone: boolean;4561 platform: string;4662 maxTouchPoints: number;63+ secureContext: boolean;64+ serviceWorkerSupported: boolean;4765 };4866 4967 type LofiPwaGlobal = typeof globalThis & { __LOFI_PWA_STATE__?: PwaState };@@ -53,6 +71,10 @@ const actionableFailureMessages: Record<PwaFailureCode, string> = {
5371 "Offline support could not start. Reload on a stable connection; if it continues, clear this site's storage and retry.",5472 installation:5573 "The offline update could not install. Reconnect, keep this tab open, and reload to try again.",74+ "install-prompt":75+ "The browser install prompt could not open. Use the browser menu if it offers Install app or Add to Home Screen.",76+ "update-check":77+ "The app could not check for an update. Reconnect and bring it to the foreground to try again.",5678 precache:5779 "Required offline files were not saved. Reconnect, reload, and wait for offline support before disconnecting.",5880 "runtime-cache":@@ -67,8 +89,17 @@ export function pwaFailureMessage(code: PwaFailureCode): string {
6789 /** Injectable browser surfaces used to test the PWA lifecycle deterministically. */6890 export type PwaControllerDependencies = {6991 readonly eventTarget?: () => EventTarget;92+ readonly visibilityTarget?: () => EventTarget;7093 readonly serviceWorker?: () => ServiceWorkerContainer | undefined;7194 readonly installEnvironment?: () => InstallEnvironment;95+ readonly isVisible?: () => boolean;96+ readonly now?: () => number;97+ readonly setTimeout?: (callback: () => void, milliseconds: number) => unknown;98+ readonly clearTimeout?: (handle: unknown) => void;99+ /** Minimum time between foreground update checks; defaults to one minute. */100+ readonly updateCheckIntervalMs?: number;101+ /** Maximum time exposed as an active update check; defaults to ten seconds. */102+ readonly updateCheckTimeoutMs?: number;72103 readonly production?: () => boolean;73104 /** Absolute URL of the configured deployment base, independent of the current route. */74105 readonly deploymentBaseUrl?: () => string;@@ -83,6 +114,7 @@ export type PwaController = {
83114 getState(): PwaState;84115 subscribe(subscriber: (state: PwaState) => void): () => void;85116 requestInstall(): Promise<PwaInstallState>;117+ checkForUpdate(): Promise<boolean>;86118 applyUpdate(): boolean;87119 initialize(): void;88120 };@@ -103,7 +135,10 @@ export function classifyInstallExperience(environment: InstallEnvironment): PwaI
103135 if (environment.displayModeStandalone || environment.navigatorStandalone) return "installed";104136 const iosLike = /^(iPhone|iPad|iPod)$/.test(environment.platform) ||105137 (environment.platform === "MacIntel" && environment.maxTouchPoints > 1);106- return iosLike ? "manual-ios" : "unavailable";138+ if (iosLike && environment.secureContext) return "manual-ios";139+ return environment.secureContext && environment.serviceWorkerSupported140+ ? "manual-browser"141+ : "unsupported";107142 }108143 109144 export function waitForActivation(worker: ServiceWorker): Promise<void> {@@ -128,11 +163,17 @@ export function waitForActivation(worker: ServiceWorker): Promise<void> {
128163 /** Creates an isolated PWA controller, primarily for custom integration and tests. */129164 export function createPwaController(dependencies: PwaControllerDependencies = {}): PwaController {130165 const subscribers = new Set<(state: PwaState) => void>();166+ const observedUpdateWorkers = new WeakSet<ServiceWorker>();131167 let deferredInstallPrompt: InstallPromptEvent | undefined;132168 let waitingWorker: ServiceWorker | undefined;169+ let activeRegistration: ServiceWorkerRegistration | undefined;170+ let activeContainer: ServiceWorkerContainer | undefined;171+ let updateCheck: Promise<boolean> | null = null;172+ let lastUpdateCheckAt = Number.NEGATIVE_INFINITY;133173 let reloadForUpdate = false;174+ let foregroundChecksAttached = false;134175 let initialized = false;135- let state: PwaState = { worker: "registering", install: "unavailable" };176+ let state: PwaState = { worker: "registering", install: "unsupported", update: "idle" };136177 137178 const publish = (next: PwaState) => {138179 state = next;@@ -140,10 +181,19 @@ export function createPwaController(dependencies: PwaControllerDependencies = {}
140181 for (const subscriber of subscribers) subscriber(state);141182 };142183 const update = (patch: Partial<PwaState>) => publish({ ...state, ...patch });143- const fail = (code: PwaFailureCode) => {184+ const failure = (code: PwaFailureCode) => ({ code, message: pwaFailureMessage(code) });185+ const clearFailure = (...codes: PwaFailureCode[]) =>186+ state.failure && codes.includes(state.failure.code) ? undefined : state.failure;187+ const failWorker = (code: "registration" | "installation" | "precache") => {144188 update({ worker: "failed", failure: { code, message: pwaFailureMessage(code) } });145189 };190+ const failUpdate = (code: "installation" | "update-check") => {191+ update({ update: "failed", failure: failure(code) });192+ };146193 const target = () => dependencies.eventTarget?.() ?? globalThis;194+ const visibilityTarget = () =>195+ dependencies.visibilityTarget?.() ??196+ (typeof document !== "undefined" ? document : target());147197 const serviceWorker = () =>148198 dependencies.serviceWorker?.() ??149199 (typeof navigator !== "undefined" && "serviceWorker" in navigator@@ -155,7 +205,106 @@ export function createPwaController(dependencies: PwaControllerDependencies = {}
155205 navigatorStandalone: (navigator as Navigator & { standalone?: boolean }).standalone === true,156206 platform: navigator.platform,157207 maxTouchPoints: navigator.maxTouchPoints,208+ secureContext: globalThis.isSecureContext,209+ serviceWorkerSupported: "serviceWorker" in navigator,158210 };211+ const isVisible = () =>212+ dependencies.isVisible?.() ??213+ (typeof document === "undefined" || document.visibilityState === "visible");214+ const now = () => dependencies.now?.() ?? Date.now();215+ const scheduleTimeout = (callback: () => void, milliseconds: number) =>216+ dependencies.setTimeout217+ ? dependencies.setTimeout(callback, milliseconds)218+ : globalThis.setTimeout(callback, milliseconds);219+ const cancelTimeout = (handle: unknown) => {220+ if (dependencies.clearTimeout) dependencies.clearTimeout(handle);221+ else globalThis.clearTimeout(handle as number);222+ };223+ const updateCheckIntervalMs = dependencies.updateCheckIntervalMs ?? 60_000;224+ const updateCheckTimeoutMs = dependencies.updateCheckTimeoutMs ?? 10_000;225+226+ const boundedRegistrationUpdate = (227+ registration: ServiceWorkerRegistration,228+ ): Promise<ServiceWorkerRegistration> =>229+ new Promise((resolve, reject) => {230+ let settled = false;231+ let timeoutHandle: unknown = undefined;232+ const finish = () => {233+ if (settled) return false;234+ settled = true;235+ if (timeoutHandle !== undefined) cancelTimeout(timeoutHandle);236+ return true;237+ };238+ timeoutHandle = scheduleTimeout(239+ () => {240+ if (finish()) reject(new Error("service worker update check timed out"));241+ },242+ updateCheckTimeoutMs,243+ );244+ Promise.resolve().then(() => registration.update()).then(245+ (next) => {246+ if (finish()) resolve(next);247+ },248+ (error) => {249+ if (finish()) reject(error instanceof Error ? error : new Error(String(error)));250+ },251+ );252+ });253+254+ const checkForUpdate = (): Promise<boolean> => {255+ const registration = activeRegistration;256+ const container = activeContainer;257+ if (!registration || !container?.controller || state.worker !== "ready") {258+ return Promise.resolve(false);259+ }260+ if (261+ state.update === "installing" || state.update === "ready" || state.update === "applying"262+ ) {263+ return Promise.resolve(false);264+ }265+ if (updateCheck) return updateCheck;266+ const checkedAt = now();267+ if (checkedAt - lastUpdateCheckAt < updateCheckIntervalMs) return Promise.resolve(false);268+ lastUpdateCheckAt = checkedAt;269+ update({270+ update: "checking",271+ failure: clearFailure("update-check"),272+ });273+ const operation = boundedRegistrationUpdate(registration).then(274+ () => {275+ if (state.update === "checking") {276+ update({ update: "idle", failure: clearFailure("update-check") });277+ }278+ return true;279+ },280+ () => {281+ if (state.update === "checking") failUpdate("update-check");282+ return true;283+ },284+ );285+ const tracked = operation.finally(() => {286+ if (updateCheck === tracked) updateCheck = null;287+ });288+ updateCheck = tracked;289+ return tracked;290+ };291+292+ const requestUpdateFromEvent = () => {293+ void checkForUpdate();294+ };295+ const onPageShow = (event: Event) => {296+ if ((event as PageTransitionEvent).persisted === true) requestUpdateFromEvent();297+ };298+ const onVisibilityChange = () => {299+ if (isVisible()) requestUpdateFromEvent();300+ };301+ const attachForegroundChecks = () => {302+ if (foregroundChecksAttached) return;303+ foregroundChecksAttached = true;304+ target().addEventListener("pageshow", onPageShow);305+ target().addEventListener("online", requestUpdateFromEvent);306+ visibilityTarget().addEventListener("visibilitychange", onVisibilityChange);307+ };159308 160309 const watchForUpdate = (161310 registration: ServiceWorkerRegistration,@@ -163,21 +312,41 @@ export function createPwaController(dependencies: PwaControllerDependencies = {}
163312 ) => {164313 const exposeWaitingWorker = (worker: ServiceWorker) => {165314 waitingWorker = worker;166- update({ worker: "update-available", failure: undefined });315+ update({316+ worker: "ready",317+ update: "ready",318+ failure: clearFailure("installation", "update-check"),319+ });320+ };321+ const observeInstallingWorker = (worker: ServiceWorker) => {322+ if (observedUpdateWorkers.has(worker)) return;323+ observedUpdateWorkers.add(worker);324+ update({325+ worker: "ready",326+ update: "installing",327+ failure: clearFailure("installation", "update-check"),328+ });329+ const onStateChange = () => {330+ if (worker.state === "installed") {331+ exposeWaitingWorker(registration.waiting ?? worker);332+ } else if (worker.state === "activated") {333+ update({ worker: "ready", update: "idle" });334+ } else if (worker.state === "redundant") {335+ failUpdate("installation");336+ }337+ };338+ worker.addEventListener("statechange", onStateChange);339+ onStateChange();167340 };168341 169342 if (registration.waiting && container.controller) exposeWaitingWorker(registration.waiting);170343 registration.addEventListener("updatefound", () => {171344 const worker = registration.installing;172- if (!worker) return;173- worker.addEventListener("statechange", () => {174- if (worker.state === "installed" && container.controller) {175- exposeWaitingWorker(registration.waiting ?? worker);176- } else if (worker.state === "redundant") {177- fail("installation");178- }179- });345+ if (worker && container.controller) observeInstallingWorker(worker);180346 });347+ if (registration.installing && container.controller) {348+ observeInstallingWorker(registration.installing);349+ }181350 };182351 183352 const watchInstallExperience = () => {@@ -185,11 +354,11 @@ export function createPwaController(dependencies: PwaControllerDependencies = {}
185354 target().addEventListener("beforeinstallprompt", (event) => {186355 event.preventDefault();187356 deferredInstallPrompt = event as InstallPromptEvent;188- update({ install: "available" });357+ update({ install: "available", failure: clearFailure("install-prompt") });189358 });190359 target().addEventListener("appinstalled", () => {191360 deferredInstallPrompt = undefined;192- update({ install: "installed" });361+ update({ install: "installed", failure: clearFailure("install-prompt") });193362 });194363 };195364 @@ -212,10 +381,20 @@ export function createPwaController(dependencies: PwaControllerDependencies = {}
212381 if (213382 message?.type === "LOFI_PWA_FAILURE" &&214383 (message.code === "precache" || message.code === "runtime-cache")215- ) fail(message.code);384+ ) {385+ if (message.code === "precache") failWorker("precache");386+ else update({ failure: failure("runtime-cache") });387+ }216388 });217389 container.addEventListener("controllerchange", () => {218390 if (!reloadForUpdate) return;391+ reloadForUpdate = false;392+ waitingWorker = undefined;393+ update({394+ worker: "ready",395+ update: "idle",396+ failure: clearFailure("installation", "update-check"),397+ });219398 if (dependencies.reload) dependencies.reload();220399 else location.reload();221400 });@@ -230,18 +409,22 @@ export function createPwaController(dependencies: PwaControllerDependencies = {}
230409 const registration = await container.register(workerUrl, {231410 scope,232411 });412+ activeRegistration = registration;413+ activeContainer = container;233414 watchForUpdate(registration, container);415+ attachForegroundChecks();234416 if (registration.waiting && container.controller) return;417+ if (registration.installing && container.controller) return;235418 const worker = registration.installing ?? registration.active ?? registration.waiting;236419 if (!worker) throw new Error("service worker registration has no worker");237420 try {238421 await waitForActivation(worker);239422 } catch {240- fail("installation");423+ failWorker("installation");241424 return;242425 }243- update({ worker: "ready", failure: undefined });244- })().catch(() => fail("registration"));426+ update({ worker: "ready", update: "idle", failure: undefined });427+ })().catch(() => failWorker("registration"));245428 };246429 247430 return {@@ -263,16 +446,24 @@ export function createPwaController(dependencies: PwaControllerDependencies = {}
263446 update({ install: outcome });264447 return outcome;265448 } catch {266- update({ install: "unavailable" });267- fail("installation");268- return "unavailable";449+ const fallback = classifyInstallExperience(installEnvironment());450+ update({ install: fallback, failure: failure("install-prompt") });451+ return fallback;269452 }270453 },454+ checkForUpdate,271455 applyUpdate() {272- if (!waitingWorker) return false;456+ if (!waitingWorker || state.update !== "ready") return false;273457 reloadForUpdate = true;274- waitingWorker.postMessage({ type: "LOFI_SKIP_WAITING" });275- return true;458+ try {459+ waitingWorker.postMessage({ type: "LOFI_SKIP_WAITING" });460+ update({ update: "applying", failure: clearFailure("installation", "update-check") });461+ return true;462+ } catch {463+ reloadForUpdate = false;464+ failUpdate("installation");465+ return false;466+ }276467 },277468 initialize,278469 };@@ -300,6 +491,14 @@ export function requestPwaInstall(): Promise<PwaInstallState> {
300491 return pwaController.requestInstall();301492 }302493 494+/**495+ * Runs the shared controller's bounded update check when registration is ready.496+ * Resolves true when a check ran or was already in flight; inspect `PwaState.update` for outcome.497+ */498+export function checkPwaUpdate(): Promise<boolean> {499+ return pwaController.checkForUpdate();500+}501+303502 /** Activates a waiting service worker; returns false when no update is ready. */304503 export function applyPwaUpdate(): boolean {305504 return pwaController.applyUpdate();@@ -36,6 +36,14 @@ class FakeRegistration extends EventTarget {
3636 installing: ServiceWorker | null = null;3737 waiting: ServiceWorker | null = null;3838 active: ServiceWorker | null = null;39+ updateCalls = 0;40+ updateImplementation: () => Promise<ServiceWorkerRegistration> = () =>41+ Promise.resolve(this as unknown as ServiceWorkerRegistration);42+43+ update(): Promise<ServiceWorkerRegistration> {44+ this.updateCalls += 1;45+ return this.updateImplementation();46+ }3947 }4048 4149 class FakeContainer extends EventTarget {@@ -64,18 +72,34 @@ function testController(options: {
6472 install?: PwaInstallState;6573 reload?: () => void;6674 deploymentBaseUrl?: string;75+ visibilityTarget?: EventTarget;76+ isVisible?: () => boolean;77+ now?: () => number;78+ setTimeout?: (callback: () => void, milliseconds: number) => unknown;79+ clearTimeout?: (handle: unknown) => void;80+ updateCheckIntervalMs?: number;81+ updateCheckTimeoutMs?: number;6782 } = {}): PwaController {6883 const environment = options.install === "manual-ios"6984 ? { platform: "iPhone", maxTouchPoints: 5 }7085 : { platform: "Linux x86_64", maxTouchPoints: 0 };7186 return createPwaController({7287 eventTarget: () => options.target ?? new EventTarget(),88+ visibilityTarget: () => options.visibilityTarget ?? new EventTarget(),7389 serviceWorker: () => options.container as unknown as ServiceWorkerContainer,7490 installEnvironment: () => ({7591 displayModeStandalone: options.install === "installed",7692 navigatorStandalone: false,93+ secureContext: options.install !== "unsupported",94+ serviceWorkerSupported: options.install !== "unsupported",7795 ...environment,7896 }),97+ isVisible: options.isVisible,98+ now: options.now,99+ setTimeout: options.setTimeout,100+ clearTimeout: options.clearTimeout,101+ updateCheckIntervalMs: options.updateCheckIntervalMs,102+ updateCheckTimeoutMs: options.updateCheckTimeoutMs,79103 production: () => options.production ?? false,80104 deploymentBaseUrl: () => options.deploymentBaseUrl ?? "http://127.0.0.1:4321/",81105 reload: options.reload,@@ -162,6 +186,8 @@ test("install experience recognizes standalone display modes", () => {
162186 navigatorStandalone: false,163187 platform: "Linux armv8l",164188 maxTouchPoints: 5,189+ secureContext: true,190+ serviceWorkerSupported: true,165191 });166192 if (state !== "installed") throw new Error(`expected installed, received ${state}`);167193 });@@ -172,18 +198,44 @@ test("install experience gives iOS manual guidance in every region", () => {
172198 navigatorStandalone: false,173199 platform: "iPhone",174200 maxTouchPoints: 5,201+ secureContext: true,202+ serviceWorkerSupported: true,175203 });176204 const ipadDesktopMode = classifyInstallExperience({177205 displayModeStandalone: false,178206 navigatorStandalone: false,179207 platform: "MacIntel",180208 maxTouchPoints: 5,209+ secureContext: true,210+ serviceWorkerSupported: true,181211 });182212 if (iphone !== "manual-ios" || ipadDesktopMode !== "manual-ios") {183213 throw new Error(`expected iOS guidance, received ${iphone} and ${ipadDesktopMode}`);184214 }185215 });186216 217+test("install experience distinguishes manual browser fallback from unsupported contexts", () => {218+ const manual = classifyInstallExperience({219+ displayModeStandalone: false,220+ navigatorStandalone: false,221+ platform: "Linux x86_64",222+ maxTouchPoints: 0,223+ secureContext: true,224+ serviceWorkerSupported: true,225+ });226+ const unsupported = classifyInstallExperience({227+ displayModeStandalone: false,228+ navigatorStandalone: false,229+ platform: "Linux x86_64",230+ maxTouchPoints: 0,231+ secureContext: false,232+ serviceWorkerSupported: true,233+ });234+ if (manual !== "manual-browser" || unsupported !== "unsupported") {235+ throw new Error(`unexpected fallback states ${manual} and ${unsupported}`);236+ }237+});238+187239 test("install prompt availability and accepted outcome are observable", async () => {188240 const target = new EventTarget();189241 const controller = testController({ target });@@ -221,15 +273,120 @@ test("waiting-worker update activation posts skip-waiting and reloads on control
221273 production: true,222274 reload: () => reloaded = true,223275 });224- const available = waitForState(controller, (state) => state.worker === "update-available");276+ const available = waitForState(controller, (state) => state.update === "ready");225277 controller.initialize();226278 await available;279+ if (await controller.checkForUpdate() || container.registration.updateCalls !== 0) {280+ throw new Error("foreground check replaced an actionable waiting update");281+ }227282 if (!controller.applyUpdate()) throw new Error("update action was unavailable");283+ if (controller.getState().update !== "applying") {284+ throw new Error("update application state was not observable");285+ }228286 if (JSON.stringify(waiting.messages) !== JSON.stringify([{ type: "LOFI_SKIP_WAITING" }])) {229287 throw new Error("update action did not request activation");230288 }231289 container.dispatchEvent(new Event("controllerchange"));232290 if (!reloaded) throw new Error("controller change did not reload after update action");291+ reloaded = false;292+ container.dispatchEvent(new Event("controllerchange"));293+ if (reloaded) throw new Error("one update action caused a reload loop");294+});295+296+test("foreground update checks are single-flight and rate-limited", async () => {297+ const target = new EventTarget();298+ const visibility = new EventTarget();299+ const container = new FakeContainer();300+ const active = new FakeServiceWorker("activated");301+ container.controller = asServiceWorker(active);302+ container.registration.active = asServiceWorker(active);303+ let currentTime = 0;304+ let resolveUpdate: ((registration: ServiceWorkerRegistration) => void) | undefined;305+ container.registration.updateImplementation = () =>306+ new Promise((resolve) => resolveUpdate = resolve);307+ const controller = testController({308+ target,309+ visibilityTarget: visibility,310+ isVisible: () => true,311+ container,312+ production: true,313+ now: () => currentTime,314+ updateCheckIntervalMs: 100,315+ });316+ controller.initialize();317+ await waitForState(controller, (state) => state.worker === "ready");318+ visibility.dispatchEvent(new Event("visibilitychange"));319+ target.dispatchEvent(new Event("online"));320+ await Promise.resolve();321+ if (container.registration.updateCalls !== 1 || controller.getState().update !== "checking") {322+ throw new Error("overlapping foreground signals did not share one update check");323+ }324+ const idle = waitForState(controller, (state) => state.update === "idle");325+ resolveUpdate?.(container.registration as unknown as ServiceWorkerRegistration);326+ await idle;327+ target.dispatchEvent(new Event("online"));328+ await Promise.resolve();329+ if (container.registration.updateCalls !== 1) {330+ throw new Error("update check ignored the rate limit");331+ }332+ currentTime = 100;333+ container.registration.updateImplementation = () =>334+ Promise.resolve(container.registration as unknown as ServiceWorkerRegistration);335+ target.dispatchEvent(new Event("online"));336+ await Promise.resolve();337+ await Promise.resolve();338+ if (Number(container.registration.updateCalls) !== 2) {339+ throw new Error("update check did not resume after the rate limit");340+ }341+});342+343+test("a bounded foreground update failure leaves the active worker ready", async () => {344+ const target = new EventTarget();345+ const container = new FakeContainer();346+ const active = new FakeServiceWorker("activated");347+ container.controller = asServiceWorker(active);348+ container.registration.active = asServiceWorker(active);349+ container.registration.updateImplementation = () => new Promise(() => undefined);350+ let expire: (() => void) | undefined;351+ const controller = testController({352+ target,353+ container,354+ production: true,355+ setTimeout: (callback) => {356+ expire = callback;357+ return 1;358+ },359+ clearTimeout: () => undefined,360+ });361+ controller.initialize();362+ await waitForState(controller, (state) => state.worker === "ready");363+ const failed = waitForState(controller, (state) => state.update === "failed");364+ target.dispatchEvent(new Event("online"));365+ await Promise.resolve();366+ expire?.();367+ const state = await failed;368+ if (state.worker !== "ready" || state.failure?.code !== "update-check") {369+ throw new Error("bounded update failure was reported as an active-worker failure");370+ }371+});372+373+test("an installing update becomes explicitly ready", async () => {374+ const container = new FakeContainer();375+ const active = new FakeServiceWorker("activated");376+ container.controller = asServiceWorker(active);377+ container.registration.active = asServiceWorker(active);378+ const controller = testController({ container, production: true });379+ controller.initialize();380+ await waitForState(controller, (state) => state.worker === "ready");381+ const installing = new FakeServiceWorker("installing");382+ container.registration.installing = asServiceWorker(installing);383+ container.registration.dispatchEvent(new Event("updatefound"));384+ if (controller.getState().update !== "installing") {385+ throw new Error("installing update state was hidden");386+ }387+ const ready = waitForState(controller, (state) => state.update === "ready");388+ installing.transition("installed");389+ await ready;233390 });234391 235392 test("registration failure is classified without leaking the browser error", async () => {@@ -279,5 +436,9 @@ for (const code of ["precache", "runtime-cache"] as const) {
279436 if (!state.failure?.message.includes("Reconnect")) {280437 throw new Error(`${code} failure is not actionable`);281438 }439+ const expectedWorker = code === "runtime-cache" ? "ready" : "failed";440+ if (state.worker !== expectedWorker) {441+ throw new Error(`${code} produced worker state ${state.worker}, expected ${expectedWorker}`);442+ }282443 });283444 }@@ -861,6 +861,31 @@ async function main() {
861861 await waitForListening(preview!, port);862862 },863863 );864+ await stage("manual install fallback browser", async () => {865+ const fallbackPage = await context!.newPage();866+ try {867+ await fallbackPage.addInitScript(() => {868+ const addEventListener = globalThis.addEventListener.bind(globalThis);869+ globalThis.addEventListener = ((870+ type: string,871+ listener: EventListenerOrEventListenerObject,872+ options?: boolean | AddEventListenerOptions,873+ ) => {874+ if (type !== "beforeinstallprompt") addEventListener(type, listener, options);875+ }) as typeof globalThis.addEventListener;876+ });877+ await fallbackPage.goto(origin, { waitUntil: "domcontentloaded" });878+ await fallbackPage.getByText("No in-page install button is available right now.")879+ .waitFor({ state: "visible", timeout: 30_000 });880+ const guidance = await fallbackPage.locator(".pwa-browser-guidance").textContent() ?? "";881+ assert(882+ guidance.includes("if offered"),883+ "manual browser guidance claimed an unavailable install capability",884+ );885+ } finally {886+ await fallbackPage.close();887+ }888+ });864889 await stage("production offline browser", async () => {865890 await page!.goto(origin, { waitUntil: "domcontentloaded" });866891 await waitForReady(@@ -925,6 +950,39 @@ async function main() {
925950 `unexpected public sync state ${state.sync}`,926951 );927952 });953+ await stage("waiting worker update browser", async () => {954+ const workerPath = join(projectRoot!, "dist", "sw.js");955+ const worker = await Deno.readTextFile(workerPath);956+ await Deno.writeTextFile(workerPath, `${worker}\n// golden update ${Date.now()}\n`);957+ await page!.evaluate(() => document.dispatchEvent(new Event("visibilitychange")));958+ await waitForReady(959+ page!,960+ () => {961+ const global = globalThis as typeof globalThis & {962+ __LOFI_PWA_STATE__?: { update?: string };963+ };964+ return global.__LOFI_PWA_STATE__?.update === "ready";965+ },966+ undefined,967+ { description: "waiting service-worker update", timeoutMs: 30_000 },968+ );969+ await Promise.all([970+ page!.waitForNavigation({ waitUntil: "domcontentloaded", timeout: 30_000 }),971+ page!.getByRole("button", { name: "Update app" }).click(),972+ ]);973+ await waitForReady(974+ page!,975+ () => {976+ const global = globalThis as typeof globalThis & {977+ __LOFI_PWA_STATE__?: { worker?: string; update?: string };978+ };979+ return global.__LOFI_PWA_STATE__?.worker === "ready" &&980+ global.__LOFI_PWA_STATE__?.update === "idle";981+ },982+ undefined,983+ { description: "applied service-worker update", timeoutMs: 30_000 },984+ );985+ });928986 await context.tracing.stop();929987 await stage("preview shutdown", () => preview!.stop());930988 preview = undefined;