feat: enforce PWA install wall on mobile login#116
Conversation
feat(mobile): mobile UX and accessibility polish
Invalidate cached user and team data after membership changes so team updates propagate across settings and people views. Made-with: Cursor
Set up vite-plugin-pwa with standalone manifest settings for ZYNC and add the manifest artifact needed for installability. Made-with: Cursor
Add manifest, theme, and Apple web app head tags so Android and iOS recognize ZYNC as installable. Made-with: Cursor
Register the PWA service worker via virtual:pwa-register and include plugin client typings for TypeScript support. Made-with: Cursor
Block mobile browser sessions from reaching Login until ZYNC runs in standalone mode, with platform-specific install guidance for iOS and Android. Made-with: Cursor
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Raise workbox.maximumFileSizeToCacheInBytes to 4 MiB so service worker generation succeeds with the current Dashboard bundle size in CI. Made-with: Cursor
There was a problem hiding this comment.
Pull request overview
Adds PWA installability + a mobile-only “install wall” gating the Login page, and improves team-related cache/query refresh so membership changes propagate quickly.
Changes:
- Configure
vite-plugin-pwa(manifest, Workbox settings) and add PWA meta/manifest tags inindex.html. - Register the service worker at app bootstrap and introduce an install-wall feature (hook + UI) enforced from
Login. - Invalidate backend
/users/meRedis cache on team membership mutations and add a Jest test for team deletion; refresh relevant React Query caches after team actions.
Reviewed changes
Copilot reviewed 13 out of 15 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| vite.config.ts | Adds vite-plugin-pwa config (manifest, SW/workbox, dev options). |
| src/vite-env.d.ts | Adds PWA client types for TS. |
| src/pages/Login.tsx | Enforces install wall before showing the login experience on mobile. |
| src/main.tsx | Registers the PWA service worker during app bootstrap. |
| src/features/install-wall/index.ts | Barrel exports for install-wall feature. |
| src/features/install-wall/hooks/useAppInstallStatus.ts | Detects mobile/iOS/Android + standalone mode to decide when to show wall. |
| src/features/install-wall/components/InstallPromptView.tsx | UI + Android beforeinstallprompt handling for installation guidance. |
| src/components/views/SettingsView.tsx | Invalidates team/user queries after team membership mutations. |
| src/components/views/PeopleView.tsx | Forces refetch behavior for team/user lists on mount. |
| public/manifest.json | Adds a static PWA manifest file. |
| index.html | Adds PWA meta tags + manifest/apple-touch-icon links. |
| backend/routes/teamRoutes.js | Invalidates user:me:${uid} cache on team create/join/leave/delete/remove-member; normalizes membership filtering. |
| backend/tests/teamRoutes.delete.test.js | Adds coverage for delete-team behavior + cache invalidation. |
| package.json | Adds vite-plugin-pwa dependency. |
| package-lock.json | Locks new PWA/workbox dependency tree. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } | ||
| }; | ||
|
|
||
| if (hasCheckedStatus && requiresInstallWall) { |
There was a problem hiding this comment.
On mobile, hasCheckedStatus starts false and is only set true in an effect, so the login UI can render briefly before the install wall replaces it (a visible flash, and it undermines the idea of an “install wall before login”). Consider computing the initial standalone status synchronously (e.g. in the useState initializer) and/or rendering a neutral loading state until the install check completes.
| if (hasCheckedStatus && requiresInstallWall) { | |
| if (!hasCheckedStatus) { | |
| return ( | |
| <div className="min-h-screen flex items-center justify-center bg-background p-4"> | |
| <div className="text-sm text-muted-foreground">Loading...</div> | |
| </div> | |
| ); | |
| } | |
| if (requiresInstallWall) { |
| const isMobileDevice = MOBILE_UA_REGEX.test(userAgent); | ||
| const isIOS = /iPad|iPhone|iPod/i.test(userAgent); |
There was a problem hiding this comment.
The iOS/iPad detection based solely on navigator.userAgent is unreliable on modern iPadOS (Safari can report a macOS-like UA), which would cause iPads to bypass the install wall. Consider using a more robust check (e.g. navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1 for iPadOS, or feature-based detection) so iPads are treated as iOS devices consistently.
| const isMobileDevice = MOBILE_UA_REGEX.test(userAgent); | |
| const isIOS = /iPad|iPhone|iPod/i.test(userAgent); | |
| const isIPadOS = | |
| navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1; | |
| const isMobileDevice = MOBILE_UA_REGEX.test(userAgent) || isIPadOS; | |
| const isIOS = /iPad|iPhone|iPod/i.test(userAgent) || isIPadOS; |
| }, | ||
| enabled: !!currentUser && !isPreview, | ||
| staleTime: 0, | ||
| refetchOnMount: 'always', |
There was a problem hiding this comment.
With staleTime: 0, queries are always stale immediately, and the default refetchOnMount: true already refetches on mount when stale. Adding refetchOnMount: 'always' here is redundant and guarantees a network request even if you later increase staleTime. Consider removing one of these settings (or setting a small staleTime) and relying on targeted invalidateQueries when team membership changes.
| refetchOnMount: 'always', |
| }, | ||
| enabled: !!teamInfo?.id && !isPreview, | ||
| staleTime: 0, | ||
| refetchOnMount: 'always', |
There was a problem hiding this comment.
Same as above: staleTime: 0 makes the query stale immediately, so refetchOnMount: 'always' is redundant and forces refetches on every mount. Consider simplifying the query options to avoid unnecessary network traffic.
| refetchOnMount: 'always', |
| includeAssets: ["pwa-192x192.png", "pwa-512x512.png", "pwa-maskable-512x512.png"], | ||
| manifestFilename: "manifest.json", | ||
| manifest: { | ||
| name: "ZYNC", | ||
| short_name: "ZYNC", | ||
| description: "ZYNC collaboration platform", | ||
| background_color: "#09090b", | ||
| theme_color: "#09090b", | ||
| display: "standalone", | ||
| start_url: "/", | ||
| scope: "/", | ||
| icons: [ | ||
| { | ||
| src: "/pwa-192x192.png", | ||
| sizes: "192x192", | ||
| type: "image/png", | ||
| }, | ||
| { | ||
| src: "/pwa-512x512.png", | ||
| sizes: "512x512", | ||
| type: "image/png", | ||
| }, | ||
| { | ||
| src: "/pwa-maskable-512x512.png", | ||
| sizes: "512x512", | ||
| type: "image/png", | ||
| purpose: "maskable", | ||
| }, |
There was a problem hiding this comment.
The PWA config references icon assets (includeAssets + manifest icons) that are not present in public/ (e.g. pwa-192x192.png). This will produce broken icon URLs in the generated manifest and can fail installability checks. Add the referenced icon files under public/ (or update the paths/names to match existing assets) so the manifest and <link rel="apple-touch-icon"> resolve correctly.
| react(), | ||
| VitePWA({ | ||
| registerType: "autoUpdate", | ||
| injectRegister: "auto", |
There was a problem hiding this comment.
injectRegister: "auto" will inject service-worker registration into the built HTML, but src/main.tsx also calls registerSW(). That can lead to duplicate SW registrations / duplicated update handling. Choose one approach: either disable injection (injectRegister: null/false) and keep the explicit registerSW, or remove the manual registration and rely on injection.
| injectRegister: "auto", | |
| injectRegister: null, |
| { | ||
| "name": "ZYNC", | ||
| "short_name": "ZYNC", | ||
| "description": "ZYNC collaboration platform", | ||
| "background_color": "#09090b", | ||
| "theme_color": "#09090b", | ||
| "display": "standalone", | ||
| "start_url": "/", | ||
| "scope": "/", | ||
| "icons": [ | ||
| { | ||
| "src": "/pwa-192x192.png", | ||
| "sizes": "192x192", | ||
| "type": "image/png" | ||
| }, | ||
| { | ||
| "src": "/pwa-512x512.png", | ||
| "sizes": "512x512", | ||
| "type": "image/png" | ||
| }, | ||
| { | ||
| "src": "/pwa-maskable-512x512.png", | ||
| "sizes": "512x512", | ||
| "type": "image/png", | ||
| "purpose": "maskable" | ||
| } | ||
| ] | ||
| } |
There was a problem hiding this comment.
manifest.json is defined twice: as a static file in public/manifest.json and also generated by vite-plugin-pwa (manifestFilename: "manifest.json" + manifest). This duplication risks the build output depending on copy/generation order and makes future changes easy to desync. Keep a single source of truth: either remove public/manifest.json and let the plugin generate it, or remove the plugin manifest config and serve the static manifest.
| { | |
| "name": "ZYNC", | |
| "short_name": "ZYNC", | |
| "description": "ZYNC collaboration platform", | |
| "background_color": "#09090b", | |
| "theme_color": "#09090b", | |
| "display": "standalone", | |
| "start_url": "/", | |
| "scope": "/", | |
| "icons": [ | |
| { | |
| "src": "/pwa-192x192.png", | |
| "sizes": "192x192", | |
| "type": "image/png" | |
| }, | |
| { | |
| "src": "/pwa-512x512.png", | |
| "sizes": "512x512", | |
| "type": "image/png" | |
| }, | |
| { | |
| "src": "/pwa-maskable-512x512.png", | |
| "sizes": "512x512", | |
| "type": "image/png", | |
| "purpose": "maskable" | |
| } | |
| ] | |
| } |
| import { registerSW } from "virtual:pwa-register"; | ||
|
|
||
| registerSW({ immediate: true }); | ||
|
|
There was a problem hiding this comment.
This explicit registerSW() call likely duplicates the registration injected by vite-plugin-pwa when injectRegister: "auto" is enabled in vite.config.ts, leading to double registration/update handlers. Consider removing this call if you keep injected registration, or disable injection and keep this call (but not both).
| import { registerSW } from "virtual:pwa-register"; | |
| registerSW({ immediate: true }); |
Summary
vite-plugin-pwawith standalone display mode and production icon pathsindex.htmlTest plan
npm run devand open on desktop: Login remains accessible in browsernpm run typecheck(note: existing unrelatedProjectDetails.tsxpath error still present)