diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index c1f17995..56837103 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -38,6 +38,13 @@ import { normalizeTeamGetDataOptions, } from '../team/teamDataRequestKeys'; import { selectTeamDataForName } from '../team/teamDataSelectors'; +import { + captureTeamLocalStateEpoch, + clearAllTeamLocalStateEpochs, + hasTeamLocalStateEpoch, + invalidateTeamLocalStateEpoch, + isTeamLocalStateEpochCurrent, +} from '../team/teamLocalStateEpoch'; import { areInboxMessageArraysEquivalent, clearTeamMessageSelectorCaches, @@ -158,7 +165,6 @@ const inFlightTeamMemberActivityMetaRequests = new Map>(); const pendingFreshTeamMemberActivityMetaRefreshes = new Set(); const pendingTeamPendingReplyRefreshTimers = new Map>(); const lastResolvedTeamDataRefreshAtByTeam = new Map(); -const teamLocalStateEpochByTeam = new Map(); let inFlightGlobalTasksRefresh: Promise | null = null; let pendingFreshGlobalTasksRefresh = false; const memberSpawnStatusesIpcBackoffUntilByTeam = new Map(); @@ -232,7 +238,7 @@ export function __resetTeamSliceModuleStateForTests(): void { pendingTeamPendingReplyRefreshTimers.clear(); clearAllPendingReplyRefreshWaits(); lastResolvedTeamDataRefreshAtByTeam.clear(); - teamLocalStateEpochByTeam.clear(); + clearAllTeamLocalStateEpochs(); memberSpawnStatusesIpcBackoffUntilByTeam.clear(); teamRefreshBurstDiagnostics.clear(); memberSpawnUiEqualLastWarnAtByTeam.clear(); @@ -432,18 +438,6 @@ function buildTeamScopedProgressTombstones( }; } -function captureTeamLocalStateEpoch(teamName: string): number { - return teamLocalStateEpochByTeam.get(teamName) ?? 0; -} - -function isTeamLocalStateEpochCurrent(teamName: string, epoch: number): boolean { - return captureTeamLocalStateEpoch(teamName) === epoch; -} - -function invalidateTeamLocalStateEpoch(teamName: string): void { - teamLocalStateEpochByTeam.set(teamName, captureTeamLocalStateEpoch(teamName) + 1); -} - function beginInFlightTeamDataRefresh(teamName: string): symbol { const token = Symbol(teamName); const existing = inFlightRefreshTeamDataCalls.get(teamName); @@ -663,7 +657,7 @@ export function __getTeamScopedTransientStateForTests(teamName: string): { hasPendingFreshMemberActivityMetaRefresh: pendingFreshTeamMemberActivityMetaRefreshes.has(teamName), hasLastResolvedTeamDataRefresh: lastResolvedTeamDataRefreshAtByTeam.has(teamName), - hasCurrentLocalStateEpoch: teamLocalStateEpochByTeam.has(teamName), + hasCurrentLocalStateEpoch: hasTeamLocalStateEpoch(teamName), hasMemberSpawnStatusesIpcBackoff: memberSpawnStatusesIpcBackoffUntilByTeam.has(teamName), hasTeamRefreshBurstDiagnostics: teamRefreshBurstDiagnostics.has(teamName), hasMemberSpawnUiEqualLastWarn: memberSpawnUiEqualLastWarnAtByTeam.has(teamName), diff --git a/src/renderer/store/team/teamLocalStateEpoch.ts b/src/renderer/store/team/teamLocalStateEpoch.ts new file mode 100644 index 00000000..4568f082 --- /dev/null +++ b/src/renderer/store/team/teamLocalStateEpoch.ts @@ -0,0 +1,25 @@ +const teamLocalStateEpochByTeam = new Map(); + +export function captureTeamLocalStateEpoch(teamName: string): number { + return teamLocalStateEpochByTeam.get(teamName) ?? 0; +} + +export function isTeamLocalStateEpochCurrent(teamName: string, epoch: number): boolean { + return captureTeamLocalStateEpoch(teamName) === epoch; +} + +export function invalidateTeamLocalStateEpoch(teamName: string): void { + teamLocalStateEpochByTeam.set(teamName, captureTeamLocalStateEpoch(teamName) + 1); +} + +export function hasTeamLocalStateEpoch(teamName: string): boolean { + return teamLocalStateEpochByTeam.has(teamName); +} + +export function clearTeamLocalStateEpoch(teamName: string): void { + teamLocalStateEpochByTeam.delete(teamName); +} + +export function clearAllTeamLocalStateEpochs(): void { + teamLocalStateEpochByTeam.clear(); +} diff --git a/test/renderer/store/teamLocalStateEpoch.test.ts b/test/renderer/store/teamLocalStateEpoch.test.ts new file mode 100644 index 00000000..3ff503ca --- /dev/null +++ b/test/renderer/store/teamLocalStateEpoch.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { + captureTeamLocalStateEpoch, + clearAllTeamLocalStateEpochs, + clearTeamLocalStateEpoch, + hasTeamLocalStateEpoch, + invalidateTeamLocalStateEpoch, + isTeamLocalStateEpochCurrent, +} from '../../../src/renderer/store/team/teamLocalStateEpoch'; + +afterEach(() => { + clearAllTeamLocalStateEpochs(); +}); + +describe('teamLocalStateEpoch', () => { + it('starts missing teams at epoch zero without materializing an entry', () => { + expect(captureTeamLocalStateEpoch('my-team')).toBe(0); + expect(isTeamLocalStateEpochCurrent('my-team', 0)).toBe(true); + expect(hasTeamLocalStateEpoch('my-team')).toBe(false); + }); + + it('increments epochs independently per team', () => { + invalidateTeamLocalStateEpoch('my-team'); + invalidateTeamLocalStateEpoch('my-team'); + invalidateTeamLocalStateEpoch('other-team'); + + expect(captureTeamLocalStateEpoch('my-team')).toBe(2); + expect(captureTeamLocalStateEpoch('other-team')).toBe(1); + expect(isTeamLocalStateEpochCurrent('my-team', 1)).toBe(false); + expect(isTeamLocalStateEpochCurrent('my-team', 2)).toBe(true); + }); + + it('clears one team epoch without touching other teams', () => { + invalidateTeamLocalStateEpoch('my-team'); + invalidateTeamLocalStateEpoch('other-team'); + + clearTeamLocalStateEpoch('my-team'); + + expect(captureTeamLocalStateEpoch('my-team')).toBe(0); + expect(hasTeamLocalStateEpoch('my-team')).toBe(false); + expect(captureTeamLocalStateEpoch('other-team')).toBe(1); + expect(hasTeamLocalStateEpoch('other-team')).toBe(true); + }); + + it('clears all materialized epochs', () => { + invalidateTeamLocalStateEpoch('my-team'); + invalidateTeamLocalStateEpoch('other-team'); + + clearAllTeamLocalStateEpochs(); + + expect(hasTeamLocalStateEpoch('my-team')).toBe(false); + expect(hasTeamLocalStateEpoch('other-team')).toBe(false); + expect(captureTeamLocalStateEpoch('my-team')).toBe(0); + expect(captureTeamLocalStateEpoch('other-team')).toBe(0); + }); +});