Skip to content

feat(frontend): offline support via service worker - #571

Open
ebrahimgamdiwala wants to merge 120 commits into
frappe:developfrom
ebrahimgamdiwala:feat/offline-support
Open

ebrahimgamdiwala wants to merge 120 commits into
frappe:developfrom
ebrahimgamdiwala:feat/offline-support

Conversation

@ebrahimgamdiwala

@ebrahimgamdiwala ebrahimgamdiwala commented Sep 9, 2026 •

Copy link
Copy Markdown
Collaborator

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 develop merges 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:

  • Download for offline (Settings → Preferences → Offline, or More → Offline on mobile). Keep the past week, month or 3 months of discussions from your communities on the device, with their comments, polls, activity and images. The default does not change: only what you open. Guests do not see the setting, because they join no communities.
  • No requests while offline. data/offline/requests.ts refuses /api/ calls locally instead of letting them fail one by one. Each resource on screen refetches once on reconnect.
  • A refused action says why, at the first click. No button is disabled for being offline. An action that needs the server shows one "You're offline. Reconnect to do this." toast: reacting, Submit, Publish and Save check first (refuseOffline()), and a confirm dialog is refused before it opens (data/offline/dialog.ts). Opening a page never warns, although call() reads over POST and a visit writes track_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.
  • Drafts that survive going offline. A discussion started offline is listed on the Drafts page and saved when the connection returns. See How drafts work.
  • Honest empty states. A page that was never fetched says so and offers Retry, instead of rendering as empty. This applies to network failures only. A 403 or 404 keeps the page's own handling.

How downloads work

  • Two POST endpoints in 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).
  • Scope is the communities you have joined: every Space in them that you can open. frappe.get_list applies the discussion permission, so private Spaces you are not in stay out.
  • Everything goes into the cache entries the app already reads, through 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.
  • An up-to-date device spends one index request. Background syncs run at most every 6 hours, in one tab at a time (Web Locks), and skip a hidden tab or Data Saver. A failed sync backs off from 5 minutes up to that interval.
  • A device keeps at most the 500 newest discussions of the window.
  • Changing the window keeps what is already on the device and fetches only what is missing.
  • Discussions the user can no longer read are removed, with images nothing else shows. A discussion that ages out of the window is no longer kept up to date, but stays like any visited page.

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:

  • One name per draft. The client names a draft at its first keystroke: 20 lowercase letters and digits. That name is its key on the device, the ?draft= in its link and its GP Draft name. 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.
  • The server refuses a deleted name. GPDraft.autoname keeps the client's name, and refuses one that is in Deleted 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."
  • Deleting a saved draft needs the connection. Offline, it is refused like any other write, and the selection stays. A draft that exists only on the device can be discarded offline.
  • One writer at a time. Saves and deletes of one draft run under a Web Lock named after it, across tabs. A save records that the draft is saved before it lets go of the lock, so a delete asked for while its upload is in flight waits for the upload, then removes the row.
  • Reply drafts keep the existing find_my_draft behaviour. A reply typed offline while another device saved one for the same discussion is saved as its own draft, and find_my_draft keeps 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:

  1. Discovering it: a fresh sign-in, normal use, then the connection drops. What was opened still reads, a reply is kept as a draft, and pages that were never opened say so and offer Retry. No API request is attempted while offline.
  2. Downloading: the offer on the next load, throttled to Slow 4G. There is one index request, then one per 20 discussions.
  3. Reading offline: a discussion downloaded in the background and never opened, with its comments and images.
  4. On a phone: the same flows at phone width.

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. Its README.md lists what each file relies on, and what in frappe-ui would let it go.

The rest of the app imports from frappe-ui exactly as on develop. A small Vite plugin (offlineResources in vite.config.ts) resolves the app's frappe-ui to data/offline/resources.ts, which re-exports frappe-ui with the offline versions of useList, useDoc, useCall and dialog. Only data/offline/ itself gets the package. When frappe-ui has these options, deleting the plugin is the whole switch; no import changes.

