Skip to content

Make MPDX a PWA: full implementation (Phases 1–4) — foundation, offline, native push, Capacitor shell#1843

Draft
dr-bizz wants to merge 53 commits into
mainfrom
pwa-phase3-4-push-shell
Draft

Make MPDX a PWA: full implementation (Phases 1–4) — foundation, offline, native push, Capacitor shell#1843
dr-bizz wants to merge 53 commits into
mainfrom
pwa-phase3-4-push-shell

Conversation

@dr-bizz

@dr-bizz dr-bizz commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Make MPDX a PWA — full implementation (Phases 1–4)

This is the complete set of frontend changes that turn MPDX into an
installable PWA delivered inside iOS/Android Capacitor shells
, with offline
reads and native push. It contains all four phases; the mpdx-pwa integration
branch was never opened as its own PR, so Phases 1+2 are part of this diff.

Draft — code-complete and reviewed, but cannot ship until the on-device
auth gates pass and credentials/accounts are provisioned (see Next Steps). The
companion backend PR is https://github.com/CruGlobal/mpdx_api/pull/3387.

What's here

Phase 1 — PWA foundation

  • Fixed public/manifest.json (scope, id, description, orientation, categories).
  • Replaced abandoned next-pwa with Serwist (@serwist/next); security-
    hardened runtime-cache allowlist (never caches authenticated HTML or
    __NEXT_DATA__, which carry the session token).
  • Offline fallback page, service-worker update prompt, iOS install meta tags,
    customHttp.yml SW no-cache headers.

Phase 2 — in-session offline reads (contacts + tasks)

  • Apollo cache persisted to IndexedDB (localforage + CachePersistor).
  • useIsOnline + OfflineNotifier; mutations blocked offline at the Apollo
    layer (offlineLink); graceful offline error handling.
  • Security invariant: every signout path runs clearApolloData() to purge the
    persisted cache.

