feat(frontend): offline support via service worker - #516
Conversation
Confidence Score: 4/5This is close, but the session-switch cache cleanup should be fixed before merging.
Files Needing Attention: frontend/src/offline.ts and frontend/src/data/session.ts
|
| cacheKey: 'Users', | ||
| staleOnError: true, |
There was a problem hiding this comment.
This stale-on-error cache is keyed only as Users, so a same-tab account switch can reuse the previous account's user list when the next fetch fails. The UI can then show another account's people, roles, or profile metadata instead of failing closed for the current session.
UI Test Results✅ All passed — 42/42 tests passed in 2m 50s.
Results for commit 01e55fc. |
Precache the app shell and serve cached data when the network is unavailable: - gameplan-sw.js: service worker that precaches built assets listed in a build-time manifest and serves them offline. - vite.config.ts: offlineAssetManifest() plugin emits gameplan-offline-assets.json (CSS/JS/font URLs) at build time. - offline.ts: registers the service worker and exposes isBrowserOffline() / isNetworkError() helpers. - data layer: add staleOnError + cacheKey to useList/useDoc so cached responses are served on network failure. - router.ts: hydrate community/space data from cache and skip route validation (NotFound) when offline or on network errors.
Show an unobtrusive pill while the browser is offline, and refetch feeds, unread counts, and open discussion timelines once connectivity returns, so content posted by others while offline shows up without a manual reload.
…en network is unreliable navigator.onLine can briefly lag the real network state right after a reload, so the home-route decision was racing communities/spaces IndexedDB hydration and occasionally sending an offline reload to onboarding instead of the cached feed. Treat a resource that already failed with a network error the same as isBrowserOffline() when deciding whether to wait for the cache.
…lists; scope offline caches per user Add a friendly "can't load this while offline" state (with retry) for a never-visited discussion or space discussion list, instead of a blank or silently-empty screen. Also scope every offline-cached list/doc/call key to the session user (cacheKey: [..., session.user], matching the existing drafts.ts pattern), so a second account signing into the same browser can't read the previous account's cached data before its own permission-checked fetch resolves — review finding from PR #516. users.ts reads the session user straight from the cookie rather than importing session.ts: session.ts itself imports users.ts before assigning its `session` export, so importing it back from users.ts at module scope threw on boot.
frappe-ui's useList sends fields/filters/start/limit as GET query-string values, but get_list type-hinted fields/filters as dict and start/limit implicitly as int, so Frappe's own request coercion rejected the strings before the function body ran. Parse fields/filters with frappe.parse_json and coerce start/limit with cint, matching how the builtin /api/v2/document/<doctype> list route and gp_discussion.api.get_discussions already handle this.
…son profiles Moves the People list from the legacy Options-API resource (no offline persistence, not scoped per user) to a data/people.ts useList singleton with cacheKey ['People', session.user]. Reworks ProfileBento's card fetch onto useCall with cacheKey ['ProfileBento', personId, session.user] so it can resolve on failure instead of hanging forever, and fixes a broken relative API URL that made bento cards fail for everyone (online included) - useCall takes its url verbatim, unlike call()'s automatic /api/method/ prefix. People.vue, PersonProfile.vue, PersonProfileProfile.vue, PersonProfilePosts/Replies.vue all get an explicit offline/network failure state (OfflineContentFallback, with retry) distinct from a genuinely empty list or a real 404 - a failed fetch used to render as "0 members", an infinite skeleton, or a misleading NotFound.
…for offline Idle-delayed after login (and again on reconnect), warms the People list, every enabled member's GP User Profile doc, their bento cards, and their avatar bytes (loaded through an <img> element so the service worker's image cache picks them up) through a small worker pool - so the People page and any member's profile render offline even for a member never directly visited this session. Posts/replies are intentionally left out; those pages fall back to the honest offline-fallback state instead.
01e55fc to
57642e9
Compare
Test reportBackend ✅ 1175 passed · 7m 13s 46 modules
Coverage 88.7% (3,403 / 3,835 statements) Backend coverage by area
Least covered
Measured over product code only. Excluded: test suite ( Cypress ❌ 3 of 126 failed · 8m 40s
37 specs
Coverage 67.3% (5,695 / 8,457 statements) Frontend coverage by area
Least covered
Collected by Cypress against an istanbul-instrumented build, so it marks lines that ran, not lines a spec asserted on — read it to find untouched areas, not as a quality score against the backend number. Excluded: generated doctype types ( Updated for commit 6b5c91c. |
… update flow Shared-computer safety (PR #516's review finding): wipe every offline cache (SW shell/runtime caches + idb-keyval) on logout, and on detecting a different user's session cookie at boot (guardAgainstUserSwitch). Plain logout deliberately leaves gameplan-drafts alone so the same person can recover an in-progress draft after logging back in; a detected switch to a different user clears drafts too. Also adds an update flow: the service worker no longer force-activates a new version under an open tab (no more unconditional skipWaiting on install); instead the app shows a "new version available" toast with a Refresh action once an update finishes installing, and reloads once the new worker takes control.
guardAgainstUserSwitch clears the service worker's SHELL_CACHE on a detected user switch, but nothing repopulated it until the next successful online navigation to /g. If the browser went offline before that happened, even a reload of the page already open failed with net::ERR_FAILED instead of falling back to the offline UI. Add a WARM_SHELL_CACHE message the page sends once the switch-triggered clear resolves (known to be online at that point, since a user just logged in), scoped separately from CLEAR_USER_CACHES so a plain logout still leaves the shell cache empty as intended. Bump CACHE_VERSION v6->v7 since the SW's message handling changed.
Migrates the offline-mode Playwright suite (12 stories: US1-US8, P1-P3) from a throwaway /tmp harness into frontend/tests/offline so it survives reboots and can gate regressions. Seeded-content coupling and creds are now env-overridable via config.js instead of hardcoded. Adds playwright as a devDependency and a yarn test:offline script.
| Promise.all([clearOfflineCaches(), clearDraftStore()]) | ||
| .then(() => rewarmShellCache()) | ||
| .catch((error) => console.error('Failed to clear offline caches', error)) |
There was a problem hiding this comment.
Cache clear races
guardAgainstUserSwitch() starts clearOfflineCaches() but returns before the service worker confirms SHELL_CACHE and RUNTIME_CACHE were deleted. The login switch path immediately hard-navigates after this return, and the boot path can run before the /g worker is registered, so the marker can be updated for the new user while the previous user's cached shell still remains. On a shared browser, a later offline /g navigation can still boot from the previous user's cached shell; this path should wait for a confirmed clear before updating the marker or redirecting, and handle the not-yet-registered worker case.
Rule Used: What: Ignore the pull request description and eval... (source)
useDoc caches every fetched doc unconditionally, keyed only doctype/name, in memory and in IndexedDB. On a shared browser, user B signing in can be served user A's cached doc offline (or before B's own fetch resolves) -- a cross-account data leak. useList/useCall avoid this with a per-call cacheKey, but that only works because their persistence is opt-in; useDoc has no such call site to namespace from, and forgetting a per-call key would keep leaking silently by default. Add a standalone setCacheNamespace(namespace) export, backed by a method on the internal docStore singleton (mirroring its existing setCacheTimeout setter): one call, as soon as the session user is known, prefixes every doc cache key for every useDoc in the app. docStore itself is not exported -- setCacheNamespace is the only entry point apps get into it, everything else about the store stays an implementation detail of useDoc/useNewDoc/useDoctype/ useList. Switching between two non-null namespaces in a running session (dev user switcher, logout/login without a reload) purges the outgoing namespace's docs from memory and IDB rather than just fencing them off. The first call in a session never purges, since it has no prior namespace to distrust and wiping IDB there would throw away a returning user's own offline cache -- the thing namespacing exists to protect. Default behavior (no namespace set) is unchanged. Ref frappe/gameplan#516, which worked around this for lists and calls.
…omatically useDoc caches every fetched doc unconditionally, keyed only doctype/name, in memory and in IndexedDB. On a shared browser, user B signing in can be served user A's cached doc offline (or before B's own fetch resolves) -- a cross-account data leak. useList/useCall avoid this with a per-call cacheKey, but that only works because their persistence is opt-in; useDoc has no such call site to namespace from. An earlier version of this change added a setCacheNamespace(namespace) entry point for apps to call once at session start. Dropped that in favor of doing it automatically instead: any opt-in API, even a single global call, is a call site an app can forget, and a missed one leaks silently by default -- the exact failure mode this is supposed to close. There is nothing to forget if there is nothing to call. docStore now reads the standard Frappe session cookie (`user_id`, set by every logged-in Frappe app already) once at construction and prefixes every doc cache key with it. No public API changes at all -- docStore stays fully internal, reached only by useDoc/useNewDoc/useDoctype/useList, and this repo's public barrel (src/data-fetching/index.ts) is untouched. Because the cookie is only read at construction, a namespace change is only ever detected on the next page load (a Frappe login/logout flow reloads the document, so this holds in practice; an app that swapped the session user without a reload would not see the new namespace take effect until the next load). The store records the last-seen namespace in IDB across loads; when a load's namespace differs from the previous one and both are real accounts (not Guest/no-cookie), the outgoing account's docs are purged from IDB rather than left to sit there indefinitely. A load with no prior recorded namespace never purges, so a returning user's own offline cache survives the first time this runs for them. Ref frappe/gameplan#516, which worked around this for lists and calls.
guardAgainstUserSwitch used to kick off clearOfflineCaches() in a fire-and-forget Promise.all().then() and return synchronously. Two consequences, both flagged by review (PR frappe#516, round 4): - session.ts's login handler hard-navigates the instant it sees `true` back from the guard, which could tear the page down mid-clear. - The marker (localStorage's last-seen-user) was written unconditionally, so a switch that got cut off still looked "handled" on the next boot - a shared browser could keep serving the previous user's SHELL_CACHE. guardAgainstUserSwitch is now async: the clear (and the marker write, which now happens only after the clear settles) are awaited, and session.ts's login handler awaits the guard before its hard-navigate. Also hardens clearServiceWorkerCaches' worker lookup: guardAgainstUserSwitch runs before this module's own service worker registration, so a plain getRegistration() could legitimately find nothing yet even though an earlier browser session's worker (and its stale SHELL_CACHE) is still around. It now falls back to a bounded wait on navigator.serviceWorker.ready when a worker is expected to exist, instead of treating "not registered on this page load yet" as "nothing to clear". Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ull space router.ts's offline/network-error fallback let navigation continue when a space or community couldn't be resolved (isRouteValidationUnavailable), so a deep link to a genuine-but-uncached space or community rendered downstream page components with space/community === null instead of either a wrongful NotFound or a working page. Flagged by review (PR frappe#516, round 3, escalated P2 -> P1: "Missing Space Proceeds"). Both branches now redirect to a new OfflineUnavailable page that says honestly that the content isn't cached yet, with a retry action - matching the pattern OfflineContentFallback.vue already uses for a failed discussion/space-list fetch. Also adds backend regression tests for GP User Profile's get_list - the only backend change in this PR (parsing fields/filters/start/limit as GET query-string values) had no test coverage. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
What
Adds offline support to the Gameplan SPA so the app shell loads and cached data is served when the network is unavailable.
How
gameplan/www/gameplan-sw.js) precaches built assets listed in a build-time manifest and serves them when offline.offlineAssetManifest()invite.config.ts) emitsgameplan-offline-assets.json(CSS/JS/font URLs) at build time.offline.tsregisters the service worker and exposesisBrowserOffline()/isNetworkError()helpers.staleOnError+cacheKeyadded touseList/useDoccalls so cached responses are served on network failure.NotFoundroute validation when offline or on network errors.Notes
Frontend-only behavior change plus one new backend static file (the service worker). No schema or API changes.
Update — 2026-08-09
Since the original submission:
develop(conflicts resolved inunreadCount.ts,Drafts.vue,PersonProfile.vue).useList/useCallcaches (useDocscoping needs an upstreamfrappe-uichange — being PR'd separately); router no longer lets offline navigation crash on missing data (offline fallbacks added).GP User Profileget_listaccepts GET query params.The offline Playwright suite now lives in the repo at
frontend/tests/offline(12 stories: US1-US8, P1-P3; run withyarn test:offlinefromfrontend/) instead of a throwaway/tmpharness, so it survives reboots and can gate regressions. This round also fixed a shell-rewarm race: after a detected user switch cleared the service worker's app-shell cache, nothing repopulated it until the next successful online navigation, so going offline first could turn even a same-page reload into a hardnet::ERR_FAILEDinstead of the offline UI. Drafts now follow a clearer policy — preserved across a plain logout/re-login as the same user, cleared only when a genuine user switch is detected.useDoccache namespacing (noted as pending above) is now up as frappe/frappe-ui#1006.🤖 Generated with Claude Code