File Job
resources.ts frappe-ui, with useList, useDoc and useCall given the offline defaults (staleOnError, reload on reconnect, a cacheLoaded promise) and dialog from dialog.ts
requests.ts Refuses /api/ requests offline, with one toast for a write the person asked for; refuseOffline() for an action to check first
dialog.ts frappe-ui's dialog, refused offline before it opens unless worksOffline
cache.ts cacheFor(user): the cache key formats of frappe-ui#1211 for one account, used by the download engine

Blockers

  1. Activities transform ran 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.
  2. The first page load after a deploy waited for the whole build: the service worker now answers at once and caches in waitUntil.
  3. The nightly Cypress run used gameplan.test, where no service worker can register: it now uses localhost, like ui-test.yml. I did not rename the CI site to gameplan.localhost. That touches 54 references in 14 files, mostly server-test workflows.
  4. Retry on "Can't load this space" could not succeed: the router now keeps the link in ?redirect=, and Retry opens it. The copy says "community" when a community was requested.
  5. frappe-ui's cache format was used in several places: it is now only in cache.ts. downloads.cy.ts writes 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 every await. So the design removes the question instead of asking it more often. The rules are in data/offline/README.md:

  • Bind, do not look up. A download builds every key through cacheFor(owner), fixed when it starts. Nothing in it reads the cookie again.
  • Answers say whose they are. Both endpoints return 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.
  • Clearing excludes writing. A logout or user switch stops this tab's download, then clears under the lock downloads hold. So no write lands after the clear.

Correctness

  • The download engine reads its metadata inside the lock on every run, so a second tab cannot sync from stale metadata or leave orphans. One AbortController replaces four tracking variables.
  • A manual sync waits for another tab's sync instead of reporting a failure.
  • Changing the window keeps what is on the device. Aged-out discussions are no longer treated as revoked.
  • A failure after a cancel or logout writes nothing.
  • The index reports its sync time a minute early, so a change committed during the read is caught next time.
  • A failing route chunk reloads a page once, not in a loop.
  • A browser without IndexedDB counts as cleared, so it no longer blocks the app after a user switch.
  • The router waits for cached lists only when there is something cached, not 3 s every time.
  • Saved images survive a new worker version. Images saved while browsing are capped at 300.
  • Pinning a Space offline is put back when the save fails.
  • The settings copy now says "the communities you've joined".

Removed: about 40 component guards and the global pointerup listener; the hand-written per-user rate limiter; the field allowlist; the second permission filter; _allowed_window (window_days is now Literal[7, 30, 90]); the service worker round trip and the deleteDatabase fallback in offline.ts (366 to 175 lines); three of the four paths that filled the asset cache.

Security fixes found in an audit of this PR

  • The worker script sends no session cookie. The browser fetches gameplan-sw.js in the background to look for an update, and Frappe sets sid on 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.py serves the script as public, no-cache, which Frappe answers without cookies. app-shell.cy.ts checks it.
  • The People list cannot read other users' account fields. gp_user_profile.get_list passed the client's fields and filters to frappe.qb.get_query, which skips permission checks by default. A field or filter can follow a link, so any member could read user.last_ip, user.mobile_no and similar fields of every profile, or guess them through a filter. The query now runs with ignore_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_limit decorator. Frappe has no per-user limiter: the rate_limit site 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 on cmd, 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 on develop.

frappe-ui migration

This PR pins frappe-ui to the merge commit of frappe/frappe-ui#1211 (9be0602) until a release includes it. #1211 runs transform once 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.64 to 1.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, and develop has already done sections 1, 2, 4 and 5 of it.

  • Codemods: list-v1, navigation-v1, editor-v1, destinations-v1 and tokens-v2 (51 token renames in 36 files).
  • Icons: frappeui() no longer installs unplugin-icons. The seven <Lucide*> tags and ~icons/ imports use CSS icon classes instead, like the rest of the app.
  • UploadedFile is now imported from frappe-ui, not frappe-ui/editor.
  • useDoc and useCall writes now reject on failure. This is the follow-up that Upgrading to frappe-ui 1.0.0 #540 section 3 names for useDoc (Take the v2 composables to bar: tests and the P1-P15 audit frappe-ui#933). The fire-and-forget calls now catch. TaskDetail uses one setTaskValue helper, and a failed post edit puts the old title back.
  • useCall.data is a shallow ref. A role change merged into the users list did not show until reload. activeUsers now reads through the reactive store.

