Skip to content

PWA Augment — Service Worker & Offline Shell

Progressive Web Application support for Objectuve web and desktop users, running alongside (not replacing) the native Capacitor builds for mobile.

Overview

The PWA layer adds:

  • Service Worker — asset caching and offline shell fallback
  • Web App Manifest — metadata for installation and home screen icon
  • Offline Status — a non-blocking chrome bar that reflects network/sync state without occluding the app
  • Installation Prompt — invite to install as a standalone app

The native Capacitor apps remain authoritative for iOS and Android. This is an augment, not a replacement — both runtimes coexist without interference.

Service Worker Registration

The service worker is registered manually in src/main.ts inside requestIdleCallback(), after custom elements are defined. This timing prevents blocking the app shell from rendering.

Registration is gated on the web platform:

typescript
// src/lib/swInit.ts
export function initServiceWorker(): void {
  if (!Capacitor.isNativePlatform()) {
    const { register } = useServiceWorker()
    register()
  }
}

The !Capacitor.isNativePlatform() guard uses the same idiom as usePushNotifications.ts (line 25–27), ensuring the Capacitor WebView never attempts SW registration.

Build-time: The vite-plugin-pwa plugin (Workbox-based) generates dist/sw.js and dist/manifest.webmanifest at build time.

Runtime: useServiceWorker.ts handles registration with registerType: 'prompt' and injectRegister: false (no auto-inject script tag). Manual registration in main.ts gives us full control over timing.

Caching Strategy

Workbox runtime caching rules in vite.config.ts specify cache handlers and TTLs:

