Implement offline shell + service worker caching (task 51)

SW is now generated by next.config.ts on every build with a versioned
cache name (timestamp in prod, "dev" in dev). Strategies: stale-while-
revalidate for static chunks and navigation, network-first with 2s
timeout for API GETs, network-only for mutations. Offline mutations
postMessage to clients; pwa-register.tsx shows amber offline banner,
red mutation-failed toast, and indigo "new version" refresh prompt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ginnoir
2026-05-06 16:14:36 -05:00
co-authored by Claude Sonnet 4.6
parent 8aab25e0ac
commit afff22e994
4 changed files with 351 additions and 60 deletions
+103 -16
View File
@@ -1,28 +1,115 @@
"use client";
import { useEffect } from "react";
import { useEffect, useState } from "react";
export function PwaRegister() {
const [showUpdate, setShowUpdate] = useState(false);
// Lazy init reads navigator.onLine on the client; SSR always returns false.
const [isOffline, setIsOffline] = useState(
() => typeof navigator !== "undefined" && !navigator.onLine
);
const [mutationFailed, setMutationFailed] = useState(false);
useEffect(() => {
if (!("serviceWorker" in navigator)) return;
const handleOffline = () => setIsOffline(true);
const handleOnline = () => setIsOffline(false);
window.addEventListener("offline", handleOffline);
window.addEventListener("online", handleOnline);
if (!("serviceWorker" in navigator)) {
return () => {
window.removeEventListener("offline", handleOffline);
window.removeEventListener("online", handleOnline);
};
}
// Capture whether a SW already controls this page before registering.
// If true and the controller later changes, a new build has deployed.
const hadController = !!navigator.serviceWorker.controller;
navigator.serviceWorker
.register("/sw.js", { scope: "/" })
.then((reg) => {
reg.addEventListener("updatefound", () => {
const next = reg.installing;
if (!next) return;
next.addEventListener("statechange", () => {
if (next.state === "installed" && navigator.serviceWorker.controller) {
// New version available — the next navigation will pick it up
// because skipWaiting() is called in the SW install handler.
console.info("[famapp] Service worker updated.");
}
});
});
})
.catch((err) => console.warn("[famapp] SW registration failed:", err));
const handleControllerChange = () => {
if (hadController) setShowUpdate(true);
};
navigator.serviceWorker.addEventListener(
"controllerchange",
handleControllerChange
);
const handleMessage = (e: MessageEvent) => {
if (e.data?.type === "OFFLINE_MUTATION") {
setMutationFailed(true);
setTimeout(() => setMutationFailed(false), 4000);
}
};
navigator.serviceWorker.addEventListener("message", handleMessage);
return () => {
window.removeEventListener("offline", handleOffline);
window.removeEventListener("online", handleOnline);
navigator.serviceWorker.removeEventListener(
"controllerchange",
handleControllerChange
);
navigator.serviceWorker.removeEventListener("message", handleMessage);
};
}, []);
return null;
return (
<>
{isOffline && (
<div
role="status"
className="fixed top-4 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 rounded-lg bg-amber-50 px-4 py-3 text-amber-900 shadow-lg ring-1 ring-amber-200 dark:bg-amber-950 dark:text-amber-100 dark:ring-amber-800"
>
<span className="text-sm font-medium">
You&rsquo;re offline showing cached content
</span>
<button
onClick={() => setIsOffline(false)}
aria-label="Dismiss"
className="text-sm opacity-60 hover:opacity-100"
>
</button>
</div>
)}
{mutationFailed && !isOffline && (
<div
role="alert"
className="fixed top-4 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 rounded-lg bg-destructive px-4 py-3 text-destructive-foreground shadow-lg"
>
<span className="text-sm font-medium">
Changes can&rsquo;t be saved while offline
</span>
</div>
)}
{showUpdate && (
<div
role="status"
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 rounded-lg bg-primary px-4 py-3 text-primary-foreground shadow-lg"
>
<span className="text-sm font-medium">New version available</span>
<button
onClick={() => window.location.reload()}
className="text-sm font-semibold underline underline-offset-2"
>
Refresh
</button>
<button
onClick={() => setShowUpdate(false)}
aria-label="Dismiss"
className="text-sm opacity-60 hover:opacity-100"
>
</button>
</div>
)}
</>
);
}