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-pwa config or web/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

BeforeProblem
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 needRefreshToasts 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 actionUsers 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 triggers SKIP_WAITING, the new SW takes over current clients immediately.
  • message → SKIP_WAITING handler — 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-pwa flips raw needRefresh false→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.

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 --copper token, see levandor-brand).
  • Buttons: Later (calls dismiss) and Reload (calls applyUpdate).

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 a vite-plugin-pwa virtual module. Vite’s import-analysis runs before vitest can apply vi.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:

FilePurpose
web/src/test/stubs/pwa-register-react.tsMinimal 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

SHASubject
b9918bfSwitch registerType to prompt; add MobileUpdateBanner + useUpdatePrompt
ab209adRemove install→skipWaiting() recovery bridge from web/src/sw.ts
9e19390Reset dismissed flag on raw needRefresh false→true transition
c8b098fVitest 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:

  1. Deploy version A, open the mobile PWA, confirm no banner.
  2. Deploy version B (any change to bundle).
  3. Reopen the mobile PWA — banner should appear within seconds (vite-plugin-pwa polls).
  4. Click “Later” → banner hides.
  5. Deploy version C → banner re-appears (dismissed flag cleared by the false→true transition).
  6. Click “Reload” → page reloads on the new SW.