feat(client): service-worker update toast (#225) #270

Merged
james merged 4 commits from 225-sw-update-toast into main 2026-06-24 12:43:29 +00:00
Owner

Summary

Adds a PWA update toast (#225) that surfaces when the service worker detects a newer deployment. The user picks "Reload" or dismisses the toast for the session.

What ships

  • apps/client/public/sw.jsmessage listener that calls self.skipWaiting() on { type: "SKIP_WAITING" }. CACHE_VERSION bumped to v2 per the file's own contract (any sw.js change bumps the version).
  • apps/client/lib/pwa/useServiceWorkerUpdate.ts — hook that observes the SW lifecycle. Flips updateAvailable to true when a new worker reaches installed AND a controller already exists (i.e. this isn't the first-ever install). reload() posts SKIP_WAITING and waits for controllerchange before calling location.reload() — reloading earlier produces a stale-shell flash. dismiss() persists pwa.update.dismissed = true in sessionStorage so the toast stays gone for the session but reappears on next visit. No-op on native and inside the Tauri shell.
  • apps/client/lib/pwa/serviceWorkerUpdateObserver.ts — passive observation lifted out of the hook as a plain function so vitest's node environment can drive it without a React renderer.
  • apps/client/lib/pwa/UpdateToast.tsx — absolutely-positioned card at the bottom of the viewport. Carol's voice: "Carol has a new version." / "Reload to get it." Split into a wired UpdateToast and a pure UpdateToastView so the DOM-shape test can exercise the presentation without a renderer.
  • apps/client/app/_layout.tsx — mount the toast inside ThemeProvider + I18nProvider but outside ServerUrlGate, so it appears on every route including /login and /server-setup.
  • packages/i18n/messages/en.json — new pwaUpdate.* namespace. Spanish stays partial per ADR-0025.

Decisions worth flagging

  • Dismiss persistence is session-scoped (sessionStorage), not persistent. The toast reappears next visit per the acceptance criterion.
  • Toast width: max 360px on wide screens, full-width minus a 16px gutter on narrow.
  • Tauri no-op: navigator.serviceWorker is absent in the Tauri webview, so isSupported() returns false and the hook hands back a stub object with no listeners ever attached. The toast component then returns null.
  • Hook split: the passive observer is a plain function (startSwUpdateObserver) and the hook is a thin wrapper. This keeps the lifecycle logic testable in vitest's node environment.

Test plan

  • pnpm -F @carol/client typecheck / lint / test / export:web
  • pnpm -F @carol/api-client typecheck / lint / test / check
  • pnpm -F @carol/api typecheck / lint / test
  • Observer fires on updatefoundstatechange: installed with a controller present.
  • Observer does not fire on the first-ever install (no controller).
  • Observer fires immediately if registration.waiting is already populated at mount time.
  • Toast renders null when no update is pending or the user dismissed.
  • Toast wires the Reload + Dismiss buttons to the hook's reload() and dismiss().
  • Manual verification on the first post-merge deploy: bump the deployed CACHE_VERSION, open the PWA in a tab that's already controlled by the previous SW, confirm the toast appears, confirm Reload + Dismiss behave per spec.

Related: #208 (where the hand-rolled SW came from).

Closes #225.

## Summary Adds a PWA update toast (#225) that surfaces when the service worker detects a newer deployment. The user picks "Reload" or dismisses the toast for the session. ## What ships - `apps/client/public/sw.js` — `message` listener that calls `self.skipWaiting()` on `{ type: "SKIP_WAITING" }`. `CACHE_VERSION` bumped to `v2` per the file's own contract (any sw.js change bumps the version). - `apps/client/lib/pwa/useServiceWorkerUpdate.ts` — hook that observes the SW lifecycle. Flips `updateAvailable` to `true` when a new worker reaches `installed` AND a controller already exists (i.e. this isn't the first-ever install). `reload()` posts `SKIP_WAITING` and waits for `controllerchange` before calling `location.reload()` — reloading earlier produces a stale-shell flash. `dismiss()` persists `pwa.update.dismissed = true` in `sessionStorage` so the toast stays gone for the session but reappears on next visit. No-op on native and inside the Tauri shell. - `apps/client/lib/pwa/serviceWorkerUpdateObserver.ts` — passive observation lifted out of the hook as a plain function so vitest's node environment can drive it without a React renderer. - `apps/client/lib/pwa/UpdateToast.tsx` — absolutely-positioned card at the bottom of the viewport. Carol's voice: "Carol has a new version." / "Reload to get it." Split into a wired `UpdateToast` and a pure `UpdateToastView` so the DOM-shape test can exercise the presentation without a renderer. - `apps/client/app/_layout.tsx` — mount the toast inside `ThemeProvider` + `I18nProvider` but outside `ServerUrlGate`, so it appears on every route including `/login` and `/server-setup`. - `packages/i18n/messages/en.json` — new `pwaUpdate.*` namespace. Spanish stays partial per ADR-0025. ## Decisions worth flagging - **Dismiss persistence is session-scoped** (`sessionStorage`), not persistent. The toast reappears next visit per the acceptance criterion. - **Toast width**: max 360px on wide screens, full-width minus a 16px gutter on narrow. - **Tauri no-op**: `navigator.serviceWorker` is absent in the Tauri webview, so `isSupported()` returns `false` and the hook hands back a stub object with no listeners ever attached. The toast component then returns `null`. - **Hook split**: the passive observer is a plain function (`startSwUpdateObserver`) and the hook is a thin wrapper. This keeps the lifecycle logic testable in vitest's node environment. ## Test plan - [x] `pnpm -F @carol/client typecheck` / `lint` / `test` / `export:web` - [x] `pnpm -F @carol/api-client typecheck` / `lint` / `test` / `check` - [x] `pnpm -F @carol/api typecheck` / `lint` / `test` - [x] Observer fires on `updatefound` → `statechange: installed` with a controller present. - [x] Observer does not fire on the first-ever install (no controller). - [x] Observer fires immediately if `registration.waiting` is already populated at mount time. - [x] Toast renders `null` when no update is pending or the user dismissed. - [x] Toast wires the Reload + Dismiss buttons to the hook's `reload()` and `dismiss()`. - [ ] Manual verification on the first post-merge deploy: bump the deployed `CACHE_VERSION`, open the PWA in a tab that's already controlled by the previous SW, confirm the toast appears, confirm Reload + Dismiss behave per spec. Related: #208 (where the hand-rolled SW came from). Closes #225.
So the page can ask a waiting SW to activate immediately. The
update-toast flow posts { type: "SKIP_WAITING" }; the SW calls
skipWaiting() which fires activate, the new SW claims existing
clients, and the browser fires controllerchange in the page.

CACHE_VERSION bumped to v2 per the file's own contract — any
change to sw.js bumps the version so the activate handler clears
prior caches.
useServiceWorkerUpdate() observes the SW lifecycle: when a new
worker reaches installed AND there's a current controller (i.e.
this isn't the first-ever install), it flips updateAvailable to
true and stashes the waiting worker.

reload() posts SKIP_WAITING and only triggers location.reload()
from a controllerchange listener — reloading earlier races the
activation and produces a stale-shell flash.

dismiss() sets pwa.update.dismissed in sessionStorage so the
toast stays gone for the session but reappears next visit.

Splits the passive observation into serviceWorkerUpdateObserver
— a plain function that vitest's node environment can drive
without a React renderer. The hook is a thin wrapper that pipes
its events into useState.
UpdateToast reads useServiceWorkerUpdate(), the pwaUpdate i18n
namespace, and the theme tokens. Renders nothing when no update
is pending or the user dismissed; otherwise an absolutely-
positioned card at the bottom of the viewport with the headline,
body, a primary "Reload" button, and a small X dismiss.

Carol's voice — sentence case, no emoji, first person. "Carol
has a new version." + "Reload to get it."

Split into a wired UpdateToast and a pure UpdateToastView so the
DOM-shape test can drive the presentation without a React
renderer.
feat(client+i18n): mount PWA update toast in root layout (#225)
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 12s
PR / Static analysis (pull_request) Successful in 36s
PR / OSV-Scanner (pull_request) Successful in 22s
PR / Lint (pull_request) Successful in 7m3s
PR / Package age policy (soft) (pull_request) Successful in 25s
PR / Trivy (image) (pull_request) Successful in 1m32s
Secrets / gitleaks (pull_request) Successful in 18s
PR / Typecheck (pull_request) Successful in 6m22s
PR / OpenAPI (pull_request) Successful in 6m2s
PR / Client (web export smoke) (pull_request) Successful in 5m42s
PR / Build (pull_request) Successful in 6m2s
PR / Test (postgres) (pull_request) Failing after 5m38s
PR / pnpm audit (pull_request) Successful in 5m20s
PR / Coverage (soft) (pull_request) Successful in 6m8s
PR / Test (sqlite) (pull_request) Successful in 7m19s
3616ce3c9d
Inside Theme + I18n providers (so it can read tokens and
translated copy) but outside the ServerUrlGate (so it surfaces on
every route including /login and /server-setup).

New pwaUpdate.* namespace in the i18n catalog — headline, body,
reload, dismiss. Spanish stays partial per ADR-0025; en.json is
the source-of-truth and react-i18next falls back per-key.

📊 Test coverage

Patch coverage: no testable lines changed.

Overall (app/, lib/, db/, excluding UI per ADR-0019):

Metric Value Soft target
Lines 81.7% ≥ 50%
Branches 72.9% ⚠️ ≥ 75%
Functions 90.7% informational

Soft thresholds per ADR-0019. Coverage is informational and does not block merge.

<!-- coverage-comment --> ## 📊 Test coverage **Patch coverage:** no testable lines changed. **Overall** (`app/`, `lib/`, `db/`, excluding UI per ADR-0019): | Metric | Value | Soft target | |---|---|---| | Lines | 81.7% ✅ | ≥ 50% | | Branches | 72.9% ⚠️ | ≥ 75% | | Functions | 90.7% | informational | Soft thresholds per [ADR-0019](docs/adr/0019-coverage-soft-targets.md). Coverage is informational and does not block merge.
james merged commit 5a095379af into main 2026-06-24 12:43:29 +00:00
james deleted branch 225-sw-update-toast 2026-06-24 12:43:30 +00:00
Sign in to join this conversation.
No description provided.