Phase 3 — native push

  • @capacitor/push-notifications registration via a new UserDevices
    REST-proxy schema; settings card is the only permission-prompt site;
    NativeDeepLinkProvider routes notification taps; token rotation handled.
  • (Backend payload/FCM v1 work is in CruGlobal/mpdx_api#3387.)

Phase 4 — Capacitor shell

  • In-repo Capacitor 7, server.url mode (judge-panel decision), empty
    allowNavigation, iOS WKAppBoundDomains. Hosts: stage.mpdx.org /
    mpdx.org.
  • Deep links (AASA + assetlinks), contact-photo camera capture
    (useNativeCamera → existing avatar pipeline; web path unchanged), native
    polish (splash, status bar, safe areas, haptics), shell-version handshake +
    blocking upgrade screen.

Quality

  • TDD throughout; full sweep green (yarn lint:ci 0 errors, lint:ts clean,
    all branch tests pass).
  • Each phase had its own multi-agent adversarial review. The Phase 3+4 review
    found 23 confirmed issues, all fixed — including a critical cross-module
    removeAllListeners() bug that wiped the deep-link tap listener, signout-
    chain ordering, user-/session-scoped push registration, and disabling Android
    backups (the webview cookie store carries the session). Deferred suggestions
    and refuted findings are in docs/pwa-design/review-follow-ups.md.

Design docs

In docs/pwa-design/: NEXT-STEPS.md (handoff), phase3-4-master-plan.md
(ordered task list), capacitor-shell.md, fcm-v1-backend.md,
push-registration-frontend.md, deep-links.md, camera-contact-photo.md,
review-follow-ups.md. The PWA roadmap is docs/pwa-roadmap.md.

Next steps before this can merge/ship (blocked on the team)

See docs/pwa-design/NEXT-STEPS.md for the full handoff. In order:

  1. Sanity-check stage.mpdx.org loads, then run the three on-device auth gates
    in docs/pwa-design/t1-gate-runbook.md (these unblock production native
    auth, tasks T20/T21, intentionally not yet implemented).
  2. AWS SNS / Firebase / APNs credentials (fcm-v1-backend.md §5). The real
    google-services.json must replace the committed placeholder and should
    be injected at build time, not committed.
  3. Register the Okta native PKCE client; append its ID to OKTA_AUTH_CLIENT_IDS.
  4. Android (org.mpdx) / Apple (team DQ48D9BF2V, bundle org.cru.mpdx)
    identity-reuse decisions.

Reviewer notes

  • Large diff (≈53 commits, all 4 phases) touching critical files
    (_app.page.tsx, next.config.ts, Apollo client, auth) — expect a high risk
    score. The pre-PR multi-agent reviews already covered these.
  • Native build artifacts (Pods, gradle, generated icons/splash) follow the
    standard Capacitor .gitignore.
  • Pre-merge cleanup: docs/pwa-roadmap.md planning doc and the
    docs/superpowers/plans/ scratch files can be pruned before merge to main.

dr-bizz added 30 commits June 9, 2026 16:31
Swaps next-pwa v5 (unmaintained, incompatible with Next 15) and
next-compose-plugins for @serwist/next ^9.5.11, serwist, and
@serwist/window. Adds src/service-worker/index.ts with skipWaiting:false
and register:false so a future ServiceWorkerUpdatePrompt component can
control SW activation. Switches yarn to nodeLinker:node-modules to
resolve a Yarn PnP + ESM incompatibility with @serwist/next's pure-ESM
distribution, which prevented the config from loading at build time.
The import/order pathGroups for next/react imports were silently skipped
for resolvable external packages (pathGroupsExcludedImportTypes defaults
to excluding 'external'), which only surfaced after switching from Yarn
PnP. Excluding only builtins keeps the repo's established import order
without reformatting 1000+ files.
- Override StatusPageWrapper minWidth to auto on xs breakpoints so the page renders correctly at phone sizes (PWA/offline scenario)
- Add aria-hidden="true" to decorative WifiOffIcon
- Save/restore window.location in afterEach with configurable:true to prevent test state leak
- Add a "Later" button giving keyboard users a non-destructive dismiss path
- Update snackbar copy to warn that updating will reload the page
- Hoist the `controlling` listener out of the `waiting` callback to prevent
  listener accumulation on repeated `waiting` events; add `preventDuplicate`
  and a stable `key` to avoid stacking banners
- Replace `void serwist.register()` with `.catch()` that resets `registeredRef`
  so a transient registration failure allows a retry on the next mount
- Wrap event-listener invocations in `act()` to eliminate act() warnings
- Add three new tests: no-serviceWorker-support, page-reload-on-controlling,
  and Later-button dismiss without update
Serwist's defaultCache stored authenticated SSR HTML and _next/data
responses (which embed NextAuth API tokens in __NEXT_DATA__) in
CacheStorage, where they persist past logout. Cache only immutable
static assets, treat everything else as NetworkOnly, and clear all
caches on logout.
Move CacheStorage clearing, clearDataDogUser, and client.clearStore to
run before signOut() so cleanup completes before the page unloads (next-auth
signOut with redirect assigns window.location.href which can fire before a
.then callback resolves). Add CacheableResponsePlugin and ExpirationPlugin
to the google-fonts StaleWhileRevalidate handler to prevent opaque-response
cache bloat.
Serwist migration (replacing unmaintained next-pwa v5), security-hardened
service worker caching, /offline fallback page, user-prompted SW update
flow, manifest/iOS meta fixes, and Amplify sw.js cache headers.
Creates OfflineNotifier component that shows a persistent notistack
warning while offline and a success toast on reconnect (skipped on
initial mount via wasOfflineRef). Renders in _app.page.tsx directly
after ServiceWorkerUpdatePrompt inside SnackbarProvider.
Adds offlineLink that intercepts mutations when navigator.onLine is false,
shows one warning snackbar, and rejects with a clear error instead of
letting them fail confusingly through the network stack. Queries pass
through so cache-and-network serves cached data offline. The onError link's
networkError branch is guarded with !isOffline() to prevent error-toast
spam while the OfflineNotifier already shows a persistent indicator.
Gate the RouterGuard expiry signIn on isOnline so that an offline user
with an expired session keeps their read-only cached view rather than
being redirected to an unreachable Okta page. The effect re-fires on
reconnect because isOnline is a dependency.
The click-time offline warning now uses a shorter distinct message
('Cannot save changes while offline.') with key 'offline-blocked-save'
so notistack does not silently dedupe it against the persistent banner.
Added assertions for the warning call, a duplicate-banner test, the
initial-false navigator.onLine branch, and explanatory comments for
the past-date session fixtures in RouterGuard tests.
…etwork errors

BLOCKER: client.clearStore() does NOT remove the Apollo cache persisted to
IndexedDB by apollo3-cache-persist; only cachePersistor.purge() does, and only
after cachePersistor.pause() stops the 1s-debounced write trigger (otherwise an
in-flight write can re-persist user data after the purge). Three of the four
signout paths (the AUTHENTICATION_ERROR handler, and both ProfileMenu Sign Out
buttons) called only clearStore(), leaving the previous user's donor PII
readable on shared devices.

Consolidate the cleanup into a single clearApolloData() helper
(pause -> clearStore -> purge) and wire it into all four signout paths:
logout page, the AUTHENTICATION_ERROR handler, ProfileMenu, and
ProfileMenuPanel.

To avoid both a circular import (client.ts <-> clearApolloData.ts) and the fact
that client.ts is unimportable under Jest (top-level await in its
restore() block breaks the CommonJS transform), the cache instance and
cachePersistor are extracted into a new cachePersistor.ts module that
clearApolloData.ts imports instead. client.ts re-exports cachePersistor for
existing consumers and keeps the top-level restore().

Also set watchQuery errorPolicy to 'all': with cache-and-network, a failed
network leg (e.g. offline) under the default 'none' policy discards data on the
error emission, blanking already-rendered lists. 'all' keeps cached data
rendered.
Run clearDataDogUser and await clearApolloData before calling signOut so
the IndexedDB purge completes deterministically rather than being cut off
when signOut assigns window.location.href before the .then callback fires.
Matches the pattern already used in pages/logout.page.tsx.
Contacts and tasks keep rendering from the Apollo cache when the
connection drops: IndexedDB cache persistence with purge-on-signout,
offline indicator, mutation blocking with clear feedback, suppressed
offline error spam, and session-expiry grace while offline.
Runtime: core, app, browser, camera, device, push-notifications,
splash-screen, status-bar, haptics, plus the ios/android platform
packages. Dev: @capacitor/cli and @capacitor/assets. Pinned to the
Capacitor 7 major per docs/pwa-design/capacitor-shell.md.
capacitor.config.ts per capacitor-shell.md \S2: server.url env-switched
(SHELL_TARGET=stage -> next.stage.mpdx.org placeholder host, else
mpdx.org), errorPath error.html, no allowNavigation entries
(single-host rule), limitsNavigationsToAppBoundDomains, and
appendUserAgent MPDXShell/0.1.0 as the version-handshake transport.

capacitor-web/error.html is the only bundled web content in shell v1:
vanilla HTML/JS (no plugin access on Android errorPath), branding,
Retry button, and an online-event auto-reload.

Native project dirs added to .prettierignore so generated Xcode/Gradle
files are never reformatted.
npx cap add ios/android with Capacitor 7 templates; Pods, build
outputs, copied web assets, and generated capacitor.config.json are
gitignored by the stock platform .gitignore files.

iOS Info.plist gains WKAppBoundDomains (mpdx.org +
next.stage.mpdx.org) -- the load-bearing key that unlocks service
workers and durable storage in WKWebView; paired with
limitsNavigationsToAppBoundDomains in capacitor.config.ts per
capacitor-shell.md \S2. Release builds must trim to prod only.

Android applicationId overridden to the org.mpdx placeholder (legacy
package reuse pending the signing-key answer, shell doc \S13 Q2);
namespace stays org.cru.mpdx.
Step-by-step manual procedure: Gate 1 bridge injection on the remote
origin (isNativePlatform, Device.getInfo, SW registration, IndexedDB,
UA handshake), Gate 2 system-browser PKCE round-trip via a stage
Doorkeeper public client landing a NextAuth session cookie in the
webview, Gate 3 cookie persistence battery + 48h soak. Each gate
documents what failure means per capacitor-shell.md \S7 (Gate 1
failure stops Phase 4; Gate 3 failure activates Plan B).
dr-bizz added 23 commits June 11, 2026 00:14
Register/destroy user push devices via the REST proxy: new
pages/api/Schema/UserDevices/ (graphql schema, resolvers, datahandler
with tests), registered in Schema/index.ts, with REST calls to
user/devices in graphql-rest.page.ts.
src/lib/nativeShell/ with shell detection (nativeShell), deep-link
parsing (deepLink), device locale resolution (deviceLocale), and push
token storage (pushStorage), each with colocated tests. Shared
Capacitor plugin mocks in __tests__/util/capacitorMocks.ts.
src/lib/images/ with base64ToFile and compressAvatar (plus tests) for
the Capacitor camera flow, and uploadAvatar updated to accept the
produced files.
Apple fetches /.well-known/apple-app-site-association without an
extension and requires application/json. Rename the file to drop the
.json extension and force the Content-Type header in next.config.ts
(dev/server) and customHttp.yml (Amplify CDN).
Record plugin-source verification of the userInfo/data mapping in
fcm-v1-backend.md, add an assetlinks dev-build fingerprint appendix to
the T1 gate runbook, and check in the PWA phase 1/2 plan docs.
Extract a single logoutCleanup(client) helper (src/lib/auth/logoutCleanup.ts)
that unregisters the push device (before clearStore, while the token is still
valid), clears service-worker CacheStorage, clears the DataDog user, and
purges persisted Apollo data. Wire it into every signout path: the logout
page, both profile menus, and the Apollo client's AUTHENTICATION_ERROR
handler.
Add PushNotificationsCard to the notification settings page. It renders only
inside the native shell (gated on isNativeShell()) and lets the user enable
or disable push notifications on the current device, including guidance when
OS-level notification permission is denied. Add a Mobile App column note to
NotificationsTable for types delivered as push.
Add PushBootstrap, a render-nothing component for the native shell that
re-registers the push device on app start when permission is already granted
and routes notification taps to their deep-link targets.
In the native shell, PersonName's avatar picker offers Take Photo / Choose
from Library via the Capacitor Camera plugin, with permission-denied
guidance, offline guarding, and client-side compression to stay under the
1MB avatar limit. Browser file-input behavior is unchanged. Also extract new
translation strings for the PWA shell features (camera, push notifications
card/bootstrap, upgrade-required screen).
Listens for Capacitor appUrlOpen events and routes push-notification and
universal-link URLs into the Next.js router inside the native shell.
- Register deep-link intent filters and push config in AndroidManifest
  and Info.plist; add iOS entitlements and google-services.json
- Regenerate branded launcher icons and splash screens (light/dark, all
  densities) from assets/logo.png
- Configure SplashScreen/StatusBar plugins in capacitor.config.ts
- Expand T1 gate runbook with device verification steps
- Add shared safe-area inset helpers and apply them to Basic/Primary
  top bars so chrome clears the iOS notch and Android status bar
- Add nativeChrome (splash hide, status-bar styling) with
  isAndroidShell gate for Android-only StatusBar calls
- Add useHaptics hook; trigger success haptic on task completion and
  warning haptic on delete confirmation (no-op outside the shell)
- Extend capacitorMocks with SplashScreen, StatusBar, and Haptics
Mount the Phase 3+4 native-shell pieces at the app level, all inert on
the web (isNativeShell() false, no plugin chunks load in browsers):

- NativeDeepLinkProvider + PushBootstrap inside the Apollo and
  UserPreference providers (deep-links.md §4.3, push-registration §3.3),
  so the cold-start push-tap replay is caught and registration has the
  client + user locale.
- Shell version handshake gate (capacitor-shell.md §8): an outdated
  shell binary renders the blocking UpgradeRequiredScreen in place of
  the page tree, inside ApolloProvider so its sign out can clear local
  Apollo data. Sign out stays reachable; no shell wiring mounts behind
  the gate.
- Native chrome after hydration (capacitor-shell.md §9): hide the
  splash screen at first paint and style the status bar, best-effort.

_app-level test proves the web tree renders unchanged with zero plugin
access, the chrome calls fire after hydration, deep-link listeners
attach, opted-in push re-registers without ever prompting, and the
upgrade gate blocks an outdated shell.
- Track this module's own PluginListenerHandles and remove only those;
  never call PushNotifications.removeAllListeners(), which wiped
  NativeDeepLinkProvider's pushNotificationActionPerformed tap listener
  and silently killed push deep-link routing (also in disablePush)
- Scope the localStorage idempotent skip to the session: the first
  registration of each app launch always POSTs, so the backend upsert
  can fix device ownership after a user switch and recreate rows the
  server deleted (SNS endpoint cleanup)
- Add a teardown epoch so a disablePush during an in-flight
  RegisterUserDevice mutation can no longer resurrect push state; the
  late mutation compensates with a DestroyUserDevice instead
- Make enablePush resolve only after the device registration actually
  completes (OS registration event + mutation), with a timeout, so the
  settings card no longer shows success before the server row exists
- PushNotificationsCard: re-check OS permission on visibilitychange so
  the denied state can clear when the user returns from OS Settings;
  render a skeleton while the permission read is pending instead of
  flashing an active Enable button; spinner on in-flight buttons; add
  disable-failure edge tests
- Encode the device id in the destroyUserDevice REST path
- Reconcile push-registration-frontend.md §3.2/§3.3/§5.1/§6 with the
  listener-ownership and completion semantics
- AUTHENTICATION_ERROR path: extract handleAuthenticationError, which
  awaits the canonical logoutCleanup chain to COMPLETION before
  signOut({ redirect: true }) — the old signOut().then(logoutCleanup)
  raced the page unload and could leave the previous user's persisted
  Apollo cache, SW caches, and push registration on a shared device;
  it also coalesces concurrent auth errors into one cleanup + signOut
- UpgradeRequiredScreen: route sign out through logoutCleanup() instead
  of bypassing it with clearDataDogUser + clearApolloData (the bypass
  skipped CacheStorage clearing and push unregistration on the one
  screen that only renders in the native shell); brand strings now use
  {{appName}} interpolation via getAppName()
- logoutCleanup: wrap the CacheStorage block in try/catch so a
  SecurityError in webview/private-browsing contexts can never block
  or abort a signout
- Strengthen logout-ordering tests to pin COMPLETION order (cleanup
  promise resolves before signOut is invoked), not just jest
  invocationCallOrder, in logout.page, ProfileMenu, ProfileMenuPanel,
  and logoutCleanup tests
- PersonName: use {{appName}} interpolation for the camera/photo
  permission-denied snackbars instead of hardcoded MPDX; wire the
  avatar menu trigger with aria-haspopup/aria-expanded/aria-controls
  and label the Menu list from the trigger
- NotificationsTable: make the Mobile App header tooltip reachable by
  keyboard/screen readers (focusable target + describeChild)
- DeleteConfirmation: disable the confirm button while the delete is
  in flight to prevent double-fires
- Android: disable backups (allowBackup=false, fullBackupContent=false,
  data_extraction_rules.xml excluding all data from cloud backup and
  device transfer) — the webview cookie store carries the NextAuth
  session and localStorage carries push state (MASVS MSTG-STORAGE-8);
  add a release-checklist item to capacitor-shell.md
- Extract updated translation keys ({{appName}} variants replace the
  hardcoded MPDX strings)
@dr-bizz dr-bizz changed the title PWA Phases 3+4: native push + Capacitor shell Make MPDX a PWA: full implementation (Phases 1–4) — foundation, offline, native push, Capacitor shell Jun 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant