From f452b88e8d2af409f0febe7c4461e2168ef53196 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 10 Jun 2026 12:36:22 +0530 Subject: [PATCH] refactor: simplify boot cache hydration to a paint-only step --- app/javascript/dashboard/App.vue | 17 +-- .../helper/CacheHelper/cacheableModels.js | 2 +- .../CacheHelper/hydrateStoresFromCache.js | 70 --------- .../CacheHelper/paintStoresFromCache.js | 32 ++++ .../hydrateStoresFromCache.spec.js | 143 ------------------ .../CacheHelper/paintStoresFromCache.spec.js | 81 ++++++++++ 6 files changed, 121 insertions(+), 224 deletions(-) delete mode 100644 app/javascript/dashboard/helper/CacheHelper/hydrateStoresFromCache.js create mode 100644 app/javascript/dashboard/helper/CacheHelper/paintStoresFromCache.js delete mode 100644 app/javascript/dashboard/helper/specs/CacheHelper/hydrateStoresFromCache.spec.js create mode 100644 app/javascript/dashboard/helper/specs/CacheHelper/paintStoresFromCache.spec.js diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue index b74f0ce7b..ea4e1e29c 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -19,7 +19,7 @@ import { verifyServiceWorkerExistence, } from './helper/pushHelper'; import ReconnectService from 'dashboard/helper/ReconnectService'; -import hydrateStoresFromCache from 'dashboard/helper/CacheHelper/hydrateStoresFromCache'; +import paintStoresFromCache from 'dashboard/helper/CacheHelper/paintStoresFromCache'; import { useUISettings } from 'dashboard/composables/useUISettings'; export default { @@ -110,16 +110,13 @@ export default { accountId: this.currentAccountId, }); const { pubsub_token: pubsubToken } = this.currentUser || {}; - const actionCable = vueActionCable.init(this.store, pubsubToken); + vueActionCable.init(this.store, pubsubToken); - // Seed Vuex from IndexedDB while ActionCable connects so warm boots paint - // cached config instantly. Once the cable subscription is confirmed, run - // one more reconciliation to catch invalidations broadcast between the - // first cache-key snapshot and the active subscription. - await hydrateStoresFromCache(this.$store, this.currentAccountId); - actionCable.connected.then(() => { - hydrateStoresFromCache(this.$store, this.currentAccountId); - }); + // 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 } = diff --git a/app/javascript/dashboard/helper/CacheHelper/cacheableModels.js b/app/javascript/dashboard/helper/CacheHelper/cacheableModels.js index 1482142fa..1c126975c 100644 --- a/app/javascript/dashboard/helper/CacheHelper/cacheableModels.js +++ b/app/javascript/dashboard/helper/CacheHelper/cacheableModels.js @@ -5,7 +5,7 @@ // with what the client looks up. // // `dispatchPath` is the full Vuex dispatch path for the revalidate action. -// `setMutation` is the full commit path used by hydrateStoresFromCache to seed +// `setMutation` is the full commit path used by paintStoresFromCache to seed // Vuex from IDB. `clearMutation` (optional) is committed BEFORE `setMutation` // for modules whose SET_* mutation merges-by-id instead of replacing — without // it, rows deleted server-side between sessions would survive as phantoms. diff --git a/app/javascript/dashboard/helper/CacheHelper/hydrateStoresFromCache.js b/app/javascript/dashboard/helper/CacheHelper/hydrateStoresFromCache.js deleted file mode 100644 index 116beb66c..000000000 --- a/app/javascript/dashboard/helper/CacheHelper/hydrateStoresFromCache.js +++ /dev/null @@ -1,70 +0,0 @@ -/* 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/CacheHelper/paintStoresFromCache.js b/app/javascript/dashboard/helper/CacheHelper/paintStoresFromCache.js new file mode 100644 index 000000000..73ca7ea2b --- /dev/null +++ b/app/javascript/dashboard/helper/CacheHelper/paintStoresFromCache.js @@ -0,0 +1,32 @@ +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; + if (model.clearMutation) store.commit(model.clearMutation); + store.commit(model.setMutation, localData); + }) + ); +} diff --git a/app/javascript/dashboard/helper/specs/CacheHelper/hydrateStoresFromCache.spec.js b/app/javascript/dashboard/helper/specs/CacheHelper/hydrateStoresFromCache.spec.js deleted file mode 100644 index 942b4fdc7..000000000 --- a/app/javascript/dashboard/helper/specs/CacheHelper/hydrateStoresFromCache.spec.js +++ /dev/null @@ -1,143 +0,0 @@ -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(); - }); -}); 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..3ee385be0 --- /dev/null +++ b/app/javascript/dashboard/helper/specs/CacheHelper/paintStoresFromCache.spec.js @@ -0,0 +1,81 @@ +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('commits CLEAR_TEAMS before SET_TEAMS to drop phantom rows', async () => { + await dm.push({ + modelName: 'team', + data: [{ id: 1, name: 'Sales' }], + }); + + await paintStoresFromCache(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'); + }); +});