feat(frontend): offline support via service worker - #571
Open
ebrahimgamdiwala wants to merge 120 commits into
Open
ebrahimgamdiwala wants to merge 120 commits into
ebrahimgamdiwala wants to merge 120 commits into
Conversation
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 frappe#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.
… update flow Shared-computer safety (PR frappe#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.
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".
…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.
Posting a comment, a poll, or publishing a new discussion while offline
hit the network and surfaced a raw "TypeError: Failed to fetch" instead
of failing gracefully. Disable the relevant submit buttons outright when
isOnline is false, rather than letting the attempt happen and reporting
the error after the fact:
- CommentsArea.vue: the comment and poll submit buttons' existing
`disabled` bindings now also check isOnline. submitComment/submitPoll
themselves are guarded too, since ctrl/cmd+Enter reaches submitComment
directly and bypasses the disabled button.
- DiscussionHeader.vue: the "Publish" button's disabled condition
(previously just isComposerEditable) is now a canPublish computed that
also requires isOnline, and its tooltip explains why ("You're offline"
alongside the existing "Draft is loading" case). The draft body and
space selector are untouched and stay editable offline - only the
final publish step needs a network round trip.
- useNewDiscussion.ts's publish() gets the same isOnline check as a
backstop, in case it's ever reached another way.
Replaces the floating pill (fixed, top-center, rounded, translucent shadow) with a full-width bar pinned to the true top of the viewport - gray (bg-surface-gray-8, matching the pill's own tone and ReadOnlyBanner.vue's status-message convention), with a wifi-off icon and "Network offline. Showing saved content." The pill only needed z-index to float over content; this banner needs to not overlap anything, so going offline pushes the app's own chrome down instead of covering it: MobileShell and DesktopShell (frappe-ui) both expose a `data-slot` attribute as a public styling hook, and index.css uses a `data-offline` attribute on <html> (toggled by OfflineIndicator.vue, same pattern as useCursorStyle.ts's data-cursor) to add padding-top equal to the banner's height to both. MobileShell is `fixed inset-0`, so this only shrinks its scroll region; DesktopShell is normal flow, so its whole row (rail + sidebar + content) shifts down together. The banner itself stays at a modest z-[60] - above normal content, below toasts/dialogs, so either still displays correctly over it if opened while offline.
Maintainer review: bg-surface-gray-8 + text-ink-white looked fine in light mode but broke in dark mode, and switching the background to the requested bg-surface-gray-3 would have broken light mode instead - frappe-ui's surface-gray-N tokens invert which raw shade they resolve to per theme (gray-8 is a medium-dark gray in light mode, a light gray in dark mode), while ink-white is a fixed color that doesn't follow. Replaced with ink-gray-N tokens throughout, which invert the same way the surface token does, so contrast holds in both themes without a dark: variant needed. Verified via frappe-ui's generated color tokens (tailwind/generated/colors.json) that gray-3/ink-gray-5/7/8 stay legible against each other in both lightMode and darkMode.
Contributor
|
ebrahimgamdiwala
marked this pull request as draft
September 9, 2026 09:38
clearServiceWorkerCaches resolved on ANY message from the worker,
discarding the { ok: true | false } payload gameplan-sw.js's
CLEAR_USER_CACHES handler actually sends - a reported failure (or the
2s no-response timeout) was silently treated the same as success, and
guardAgainstUserSwitch would still mark the switch as "handled," so a
genuinely failed clear was never retried on a later boot.
- clearServiceWorkerCaches now resolves to the worker's real answer,
and falls back to deleting the same caches directly via the page's
own Cache Storage API when the worker doesn't confirm one - not
registered yet, unsupported, timed out, or an explicit
{ ok: false }. Matched by the gameplan-sw.js cache-name prefix
(duplicated as a constant, same as the message-type strings already
are - the worker runs in a separate script/global scope), excluding
the content-addressed asset cache, so this fallback doesn't need the
worker's cooperation at all.
- clearOfflineCaches now resolves to whether every store (service
worker caches + IndexedDB) actually confirmed it cleared, instead of
Promise<void>.
- guardAgainstUserSwitch only writes the last-seen-user marker once
clearOfflineCaches confirms success; on failure it returns without
touching the marker, so the same mismatch is seen - and the clear
retried - the next time this runs.
Reported in PR review (frappe#571).
ebrahimgamdiwala
added a commit
to ebrahimgamdiwala/gameplan
that referenced
this pull request
Sep 9, 2026
clearServiceWorkerCaches resolved on ANY message from the worker,
discarding the { ok: true | false } payload gameplan-sw.js's
CLEAR_USER_CACHES handler actually sends - a reported failure (or the
2s no-response timeout) was silently treated the same as success, and
guardAgainstUserSwitch would still mark the switch as "handled," so a
genuinely failed clear was never retried on a later boot.
- clearServiceWorkerCaches now resolves to the worker's real answer,
and falls back to deleting the same caches directly via the page's
own Cache Storage API when the worker doesn't confirm one - not
registered yet, unsupported, timed out, or an explicit
{ ok: false }. Matched by the gameplan-sw.js cache-name prefix
(duplicated as a constant, same as the message-type strings already
are - the worker runs in a separate script/global scope), excluding
the content-addressed asset cache, so this fallback doesn't need the
worker's cooperation at all.
- clearOfflineCaches now resolves to whether every store (service
worker caches + IndexedDB) actually confirmed it cleared, instead of
Promise<void>.
- guardAgainstUserSwitch only writes the last-seen-user marker once
clearOfflineCaches confirms success; on failure it returns without
touching the marker, so the same mismatch is seen - and the clear
retried - the next time this runs.
Reported in PR review (frappe#571).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ebrahimgamdiwala
marked this pull request as ready for review
September 9, 2026 10:03
…allback requestWorkerClear() can reject, not just resolve false: postMessage throws synchronously (auto-rejecting the wrapping Promise) if the worker became redundant between the registration lookup and the send, and the lookup itself (getActiveWorker -> getRegistration/ready) can reject too. clearServiceWorkerCaches awaited it with no catch, so a rejection skipped clearCachesDirectly() entirely and propagated out of clearOfflineCaches - breaking session.ts's logout redirect (no try/catch there), and in guardAgainstUserSwitch's login path, skipping the one thing (the direct Cache Storage fallback) that could have cleared the previous user's caches immediately instead of only on a later retry. clearServiceWorkerCaches now catches requestWorkerClear() and falls through to clearCachesDirectly() on any failure, not just a resolved `false` - the function can no longer reject at all. Reported in PR review (frappe#571).
ebrahimgamdiwala
marked this pull request as draft
September 9, 2026 10:12
ebrahimgamdiwala
added a commit
to ebrahimgamdiwala/gameplan
that referenced
this pull request
Sep 9, 2026
…allback requestWorkerClear() can reject, not just resolve false: postMessage throws synchronously (auto-rejecting the wrapping Promise) if the worker became redundant between the registration lookup and the send, and the lookup itself (getActiveWorker -> getRegistration/ready) can reject too. clearServiceWorkerCaches awaited it with no catch, so a rejection skipped clearCachesDirectly() entirely and propagated out of clearOfflineCaches - breaking session.ts's logout redirect (no try/catch there), and in guardAgainstUserSwitch's login path, skipping the one thing (the direct Cache Storage fallback) that could have cleared the previous user's caches immediately instead of only on a later retry. clearServiceWorkerCaches now catches requestWorkerClear() and falls through to clearCachesDirectly() on any failure, not just a resolved `false` - the function can no longer reject at all. Reported in PR review (frappe#571). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ebrahimgamdiwala
marked this pull request as ready for review
September 9, 2026 10:13
createServerProfileBentoSource's save() and reset() (used by the Settings dialog's bento-card editor) each discarded the server's response, including the `profile` field identifying which profile's layout just changed. Meanwhile useProfileBento() (PersonProfile.vue's Profile tab) and prefetchProfileBento() (the background offline prefetcher) share one per-profile cache (bentoCalls) neither save nor reset had any way to invalidate. Concretely this broke profile-settings.cy.ts's "edits the profile, adds a bento card, and sets quick reactions" test: the background prefetcher (offlinePrefetch.ts) warms every enabled member's own bento cache too - it does not exclude the session user - so a save made after that warm-up left a stale, already-`isFinished` cache entry that useProfileBento's own watch has no reason to refetch. Visiting the just-edited profile then showed the pre-save layout. Both mutations now call the new invalidateProfileBentoCall(profile), using the profile name their own response already identifies (GP User Profile.get_profile_bento_response returns it) rather than looking it up separately. It reloads an existing cache entry in place so an already-mounted PersonProfile.vue viewing that profile (e.g. behind the settings dialog overlay) picks up the change reactively too.
The first time someone went offline, their next visit opened a "Read Gameplan offline" dialog offering a month of downloads. Remove that flow: downloads are chosen in Settings > Preferences > Offline (More > Offline on mobile) only.
ebrahimgamdiwala
force-pushed
the
feat/offline-support
branch
from
September 29, 2026 10:20
8daeb78 to
2efd87f
Compare
ebrahimgamdiwala
force-pushed
the
feat/offline-support
branch
from
September 29, 2026 10:25
2efd87f to
7a88b07
Compare
ebrahimgamdiwala
force-pushed
the
feat/offline-support
branch
from
September 29, 2026 10:32
7a88b07 to
915de89
Compare
# Conflicts: # frontend/src/data/useDraftSync.ts # frontend/src/pages/NewDiscussion/DiscussionHeader.vue
A draft got its name from the server when it was first saved, so the client had to join a local copy to a server row later, and a delete was known only to the browser that made it. Every offline drafts bug came from one of the two. GP Draft now keeps a name the client sends (20 lowercase letters and digits) and refuses one that was ever deleted, using the Deleted Document record Frappe already keeps. A draft has one name from its first keystroke, saving it twice can never make a second row, and nothing can re-create a deleted draft. Rows made without a client name are named as before.
The client now names a draft when it is started. That name is its key on this device, the ?draft= in its link and its GP Draft name on the server, so a retried save can never make a second row and nothing has to map one name to another. - Saving creates the row under that name, and updates it if it exists. - Saves and deletes of one draft run one at a time across tabs, so a delete asked for mid-upload waits for the upload and then removes it. - A copy saved after its draft was deleted is refused by the server; the tab drops it and says so, instead of bringing the draft back. - A saved draft needs the connection to be deleted. Drafts that exist only on this device are listed on the Drafts page and can be discarded offline. - Records from older versions move to the new keys once, on first use.
A composer kept the server row's name apart from the draft's own, so the two could disagree. An unsaved reply that met another device's reply draft took that row's name as its own saved row, and its next save updated a row that did not exist, which dropped the reply. The composer now holds only whether its draft is saved; the row is always named after the draft. - An unsaved reply is saved as a draft of its own when another device has one for the same discussion, and find_my_draft keeps the newest. - A save records that the draft is saved before letting go of its lock, so a delete waiting on the lock also deletes the server row. - A record once saved stays saved until it is deleted: a writer that has not heard of the save cannot undo it. - One draft failing no longer stops the recovery sweep, and drafts deleted by an older version are not brought back when their records move.
- Index Deleted Document by (deleted_doctype, deleted_name). Naming a draft looks its name up there, and Frappe does not index it, so every new draft scanned a row for every deletion on the site. Added through on_doctype_update, with a patch for existing sites, as GP Project Visit does. - Use idb-keyval's update for the read-and-write that keeps a saved record saved, instead of a hand-written transaction. - One content check (hasContent) and one lock helper (withLock) instead of copies in each file. errorType moves next to isPermissionError. - Import _ as the rest of the app does, rename the Drafts list predicate so two different checks no longer share a name, and drop comments left from the old keys. - Report a failed local draft listing instead of dropping the error, and log a timed-out lookup under the draft's name, which is never null.
…lete The conversion reads every record in one readwrite transaction instead of walking a cursor, and deleting a draft's server row goes through the doctype, which drops it from every GP Draft list, instead of a call plus a manual list update.
- Add the Deleted Document index with an execute: line in patches.txt instead of a patch module that only called it. - Match a reply or edit draft to its target with the existing singletonKey instead of a new field-by-field comparison. - Reload the local drafts after deleting some instead of filtering the list by hand, and let dayjs fall back to local time itself.
A 20-digit JSON integer matched the name pattern once converted to a string, and was then kept as the name unconverted.
The browser fetches gameplan-sw.js in the background to look for an update, and Frappe sets the sid cookie on every response, for the session the request began under. An update check started before a login and finished after it put the previous user's session back, so the next page opened as that user. Cypress hit this now and then: profile-customize logged in as a member and got Administrator's profile. The script is the same for everyone, so it is now served as public, which Frappe answers without cookies.
get_list passes the client's fields and filters to frappe.qb.get_query, which skips permission checks by default. Fields and filters may follow a link, so any member could read another user's account fields through the profile's `user` link, such as `user.last_ip` or `user.mobile_no`, or find them one character at a time with a filter. The query now runs with permissions, so linked fields the user may not read come back empty and filters on them match nothing. The People list returns the same rows for members and guests. An empty result also no longer breaks the count queries that follow it.
Comment on lines
+2
to
+4
| * frappe-ui's IndexedDB cache, addressed from outside its resources. frappe-ui has no public | ||
| * API for this yet (see README.md), so the key formats below mirror its | ||
| * `data-fetching/utils.ts` (lists and calls) and `docStore.ts` (documents). |
Contributor
There was a problem hiding this comment.
Workaround Reference Missing
cache.ts mirrors frappe-ui’s private cache keys without naming an upstream PR that will replace this workaround. The repository requires that reference for local workarounds; please add it or move the cache API upstream before merging.
Context Used: AGENTS.md (source)
Prompt To Fix With AI
This is a comment left during a code review.
Path: frontend/src/data/offline/cache.ts
Line: 2-4
Comment:
**Workaround Reference Missing**
`cache.ts` mirrors frappe-ui’s private cache keys without naming an upstream PR that will replace this workaround. The repository requires that reference for local workarounds; please add it or move the cache API upstream before merging.
**Context Used:** AGENTS.md ([source](https://github.com/frappe/gameplan/blob/develop/AGENTS.md))
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
The app imported useList, useDoc, useCall and dialog from data/offline instead of frappe-ui, in some 40 files, to get their offline defaults. A Vite plugin now resolves the app's `frappe-ui` to data/offline/resources.ts, which re-exports frappe-ui with those four overridden; only data/offline itself gets the package. Every caller keeps develop's imports, and once frappe-ui has these options, deleting the plugin is the whole switch. Files that pass dialog's `worksOffline` still import it from data/offline, since TypeScript types `frappe-ui` from the package.
The app's last two JavaScript modules. Boot values set on window are typed in globals.d.ts, as site_name already was.
One helper posts images to the worker, for saving and for forgetting. Comments that restated a function's name, or took four lines to say two, are cut down; the reasons stay.
VueUse already reads and writes localStorage, catching blocked storage, and offlineDownloads.ts uses it for the window setting.
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.
Continuation of #516
netchampfaris's PR was handed over to me to finish. It is reopened from my fork because I have no write access. The commit history from #516 is intact, with
developmerges and new commits on top.What it does
From #516: a service worker serves the app shell offline, and anything already fetched (discussions, spaces, people, profiles) renders from the frappe-ui IndexedDB cache when a request fails.
New here:
data/offline/requests.tsrefuses/api/calls locally instead of letting them fail one by one. Each resource on screen refetches once on reconnect.refuseOffline()), and a confirm dialog is refused before it opens (data/offline/dialog.ts). Opening a page never warns, althoughcall()reads over POST and a visit writestrack_visit: the toast is only for a write that starts within 1.5 s of a click, Enter or Space, on the same page. What was typed is kept as a draft. Fields that save as you type (task, page, profile) are read-only offline, because typing there would be lost.How downloads work
gameplan/offline_downloads.py. The index lists what the device should hold and what changed since its last sync. The bundle returns those discussions 20 at a time. Both use frappe's@rate_limit: 1,000 requests per hour per endpoint and IP address (see below).frappe.get_listapplies the discussion permission, so private Spaces you are not in stay out.data/offline/cache.ts. So a downloaded discussion opens exactly like a visited one, and a Space you have never opened still lists its discussions.How drafts work
Earlier rounds of review kept finding drafts that came back after being deleted, were saved twice, or were lost. Each came from one of four causes: a draft had two names (a local key and a server name), "deleted" was known only to the device that deleted it, replies and edits shared a slot per discussion, and two writers could save the same draft at once. So the design removes those causes instead of patching each case:
?draft=in its link and itsGP Draftname. Saving creates the row under that name, and updates it if it already exists, so a retried save never makes a second row. The composer holds only whether its draft is saved, never a second name, so it cannot mistake another device's row for its own. Once a record is saved it stays saved until it is deleted, so a tab that has not heard of the save cannot undo it.GPDraft.autonamekeeps the client's name, and refuses one that is inDeleted Document. So a tab or device still holding a deleted draft cannot bring it back: its save fails, it drops its copy and says "This draft was deleted, so your changes were not saved."find_my_draftbehaviour. A reply typed offline while another device saved one for the same discussion is saved as its own draft, andfind_my_draftkeeps the newest. Drafts stored by an earlier version move to their new keys once, on first use. Records that an earlier version had marked deleted are dropped, not brought back, and one bad record cannot stop the recovery sweep.Recording
gameplan-offline-demo-github.mp4
Recorded before the review changes below. Writes now show a toast instead of looking disabled, and the download offer in step 2 has since been removed; the rest is the same. Four minutes, with the app on the left and the DevTools Network panel on the right, filtered to
/api:Response to the review
The review said the feature was built in the wrong layer: knowledge of frappe-ui's internals was spread across the app. This round moves it into one folder, fixes the five blockers and the correctness issues, and cuts the guards.
frontend/src/data/offline/is now the only code that knows how frappe-ui fetches and caches. ItsREADME.mdlists what each file relies on, and what in frappe-ui would let it go.The rest of the app imports from
frappe-uiexactly as ondevelop. A small Vite plugin (offlineResourcesinvite.config.ts) resolves the app'sfrappe-uitodata/offline/resources.ts, which re-exports frappe-ui with the offline versions ofuseList,useDoc,useCallanddialog. Onlydata/offline/itself gets the package. When frappe-ui has these options, deleting the plugin is the whole switch; no import changes.resources.tsuseList,useDocanduseCallgiven the offline defaults (staleOnError, reload on reconnect, acacheLoadedpromise) anddialogfromdialog.tsrequests.ts/api/requests offline, with one toast for a write the person asked for;refuseOffline()for an action to check firstdialog.tsdialog, refused offline before it opens unlessworksOfflinecache.tscacheFor(user): the cache key formats of frappe-ui#1211 for one account, used by the download engineBlockers
transformran twice on cached rows: fixed at the source by Cache: run transform once, and keep each user's cache apart frappe-ui#1211. This PR pins that commit.waitUntil.gameplan.test, where no service worker can register: it now useslocalhost, likeui-test.yml. I did not rename the CI site togameplan.localhost. That touches 54 references in 14 files, mostly server-test workflows.?redirect=, and Retry opens it. The copy says "community" when a community was requested.cache.ts.downloads.cy.tswrites through it and reads back through the app, so a format change fails CI.Two accounts never mix
The session cookie can change at any moment: a sign-in in another tab,
bench browse --sid, the dev user switcher. Checking it before each write still left a gap at everyawait. So the design removes the question instead of asking it more often. The rules are indata/offline/README.md:cacheFor(owner), fixed when it starts. Nothing in it reads the cookie again.user, and the client drops an answer for another account before storing it. A Cypress test sends such an answer and checks that nothing is stored.Correctness
AbortControllerreplaces four tracking variables.Removed: about 40 component guards and the global
pointeruplistener; the hand-written per-user rate limiter; the field allowlist; the second permission filter;_allowed_window(window_daysis nowLiteral[7, 30, 90]); the service worker round trip and thedeleteDatabasefallback inoffline.ts(366 to 175 lines); three of the four paths that filled the asset cache.Security fixes found in an audit of this PR
gameplan-sw.jsin the background to look for an update, and Frappe setssidon every response for the session the request began under. An update check that started before a login and ended after it put the previous user's session back, so the next page opened as that user.www/gameplan_sw.pyserves the script aspublic, no-cache, which Frappe answers without cookies.app-shell.cy.tschecks it.gp_user_profile.get_listpassed the client'sfieldsandfilterstofrappe.qb.get_query, which skips permission checks by default. A field or filter can follow a link, so any member could readuser.last_ip,user.mobile_noand similar fields of every profile, or guess them through a filter. The query now runs withignore_permissions=False: linked fields the user may not read come back empty, and filters on them match nothing. The list returns the same rows for members and guests. A backend test covers both the select and the filter.Rate limiting: the hand-written per-user limiter is replaced by frappe's
@rate_limitdecorator. Frappe has no per-user limiter: therate_limitsite setting is one site-wide counter, and the decorator keys on IP address. An office shares one address, so the limit is 1,000 requests per hour per endpoint. A first 3-month download is at most 26 requests, which leaves room for about 40 people behind one address in the same hour, and a client stuck in a loop is still stopped. The counter is keyed oncmd, which the app's/api/method/calls set.Kept on purpose: the size readout in the settings, and the rotating check of visited discussions for lost access.
Removed after review: the one-time download offer (
offlineIntroduction.ts). Downloads are switched on in Settings only. Reactions made offline are no longer queued: reacting offline is refused like any other write, and reactions otherwise work as ondevelop.frappe-ui migration
This PR pins frappe-ui to the merge commit of frappe/frappe-ui#1211 (
9be0602) until a release includes it. #1211 runstransformonce on cached data and keys the cache per user. So Gameplan's own per-user key suffix is gone.The pin moves frappe-ui from
1.0.0-beta.64to1.0.0-rc.1, so this PR carries the parts of the 1.0 migration that the build and the Cypress suite need. The rest of the migration is tracked in #540, anddevelophas already done sections 1, 2, 4 and 5 of it.list-v1,navigation-v1,editor-v1,destinations-v1andtokens-v2(51 token renames in 36 files).frappeui()no longer installsunplugin-icons. The seven<Lucide*>tags and~icons/imports use CSS icon classes instead, like the rest of the app.UploadedFileis now imported fromfrappe-ui, notfrappe-ui/editor.useDocanduseCallwrites now reject on failure. This is the follow-up that Upgrading to frappe-ui 1.0.0 #540 section 3 names foruseDoc(Take the v2 composables to bar: tests and the P1-P15 audit frappe-ui#933). The fire-and-forget calls now catch.TaskDetailuses onesetTaskValuehelper, and a failed post edit puts the old title back.useCall.datais a shallow ref. A role change merged into the users list did not show until reload.activeUsersnow reads through the reactive store.Not in this PR: the
useListanduseDoctypewrites in #540 section 3.2. They already reject on beta.64, so they are existing work, not something the pin causes. Thequeued()clean-up from section 3.4 is also left for #540.Also in this PR
main.jsandutils/resetDataMixin.jsare now TypeScript. Boot values set onwindoware typed inglobals.d.ts, assite_namealready was.gameplan-sw.jsstays JavaScript, because Frappe serves it fromwww/without a build.Testing
userlink, by select or by filter.frontend/cypress/e2e/offline, 9 specs, 30 tests): the browser goes offline through the DevTools protocol. These specs can now fail:drafts.cy.tshas one test per draft rule above. Each was checked by breaking that rule in the code and watching its test fail.realtime-activity, and the poll's live-tally step. The third,comment-actions, is the edit-then-delete race that fix(comments): await draft commit before closing edit mode #600 fixes; it passed three times when run again. After the latest changes, the offline and draft specs were run again and pass.One CI change
A service worker only registers on a secure origin, and over plain http that means
localhost.install.shmakesgameplan.testthe default site (bench use), and both UI workflows point Cypress athttp://localhost:8000withGAMEPLAN_SITE: gameplan.test. The site check incypress.config.tsreads that variable and still refuses to run against any other site.