feat: add PWA support for Android and iOS installation#427
Conversation
Add web app manifest, minimal service worker, and PWA meta tags so users can install ROTV from their browser on both Android and iOS. The installed app launches in standalone mode with the ROTV icon and forest green theme. No new dependencies — leverages existing brand icons (192x192, 512x512) and the Express static-serving pipeline. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces Progressive Web App (PWA) support for Roots of The Valley by adding a web app manifest, a minimal service worker, and iOS-specific meta tags. The review feedback highlights two key improvements: first, returning the cached index.html on network failure in the service worker can cause a blank screen because Vite-hashed assets are not cached, so returning a self-contained offline HTML fallback is recommended instead. Second, registering the service worker on the load event may fail if the script is loaded asynchronously after the event has already fired, which can be resolved by checking document.readyState before registering.
| self.addEventListener('fetch', function (event) { | ||
| if (event.request.method !== 'GET') return; | ||
|
|
||
| event.respondWith( | ||
| fetch(event.request).catch(function () { | ||
| if (event.request.mode === 'navigate') { | ||
| return caches.match('/'); | ||
| } | ||
| return caches.match(event.request); | ||
| }) | ||
| ); | ||
| }); |
There was a problem hiding this comment.
Returning the cached / (index.html) when offline or on network failure will result in a White Screen of Death (blank page) for the user.
Why this happens:
- The service worker only caches
/(index.html) during installation. It does not cache the compiled JS and CSS assets (which are hashed by Vite, e.g.,index-XXXX.js). - When the user is offline and navigates, the service worker intercepts the request and returns the cached
index.html. - The browser loads this HTML and immediately attempts to fetch the referenced JS and CSS assets from the network.
- Since the user is offline and these assets are not cached, the network requests fail, leaving the user with a completely blank, non-functional page.
Solution:
Instead of returning the cached / which depends on non-cached external assets, return a self-contained, branded offline HTML fallback response directly from the service worker. This satisfies the PWA installability requirement (serving a 200 OK when offline) while providing a clean, user-friendly offline message instead of a broken blank screen.
| self.addEventListener('fetch', function (event) { | |
| if (event.request.method !== 'GET') return; | |
| event.respondWith( | |
| fetch(event.request).catch(function () { | |
| if (event.request.mode === 'navigate') { | |
| return caches.match('/'); | |
| } | |
| return caches.match(event.request); | |
| }) | |
| ); | |
| }); | |
| self.addEventListener('fetch', function (event) { | |
| if (event.request.method !== 'GET') return; | |
| event.respondWith( | |
| fetch(event.request).catch(function () { | |
| if (event.request.mode === 'navigate') { | |
| return new Response( | |
| "<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Offline - Roots of The Valley</title><style>body { font-family: system-ui, sans-serif; text-align: center; padding: 2rem; background: #f5f5f5; color: #333; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 80vh; } h1 { color: #2d5016; margin-bottom: 0.5rem; } p { color: #666; margin-top: 0; }</style></head><body><h1>Roots of The Valley</h1><p>You are currently offline. Please check your connection and try again.</p></body></html>", | |
| { | |
| status: 200, | |
| headers: { "Content-Type": "text/html; charset=utf-8" } | |
| } | |
| ); | |
| } | |
| return caches.match(event.request); | |
| }) | |
| ); | |
| }); |
| if ('serviceWorker' in navigator) { | ||
| window.addEventListener('load', () => { | ||
| navigator.serviceWorker.register('/sw.js').catch((err) => { | ||
| console.warn('SW registration failed:', err); | ||
| }); | ||
| }); | ||
| } |
There was a problem hiding this comment.
If the script is loaded asynchronously or deferred (which is common for Vite module scripts), the browser's load event might have already fired by the time this code executes. In such cases, the load event listener will never trigger, and the service worker will not be registered.
To ensure reliable registration, check document.readyState and register immediately if the document is already fully loaded.
| if ('serviceWorker' in navigator) { | |
| window.addEventListener('load', () => { | |
| navigator.serviceWorker.register('/sw.js').catch((err) => { | |
| console.warn('SW registration failed:', err); | |
| }); | |
| }); | |
| } | |
| if ('serviceWorker' in navigator) { | |
| const registerSW = () => { | |
| navigator.serviceWorker.register('/sw.js').catch((err) => { | |
| console.warn('SW registration failed:', err); | |
| }); | |
| }; | |
| if (document.readyState === 'complete') { | |
| registerSW(); | |
| } else { | |
| window.addEventListener('load', registerSW); | |
| } | |
| } |
Summary
manifest.webmanifestwith app identity, icons, and standalone display modesw.js) for installability (network-first, shell-only cache)index.html(manifest link, theme-color, iOS support)main.jsx028-pwawith spec and plan docsNo new npm dependencies. Reuses existing 192x192 and 512x512 brand icons. No backend, Vite config, or Containerfile changes needed.
Closes #413
Closes #414
Test plan
/manifest.webmanifest— returns valid JSON withapplication/manifest+jsoncontent type/sw.js— returns JavaScriptsw.jsactive🤖 Generated with Claude Code