- Implemented a service worker (sw.js) that handles installation, activation, and fetch events. - Added caching logic for public assets and a fallback for offline navigation. - Created unit tests (sw.test.ts) to validate service worker behavior, including asset caching, navigation handling, and activation logic. - Ensured that private API routes and third-party requests are excluded from caching.
91 lines
4.2 KiB
TypeScript
91 lines
4.2 KiB
TypeScript
import { useSyncExternalStore } from "react";
|
||
|
||
type InstallPrompt = Event & {
|
||
prompt: () => Promise<void>;
|
||
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
||
};
|
||
type PwaState = { enabled: boolean; installed: boolean; installPrompt: InstallPrompt | null; updateReady: boolean; error: string };
|
||
let state: PwaState = { enabled: false, installed: false, installPrompt: null, updateReady: false, error: "" };
|
||
const listeners = new Set<() => void>();
|
||
let registration: ServiceWorkerRegistration | undefined;
|
||
let reloading = false;
|
||
let started = false;
|
||
const publish = (change: Partial<PwaState>) => {
|
||
state = { ...state, ...change };
|
||
listeners.forEach(listener => listener());
|
||
};
|
||
const subscribe = (listener: () => void) => { listeners.add(listener); return () => { listeners.delete(listener); }; };
|
||
export const usePwa = () => useSyncExternalStore(subscribe, () => state, () => state);
|
||
const subscribeOnline = (listener: () => void) => {
|
||
window.addEventListener("online", listener);
|
||
window.addEventListener("offline", listener);
|
||
return () => { window.removeEventListener("online", listener); window.removeEventListener("offline", listener); };
|
||
};
|
||
export const useOnline = () => useSyncExternalStore(subscribeOnline, () => navigator.onLine !== false, () => true);
|
||
|
||
/** Called once before React renders so early install events cannot be missed. */
|
||
export function startPwa() {
|
||
if (started || !window.isSecureContext || !("serviceWorker" in navigator)) return;
|
||
started = true;
|
||
const standalone = window.matchMedia("(display-mode: standalone)");
|
||
const installed = () => standalone.matches || (navigator as Navigator & { standalone?: boolean }).standalone === true;
|
||
publish({ enabled: true, installed: installed() });
|
||
standalone.addEventListener("change", () => publish({ installed: installed() }));
|
||
window.addEventListener("beforeinstallprompt", event => {
|
||
event.preventDefault();
|
||
publish({ installPrompt: event as InstallPrompt });
|
||
});
|
||
window.addEventListener("appinstalled", () => publish({ installed: true, installPrompt: null }));
|
||
let controller = navigator.serviceWorker.controller;
|
||
navigator.serviceWorker.addEventListener("controllerchange", () => {
|
||
if (reloading) window.location.reload();
|
||
else if (controller) publish({ updateReady: true });
|
||
controller = navigator.serviceWorker.controller;
|
||
});
|
||
const register = async () => {
|
||
try {
|
||
registration = await navigator.serviceWorker.register("/sw.js", { scope: "/", updateViaCache: "none" });
|
||
publish({ updateReady: Boolean(registration.waiting) });
|
||
const observeInstall = () => {
|
||
const worker = registration?.installing;
|
||
worker?.addEventListener("statechange", () => {
|
||
if (worker.state === "installed") publish({ updateReady: Boolean(navigator.serviceWorker.controller), error: "" });
|
||
if (worker.state === "redundant" && !navigator.serviceWorker.controller)
|
||
publish({ error: "Offline setup didn’t finish. Reconnect and reload to try again." });
|
||
});
|
||
};
|
||
registration.addEventListener("updatefound", observeInstall);
|
||
observeInstall();
|
||
const check = () => {
|
||
if (navigator.onLine && document.visibilityState === "visible") void registration?.update().catch(() => {});
|
||
};
|
||
document.addEventListener("visibilitychange", check);
|
||
window.addEventListener("online", check);
|
||
window.setInterval(check, 60 * 60 * 1000);
|
||
} catch {
|
||
publish({ error: "Offline setup is unavailable. Reconnect and reload to try again." });
|
||
}
|
||
};
|
||
if (document.readyState === "complete") void register();
|
||
else window.addEventListener("load", () => { void register(); }, { once: true });
|
||
}
|
||
|
||
export async function installApp(): Promise<boolean> {
|
||
const prompt = state.installPrompt;
|
||
if (!prompt) return false;
|
||
publish({ installPrompt: null });
|
||
try {
|
||
await prompt.prompt();
|
||
await prompt.userChoice;
|
||
} catch { publish({ error: "Could not open installation. Try your browser’s install menu." }); }
|
||
return true;
|
||
}
|
||
|
||
export function applyUpdate() {
|
||
if (!state.updateReady) return;
|
||
if (registration?.waiting) {
|
||
reloading = true;
|
||
registration.waiting.postMessage({ type: "ACTIVATE_UPDATE" });
|
||
} else window.location.reload();
|
||
}
|