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:
co-authored by
Claude Sonnet 4.6
parent
8aab25e0ac
commit
afff22e994
+103
-16
@@ -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’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’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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user