diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue index 99aebfd1a..29a252a3b 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 hydrateStoresFromCache from 'dashboard/helper/CacheHelper/hydrateStoresFromCache'; import { useUISettings } from 'dashboard/composables/useUISettings'; export default { @@ -108,6 +109,11 @@ export default { this.$store.dispatch('setActiveAccount', { accountId: this.currentAccountId, }); + // Seed Vuex from IndexedDB before ActionCable connects so warm boots + // paint cached config (inboxes, labels, teams, canned responses, agents) + // instantly. Stale entries are revalidated in the background against the + // server's authoritative cache keys. + await hydrateStoresFromCache(this.$store, this.currentAccountId); const account = this.getAccount(this.currentAccountId); const { locale, latest_chatwoot_version: latestChatwootVersion } = account; diff --git a/app/javascript/dashboard/api/CacheEnabledApiClient.js b/app/javascript/dashboard/api/CacheEnabledApiClient.js index 9af939c00..a58ee5163 100644 --- a/app/javascript/dashboard/api/CacheEnabledApiClient.js +++ b/app/javascript/dashboard/api/CacheEnabledApiClient.js @@ -43,24 +43,24 @@ class CacheEnabledApiClient extends ApiClient { return this.getFromNetwork(); } - const { data } = await axios.get( - `/api/v1/accounts/${this.accountIdFromRoute}/cache_keys` - ); - const cacheKeyFromApi = data.cache_keys[this.cacheModelName]; - const isCacheValid = await this.validateCacheKey(cacheKeyFromApi); + // Trust the IDB cache. Freshness is maintained by: + // - boot-time hydrateStoresFromCache (compares server keys once on boot) + // - ActionCable ACCOUNT_CACHE_INVALIDATED broadcasts (live updates) + // - ReconnectService.revalidateCaches (on WebSocket reconnect) + // Skipping the per-call /cache_keys preflight eliminates N GET requests per + // cold settings-page load. + const localData = await this.dataManager.get({ + modelName: this.cacheModelName, + }); - let localData = []; - if (isCacheValid) { - localData = await this.dataManager.get({ - modelName: this.cacheModelName, - }); + if (localData.length > 0) { + return this.marshallData(localData); } - if (localData.length === 0) { - return this.refetchAndCommit(cacheKeyFromApi); - } - - return this.marshallData(localData); + // Empty IDB (first load or wiped): seed from network using whatever local + // cache key we have (null when never seen). refetchAndCommit handles null. + const localKey = await this.dataManager.getCacheKey(this.cacheModelName); + return this.refetchAndCommit(localKey); } async refetchAndCommit(newKey = null) { @@ -69,7 +69,9 @@ class CacheEnabledApiClient extends ApiClient { try { await this.dataManager.initDb(); - this.dataManager.replace({ + // Await replace so data is persisted before the cache key is — otherwise + // a concurrent reader could see a fresh key paired with stale data. + await this.dataManager.replace({ modelName: this.cacheModelName, data: this.extractDataFromResponse(response), }); diff --git a/app/javascript/dashboard/helper/CacheHelper/hydrateStoresFromCache.js b/app/javascript/dashboard/helper/CacheHelper/hydrateStoresFromCache.js new file mode 100644 index 000000000..116beb66c --- /dev/null +++ b/app/javascript/dashboard/helper/CacheHelper/hydrateStoresFromCache.js @@ -0,0 +1,70 @@ +/* global axios */ + +import { DataManager } from './DataManager'; +import { cacheableModels } from './cacheableModels'; + +// Seed Vuex from IndexedDB before the dashboard renders, then reconcile +// against the server's authoritative cache keys in the background. +// +// CRITICAL ORDERING: capture local cache keys BEFORE fetching server keys. +// Each per-model `revalidate` action calls `validateCacheKey(newKey)`, which +// compares `newKey` against whatever is currently persisted in IDB. If we +// wrote the server keys first, that comparison would return true against the +// just-written key and stale IDB data would be served forever. +export default async function hydrateStoresFromCache(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; + } + + // 1. Snapshot local cache keys before any server interaction. + const localKeys = {}; + await Promise.all( + cacheableModels.map(async model => { + localKeys[model.name] = await dm.getCacheKey(model.name); + }) + ); + + // 2. 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; + if (model.clearMutation) store.commit(model.clearMutation); + store.commit(model.setMutation, localData); + }) + ); + + // 3. Fetch the server's authoritative cache keys once. + let serverKeys; + try { + const { data } = await axios.get( + `/api/v1/accounts/${accountId}/cache_keys` + ); + serverKeys = data.cache_keys || {}; + } catch { + return; + } + + // 4. For each stale model, dispatch revalidate with the NEW server key. + // Do NOT await — the UI is already painted with stale data; refetches + // swap it in as they complete. + // + // Skip models with no local cache key — they've never been fetched on + // this device. The first downstream component that dispatches + // `/get` will network-fetch via the cache-enabled API client + // and populate IDB. Boot stays cheap on cold devices. + cacheableModels.forEach(model => { + const serverKey = serverKeys[model.name]; + if (serverKey === undefined) return; + const localKey = localKeys[model.name]; + if (localKey === undefined) return; + if (serverKey === localKey) return; + store.dispatch(model.dispatchPath, { newKey: serverKey }); + }); +} diff --git a/app/javascript/dashboard/helper/specs/CacheHelper/hydrateStoresFromCache.spec.js b/app/javascript/dashboard/helper/specs/CacheHelper/hydrateStoresFromCache.spec.js new file mode 100644 index 000000000..942b4fdc7 --- /dev/null +++ b/app/javascript/dashboard/helper/specs/CacheHelper/hydrateStoresFromCache.spec.js @@ -0,0 +1,143 @@ +import hydrateStoresFromCache from '../../CacheHelper/hydrateStoresFromCache'; +import { DataManager } from '../../CacheHelper/DataManager'; + +describe('hydrateStoresFromCache', () => { + const accountId = 'hydrate-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 () => { + axiosMock.get.mockResolvedValueOnce({ + data: { cache_keys: { inbox: 'k1', label: 'k2', team: 'k3' } }, + }); + + await hydrateStoresFromCache(storeMock, accountId); + + expect(storeMock.commit).not.toHaveBeenCalled(); + expect(storeMock.dispatch).not.toHaveBeenCalled(); + }); + + it('seeds Vuex from IDB then revalidates only stale models', async () => { + await dm.push({ + modelName: 'inbox', + data: [{ id: 1, name: 'Support' }], + }); + await dm.push({ + modelName: 'label', + data: [{ id: 9, title: 'Bug' }], + }); + await dm.setCacheKeys({ inbox: 'inbox-old', label: 'label-current' }); + + axiosMock.get.mockResolvedValueOnce({ + data: { + cache_keys: { + inbox: 'inbox-new', // changed → revalidate + label: 'label-current', // matches → no dispatch + }, + }, + }); + + await hydrateStoresFromCache(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(storeMock.dispatch).toHaveBeenCalledWith('inboxes/revalidate', { + newKey: 'inbox-new', + }); + expect(storeMock.dispatch).not.toHaveBeenCalledWith( + 'labels/revalidate', + expect.anything() + ); + }); + + it('commits CLEAR_TEAMS before SET_TEAMS to drop phantom rows', async () => { + await dm.push({ + modelName: 'team', + data: [{ id: 1, name: 'Sales' }], + }); + + axiosMock.get.mockResolvedValueOnce({ data: { cache_keys: {} } }); + + await hydrateStoresFromCache(storeMock, accountId); + + const teamCommits = storeMock.commit.mock.calls.filter(call => + call[0].startsWith('teams/') + ); + expect(teamCommits[0][0]).toBe('teams/CLEAR_TEAMS'); + expect(teamCommits[1][0]).toBe('teams/SET_TEAMS'); + }); + + it('captures local keys BEFORE comparing to server keys (sequencing guard)', async () => { + await dm.push({ + modelName: 'canned_response', + data: [{ id: 1, short_code: 'hi', content: 'Hello' }], + }); + await dm.setCacheKeys({ canned_response: 'canned-old' }); + + let serverKeyResolved = false; + axiosMock.get.mockImplementationOnce(async () => { + // If the implementation persisted the server key before snapshotting the + // local one, by the time this resolves the IDB would already say + // 'canned-new' and the revalidate dispatch would be skipped. Asserting + // the dispatch fires proves the original local key was captured first. + serverKeyResolved = true; + return { data: { cache_keys: { canned_response: 'canned-new' } } }; + }); + + await hydrateStoresFromCache(storeMock, accountId); + + expect(serverKeyResolved).toBe(true); + expect(storeMock.dispatch).toHaveBeenCalledWith( + 'revalidateCannedResponses', + { newKey: 'canned-new' } + ); + }); + + it('still paints cached data when the cache_keys network call fails', async () => { + await dm.push({ + modelName: 'inbox', + data: [{ id: 7, name: 'Email' }], + }); + axiosMock.get.mockRejectedValueOnce(new Error('offline')); + + await hydrateStoresFromCache(storeMock, accountId); + + expect(storeMock.commit).toHaveBeenCalledWith('inboxes/SET_INBOXES', [ + { id: 7, name: 'Email' }, + ]); + expect(storeMock.dispatch).not.toHaveBeenCalled(); + }); +});