The PWA (Progressive Web App) infrastructure makes your scouting app installable on mobile devices and provides offline-first functionality.
Three main components:
- InstallPrompt - Handles app installation prompts
- PWAUpdatePrompt - Notifies users when updates are available
- usePWA hook - Detects if running as installed PWA
Displays installation banner to users on supported platforms.
- Platform-specific instructions: iOS Safari, Android Chrome, Desktop browsers
- Smart timing: Shows 5 seconds after page load (not immediately)
- Dismissal persistence: 7-day cooldown after user dismisses
- beforeinstallprompt handling: Automatic prompt on Android/Desktop
import { InstallPrompt } from '@/core/components/pwa/InstallPrompt';
function App() {
return (
<div>
{/* Your app content */}
<InstallPrompt />
</div>
);
}Android/Desktop (Chrome, Edge, etc.):
- Captures
beforeinstallpromptevent - Shows custom banner after 5 seconds
- Triggers native install dialog on button click
iOS Safari:
- Shows manual instructions (no native API support)
- Guides user to Share button → "Add to Home Screen"
Dismissal Logic:
// User dismisses prompt
localStorage.setItem('install-prompt-dismissed', Date.now().toString());
// Check if 7 days have passed
const daysSinceLastDismiss = (Date.now() - parseInt(lastDismissed)) / (1000 * 60 * 60 * 24);
if (daysSinceLastDismiss >= 7) {
// Show prompt again
}Set FORCE_SHOW_INSTALL_PROMPT = true to test prompt display:
const FORCE_SHOW_INSTALL_PROMPT = true; // Enable for testingThis shows the prompt after 2 seconds regardless of dismissal state.
Notifies users when a new service worker version is available.
- Auto-detection: Listens for service worker updates
- Custom events: Responds to
sw-update-availableevent - Immediate update: Sends
SKIP_WAITINGmessage to service worker - Auto-reload: Refreshes page after update
import { PWAUpdatePrompt } from '@/core/components/pwa/PWAUpdatePrompt';
function App() {
return (
<div>
{/* Your app content */}
<PWAUpdatePrompt />
</div>
);
}1. Service Worker Registration (main.tsx):
if ('serviceWorker' in navigator && import.meta.env.PROD) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then((registration) => {
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
if (newWorker) {
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
window.dispatchEvent(new CustomEvent('sw-update-available', {
detail: { waiting: newWorker }
}));
}
});
}
});
});
});
}2. Update Prompt Listens:
window.addEventListener('sw-update-available', (event) => {
setWaitingWorker(event.detail.waiting);
setShowPrompt(true);
});3. User Clicks "Update Now":
waitingWorker.postMessage({ type: 'SKIP_WAITING' });
window.location.reload();4. Service Worker Activates:
// In service worker (sw.js)
self.addEventListener('message', (event) => {
if (event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});User opens app
↓
Service worker checks for update
↓
New version available
↓
Download new service worker
↓
New SW enters "installed" state
↓
Dispatch 'sw-update-available' event
↓
Show update prompt to user
↓
User clicks "Update Now"
↓
Send SKIP_WAITING message
↓
New SW activates immediately
↓
Page reloads with new version
Detects if the app is running as an installed PWA.
- Cross-platform detection: Works on iOS and Android
- Reactive: Updates if display mode changes
- Lightweight: No external dependencies
import { usePWA } from '@/core/hooks/usePWA';
function MyComponent() {
const isPWA = usePWA();
return (
<div>
{isPWA ? (
<p>Running as installed app</p>
) : (
<p>Running in browser</p>
)}
</div>
);
}// Standard browsers (Android, Desktop)
const isStandalone = window.matchMedia('(display-mode: standalone)').matches;
// iOS Safari
const isIOSPWA = 'standalone' in window.navigator && window.navigator.standalone === true;
// Combined check
const isPWA = isStandalone || isIOSPWA;1. Conditional Bottom Navigation:
function BottomNav() {
const isPWA = usePWA();
const isMobile = useIsMobile();
// Only show on mobile when installed as PWA
if (!isMobile || !isPWA) return null;
return <nav>{/* Navigation items */}</nav>;
}2. Feature Toggles:
function Settings() {
const isPWA = usePWA();
return (
<div>
{isPWA && <PushNotificationToggle />}
{!isPWA && <InstallAppBanner />}
</div>
);
}3. Analytics:
function App() {
const isPWA = usePWA();
useEffect(() => {
if (isPWA) {
analytics.track('pwa_launched');
}
}, [isPWA]);
return <div>{/* App content */}</div>;
}The service worker is generated by vite-plugin-pwa. Here's the Vite config:
// vite.config.ts
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
devOptions: {
enabled: true, // Enable for testing in dev
},
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'android-chrome-192x192.png', 'android-chrome-512x512.png'],
workbox: {
maximumFileSizeToCacheInBytes: 4 * 1024 * 1024, // 4 MB
runtimeCaching: [
{
urlPattern: ({ request }) => request.destination === 'document',
handler: 'NetworkFirst',
options: {
cacheName: 'html-cache',
},
},
{
urlPattern: ({ request }) => request.destination === 'script',
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'js-cache',
},
},
// Add more caching strategies as needed
],
},
manifest: {
name: 'Your Scouting App',
short_name: 'ScoutApp',
description: 'FRC Scouting Application',
theme_color: '#000000',
icons: [
{
src: 'android-chrome-192x192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: 'android-chrome-512x512.png',
sizes: '512x512',
type: 'image/png',
},
],
},
}),
],
});Add these to your index.html:
<!-- PWA Support -->
<meta name="mobile-web-app-capable" content="yes">
<meta name="application-name" content="Your Scouting App">
<!-- iOS Safari -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Your Scouting App" />
<!-- Microsoft Tiles -->
<meta name="msapplication-TileColor" content="#000000" />
<meta name="msapplication-TileImage" content="/android-chrome-192x192.png" />
<!-- Theme Color -->
<meta name="theme-color" content="#000000" />-
Development Mode:
- Set
FORCE_SHOW_INSTALL_PROMPT = true - Prompt appears after 2 seconds
- Set
-
Production Testing (Chrome):
- Open DevTools → Application → Manifest
- Click "Update" to refresh manifest
- Look for errors
- Chrome will show install prompt if criteria met
-
Dismissal Testing:
- Dismiss prompt
- Check localStorage:
install-prompt-dismissed - Change system time forward 7 days
- Prompt should reappear
-
Manual Update Test:
- Build app:
npm run build - Deploy to hosting
- Open app in browser
- Make code change
- Build again:
npm run build - Deploy new version
- Reload page (not hard refresh)
- Update prompt should appear
- Build app:
-
Skip Waiting Test:
- When update prompt appears
- Click "Update Now"
- Page should reload with new version
- Check DevTools → Application → Service Workers
- Should show new version as activated
-
Development Testing:
- Set
devOptions: { enabled: true }in Vite config - Service worker runs in development mode
- Updates work like production
- Set
-
Browser Mode:
const isPWA = usePWA(); // Should be false
-
Installed PWA:
- Install app via prompt
- Launch from home screen
const isPWA = usePWA(); // Should be true
-
iOS Testing:
- Open Safari
- Share button → "Add to Home Screen"
- Launch from home screen
const isPWA = usePWA(); // Should be true
| Feature | Chrome | Edge | Firefox | Safari | Safari iOS |
|---|---|---|---|---|---|
| Install Prompt | ✅ | ✅ | ❌ | ❌ | ❌ |
| Service Worker | ✅ | ✅ | ✅ | ✅ | ✅ |
| PWA Detection | ✅ | ✅ | ✅ | ✅ | ✅ |
| beforeinstallprompt | ✅ | ✅ | ❌ | ❌ | ❌ |
| Add to Home Screen | ✅ | ✅ | ❌ | ✅ | ✅ |
Chrome/Edge:
- Full PWA support
- Native install prompts
- Service worker updates work perfectly
Firefox:
- Service workers work
- No native install prompt (manual instructions only)
- Updates work but no beforeinstallprompt
Safari (macOS):
- Service workers work
- Can add to Dock manually
- No beforeinstallprompt event
Safari (iOS):
- Limited PWA support
- Must use Share → "Add to Home Screen"
- Service workers work
- No beforeinstallprompt
Possible causes:
- App doesn't meet PWA criteria
- User already dismissed (check localStorage)
- App already installed
- HTTPS not enabled (required for PWA)
- Manifest file missing or invalid
Solutions:
// Check PWA criteria in Chrome DevTools
// Application → Manifest → "Installability"
// Clear dismissal
localStorage.removeItem('install-prompt-dismissed');
// Force show for testing
const FORCE_SHOW_INSTALL_PROMPT = true;Possible causes:
- Service worker not registered
- No actual code changes
- Cache-Control headers preventing updates
- Service worker not in "installed" state
Solutions:
// Check service worker status
navigator.serviceWorker.getRegistrations().then(registrations => {
console.log('Active SWs:', registrations);
});
// Force update check
navigator.serviceWorker.ready.then(registration => {
registration.update();
});
// Check for waiting worker
navigator.serviceWorker.ready.then(registration => {
console.log('Waiting:', registration.waiting);
console.log('Active:', registration.active);
});Add to Home Screen not working:
- Must use Safari (not Chrome/Firefox on iOS)
- Requires HTTPS
- Manifest must be valid
PWA not launching:
- Check
apple-mobile-web-app-capablemeta tag - Verify
apple-touch-iconexists - Test with
usePWA()hook
- Always include all three PWA components in your app root
- Test on real devices - iOS and Android behave differently
- Use HTTPS in production - Required for service workers
- Provide offline fallback - Service worker should cache critical assets
- Clear localStorage on major updates - Prevents stale dismissal state
- Monitor service worker lifecycle - Log states for debugging
- Test update flow - Deploy updates regularly to test
// main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App.tsx';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
// Service worker registration
if ('serviceWorker' in navigator && import.meta.env.PROD) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then((registration) => {
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
if (newWorker) {
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
window.dispatchEvent(new CustomEvent('sw-update-available', {
detail: { waiting: newWorker }
}));
}
});
}
});
})
.catch((registrationError) => {
console.log('SW registration failed: ', registrationError);
});
});
}// App.tsx
import { InstallPrompt } from '@/core/components/pwa/InstallPrompt';
import { PWAUpdatePrompt } from '@/core/components/pwa/PWAUpdatePrompt';
import { usePWA } from '@/core/hooks/usePWA';
function App() {
const isPWA = usePWA();
return (
<div>
<h1>My Scouting App</h1>
{isPWA && <p>Running as installed app!</p>}
{/* Your app content */}
<InstallPrompt />
<PWAUpdatePrompt />
</div>
);
}
export default App;