PWA Update Prompt (Mobile Bottom Banner)
Replaces the silent autoUpdate + sonner toast pattern (which nobody saw on iOS standalone PWAs) with an explicit prompt-mode flow and a copper-accented bottom banner mounted above the mobile BottomTabs. Shipped to production at wrcm.levandor.io on 2026-05-10.
For Agents
This is the canonical PWA update flow for the CRM going forward. The previous toast-based hint was effectively invisible on iOS standalone — sonner toasts render in a layer iOS PWAs frequently don’t surface, and meanwhile the new SW would silently take over and the user would keep running stale JS until the next hard reload. If you ever revisit
vite-plugin-pwaconfig orweb/src/sw.ts, also re-read incident-2026-05-10-sw-cf-access-lockout before changing anything — these features sit on top of that fix.
Why this changed
| Before | Problem |
|---|---|
vite-plugin-pwa registerType: 'autoUpdate' | New SW would skipWaiting automatically and take over without the user knowing they were now running a new bundle (or, worse, a partially-cached one) |
sonner toast on needRefresh | Toasts on iOS standalone PWAs are routinely missed (off-screen, dismissed by gesture, or not surfaced after the app comes back from background) |
| No explicit user action | Users would see UI bugs that didn’t reproduce on desktop because they were silently running a months-old service-worker copy |
Why prompt mode is safe now
The install → skipWaiting() line in web/src/sw.ts was a one-time recovery bridge from the 2026-05-10 SW lockout incident (commit cd8394f). It was needed there to force every existing client off the broken NavigationRoute SW. Once everyone was off it, that line became actively harmful for an explicit-prompt flow — it would still skip waiting before the user clicked Reload.
The new SW keeps:
activate → clients.claim()— so when the user triggersSKIP_WAITING, the new SW takes over current clients immediately.message → SKIP_WAITINGhandler — fires only when the banner’s “Reload” button posts the message. User-initiated.
Removed: install → skipWaiting(). This is the single most important difference vs the recovery-bridge SW from cd8394f.
Architecture
File layout
web/
├── vite.config.ts # registerType: 'prompt'
├── src/
│ ├── sw.ts # activate→clients.claim, SKIP_WAITING msg handler
│ ├── App.tsx # mounts <MobileUpdateBanner /> in mobile branch
│ └── mobile/
│ ├── lib/
│ │ └── useUpdatePrompt.ts # { needRefresh, applyUpdate, dismiss }
│ └── MobileUpdateBanner.tsx # bottom banner UI
Hook contract — useUpdatePrompt
web/src/mobile/lib/useUpdatePrompt.ts returns:
{
needRefresh: boolean // banner-visible flag (already accounts for dismissal)
applyUpdate: () => void // postMessage({ type: 'SKIP_WAITING' }) + reload
dismiss: () => void // hide for this update only
}Key behaviour: the dismissed flag clears whenever raw needRefresh transitions false→true. That means:
- User dismisses update v1 → banner hidden.
- v2 ships →
vite-plugin-pwaflips rawneedRefreshfalse→true again → dismissed flag resets → banner re-appears. - User does not have to live in dismiss-purgatory if they say “Later” once.
The hook no longer side-effects a sonner toast — it is pure state. The banner component is the only renderer.
Banner UI — MobileUpdateBanner
web/src/mobile/MobileUpdateBanner.tsx:
- Position: fixed at the bottom of the viewport, above the BottomTabs (50px), respecting the safe-area inset.
bottom: calc(50px + env(safe-area-inset-bottom)); z-index: 40; - Color: copper accent —
hsl(31 53% 64%)(the project’s--coppertoken, see levandor-brand). - Buttons:
Later(callsdismiss) andReload(callsapplyUpdate).
Mount point — App.tsx
{isMobile && <MobileUpdateBanner />}Mounted next to (not inside) the existing mobile branch so the banner stays visible across route changes within the mobile tabs. Desktop deliberately doesn’t get this banner — desktop users see app updates often enough during normal browser usage and the prior toast UX was acceptable there.
Vite config delta
- registerType: 'autoUpdate'
+ registerType: 'prompt'vite.config.ts. No other PWA-related changes were required — the injectManifest setup from incident-2026-05-10-sw-cf-access-lockout still applies, and the precaching strategy is unchanged.
SW source delta
web/src/sw.ts after this work:
- Has:
self.skipWaiting()listener for'SKIP_WAITING'message. - Has:
activate → event.waitUntil(self.clients.claim()). - Does not have:
install → self.skipWaiting()(removed; was the recovery bridge). - Does not have:
NavigationRoute(...)(forbidden — see incident-2026-05-10-sw-cf-access-lockout).
Test gotcha — virtual:pwa-register/react
Vitest + virtual:pwa-register/react
The hook imports from
virtual:pwa-register/react, which is avite-plugin-pwavirtual module. Vite’s import-analysis runs before vitest can applyvi.mock(...), so attempts to mock the module at the test layer fail with an unresolved-import error before any test code runs. Fix: add a Vite resolve alias to a tiny stub.
Files added:
| File | Purpose |
|---|---|
web/src/test/stubs/pwa-register-react.ts | Minimal export of useRegisterSW returning { needRefresh: [false, () => {}], updateServiceWorker: async () => {} } shape |
web/vitest.config.ts (alias) | 'virtual:pwa-register/react' → the stub above |
Why an alias and not vi.mock: vitest’s vi.mock is hoisted before test code but after Vite’s module-graph resolution; if Vite can’t resolve virtual:pwa-register/react at all (because the plugin isn’t loaded in the test config), it errors out before the mock applies. A resolve alias short-circuits resolution to a real on-disk file.
Commits
| SHA | Subject |
|---|---|
b9918bf | Switch registerType to prompt; add MobileUpdateBanner + useUpdatePrompt |
ab209ad | Remove install→skipWaiting() recovery bridge from web/src/sw.ts |
9e19390 | Reset dismissed flag on raw needRefresh false→true transition |
c8b098f | Vitest alias + stub for virtual:pwa-register/react |
Spec & Plan
- Spec —
docs/superpowers/specs/2026-05-10-pwa-update-prompt-design.md - Plan —
docs/superpowers/plans/2026-05-10-pwa-update-prompt.md
Verification
When changing vite.config.ts PWA settings, web/src/sw.ts, or useUpdatePrompt, also re-run the CF Access expired-cookie test — that scenario is independent of update-prompt behaviour but lives in the same code path.
For the update prompt specifically:
- Deploy version A, open the mobile PWA, confirm no banner.
- Deploy version B (any change to bundle).
- Reopen the mobile PWA — banner should appear within seconds (vite-plugin-pwa polls).
- Click “Later” → banner hides.
- Deploy version C → banner re-appears (dismissed flag cleared by the false→true transition).
- Click “Reload” → page reloads on the new SW.
Related
- incident-2026-05-10-sw-cf-access-lockout — Why the previous SW had
install→skipWaiting()and why it had to be removed for prompt mode - mobile-native-feel — Mobile shell architecture this banner sits on top of
- levandor-brand — Copper / warm-black color tokens
- push-notifications — Sibling work that motivated
injectManifest; future push SW must not regress this prompt flow - levandor-crm — Project overview