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
Draft
Make MPDX a PWA: full implementation (Phases 1–4) — foundation, offline, native push, Capacitor shell#1843dr-bizz wants to merge 53 commits into
dr-bizz wants to merge 53 commits into
Conversation
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).
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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-pwaintegrationbranch 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
public/manifest.json(scope, id, description, orientation, categories).next-pwawith Serwist (@serwist/next); security-hardened runtime-cache allowlist (never caches authenticated HTML or
__NEXT_DATA__, which carry the session token).customHttp.ymlSW no-cache headers.Phase 2 — in-session offline reads (contacts + tasks)
localforage+CachePersistor).useIsOnline+OfflineNotifier; mutations blocked offline at the Apollolayer (
offlineLink); graceful offline error handling.clearApolloData()to purge thepersisted cache.
Phase 3 — native push
@capacitor/push-notificationsregistration via a newUserDevicesREST-proxy schema; settings card is the only permission-prompt site;
NativeDeepLinkProviderroutes notification taps; token rotation handled.CruGlobal/mpdx_api#3387.)Phase 4 — Capacitor shell
server.urlmode (judge-panel decision), emptyallowNavigation, iOSWKAppBoundDomains. Hosts:stage.mpdx.org/mpdx.org.(
useNativeCamera→ existing avatar pipeline; web path unchanged), nativepolish (splash, status bar, safe areas, haptics), shell-version handshake +
blocking upgrade screen.
Quality
yarn lint:ci0 errors,lint:tsclean,all branch tests pass).
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 isdocs/pwa-roadmap.md.Next steps before this can merge/ship (blocked on the team)
See
docs/pwa-design/NEXT-STEPS.mdfor the full handoff. In order:stage.mpdx.orgloads, then run the three on-device auth gatesin
docs/pwa-design/t1-gate-runbook.md(these unblock production nativeauth, tasks T20/T21, intentionally not yet implemented).
fcm-v1-backend.md§5). The realgoogle-services.jsonmust replace the committed placeholder and shouldbe injected at build time, not committed.
OKTA_AUTH_CLIENT_IDS.org.mpdx) / Apple (teamDQ48D9BF2V, bundleorg.cru.mpdx)identity-reuse decisions.
Reviewer notes
(
_app.page.tsx,next.config.ts, Apollo client, auth) — expect a high riskscore. The pre-PR multi-agent reviews already covered these.
standard Capacitor
.gitignore.docs/pwa-roadmap.mdplanning doc and thedocs/superpowers/plans/scratch files can be pruned before merge tomain.