refactor: simplify boot cache hydration to a paint-only step
This commit is contained in:
@@ -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 } =
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
// `<store>/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 });
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user