diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue index 99aebfd1a..ea4e1e29c 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -19,6 +19,7 @@ import { verifyServiceWorkerExistence, } from './helper/pushHelper'; import ReconnectService from 'dashboard/helper/ReconnectService'; +import paintStoresFromCache from 'dashboard/helper/CacheHelper/paintStoresFromCache'; import { useUISettings } from 'dashboard/composables/useUISettings'; export default { @@ -108,14 +109,21 @@ export default { this.$store.dispatch('setActiveAccount', { accountId: this.currentAccountId, }); + const { pubsub_token: pubsubToken } = this.currentUser || {}; + vueActionCable.init(this.store, pubsubToken); + + // Paint cached config from IndexedDB instantly while the cable + // connects. Freshness needs no orchestration here: RoomChannel pushes + // the cache-key map on every (re)subscribe and on every server-side + // change, all through the same account.cache_invalidated event. + await paintStoresFromCache(this.$store, this.currentAccountId); + const account = this.getAccount(this.currentAccountId); const { locale, latest_chatwoot_version: latestChatwootVersion } = account; - const { pubsub_token: pubsubToken } = this.currentUser || {}; // If user locale is set, use it; otherwise use account locale this.setLocale(this.uiSettings?.locale || locale); this.latestChatwootVersion = latestChatwootVersion; - vueActionCable.init(this.store, pubsubToken); this.reconnectService = new ReconnectService(this.store, this.router); window.reconnectService = this.reconnectService; diff --git a/app/javascript/dashboard/helper/CacheHelper/DataManager.js b/app/javascript/dashboard/helper/CacheHelper/DataManager.js index 23beaa971..6be2b7b06 100644 --- a/app/javascript/dashboard/helper/CacheHelper/DataManager.js +++ b/app/javascript/dashboard/helper/CacheHelper/DataManager.js @@ -1,9 +1,10 @@ import { openDB } from 'idb'; import { DATA_VERSION } from './version'; +import { cacheableModels, cacheableModelNames } from './cacheableModels'; export class DataManager { constructor(accountId) { - this.modelsToSync = ['inbox', 'label', 'team']; + this.modelsToSync = cacheableModelNames; this.accountId = accountId; this.db = null; } @@ -11,12 +12,26 @@ export class DataManager { async initDb() { if (this.db) return this.db; const dbName = `cw-store-${this.accountId}`; - this.db = await openDB(`cw-store-${this.accountId}`, DATA_VERSION, { - upgrade(db) { - db.createObjectStore('cache-keys'); - db.createObjectStore('inbox', { keyPath: 'id' }); - db.createObjectStore('label', { keyPath: 'id' }); - db.createObjectStore('team', { keyPath: 'id' }); + this.db = await openDB(dbName, DATA_VERSION, { + upgrade(db, oldVersion, _newVersion, tx) { + // Flush data carried over from a previous schema version so a + // DATA_VERSION bump acts as a global cache reset. oldVersion === 0 on + // first install, so fresh devices skip this. Clearing before creating + // means we only ever clear stores that pre-existed this upgrade. + if (oldVersion > 0) { + for (let index = 0; index < db.objectStoreNames.length; index += 1) { + tx.objectStore(db.objectStoreNames.item(index)).clear(); + } + } + + if (!db.objectStoreNames.contains('cache-keys')) { + db.createObjectStore('cache-keys'); + } + cacheableModels.forEach(model => { + if (!db.objectStoreNames.contains(model.name)) { + db.createObjectStore(model.name, { keyPath: 'id' }); + } + }); }, }); @@ -41,7 +56,7 @@ export class DataManager { async replace({ modelName, data }) { this.validateModel(modelName); - this.db.clear(modelName); + await this.db.clear(modelName); return this.push({ modelName, data }); } @@ -65,9 +80,11 @@ export class DataManager { } async setCacheKeys(cacheKeys) { - Object.keys(cacheKeys).forEach(async modelName => { - this.db.put('cache-keys', cacheKeys[modelName], modelName); - }); + await Promise.all( + Object.entries(cacheKeys).map(([modelName, value]) => + this.db.put('cache-keys', value, modelName) + ) + ); } async getCacheKey(modelName) { diff --git a/app/javascript/dashboard/helper/CacheHelper/cacheableModels.js b/app/javascript/dashboard/helper/CacheHelper/cacheableModels.js new file mode 100644 index 000000000..b782f999f --- /dev/null +++ b/app/javascript/dashboard/helper/CacheHelper/cacheableModels.js @@ -0,0 +1,17 @@ +// Single source of truth for IDB-cached workspace config. +// +// Each entry must keep `name` equal to the Rails `Model.name.underscore` value +// so the server's `cache_keys` payload (and the IDB object store name) lines up +// with what the client looks up. +// +// `setMutation` is the full commit path used to seed Vuex from IDB (boot +// paint) and to swap in refetched rows (event-driven revalidation). Every +// SET_* mutation must REPLACE its records (not merge) so rows deleted +// server-side never survive as phantoms. +export const cacheableModels = [ + { name: 'inbox', setMutation: 'inboxes/SET_INBOXES' }, + { name: 'label', setMutation: 'labels/SET_LABELS' }, + { name: 'team', setMutation: 'teams/SET_TEAMS' }, +]; + +export const cacheableModelNames = cacheableModels.map(model => model.name); diff --git a/app/javascript/dashboard/helper/CacheHelper/paintStoresFromCache.js b/app/javascript/dashboard/helper/CacheHelper/paintStoresFromCache.js new file mode 100644 index 000000000..cd96208ac --- /dev/null +++ b/app/javascript/dashboard/helper/CacheHelper/paintStoresFromCache.js @@ -0,0 +1,31 @@ +import { DataManager } from './DataManager'; +import { cacheableModels } from './cacheableModels'; + +// Seed Vuex from IndexedDB before the dashboard renders so warm boots paint +// cached config instantly. This is purely local — zero network calls. +// +// Freshness is handled entirely by the account.cache_invalidated event: +// RoomChannel transmits the current cache-key map on every (re)subscribe, and +// the server broadcasts it on every change. dispatchCacheRevalidations diffs +// those keys against IDB and refetches mismatches — the client never pulls +// cache keys itself. +export default async function paintStoresFromCache(store, accountId) { + let dm; + try { + dm = new DataManager(accountId); + await dm.initDb(); + } catch { + // IDB unsupported (e.g. Firefox private mode) — silent no-op. Components + // will fetch from the network normally via the cache-enabled API client. + return; + } + + // Stale-while-revalidate paint: commit cached data into Vuex immediately. + await Promise.all( + cacheableModels.map(async model => { + const localData = await dm.get({ modelName: model.name }); + if (localData.length === 0) return; + store.commit(model.setMutation, localData); + }) + ); +} diff --git a/app/javascript/dashboard/helper/specs/CacheHelper/paintStoresFromCache.spec.js b/app/javascript/dashboard/helper/specs/CacheHelper/paintStoresFromCache.spec.js new file mode 100644 index 000000000..31305127e --- /dev/null +++ b/app/javascript/dashboard/helper/specs/CacheHelper/paintStoresFromCache.spec.js @@ -0,0 +1,79 @@ +import paintStoresFromCache from '../../CacheHelper/paintStoresFromCache'; +import { DataManager } from '../../CacheHelper/DataManager'; + +describe('paintStoresFromCache', () => { + const accountId = 'paint-test-account'; + const originalAxios = window.axios; + let axiosMock; + let dm; + let storeMock; + + beforeEach(async () => { + axiosMock = { + get: vi.fn(), + }; + window.axios = axiosMock; + + storeMock = { + commit: vi.fn(), + dispatch: vi.fn(), + }; + + dm = new DataManager(accountId); + await dm.initDb(); + }); + + afterEach(async () => { + const tx = dm.db.transaction( + [...dm.modelsToSync, 'cache-keys'], + 'readwrite' + ); + [...dm.modelsToSync, 'cache-keys'].forEach(name => { + tx.objectStore(name).clear(); + }); + await tx.done; + window.axios = originalAxios; + }); + + it('does nothing when IDB is empty (first ever load)', async () => { + await paintStoresFromCache(storeMock, accountId); + + expect(storeMock.commit).not.toHaveBeenCalled(); + expect(storeMock.dispatch).not.toHaveBeenCalled(); + }); + + it('seeds Vuex from IDB without any network interaction', async () => { + await dm.push({ + modelName: 'inbox', + data: [{ id: 1, name: 'Support' }], + }); + await dm.push({ + modelName: 'label', + data: [{ id: 9, title: 'Bug' }], + }); + + await paintStoresFromCache(storeMock, accountId); + + expect(storeMock.commit).toHaveBeenCalledWith('inboxes/SET_INBOXES', [ + { id: 1, name: 'Support' }, + ]); + expect(storeMock.commit).toHaveBeenCalledWith('labels/SET_LABELS', [ + { id: 9, title: 'Bug' }, + ]); + expect(axiosMock.get).not.toHaveBeenCalled(); + expect(storeMock.dispatch).not.toHaveBeenCalled(); + }); + + it('seeds teams via SET_TEAMS', async () => { + await dm.push({ + modelName: 'team', + data: [{ id: 1, name: 'Sales' }], + }); + + await paintStoresFromCache(storeMock, accountId); + + expect(storeMock.commit).toHaveBeenCalledWith('teams/SET_TEAMS', [ + { id: 1, name: 'Sales' }, + ]); + }); +});