refactor: simplify boot cache hydration to a paint-only step

This commit is contained in:
Shivam Mishra
2026-06-10 12:37:11 +05:30
parent 9fb75656d2
commit f452b88e8d
6 changed files with 121 additions and 224 deletions
@@ -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');
});
});