PWA Pattern¶
Progressive Web App with offline support and update management.
Overview¶
flowchart TB
subgraph Registration
A[App Load] --> B[Register SW]
B --> C[SW Active]
end
subgraph Caching
D[Request] --> E{Cache Strategy}
E -->|HTML| F[NetworkFirst]
E -->|API| G[NetworkOnly]
E -->|Images| H[CacheFirst]
E -->|Fonts| I[CacheFirst]
end
subgraph Updates
J[Check Updates] --> K{New Version?}
K -->|Yes| L[Show Prompt]
L --> M[User Accepts]
M --> N[Reload App]
end Service Worker Registration¶
import { registerSW } from "virtual:pwa-register";
import { PWA, EVENTS } from "./constants/app.constants";
import { usePwaUpdateStore } from "./stores/pwaUpdateStore";
export const updateSW = registerSW({
immediate: true,
onNeedRefresh() {
usePwaUpdateStore.getState().setUpdateAvailable();
},
onRegisteredSW(_swUrl, registration) {
if (!registration) {
return;
}
setInterval(() => {
registration.update().catch(() => {});
}, PWA.UPDATE_CHECK_INTERVAL_MS);
},
onRegisterError(error) {
Sentry.captureException(error, {
tags: { context: "service_worker_registration" },
});
},
});
window.addEventListener(EVENTS.PWA_APPLY_UPDATE, () => updateSW(true));
Cache Strategies¶
| Resource | Strategy | TTL | Max Entries |
|---|---|---|---|
| HTML | NetworkFirst | 3s timeout | — |
| API calls | NetworkOnly | — | — |
| Images | CacheFirst | 30 days | 100 |
| Fonts | CacheFirst | 1 year | 30 |
| JS/CSS | StaleWhileRevalidate | — | — |
Workbox Configuration¶
// vite.config.ts
VitePWA({
registerType: "prompt",
workbox: {
runtimeCaching: [
{
urlPattern: /\.html$/,
handler: "NetworkFirst",
options: {
networkTimeoutSeconds: 3,
cacheName: "html-cache",
},
},
{
urlPattern: /\/api\//,
handler: "NetworkOnly",
},
{
urlPattern: /\.(png|jpg|jpeg|webp|svg)$/,
handler: "CacheFirst",
options: {
cacheName: "image-cache",
expiration: {
maxEntries: 100,
maxAgeSeconds: 30 * 24 * 60 * 60,
},
},
},
{
urlPattern: /\.(woff2?|ttf|otf)$/,
handler: "CacheFirst",
options: {
cacheName: "font-cache",
expiration: {
maxEntries: 30,
maxAgeSeconds: 365 * 24 * 60 * 60,
},
},
},
],
},
});
Update Store¶
type PwaUpdateState = {
updateAvailable: boolean;
dismissed: boolean;
setUpdateAvailable: (available: boolean) => void;
dismiss: () => void;
applyUpdate: () => void;
};
const usePwaUpdateStore = create<PwaUpdateState>((set) => ({
updateAvailable: false,
dismissed: false,
setUpdateAvailable: (available) => set({ updateAvailable: available }),
dismiss: () => set({ dismissed: true }),
applyUpdate: () => {
window.dispatchEvent(new CustomEvent(EVENTS.PWA_APPLY_UPDATE));
},
}));
Update Dialog¶
function PwaUpdateDialog() {
const { updateAvailable, dismissed, dismiss, applyUpdate } =
usePwaUpdateStore();
if (!updateAvailable || dismissed) return null;
return (
<Dialog open>
<DialogTitle>{t("pwa.updateAvailable")}</DialogTitle>
<DialogContent>
{t("pwa.updateDescription")}
</DialogContent>
<DialogActions>
<Button onClick={dismiss}>{t("shared.later")}</Button>
<Button onClick={applyUpdate} variant="contained">
{t("shared.update")}
</Button>
</DialogActions>
</Dialog>
);
}
Auto-Update Check¶
function useAutoUpdateOnIdle() {
useEffect(() => {
let timeout: number;
const resetTimer = () => {
clearTimeout(timeout);
timeout = window.setTimeout(() => {
// Check for updates after 30 min idle
navigator.serviceWorker?.ready.then((registration) => {
registration.update();
});
}, 30 * 60 * 1000);
};
window.addEventListener("mousemove", resetTimer);
window.addEventListener("keydown", resetTimer);
resetTimer();
return () => {
clearTimeout(timeout);
window.removeEventListener("mousemove", resetTimer);
window.removeEventListener("keydown", resetTimer);
};
}, []);
}
Manifest¶
{
"name": "Unicorn Trails",
"short_name": "Trails",
"theme_color": "#673ab7",
"background_color": "#ffffff",
"display": "standalone",
"start_url": "/",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}
Related¶
- State Management — Update store
- Dialogs — Update dialog