Not in this PR: the useList and useDoctype writes in #540 section 3.2. They already reject on beta.64, so they are existing work, not something the pin causes. The queued() clean-up from section 3.4 is also left for #540.

Also in this PR

  • main.js and utils/resetDataMixin.js are now TypeScript. Boot values set on window are typed in globals.d.ts, as site_name already was. gameplan-sw.js stays JavaScript, because Frappe serves it from www/ without a build.

Testing

  • Backend, drafts: a draft keeps the name its client gave it, a second create with that name fails without a second row, a deleted name is refused, and a malformed name is rejected.
  • Backend, profiles: the People list reads nothing of another user's account through the user link, by select or by filter.
  • Backend, downloads: 11 tests for the download endpoints: scope, what takes a discussion out, the device cap, Space moves, revoked access, every kind of change, the bundle contents and bounds, the account each answer names, the per-address rate limit, and POST-only. They compare only the discussions they create, so data already on the site does not break them. The full suite passes.
  • Offline e2e (frontend/cypress/e2e/offline, 9 specs, 30 tests): the browser goes offline through the DevTools protocol. These specs can now fail:
    • The network is restored before and after every test.
    • The scroll test seeds a thread long enough to scroll.
    • The profile and fallback checks match the real copy and real content.
    • drafts.cy.ts has one test per draft rule above. Each was checked by breaking that rule in the code and watching its test fail.
    • The refused-write toast, a Sync now refused before its dialog, a saved discussion opened without a toast, and a reaction refused offline have their own tests.
  • Full suite: 184 of 187 pass locally. Two failures need the realtime server, which this machine does not run: 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.
  • Open item: "reacts to a poll" failed several times, but only when run after the other poll tests. It passes alone, repeated, and under a service worker reload, and it passed in the final full run. I have not found the cause.
  • Browser: checked on desktop and at phone width.

One CI change

A service worker only registers on a secure origin, and over plain http that means localhost. install.sh makes gameplan.test the default site (bench use), and both UI workflows point Cypress at http://localhost:8000 with GAMEPLAN_SITE: gameplan.test. The site check in cypress.config.ts reads that variable and still refuses to run against any other site.

netchampfaris and others added 16 commits August 9, 2026 01:18
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.
@greptile-apps

greptile-apps Bot commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 4/5

[Medium risk] Adds offline support via service worker and caching.

The PR should not merge until the outstanding workaround-documentation requirement is addressed.

Reviews (71) · Last reviewed commit: "refactor(offline): keep the last-seen us..."

Comment thread frontend/src/offline.ts Outdated
@ebrahimgamdiwala
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
ebrahimgamdiwala marked this pull request as ready for review September 9, 2026 10:03
Comment thread frontend/src/offline.ts Outdated
…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
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
ebrahimgamdiwala marked this pull request as ready for review September 9, 2026 10:13
Comment thread frontend/src/components/ProfileBento/profileBentoSource.ts
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.
Comment thread frontend/src/pages/Drafts.vue Outdated
Comment thread frontend/src/data/useDraftSync.ts Outdated
Comment thread frontend/src/data/useDraftSync.ts Outdated
Comment thread frontend/src/data/useDraftSync.ts Outdated
Comment thread frontend/src/data/useDraftSync.ts Outdated
Comment thread frontend/src/data/useDraftSync.ts Outdated
Comment thread frontend/src/data/draftStore.ts
Comment thread frontend/src/data/drafts.ts Outdated
Comment thread frontend/src/pages/Drafts.vue Outdated
# 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.
Comment thread frontend/src/data/useDraftSync.ts Outdated
Comment thread frontend/src/data/useDraftSync.ts Outdated
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.
Comment thread frontend/src/data/draftStore.ts
- 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.
Comment thread gameplan/gameplan/doctype/gp_draft/gp_draft.py Outdated
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).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.
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.

2 participants