refactor: generalize ActionCable cache invalidation over the model registry

Replaces the three hardcoded dispatches in actionCable.onCacheInvalidate and
ReconnectService.revalidateCaches with a loop over cacheableModels. Adding a
new cached model no longer requires editing these two handlers — they pick
it up automatically from the registry.

Also guards against partial server payloads: dispatches are skipped for
models whose key is undefined, so a client running ahead of a server
deploy keeps working without spurious dispatches.
This commit is contained in:
Shivam Mishra
2026-05-21 17:21:42 +05:30
parent 0ae3a82339
commit 1a61e8102e
3 changed files with 43 additions and 12 deletions
@@ -6,6 +6,7 @@ import {
isAInboxViewRoute,
isNotificationRoute,
} from 'dashboard/helper/routeHelpers';
import { cacheableModels } from 'dashboard/helper/CacheHelper/cacheableModels';
const MAX_DISCONNECT_SECONDS = 10800;
@@ -99,14 +100,14 @@ class ReconnectService {
};
revalidateCaches = async () => {
const { label, inbox, team } = await this.store.dispatch(
'accounts/getCacheKeys'
const keys = (await this.store.dispatch('accounts/getCacheKeys')) || {};
await Promise.all(
cacheableModels
.filter(model => keys[model.name] !== undefined)
.map(model =>
this.store.dispatch(model.dispatchPath, { newKey: keys[model.name] })
)
);
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 () => {
@@ -5,6 +5,7 @@ import { BUS_EVENTS } from 'shared/constants/busEvents';
import { emitter } from 'shared/helpers/mitt';
import { useImpersonation } from 'dashboard/composables/useImpersonation';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { cacheableModels } from './CacheHelper/cacheableModels';
const { isImpersonating } = useImpersonation();
const UNREAD_COUNTS_REFETCH_THROTTLE_MS = 5000;
@@ -256,10 +257,12 @@ 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 });
const keys = data.cache_keys || {};
cacheableModels.forEach(model => {
const newKey = keys[model.name];
if (newKey === undefined) return;
this.app.$store.dispatch(model.dispatchPath, { newKey });
});
};
}
@@ -254,11 +254,13 @@ describe('ReconnectService', () => {
});
describe('revalidateCaches', () => {
it('should dispatch revalidate actions for labels, inboxes, and teams', async () => {
it('should dispatch revalidate actions for every cacheable model returned by the server', async () => {
storeMock.dispatch.mockResolvedValueOnce({
label: 'labelKey',
inbox: 'inboxKey',
team: 'teamKey',
canned_response: 'cannedKey',
account_user: 'accountUserKey',
});
await reconnectService.revalidateCaches();
expect(storeMock.dispatch).toHaveBeenCalledWith('accounts/getCacheKeys');
@@ -271,6 +273,31 @@ describe('ReconnectService', () => {
expect(storeMock.dispatch).toHaveBeenCalledWith('teams/revalidate', {
newKey: 'teamKey',
});
expect(storeMock.dispatch).toHaveBeenCalledWith(
'revalidateCannedResponses',
{ newKey: 'cannedKey' }
);
expect(storeMock.dispatch).toHaveBeenCalledWith('agents/revalidate', {
newKey: 'accountUserKey',
});
});
it('should skip dispatches for models the server does not yet emit', async () => {
storeMock.dispatch.mockResolvedValueOnce({
label: 'labelKey',
inbox: 'inboxKey',
team: 'teamKey',
// canned_response / account_user omitted (older server)
});
await reconnectService.revalidateCaches();
expect(storeMock.dispatch).not.toHaveBeenCalledWith(
'revalidateCannedResponses',
expect.anything()
);
expect(storeMock.dispatch).not.toHaveBeenCalledWith(
'agents/revalidate',
expect.anything()
);
});
});