From 4633b49c39b95b40d247ebeb9eba62aab11568ea Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Thu, 21 May 2026 17:22:04 +0530 Subject: [PATCH] feat: hydrate Vuex stores from IndexedDB on boot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cached fetches no longer issue a /cache_keys preflight per call. The client trusts whatever it has in IDB and keeps it fresh through ActionCable broadcasts and the existing reconnect-time batch revalidate. On warm boots a new hydrateStoresFromCache helper seeds Vuex from IDB before ActionCable connects, so inboxes, labels, teams, canned responses, and agents render immediately from cache. The helper snapshots local cache keys before fetching server keys — without that ordering the comparison would always see "fresh" against the just-written key and stale data would be served forever. Stale entries are revalidated in the background; cold devices stay on the network-fetch path with no regression. Also awaits replace inside refetchAndCommit so the data write completes before the cache key is persisted, closing a window where a concurrent reader could see a fresh key paired with stale data. --- app/javascript/dashboard/App.vue | 6 + .../dashboard/api/CacheEnabledApiClient.js | 34 +++-- .../CacheHelper/hydrateStoresFromCache.js | 70 +++++++++ .../hydrateStoresFromCache.spec.js | 143 ++++++++++++++++++ 4 files changed, 237 insertions(+), 16 deletions(-) create mode 100644 app/javascript/dashboard/helper/CacheHelper/hydrateStoresFromCache.js create mode 100644 app/javascript/dashboard/helper/specs/CacheHelper/hydrateStoresFromCache.spec.js 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(); + }); +});