ResourceHandlerTTLMax EntriesNotes
/statics/**, /uploads/**CacheFirst30 days200Images, avatars, static assets
Google Fonts stylesheetsStaleWhileRevalidateServe cached + validate in background
Google Fonts filesCacheFirst1 year30Font files rarely change
GET /graphqlNetworkFirst5 min50Dead rule — see note below

GraphQL mutations are intentionally excluded from caching. The GET /graphql rule matches request.method === 'GET' only, so POST /graphql mutations bypass the service worker entirely. This ensures mutations always go to the network (no stale cache serving mutations) — critical since OBJ-32 (Offline-First Sync Queue) owns mutation buffering.

The GET /graphql rule never actually fires (v4.58 Phase 6, OBJ-3745). Backend routing (rails_api/config/routes.rb:17) registers only post '/graphql' — there is no GET /graphql route to serve. On the client, apollo-client.ts builds its link from createUploadLink({ uri: API_ENDPOINT }) with no useGETForQueries option, so Apollo Client's default applies and every operation — queries included — goes out as POST. No request this app ever sends can match request.method === 'GET' on /graphql; the rule has been structurally unreachable since it was added, independent of the Phase 5a Apollo cache-persistence question that originally flagged it for review (.planning/milestones/v4.58-offline-capability-rebuild-ROADMAP.md § Phase 5a). It was left in place rather than removed here, since deleting a Workbox rule is a behavior change outside this docs-only phase's scope — flagged as a follow-up (see docs/architecture/offline-contract.md).

WebSocket routes (/cable) are also excluded via navigateFallbackDenylist: [/^\/graphql/, /^\/cable/]. This prevents the offline shell fallback from serving API and WebSocket endpoints.

Update Flow

New service worker versions are detected by useServiceWorker.ts:

  1. Browser fetches sw.js in the background (part of the update check).
  2. If a new SW arrives, it enters the 'installing' state.
  3. Once installed, it enters the 'installed' state while the old SW remains active (no immediate takeover).
  4. We detect this via the 'updatefound' event and set updateAvailable.value = true.
  5. User sees a persistent toast (usePwaUpdateToast.ts) prompting "Update ready → Refresh".
  6. On click, we call postMessage({ type: 'SKIP_WAITING' }) to the waiting SW.
  7. The new SW immediately activates ('activate' event), and we reload the page to pick up new assets.

Alternatively, if the user opens the app in a second tab/window and the new SW activates there, the 'controllerchange' event fires in all tabs, also setting updateAvailable.value = true.

Toast behavior: The update toast fires only in PROD (import.meta.env.PROD). During local development, updates are logged to console to reduce noise.

Offline Status

When the network goes down, the service worker's navigateFallback routes users to the app shell without a specific route. This shell is rendered by src/App.vue.

Key design (v4.58 Phase 4b, OBJ-3743): offline/sync state is surfaced by ConnectionStatusBar.vuechrome, not a modal. It never occludes the app underneath it, so a check-in stays reachable and still enqueues while offline. This replaces the earlier full-viewport PwaOfflineOverlay.vue (removed in this phase, along with its App.vue mount and its cachedGoalName last-goal callout), which blocked interaction with everything behind it while shown.

See docs/architecture/offline-contract.md for the full offline contract — what's queued, what's persisted, and how staleness and failure are handled.

The ConnectionStatusBar.vue component:

  • Mounts in App.vue behind the offline_status_bar_enabled flag (100% rollout in production as of v4.58 Phase 5b, OBJ-3800, 2026-09-17 — go-live is complete) and has zero DOM footprint while online with an empty sync queue and no failures
  • Renders as a docked bar (role="status", aria-live="polite"; no aria-modal, no backdrop, no focus trap) — mobile docks it flush above the tab bar, desktop docks it at the content edge; ion-content gains matching bottom padding so the bar never covers scrollable content
  • Reflects one of four states, in precedence order — failed > offline > syncing > synced — driven by useNetworkStatus() and syncStore's queue/failed arrays
  • The synced state fires once per drain-to-zero and self-dismisses ~2.5s later (see docs/reference/motion-vocabulary.md § Status surfaces — this value is an unlocked fallback, not a ruled tier)
  • No retry button and no router.go(0) reload — the bar reports state, it doesn't drive recovery; syncStore handles retry on network return
  • Carries no platform branch: since it never blocks, native never needed the exclusion the old overlay's grace-timer callback carried (Capacitor.isNativePlatform()) — that guarantee is now architectural rather than a runtime check

Network Status — OBJ-32 Integration

Both the PWA and the upcoming Offline-First Sync Queue (OBJ-32) need to know when the network is available. There is one canonical source: useNetworkStatus.ts.

Contract:

  • Platform-agnostic interface — returns { isOnline: Ref<boolean>, connectionType: Ref<string | null> }
  • Web: Wraps navigator.onLine + window online/offline events; reads navigator.connection.effectiveType for connection type
  • Native: Wraps @capacitor/network plugin
  • Both consumers (PWA and OBJ-32 syncStore) must import from this composable, not roll their own

This ensures:

  • Single source of truth for network state across all platforms
  • No duplication or divergence between offline detection in the SW and in the sync queue
  • Consistent behavior across tabs (navigator.onLine is global)

Out of scope: iOS PWA push notifications and Web Background Sync API remain unsupported — iOS Safari PWA capability gaps make these unreliable. See OBJ-226 for PWA strategy and risk analysis.

Components & Composables

Installation:

  • InstallPwaPrompt.vue — Banner that appears when the browser fires beforeinstallprompt. Shows on desktop (Chrome, Edge, Firefox) and some Android browsers; iOS Safari does not fire the event (users add via "Share → Add to Home Screen" manually).

Updates:

  • usePwaUpdateToast.ts — Watches useServiceWorker().updateAvailable and fires a persistent toast in PROD.

Offline:

  • ConnectionStatusBar.vue — Non-blocking chrome bar reflecting offline/syncing/synced/failed state (v4.58 Phase 4b, OBJ-3743; behind offline_status_bar_enabled, 100% rollout since Phase 5b/OBJ-3800, 2026-09-17).
  • useNetworkStatus.ts — Platform-agnostic composable for network detection.

Build & Manifest

The Web App Manifest (manifest.webmanifest) is generated by vite-plugin-pwa at build time:

json
{
  "name": "Objectuve",
  "short_name": "Objectuve",
  "description": "Gamified goal tracking. Build streaks, earn badges, and join communities that celebrate your progress.",
  "theme_color": "#177bbb",
  "background_color": "#0a0a0a",
  "display": "standalone",
  "start_url": "/",
  "scope": "/",
  "orientation": "portrait",
  "icons": [...]
}

Icons include:

  • pwa-192.png (192×192, general purpose)
  • pwa-512.png (512×512, general purpose)
  • pwa-maskable-512.png (512×512, with safe zone for adaptive masking on Android)

Platform Gate Idiom

Service worker registration gates on the web platform to prevent activation on native:

typescript
if (!Capacitor.isNativePlatform()) {
  const { register } = useServiceWorker()
  register()
}

This same pattern is used in usePushNotifications.ts and is the standard way to conditionally load web-only features in a Capacitor app.

Rollback & Cache Clearing

If a PWA service worker version causes issues:

  1. Deploy a new version of the app (e.g., fixed assets)
  2. On next visit, the browser checks for a new sw.js
  3. Workbox's precache manifest (generated at build time) includes hashes of all assets; a mismatch invalidates the entire cache
  4. Users who had the broken version installed will see an update prompt on next visit

To manually clear the SW and cache (development only):

javascript
// In browser console
navigator.serviceWorker.getRegistrations().then(regs => {
  regs.forEach(reg => reg.unregister())
})
caches.keys().then(names => {
  names.forEach(name => caches.delete(name))
})

Testing Coverage

  • tests/unit/composables/useServiceWorker.spec.ts — registration, update detection, skip-waiting flow
  • tests/unit/composables/useNetworkStatus.spec.ts — online/offline event handling, Capacitor integration
  • tests/smoke/specs/pwa/01-pwa-foundation.cy.ts — smoke test verifying SW registers and cache headers respond
  • OBJ-226 — Convert frontend app to a PWA (parent epic)
  • OBJ-32 — Offline-First: Optimistic UI & Sync Queue (consumes useNetworkStatus)
  • PWA strategy analysis — rationale for augment vs. replace documented in epic OBJ-226

Verified against source: b595a99aa

  • ionic_frontend/src/lib/swInit.ts
  • ionic_frontend/src/composables/useServiceWorker.ts
  • ionic_frontend/src/composables/useNetworkStatus.ts
  • ionic_frontend/src/composables/usePwaUpdateToast.ts
  • ionic_frontend/src/components/pwa/InstallPwaPrompt.vue
  • ionic_frontend/src/components/pwa/ConnectionStatusBar.vue
  • ionic_frontend/src/App.vue (mount + ion-content::part(scroll) reflow, lines ~143–158, ~532–549)
  • ionic_frontend/vite.config.ts (Workbox config, lines 31–108)
  • rails_api/config/routes.rb (confirms no GET /graphql route exists)

Last updated: 2026-09-17 (v4.58 Phase 6, OBJ-3745: offline_status_bar_enabled rollout corrected to 100% (Phase 5b/OBJ-3800 go-live); documented the GET /graphql Workbox rule as structurally dead code; linked the new offline contract page)

Loading…