refactor: drive cache freshness from pushed key maps
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
import InboxesAPI from 'dashboard/api/inboxes';
|
||||
import LabelsAPI from 'dashboard/api/labels';
|
||||
import TeamsAPI from 'dashboard/api/teams';
|
||||
import { cacheableModels } from './cacheableModels';
|
||||
|
||||
// model name → cache-enabled API client. Lives here rather than in
|
||||
// cacheableModels to keep that module import-cycle-free: the API clients
|
||||
// import DataManager, which imports cacheableModels.
|
||||
const apiByModel = {
|
||||
inbox: InboxesAPI,
|
||||
label: LabelsAPI,
|
||||
team: TeamsAPI,
|
||||
};
|
||||
|
||||
const revalidateModel = async (store, model, newKey) => {
|
||||
try {
|
||||
const api = apiByModel[model.name];
|
||||
if (await api.validateCacheKey(newKey)) return;
|
||||
|
||||
const response = await api.refetchAndCommit(newKey);
|
||||
store.commit(model.setMutation, api.extractDataFromResponse(response));
|
||||
} catch {
|
||||
// Ignore error — a failed refetch leaves the painted data in place; the
|
||||
// next pushed key map retries.
|
||||
}
|
||||
};
|
||||
|
||||
// The single freshness engine: given a pushed { model_name => key } map
|
||||
// (RoomChannel transmits one on every (re)subscribe, the server broadcasts
|
||||
// one on every change), diff each key against IDB and refetch mismatches.
|
||||
export const dispatchCacheRevalidations = (store, keys = {}) =>
|
||||
Promise.all(
|
||||
cacheableModels
|
||||
.filter(model => keys[model.name] !== undefined)
|
||||
.map(model => revalidateModel(store, model, keys[model.name]))
|
||||
);
|
||||
@@ -98,17 +98,6 @@ class ReconnectService {
|
||||
await this.store.dispatch('notifications/index', { ...filter, page: 1 });
|
||||
};
|
||||
|
||||
revalidateCaches = async () => {
|
||||
const { label, inbox, team } = await this.store.dispatch(
|
||||
'accounts/getCacheKeys'
|
||||
);
|
||||
await Promise.all([
|
||||
this.store.dispatch('labels/revalidate', { newKey: label }),
|
||||
this.store.dispatch('inboxes/revalidate', { newKey: inbox }),
|
||||
this.store.dispatch('teams/revalidate', { newKey: team }),
|
||||
]);
|
||||
};
|
||||
|
||||
handleRouteSpecificFetch = async () => {
|
||||
const currentRoute = this.router.currentRoute.value.name;
|
||||
if (isAConversationRoute(currentRoute, true)) {
|
||||
@@ -138,9 +127,11 @@ class ReconnectService {
|
||||
this.setConversationLastMessageId();
|
||||
};
|
||||
|
||||
// Cached workspace config needs no explicit revalidation here: ActionCable
|
||||
// auto-resubscribes after a drop, and RoomChannel pushes the cache-key map
|
||||
// on every subscribe via the account.cache_invalidated event.
|
||||
onReconnect = async () => {
|
||||
await this.handleRouteSpecificFetch();
|
||||
await this.revalidateCaches();
|
||||
emitter.emit(BUS_EVENTS.WEBSOCKET_RECONNECT_COMPLETED);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
|
||||
import { VOICE_CALL_DIRECTION } from 'dashboard/components-next/message/constants';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { dispatchCacheRevalidations } from './CacheHelper/dispatchCacheRevalidations';
|
||||
|
||||
const { isImpersonating } = useImpersonation();
|
||||
const UNREAD_COUNTS_REFETCH_THROTTLE_MS = 5000;
|
||||
@@ -269,10 +270,7 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
};
|
||||
|
||||
onCacheInvalidate = data => {
|
||||
const keys = data.cache_keys;
|
||||
this.app.$store.dispatch('labels/revalidate', { newKey: keys.label });
|
||||
this.app.$store.dispatch('inboxes/revalidate', { newKey: keys.inbox });
|
||||
this.app.$store.dispatch('teams/revalidate', { newKey: keys.team });
|
||||
dispatchCacheRevalidations(this.app.$store, data.cache_keys);
|
||||
};
|
||||
|
||||
onVoiceCallIncoming = data => {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { dispatchCacheRevalidations } from '../../CacheHelper/dispatchCacheRevalidations';
|
||||
import InboxesAPI from 'dashboard/api/inboxes';
|
||||
import LabelsAPI from 'dashboard/api/labels';
|
||||
import TeamsAPI from 'dashboard/api/teams';
|
||||
|
||||
vi.mock('dashboard/api/inboxes', () => ({
|
||||
default: {
|
||||
validateCacheKey: vi.fn(),
|
||||
refetchAndCommit: vi.fn(),
|
||||
extractDataFromResponse: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('dashboard/api/labels', () => ({
|
||||
default: {
|
||||
validateCacheKey: vi.fn(),
|
||||
refetchAndCommit: vi.fn(),
|
||||
extractDataFromResponse: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('dashboard/api/teams', () => ({
|
||||
default: {
|
||||
validateCacheKey: vi.fn(),
|
||||
refetchAndCommit: vi.fn(),
|
||||
extractDataFromResponse: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('dispatchCacheRevalidations', () => {
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
store = { commit: vi.fn() };
|
||||
});
|
||||
|
||||
it('refetches stale models and commits via their setMutation', async () => {
|
||||
InboxesAPI.validateCacheKey.mockResolvedValue(false);
|
||||
InboxesAPI.refetchAndCommit.mockResolvedValue({ data: { payload: [] } });
|
||||
InboxesAPI.extractDataFromResponse.mockReturnValue([{ id: 1 }]);
|
||||
LabelsAPI.validateCacheKey.mockResolvedValue(true);
|
||||
|
||||
await dispatchCacheRevalidations(store, {
|
||||
inbox: 'inbox-key',
|
||||
label: 'label-key',
|
||||
});
|
||||
|
||||
expect(InboxesAPI.refetchAndCommit).toHaveBeenCalledWith('inbox-key');
|
||||
expect(store.commit).toHaveBeenCalledWith('inboxes/SET_INBOXES', [
|
||||
{ id: 1 },
|
||||
]);
|
||||
expect(LabelsAPI.refetchAndCommit).not.toHaveBeenCalled();
|
||||
expect(store.commit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('skips models absent from the key payload', async () => {
|
||||
InboxesAPI.validateCacheKey.mockResolvedValue(true);
|
||||
|
||||
await dispatchCacheRevalidations(store, { inbox: 'inbox-key' });
|
||||
|
||||
expect(TeamsAPI.validateCacheKey).not.toHaveBeenCalled();
|
||||
expect(store.commit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats missing keys as an empty payload', async () => {
|
||||
await dispatchCacheRevalidations(store);
|
||||
|
||||
expect(InboxesAPI.validateCacheKey).not.toHaveBeenCalled();
|
||||
expect(store.commit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('swallows per-model errors so one failure does not block the rest', async () => {
|
||||
InboxesAPI.validateCacheKey.mockResolvedValue(false);
|
||||
InboxesAPI.refetchAndCommit.mockRejectedValue(new Error('network down'));
|
||||
TeamsAPI.validateCacheKey.mockResolvedValue(false);
|
||||
TeamsAPI.refetchAndCommit.mockResolvedValue({ data: [] });
|
||||
TeamsAPI.extractDataFromResponse.mockReturnValue([{ id: 7 }]);
|
||||
|
||||
await dispatchCacheRevalidations(store, {
|
||||
inbox: 'inbox-key',
|
||||
team: 'team-key',
|
||||
});
|
||||
|
||||
expect(store.commit).toHaveBeenCalledWith('teams/SET_TEAMS', [{ id: 7 }]);
|
||||
expect(store.commit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -253,27 +253,6 @@ describe('ReconnectService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('revalidateCaches', () => {
|
||||
it('should dispatch revalidate actions for labels, inboxes, and teams', async () => {
|
||||
storeMock.dispatch.mockResolvedValueOnce({
|
||||
label: 'labelKey',
|
||||
inbox: 'inboxKey',
|
||||
team: 'teamKey',
|
||||
});
|
||||
await reconnectService.revalidateCaches();
|
||||
expect(storeMock.dispatch).toHaveBeenCalledWith('accounts/getCacheKeys');
|
||||
expect(storeMock.dispatch).toHaveBeenCalledWith('labels/revalidate', {
|
||||
newKey: 'labelKey',
|
||||
});
|
||||
expect(storeMock.dispatch).toHaveBeenCalledWith('inboxes/revalidate', {
|
||||
newKey: 'inboxKey',
|
||||
});
|
||||
expect(storeMock.dispatch).toHaveBeenCalledWith('teams/revalidate', {
|
||||
newKey: 'teamKey',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleRouteSpecificFetch', () => {
|
||||
it('should fetch conversations and messages if current route is a conversation route', async () => {
|
||||
isAConversationRoute.mockReturnValue(true);
|
||||
@@ -335,12 +314,10 @@ describe('ReconnectService', () => {
|
||||
});
|
||||
|
||||
describe('onReconnect', () => {
|
||||
it('should handle route-specific fetch, revalidate caches, and emit WEBSOCKET_RECONNECT_COMPLETED event', async () => {
|
||||
it('should handle route-specific fetch and emit WEBSOCKET_RECONNECT_COMPLETED event', async () => {
|
||||
reconnectService.handleRouteSpecificFetch = vi.fn();
|
||||
reconnectService.revalidateCaches = vi.fn();
|
||||
await reconnectService.onReconnect();
|
||||
expect(reconnectService.handleRouteSpecificFetch).toHaveBeenCalled();
|
||||
expect(reconnectService.revalidateCaches).toHaveBeenCalled();
|
||||
expect(emitter.emit).toHaveBeenCalledWith(
|
||||
BUS_EVENTS.WEBSOCKET_RECONNECT_COMPLETED
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user