refactor(team): extract message cache layer

This commit is contained in:
777genius 2026-05-22 00:01:53 +03:00
parent a8e7f1ccd5
commit 8589391ccf
3 changed files with 503 additions and 275 deletions

View file

@ -11,7 +11,6 @@ import {
canDisplayTaskChangesForOptions,
type TaskChangeRequestOptions,
} from '@renderer/utils/taskChangeRequest';
import { toMessageKey } from '@renderer/utils/teamMessageKey';
import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext';
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
@ -30,9 +29,24 @@ import {
isTeamTaskNeedsFixActionable,
} from '@shared/utils/teamTaskState';
import {
areInboxMessageArraysEquivalent,
clearTeamMessageSelectorCaches,
clearTeamMessageSelectorCachesForTeam,
extractRetainedCanonicalOlderTail,
getCanonicalHeadSlice,
getTeamMessagesCacheEntry,
getTeamMessageSelectorCacheSnapshotForTeam,
pruneOptimisticMessages,
upsertOptimisticTeamMessage,
} from '../team/teamMessagesCache';
import { noteTeamRefreshFanout } from '../teamRefreshFanoutDiagnostics';
import { getWorktreeNavigationState } from '../utils/stateResetHelpers';
import type {
RefreshTeamMessagesHeadResult,
TeamMessagesCacheEntry,
} from '../team/teamMessagesCache';
import type { AppState } from '../types';
import type { GraphLayoutMode, GraphOwnerSlotAssignment } from '@claude-teams/agent-graph';
import type { AppConfig } from '@renderer/types/data';
@ -81,6 +95,12 @@ import type {
} from '@shared/types';
import type { StateCreator } from 'zustand';
export type {
RefreshTeamMessagesHeadResult,
TeamMessagesCacheEntry,
} from '../team/teamMessagesCache';
export { selectMemberMessagesForTeamMember, selectTeamMessages } from '../team/teamMessagesCache';
const GRAPH_STABLE_SLOT_LAYOUT_VERSION = 'stable-slots-v1' as const;
const DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS = true;
const logger = createLogger('teamSlice');
@ -240,13 +260,12 @@ export function __resetTeamSliceModuleStateForTests(): void {
memberSpawnUiEqualLastWarnAtByTeam.clear();
resolvedMembersSelectorCache.clear();
resolvedMemberSelectorCache.clear();
mergedMessagesSelectorCache.clear();
memberMessagesSelectorCache.clear();
clearTeamMessageSelectorCaches();
}
function clearTeamScopedSelectorCaches(teamName: string): void {
resolvedMembersSelectorCache.delete(teamName);
mergedMessagesSelectorCache.delete(teamName);
clearTeamMessageSelectorCachesForTeam(teamName);
const teamScopedPrefix = `${teamName}:`;
for (const key of resolvedMemberSelectorCache.keys()) {
@ -254,11 +273,6 @@ function clearTeamScopedSelectorCaches(teamName: string): void {
resolvedMemberSelectorCache.delete(key);
}
}
for (const key of memberMessagesSelectorCache.keys()) {
if (key.startsWith(teamScopedPrefix)) {
memberMessagesSelectorCache.delete(key);
}
}
}
function clearTeamScopedTransientState(teamName: string): void {
@ -649,25 +663,20 @@ export function __getTeamScopedTransientStateForTests(teamName: string): {
hasMemberSpawnUiEqualLastWarn: boolean;
} {
const teamScopedPrefix = `${teamName}:`;
const messageSelectorCache = getTeamMessageSelectorCacheSnapshotForTeam(teamName);
let resolvedMemberSelectorCount = 0;
let memberMessagesSelectorCount = 0;
for (const key of resolvedMemberSelectorCache.keys()) {
if (key.startsWith(teamScopedPrefix)) {
resolvedMemberSelectorCount += 1;
}
}
for (const key of memberMessagesSelectorCache.keys()) {
if (key.startsWith(teamScopedPrefix)) {
memberMessagesSelectorCount += 1;
}
}
return {
hasResolvedMembersSelector: resolvedMembersSelectorCache.has(teamName),
resolvedMemberSelectorCount,
hasMergedMessagesSelector: mergedMessagesSelectorCache.has(teamName),
memberMessagesSelectorCount,
hasMergedMessagesSelector: messageSelectorCache.hasMergedMessagesSelector,
memberMessagesSelectorCount: messageSelectorCache.memberMessagesSelectorCount,
hasPendingFreshTeamDataRefresh: pendingFreshTeamDataRefreshes.has(teamName),
hasQueuedFullTeamDataRefreshAfterThin: queuedFullTeamDataRefreshesAfterThin.has(teamName),
hasPostPaintTeamEnrichmentTimer: postPaintTeamEnrichmentTimers.has(teamName),
@ -1082,153 +1091,6 @@ function areTeamAgentRuntimeSnapshotsEqual(
return true;
}
function compareInboxMessagesByTimestamp(a: InboxMessage, b: InboxMessage): number {
const aTime = Date.parse(a.timestamp);
const bTime = Date.parse(b.timestamp);
const aValid = Number.isFinite(aTime);
const bValid = Number.isFinite(bTime);
if (aValid && bValid && aTime !== bTime) {
return aTime - bTime;
}
if (aValid !== bValid) {
return aValid ? -1 : 1;
}
const aId = typeof a.messageId === 'string' ? a.messageId : '';
const bId = typeof b.messageId === 'string' ? b.messageId : '';
return aId.localeCompare(bId);
}
export interface TeamMessagesCacheEntry {
canonicalMessages: InboxMessage[];
optimisticMessages: InboxMessage[];
feedRevision: string | null;
nextCursor: string | null;
hasMore: boolean;
lastFetchedAt: number | null;
loadingHead: boolean;
loadingOlder: boolean;
headHydrated: boolean;
}
export interface RefreshTeamMessagesHeadResult {
feedChanged: boolean;
headChanged: boolean;
feedRevision: string | null;
}
const EMPTY_TEAM_MESSAGES_CACHE_ENTRY: TeamMessagesCacheEntry = {
canonicalMessages: [],
optimisticMessages: [],
feedRevision: null,
nextCursor: null,
hasMore: false,
lastFetchedAt: null,
loadingHead: false,
loadingOlder: false,
headHydrated: false,
};
function createEmptyTeamMessagesCacheEntry(): TeamMessagesCacheEntry {
return {
canonicalMessages: [],
optimisticMessages: [],
feedRevision: null,
nextCursor: null,
hasMore: false,
lastFetchedAt: null,
loadingHead: false,
loadingOlder: false,
headHydrated: false,
};
}
function getTeamMessagesCacheEntry(
state: Pick<TeamSlice, 'teamMessagesByName'>,
teamName: string
): TeamMessagesCacheEntry {
return state.teamMessagesByName[teamName] ?? EMPTY_TEAM_MESSAGES_CACHE_ENTRY;
}
function upsertOptimisticTeamMessage(
entry: TeamMessagesCacheEntry,
message: InboxMessage
): TeamMessagesCacheEntry {
const nextOptimistic = [...entry.optimisticMessages];
const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : '';
if (messageId.length > 0) {
const existingIndex = nextOptimistic.findIndex(
(candidate) =>
typeof candidate.messageId === 'string' && candidate.messageId.trim() === messageId
);
if (existingIndex >= 0) {
nextOptimistic[existingIndex] = {
...nextOptimistic[existingIndex],
...message,
};
} else {
nextOptimistic.push(message);
}
} else {
nextOptimistic.push(message);
}
nextOptimistic.sort(compareInboxMessagesByTimestamp);
return {
...entry,
optimisticMessages: nextOptimistic,
};
}
function areInboxMessageArraysEquivalent(
left: readonly InboxMessage[],
right: readonly InboxMessage[]
): boolean {
if (left === right) return true;
if (left.length !== right.length) return false;
for (let index = 0; index < left.length; index += 1) {
const leftItem = left[index];
const rightItem = right[index];
if (
leftItem.messageId !== rightItem.messageId ||
leftItem.timestamp !== rightItem.timestamp ||
leftItem.from !== rightItem.from ||
leftItem.to !== rightItem.to ||
leftItem.text !== rightItem.text ||
leftItem.summary !== rightItem.summary ||
leftItem.read !== rightItem.read ||
leftItem.actionMode !== rightItem.actionMode ||
leftItem.commentId !== rightItem.commentId ||
leftItem.relayOfMessageId !== rightItem.relayOfMessageId ||
leftItem.source !== rightItem.source ||
leftItem.leadSessionId !== rightItem.leadSessionId ||
leftItem.messageKind !== rightItem.messageKind ||
JSON.stringify(leftItem.taskRefs ?? null) !== JSON.stringify(rightItem.taskRefs ?? null)
) {
return false;
}
}
return true;
}
function pruneOptimisticMessages(
optimistic: readonly InboxMessage[],
canonical: readonly InboxMessage[]
): InboxMessage[] {
if (optimistic.length === 0) {
return [];
}
const canonicalIds = new Set(
canonical
.map((message) => (typeof message.messageId === 'string' ? message.messageId.trim() : ''))
.filter((messageId) => messageId.length > 0)
);
return optimistic.filter((message) => {
const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : '';
return !messageId || !canonicalIds.has(messageId);
});
}
function clearPendingReplyRefreshTimer(teamName: string): void {
const existingTimer = pendingTeamPendingReplyRefreshTimers.get(teamName);
if (existingTimer == null) {
@ -1266,50 +1128,6 @@ function setPendingReplyRefreshEnabled(
return true;
}
function getCanonicalHeadSlice(
canonicalMessages: readonly InboxMessage[],
headLength: number
): readonly InboxMessage[] {
if (headLength <= 0) {
return [];
}
return canonicalMessages.slice(0, headLength);
}
function extractRetainedCanonicalOlderTail(
canonicalMessages: readonly InboxMessage[],
freshHeadMessages: readonly InboxMessage[]
): InboxMessage[] | null {
if (canonicalMessages.length === 0) {
return [];
}
if (freshHeadMessages.length === 0) {
return null;
}
const freshHeadKeys = new Set(freshHeadMessages.map((message) => toMessageKey(message)));
let hasMessagesOutsideFreshHead = false;
for (const message of canonicalMessages) {
if (!freshHeadKeys.has(toMessageKey(message))) {
hasMessagesOutsideFreshHead = true;
break;
}
}
if (!hasMessagesOutsideFreshHead) {
return [];
}
const anchorKey = toMessageKey(freshHeadMessages[freshHeadMessages.length - 1]);
const anchorIndex = canonicalMessages.findIndex((message) => toMessageKey(message) === anchorKey);
if (anchorIndex < 0) {
return null;
}
return canonicalMessages
.slice(anchorIndex + 1)
.filter((message) => !freshHeadKeys.has(toMessageKey(message)));
}
async function refreshTaskChangePresenceForUpdatedTask(
getState: () => AppState,
teamName: string,
@ -1945,23 +1763,8 @@ const resolvedMemberSelectorCache = new Map<
result: ResolvedTeamMember | null;
}
>();
const mergedMessagesSelectorCache = new Map<
string,
{
canonicalRef: InboxMessage[];
optimisticRef: InboxMessage[];
result: InboxMessage[];
}
>();
const EMPTY_TEAM_MEMBER_SNAPSHOTS: TeamMemberSnapshot[] = [];
const EMPTY_TEAM_TASKS: TeamViewSnapshot['tasks'] = [];
const memberMessagesSelectorCache = new Map<
string,
{
messagesRef: InboxMessage[];
result: InboxMessage[];
}
>();
function resolveMemberStatus(
snapshot: TeamMemberSnapshot,
@ -2534,58 +2337,6 @@ export function selectTeamIsAliveForName(
return selectTeamDataForName(state, teamName)?.isAlive;
}
export function selectTeamMessages(
state: Pick<TeamSlice, 'teamMessagesByName'>,
teamName: string | null | undefined
): InboxMessage[] {
if (!teamName) {
return [];
}
const entry = getTeamMessagesCacheEntry(state, teamName);
const cached = mergedMessagesSelectorCache.get(teamName);
if (
cached?.canonicalRef === entry.canonicalMessages &&
cached.optimisticRef === entry.optimisticMessages
) {
return cached.result;
}
const result = mergeTeamMessages(entry.canonicalMessages, entry.optimisticMessages);
mergedMessagesSelectorCache.set(teamName, {
canonicalRef: entry.canonicalMessages,
optimisticRef: entry.optimisticMessages,
result,
});
return result;
}
export function selectMemberMessagesForTeamMember(
state: Pick<TeamSlice, 'teamMessagesByName'>,
teamName: string | null | undefined,
memberName: string | null | undefined
): InboxMessage[] {
if (!teamName || !memberName) {
return [];
}
const messages = selectTeamMessages(state, teamName);
const cacheKey = `${teamName}:${memberName}`;
const cached = memberMessagesSelectorCache.get(cacheKey);
if (cached?.messagesRef === messages) {
return cached.result;
}
const result = messages.filter(
(message) => message.from === memberName || message.to === memberName
);
memberMessagesSelectorCache.set(cacheKey, {
messagesRef: messages,
result,
});
return result;
}
function isMemberActivityMetaStale(
state: Pick<TeamSlice, 'memberActivityMetaByTeam' | 'teamMessagesByName'>,
teamName: string

View file

@ -0,0 +1,291 @@
import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages';
import { toMessageKey } from '@renderer/utils/teamMessageKey';
import type { InboxMessage } from '@shared/types';
export interface TeamMessagesCacheEntry {
canonicalMessages: InboxMessage[];
optimisticMessages: InboxMessage[];
feedRevision: string | null;
nextCursor: string | null;
hasMore: boolean;
lastFetchedAt: number | null;
loadingHead: boolean;
loadingOlder: boolean;
headHydrated: boolean;
}
export interface RefreshTeamMessagesHeadResult {
feedChanged: boolean;
headChanged: boolean;
feedRevision: string | null;
}
export interface TeamMessagesCacheState {
teamMessagesByName: Record<string, TeamMessagesCacheEntry>;
}
export interface TeamMessageSelectorCacheSnapshot {
hasMergedMessagesSelector: boolean;
memberMessagesSelectorCount: number;
}
export const EMPTY_TEAM_MESSAGES_CACHE_ENTRY: TeamMessagesCacheEntry = {
canonicalMessages: [],
optimisticMessages: [],
feedRevision: null,
nextCursor: null,
hasMore: false,
lastFetchedAt: null,
loadingHead: false,
loadingOlder: false,
headHydrated: false,
};
const mergedMessagesSelectorCache = new Map<
string,
{
canonicalRef: readonly InboxMessage[];
optimisticRef: readonly InboxMessage[];
result: InboxMessage[];
}
>();
const memberMessagesSelectorCache = new Map<
string,
{
messagesRef: readonly InboxMessage[];
result: InboxMessage[];
}
>();
export function clearTeamMessageSelectorCaches(): void {
mergedMessagesSelectorCache.clear();
memberMessagesSelectorCache.clear();
}
export function clearTeamMessageSelectorCachesForTeam(teamName: string): void {
mergedMessagesSelectorCache.delete(teamName);
const teamScopedPrefix = `${teamName}:`;
for (const key of memberMessagesSelectorCache.keys()) {
if (key.startsWith(teamScopedPrefix)) {
memberMessagesSelectorCache.delete(key);
}
}
}
export function getTeamMessageSelectorCacheSnapshotForTeam(
teamName: string
): TeamMessageSelectorCacheSnapshot {
const teamScopedPrefix = `${teamName}:`;
let memberMessagesSelectorCount = 0;
for (const key of memberMessagesSelectorCache.keys()) {
if (key.startsWith(teamScopedPrefix)) {
memberMessagesSelectorCount += 1;
}
}
return {
hasMergedMessagesSelector: mergedMessagesSelectorCache.has(teamName),
memberMessagesSelectorCount,
};
}
export function compareInboxMessagesByTimestamp(a: InboxMessage, b: InboxMessage): number {
const aTime = Date.parse(a.timestamp);
const bTime = Date.parse(b.timestamp);
const aValid = Number.isFinite(aTime);
const bValid = Number.isFinite(bTime);
if (aValid && bValid && aTime !== bTime) {
return aTime - bTime;
}
if (aValid !== bValid) {
return aValid ? -1 : 1;
}
const aId = typeof a.messageId === 'string' ? a.messageId : '';
const bId = typeof b.messageId === 'string' ? b.messageId : '';
return aId.localeCompare(bId);
}
export function getTeamMessagesCacheEntry(
state: TeamMessagesCacheState,
teamName: string
): TeamMessagesCacheEntry {
return state.teamMessagesByName[teamName] ?? EMPTY_TEAM_MESSAGES_CACHE_ENTRY;
}
export function upsertOptimisticTeamMessage(
entry: TeamMessagesCacheEntry,
message: InboxMessage
): TeamMessagesCacheEntry {
const nextOptimistic = [...entry.optimisticMessages];
const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : '';
if (messageId.length > 0) {
const existingIndex = nextOptimistic.findIndex(
(candidate) =>
typeof candidate.messageId === 'string' && candidate.messageId.trim() === messageId
);
if (existingIndex >= 0) {
nextOptimistic[existingIndex] = {
...nextOptimistic[existingIndex],
...message,
};
} else {
nextOptimistic.push(message);
}
} else {
nextOptimistic.push(message);
}
nextOptimistic.sort(compareInboxMessagesByTimestamp);
return {
...entry,
optimisticMessages: nextOptimistic,
};
}
export function areInboxMessageArraysEquivalent(
left: readonly InboxMessage[],
right: readonly InboxMessage[]
): boolean {
if (left === right) return true;
if (left.length !== right.length) return false;
for (let index = 0; index < left.length; index += 1) {
const leftItem = left[index];
const rightItem = right[index];
if (
leftItem.messageId !== rightItem.messageId ||
leftItem.timestamp !== rightItem.timestamp ||
leftItem.from !== rightItem.from ||
leftItem.to !== rightItem.to ||
leftItem.text !== rightItem.text ||
leftItem.summary !== rightItem.summary ||
leftItem.read !== rightItem.read ||
leftItem.actionMode !== rightItem.actionMode ||
leftItem.commentId !== rightItem.commentId ||
leftItem.relayOfMessageId !== rightItem.relayOfMessageId ||
leftItem.source !== rightItem.source ||
leftItem.leadSessionId !== rightItem.leadSessionId ||
leftItem.messageKind !== rightItem.messageKind ||
JSON.stringify(leftItem.taskRefs ?? null) !== JSON.stringify(rightItem.taskRefs ?? null)
) {
return false;
}
}
return true;
}
export function pruneOptimisticMessages(
optimistic: readonly InboxMessage[],
canonical: readonly InboxMessage[]
): InboxMessage[] {
if (optimistic.length === 0) {
return [];
}
const canonicalIds = new Set(
canonical
.map((message) => (typeof message.messageId === 'string' ? message.messageId.trim() : ''))
.filter((messageId) => messageId.length > 0)
);
return optimistic.filter((message) => {
const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : '';
return !messageId || !canonicalIds.has(messageId);
});
}
export function getCanonicalHeadSlice(
canonicalMessages: readonly InboxMessage[],
headLength: number
): readonly InboxMessage[] {
if (headLength <= 0) {
return [];
}
return canonicalMessages.slice(0, headLength);
}
export function extractRetainedCanonicalOlderTail(
canonicalMessages: readonly InboxMessage[],
freshHeadMessages: readonly InboxMessage[]
): InboxMessage[] | null {
if (canonicalMessages.length === 0) {
return [];
}
if (freshHeadMessages.length === 0) {
return null;
}
const freshHeadKeys = new Set(freshHeadMessages.map((message) => toMessageKey(message)));
let hasMessagesOutsideFreshHead = false;
for (const message of canonicalMessages) {
if (!freshHeadKeys.has(toMessageKey(message))) {
hasMessagesOutsideFreshHead = true;
break;
}
}
if (!hasMessagesOutsideFreshHead) {
return [];
}
const anchorKey = toMessageKey(freshHeadMessages[freshHeadMessages.length - 1]);
const anchorIndex = canonicalMessages.findIndex((message) => toMessageKey(message) === anchorKey);
if (anchorIndex < 0) {
return null;
}
return canonicalMessages
.slice(anchorIndex + 1)
.filter((message) => !freshHeadKeys.has(toMessageKey(message)));
}
export function selectTeamMessages(
state: TeamMessagesCacheState,
teamName: string | null | undefined
): InboxMessage[] {
if (!teamName) {
return [];
}
const entry = getTeamMessagesCacheEntry(state, teamName);
const cached = mergedMessagesSelectorCache.get(teamName);
if (
cached?.canonicalRef === entry.canonicalMessages &&
cached.optimisticRef === entry.optimisticMessages
) {
return cached.result;
}
const result = mergeTeamMessages(entry.canonicalMessages, entry.optimisticMessages);
mergedMessagesSelectorCache.set(teamName, {
canonicalRef: entry.canonicalMessages,
optimisticRef: entry.optimisticMessages,
result,
});
return result;
}
export function selectMemberMessagesForTeamMember(
state: TeamMessagesCacheState,
teamName: string | null | undefined,
memberName: string | null | undefined
): InboxMessage[] {
if (!teamName || !memberName) {
return [];
}
const messages = selectTeamMessages(state, teamName);
const cacheKey = `${teamName}:${memberName}`;
const cached = memberMessagesSelectorCache.get(cacheKey);
if (cached?.messagesRef === messages) {
return cached.result;
}
const result = messages.filter(
(message) => message.from === memberName || message.to === memberName
);
memberMessagesSelectorCache.set(cacheKey, {
messagesRef: messages,
result,
});
return result;
}

View file

@ -0,0 +1,186 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
areInboxMessageArraysEquivalent,
clearTeamMessageSelectorCaches,
clearTeamMessageSelectorCachesForTeam,
EMPTY_TEAM_MESSAGES_CACHE_ENTRY,
extractRetainedCanonicalOlderTail,
getCanonicalHeadSlice,
getTeamMessagesCacheEntry,
getTeamMessageSelectorCacheSnapshotForTeam,
pruneOptimisticMessages,
selectMemberMessagesForTeamMember,
selectTeamMessages,
type TeamMessagesCacheEntry,
type TeamMessagesCacheState,
upsertOptimisticTeamMessage,
} from '../../../src/renderer/store/team/teamMessagesCache';
import type { InboxMessage } from '../../../src/shared/types';
afterEach(() => {
clearTeamMessageSelectorCaches();
});
function createMessage(overrides: Partial<InboxMessage> & { messageId: string }): InboxMessage {
return {
from: 'lead',
to: 'alice',
text: overrides.messageId,
timestamp: '2026-03-12T10:00:00.000Z',
read: false,
...overrides,
};
}
function createEntry(overrides: Partial<TeamMessagesCacheEntry> = {}): TeamMessagesCacheEntry {
return {
...EMPTY_TEAM_MESSAGES_CACHE_ENTRY,
...overrides,
};
}
describe('teamMessagesCache', () => {
it('returns the immutable empty entry when a team has no cached messages', () => {
const state: TeamMessagesCacheState = { teamMessagesByName: {} };
expect(getTeamMessagesCacheEntry(state, 'missing-team')).toBe(EMPTY_TEAM_MESSAGES_CACHE_ENTRY);
});
it('upserts optimistic messages by durable id and keeps deterministic timestamp order', () => {
const first = upsertOptimisticTeamMessage(
createEntry(),
createMessage({
messageId: 'msg-new',
timestamp: '2026-03-12T10:00:03.000Z',
text: 'draft',
})
);
const second = upsertOptimisticTeamMessage(
first,
createMessage({
messageId: 'msg-old',
timestamp: '2026-03-12T10:00:01.000Z',
})
);
const replaced = upsertOptimisticTeamMessage(
second,
createMessage({
messageId: 'msg-new',
timestamp: '2026-03-12T10:00:03.000Z',
text: 'sent',
})
);
expect(replaced.optimisticMessages.map((message) => message.messageId)).toEqual([
'msg-old',
'msg-new',
]);
expect(replaced.optimisticMessages[1].text).toBe('sent');
});
it('compares semantic message arrays and prunes optimistic rows confirmed by canonical data', () => {
const canonical = [
createMessage({ messageId: 'msg-1', text: 'confirmed' }),
createMessage({ messageId: 'msg-2' }),
];
const equivalentCanonical = [
createMessage({ messageId: 'msg-1', text: 'confirmed' }),
createMessage({ messageId: 'msg-2' }),
];
const optimistic = [
createMessage({ messageId: 'msg-1', text: 'draft that arrived' }),
createMessage({ messageId: 'msg-local', text: 'still local' }),
];
expect(areInboxMessageArraysEquivalent(canonical, equivalentCanonical)).toBe(true);
expect(
areInboxMessageArraysEquivalent(canonical, [
createMessage({ messageId: 'msg-1', text: 'changed' }),
createMessage({ messageId: 'msg-2' }),
])
).toBe(false);
expect(pruneOptimisticMessages(optimistic, canonical).map((message) => message.messageId)).toEqual(
['msg-local']
);
});
it('retains already-loaded older tail only when the fresh head anchors into canonical data', () => {
const canonical = [
createMessage({ messageId: 'msg-4', timestamp: '2026-03-12T10:00:04.000Z' }),
createMessage({ messageId: 'msg-3', timestamp: '2026-03-12T10:00:03.000Z' }),
createMessage({ messageId: 'msg-2', timestamp: '2026-03-12T10:00:02.000Z' }),
createMessage({ messageId: 'msg-1', timestamp: '2026-03-12T10:00:01.000Z' }),
];
const freshHead = [
createMessage({ messageId: 'msg-5', timestamp: '2026-03-12T10:00:05.000Z' }),
createMessage({ messageId: 'msg-3', timestamp: '2026-03-12T10:00:03.000Z' }),
];
expect(getCanonicalHeadSlice(canonical, 2).map((message) => message.messageId)).toEqual([
'msg-4',
'msg-3',
]);
expect(
extractRetainedCanonicalOlderTail(canonical, freshHead)?.map((message) => message.messageId)
).toEqual(['msg-2', 'msg-1']);
expect(
extractRetainedCanonicalOlderTail(canonical, [createMessage({ messageId: 'disjoint' })])
).toBeNull();
});
it('memoizes merged and member-scoped selectors and clears team-scoped caches', () => {
const state: TeamMessagesCacheState = {
teamMessagesByName: {
'my-team': createEntry({
canonicalMessages: [
createMessage({
messageId: 'msg-1',
to: 'alice',
timestamp: '2026-03-12T10:00:01.000Z',
}),
createMessage({
messageId: 'msg-2',
to: 'bob',
timestamp: '2026-03-12T10:00:02.000Z',
}),
],
optimisticMessages: [
createMessage({
messageId: 'msg-3',
from: 'alice',
to: 'lead',
timestamp: '2026-03-12T10:00:03.000Z',
}),
],
}),
},
};
const firstTeamMessages = selectTeamMessages(state, 'my-team');
const secondTeamMessages = selectTeamMessages(state, 'my-team');
const firstAliceMessages = selectMemberMessagesForTeamMember(state, 'my-team', 'alice');
const secondAliceMessages = selectMemberMessagesForTeamMember(state, 'my-team', 'alice');
expect(firstTeamMessages).toBe(secondTeamMessages);
expect(firstAliceMessages).toBe(secondAliceMessages);
expect(firstTeamMessages.map((message) => message.messageId)).toEqual([
'msg-3',
'msg-2',
'msg-1',
]);
expect(firstAliceMessages.map((message) => message.messageId)).toEqual(['msg-3', 'msg-1']);
expect(getTeamMessageSelectorCacheSnapshotForTeam('my-team')).toEqual({
hasMergedMessagesSelector: true,
memberMessagesSelectorCount: 1,
});
clearTeamMessageSelectorCachesForTeam('my-team');
expect(getTeamMessageSelectorCacheSnapshotForTeam('my-team')).toEqual({
hasMergedMessagesSelector: false,
memberMessagesSelectorCount: 0,
});
});
});