pwa-development ยท diff

git:20251218.8e57ad2 to git:20260113.90b2d6e

115 added, 103 removed. Audit B to B.

---
name: pwa-development
description: Progressive Web App development for installable, offline-capable web applications. Use when building PWAs, implementing service workers, or creating offline-first experiences.
---
# Progressive Web App (PWA) Development
Build installable, offline-capable web applications that work across all platforms.
## PWA Capabilities
- | Feature | Support |
- |---------|---------|
- | Offline access | All modern browsers |
- | Install prompt | Chrome, Edge, Samsung |
+ | Feature | Support |
+ | ------------------ | --------------------- |
+ | Offline access | All modern browsers |
+ | Install prompt | Chrome, Edge, Samsung |
| Push notifications | All except iOS Safari |
- | Background sync | Chrome, Edge |
- | File handling | Chrome, Edge |
- | Share target | Chrome, Edge, Safari |
+ | Background sync | Chrome, Edge |
+ | File handling | Chrome, Edge |
+ | Share target | Chrome, Edge, Safari |
---
## Core Requirements
### 1. Web App Manifest
+
```json
// public/manifest.json
{
"name": "My Progressive Web App",
"short_name": "MyPWA",
"description": "A progressive web application",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#3b82f6",
"orientation": "portrait-primary",
"scope": "/",
"icons": [
{
"src": "/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src": "/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png"
},
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"screenshots": [
{
"src": "/screenshots/desktop.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide"
},
{
"src": "/screenshots/mobile.png",
"sizes": "750x1334",
"type": "image/png",
"form_factor": "narrow"
}
],
"shortcuts": [
{
"name": "New Document",
"short_name": "New",
"url": "/new",
"icons": [{ "src": "/icons/new.png", "sizes": "96x96" }]
}
],
"share_target": {
"action": "/share",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"title": "title",
"text": "text",
"url": "url",
"files": [
{
"name": "files",
"accept": ["image/*", "text/*"]
}
]
}
}
}
```
### 2. HTML Meta Tags
+
```html
<!DOCTYPE html>
<html lang="en">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
- <!-- PWA Meta Tags -->
- <link rel="manifest" href="/manifest.json">
- <meta name="theme-color" content="#3b82f6">
- <meta name="description" content="My Progressive Web App">
+ <!-- PWA Meta Tags -->
+ <link rel="manifest" href="/manifest.json" />
+ <meta name="theme-color" content="#3b82f6" />
+ <meta name="description" content="My Progressive Web App" />
- <!-- iOS Specific -->
- <meta name="apple-mobile-web-app-capable" content="yes">
- <meta name="apple-mobile-web-app-status-bar-style" content="default">
- <meta name="apple-mobile-web-app-title" content="MyPWA">
- <link rel="apple-touch-icon" href="/icons/icon-192x192.png">
+ <!-- iOS Specific -->
+ <meta name="apple-mobile-web-app-capable" content="yes" />
+ <meta name="apple-mobile-web-app-status-bar-style" content="default" />
+ <meta name="apple-mobile-web-app-title" content="MyPWA" />
+ <link rel="apple-touch-icon" href="/icons/icon-192x192.png" />
- <!-- Windows Specific -->
- <meta name="msapplication-TileColor" content="#3b82f6">
- <meta name="msapplication-TileImage" content="/icons/icon-144x144.png">
+ <!-- Windows Specific -->
+ <meta name="msapplication-TileColor" content="#3b82f6" />
+ <meta name="msapplication-TileImage" content="/icons/icon-144x144.png" />
- <title>My PWA</title>
- </head>
- <body>
- <div id="root"></div>
- <script type="module" src="/src/main.tsx"></script>
- </body>
+ <title>My PWA</title>
+ </head>
+ <body>
+ <div id="root"></div>
+ <script type="module" src="/src/main.tsx"></script>
+ </body>
</html>
```
---
## Service Worker
### Basic Service Worker
+
```typescript
// public/sw.js
- const CACHE_NAME = 'my-pwa-v1';
+ const CACHE_NAME = "my-pwa-v1";
const STATIC_ASSETS = [
- '/',
- '/index.html',
- '/manifest.json',
- '/icons/icon-192x192.png',
+ "/",
+ "/index.html",
+ "/manifest.json",
+ "/icons/icon-192x192.png",
];
// Install - cache static assets
- self.addEventListener('install', (event) => {
+ self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(STATIC_ASSETS);
- })
+ }),
);
self.skipWaiting();
});
// Activate - clean old caches
- self.addEventListener('activate', (event) => {
+ self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
- .map((name) => caches.delete(name))
+ .map((name) => caches.delete(name)),
);
- })
+ }),
);
self.clients.claim();
});
// Fetch - serve from cache, fallback to network
- self.addEventListener('fetch', (event) => {
+ self.addEventListener("fetch", (event) => {
event.respondWith(
caches.match(event.request).then((cached) => {
// Cache-first strategy
if (cached) {
return cached;
}
// Network fallback
return fetch(event.request).then((response) => {
// Don't cache non-GET or failed requests
- if (event.request.method !== 'GET' || !response.ok) {
+ if (event.request.method !== "GET" || !response.ok) {
return response;
}
// Cache successful responses
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseClone);
});
return response;
});
- })
+ }),
);
});
```
### Workbox (Recommended)
+
```typescript
// src/sw.ts
- import { precacheAndRoute } from 'workbox-precaching';
- import { registerRoute } from 'workbox-routing';
+ import { precacheAndRoute } from "workbox-precaching";
+ import { registerRoute } from "workbox-routing";
import {
CacheFirst,
NetworkFirst,
StaleWhileRevalidate,
- } from 'workbox-strategies';
- import { ExpirationPlugin } from 'workbox-expiration';
+ } from "workbox-strategies";
+ import { ExpirationPlugin } from "workbox-expiration";
// Precache static assets
precacheAndRoute(self.__WB_MANIFEST);
// Cache images
registerRoute(
- ({ request }) => request.destination === 'image',
+ ({ request }) => request.destination === "image",
new CacheFirst({
- cacheName: 'images',
+ cacheName: "images",
plugins: [
new ExpirationPlugin({
maxEntries: 100,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
}),
],
- })
+ }),
);
// Network-first for API calls
registerRoute(
- ({ url }) => url.pathname.startsWith('/api/'),
+ ({ url }) => url.pathname.startsWith("/api/"),
new NetworkFirst({
- cacheName: 'api-cache',
+ cacheName: "api-cache",
plugins: [
new ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 5 * 60, // 5 minutes
}),
],
- })
+ }),
);
// Stale-while-revalidate for pages
registerRoute(
- ({ request }) => request.mode === 'navigate',
+ ({ request }) => request.mode === "navigate",
new StaleWhileRevalidate({
- cacheName: 'pages',
- })
+ cacheName: "pages",
+ }),
);
```
### Service Worker Registration
+
```typescript
// src/registerSW.ts
export async function registerServiceWorker() {
- if ('serviceWorker' in navigator) {
+ if ("serviceWorker" in navigator) {
try {
- const registration = await navigator.serviceWorker.register('/sw.js', {
- scope: '/',
+ const registration = await navigator.serviceWorker.register("/sw.js", {
+ scope: "/",
});
- registration.addEventListener('updatefound', () => {
+ registration.addEventListener("updatefound", () => {
const newWorker = registration.installing;
if (newWorker) {
- newWorker.addEventListener('statechange', () => {
- if (newWorker.state === 'installed') {
+ newWorker.addEventListener("statechange", () => {
+ if (newWorker.state === "installed") {
if (navigator.serviceWorker.controller) {
// New update available
- dispatchEvent(new CustomEvent('sw:update'));
+ dispatchEvent(new CustomEvent("sw:update"));
}
}
});
}
});
- console.log('Service Worker registered:', registration.scope);
+ console.log("Service Worker registered:", registration.scope);
} catch (error) {
- console.error('Service Worker registration failed:', error);
+ console.error("Service Worker registration failed:", error);
}
}
}
```
---
## Install Prompt
### Custom Install Button
+
```tsx
- import { useState, useEffect } from 'react';
+ import { useState, useEffect } from "react";
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
- userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
+ userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
}
function InstallButton() {
const [deferredPrompt, setDeferredPrompt] =
useState<BeforeInstallPromptEvent | null>(null);
const [isInstalled, setIsInstalled] = useState(false);
useEffect(() => {
// Check if already installed
- if (window.matchMedia('(display-mode: standalone)').matches) {
+ if (window.matchMedia("(display-mode: standalone)").matches) {
setIsInstalled(true);
return;
}
const handler = (e: Event) => {
e.preventDefault();
setDeferredPrompt(e as BeforeInstallPromptEvent);
};
- window.addEventListener('beforeinstallprompt', handler);
- return () => window.removeEventListener('beforeinstallprompt', handler);
+ window.addEventListener("beforeinstallprompt", handler);
+ return () => window.removeEventListener("beforeinstallprompt", handler);
}, []);
const handleInstall = async () => {
if (!deferredPrompt) return;
await deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
- if (outcome === 'accepted') {
+ if (outcome === "accepted") {
setIsInstalled(true);
}
setDeferredPrompt(null);
};
if (isInstalled || !deferredPrompt) return null;
return (
<button onClick={handleInstall} className="install-button">
Install App
</button>
);
}
```
---
## Offline Support
### Offline Detection
+
```tsx
- import { useState, useEffect } from 'react';
+ import { useState, useEffect } from "react";
function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
- window.addEventListener('online', handleOnline);
- window.addEventListener('offline', handleOffline);
+ window.addEventListener("online", handleOnline);
+ window.addEventListener("offline", handleOffline);
return () => {
- window.removeEventListener('online', handleOnline);
- window.removeEventListener('offline', handleOffline);
+ window.removeEventListener("online", handleOnline);
+ window.removeEventListener("offline", handleOffline);
};
}, []);
return isOnline;
}
// Usage
function App() {
const isOnline = useOnlineStatus();
return (
<div>
{!isOnline && (
<div className="offline-banner">
You're offline. Some features may be unavailable.
</div>
)}
</div>
);
}
```
### Background Sync
+
```typescript
// In Service Worker
- self.addEventListener('sync', (event) => {
- if (event.tag === 'sync-data') {
+ self.addEventListener("sync", (event) => {
+ if (event.tag === "sync-data") {
event.waitUntil(syncData());
}
});
async function syncData() {
- const db = await openDB('pending-requests', 1);
- const requests = await db.getAll('requests');
+ const db = await openDB("pending-requests", 1);
+ const requests = await db.getAll("requests");
for (const request of requests) {
try {
await fetch(request.url, request.options);
- await db.delete('requests', request.id);
+ await db.delete("requests", request.id);
} catch {
// Will retry on next sync
}
}
}
// Register sync from app
async function queueRequest(url: string, options: RequestInit) {
await navigator.serviceWorker.ready;
- if ('sync' in window.ServiceWorkerRegistration.prototype) {
+ if ("sync" in window.ServiceWorkerRegistration.prototype) {
// Store request and trigger sync
- const db = await openDB('pending-requests', 1);
- await db.add('requests', { url, options, id: Date.now() });
+ const db = await openDB("pending-requests", 1);
+ await db.add("requests", { url, options, id: Date.now() });
await navigator.serviceWorker.ready.then((reg) =>
- reg.sync.register('sync-data')
+ reg.sync.register("sync-data"),
);
} else {
// Fallback to immediate fetch
await fetch(url, options);
}
}
```
---
## Push Notifications
### Request Permission
+
```typescript
async function requestNotificationPermission() {
const permission = await Notification.requestPermission();
- if (permission === 'granted') {
+ if (permission === "granted") {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});
// Send subscription to server
- await fetch('/api/push/subscribe', {
- method: 'POST',
+ await fetch("/api/push/subscribe", {
+ method: "POST",
body: JSON.stringify(subscription),
});
}
}
```
### Handle Push in Service Worker
+
```typescript
- self.addEventListener('push', (event) => {
+ self.addEventListener("push", (event) => {
const data = event.data?.json() ?? {};
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
- icon: '/icons/icon-192x192.png',
- badge: '/icons/badge.png',
+ icon: "/icons/icon-192x192.png",
+ badge: "/icons/badge.png",
data: data.url,
- })
+ }),
);
});
- self.addEventListener('notificationclick', (event) => {
+ self.addEventListener("notificationclick", (event) => {
event.notification.close();
if (event.notification.data) {
- event.waitUntil(
- clients.openWindow(event.notification.data)
- );
+ event.waitUntil(clients.openWindow(event.notification.data));
}
});
```
---
## Vite PWA Plugin
```typescript
// vite.config.ts
- import { VitePWA } from 'vite-plugin-pwa';
+ import { VitePWA } from "vite-plugin-pwa";
export default defineConfig({
plugins: [
react(),
VitePWA({
- registerType: 'autoUpdate',
- includeAssets: ['favicon.ico', 'robots.txt', 'icons/*.png'],
+ registerType: "autoUpdate",
+ includeAssets: ["favicon.ico", "robots.txt", "icons/*.png"],
manifest: {
- name: 'My PWA',
- short_name: 'MyPWA',
- theme_color: '#3b82f6',
+ name: "My PWA",
+ short_name: "MyPWA",
+ theme_color: "#3b82f6",
icons: [
// ... icon definitions
],
},
workbox: {
- globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
+ globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"],
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.example\.com\/.*/i,
- handler: 'NetworkFirst',
+ handler: "NetworkFirst",
options: {
- cacheName: 'api-cache',
+ cacheName: "api-cache",
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24, // 24 hours
},
},
},
],
},
}),
],
});
```
---
## Testing PWA
### Lighthouse Audit
+
1. Open Chrome DevTools
2. Go to Lighthouse tab
3. Select "Progressive Web App"
4. Run audit
### Required Scores
+
- Performance: 90+
- PWA: 100
- Accessibility: 90+
- Best Practices: 90+
---
## Best Practices
### DO:
+
- Use HTTPS (required)
- Provide offline fallback
- Cache static assets
- Show update notification
- Handle all screen sizes
### DON'T:
+
- Cache everything forever
- Block app on SW update
- Ignore iOS limitations
- Skip manifest icons
- Forget offline states