twenty/packages/twenty-front/src/modules/ai/components/AgentChatStreamingPartsDiffSyncEffect.tsx
Sonarly Claude Code e87fea949e fix(twenty-front): add eviction to atom family cache and deduplicate AI chat message storage
https://sonarly.com/issue/27119?type=bug

Chrome tab crashes with OOM (error code 5) because the Jotai atom family cache (`atomCache` Map) never evicts entries, and each AI chat message is stored three times (thread-level atom, per-message atom via structuredClone, Apollo InMemoryCache), causing unbounded heap growth during long conversations, rapid workspace switching, or Settings page navigation.

Fix: Three changes that address the two largest memory growth vectors in the AI chat module:

1. **Added `evictFamilyKey` to `ComponentFamilyState`** (`ComponentFamilyState.ts` type + `createAtomComponentFamilyState.ts` implementation): Exposes `atomCache.delete()` so consumers can remove atoms they no longer need. This is the missing primitive — the atomCache Map previously had no way to remove entries.

2. **Evict per-message atoms on thread switch** (`AgentChatStreamingPartsDiffSyncEffect.tsx`): Tracks message IDs seen during diff sync in a ref. When `currentAIChatThread` changes, the cleanup effect fires and calls `evictFamilyKey` for every tracked message ID, removing those atoms from the cache. This prevents the biggest accumulation vector: hundreds of per-message atoms (keyed by UUID) holding large ExtendedUIMessage payloads that were never released.

3. **Remove `structuredClone` from `useUpdateStreamingPartsWithDiff.ts`**: The deep clone was creating a full copy of every message object before storing it in the per-message atom. Since `isDeeplyEqual` already gates updates (preventing unnecessary writes), and the message reference from the thread-level atom is stable within a render cycle, the clone is unnecessary. Removing it eliminates one full copy of every message in memory.

Together these changes bound per-message atom memory to the current thread's messages (instead of all threads ever viewed), and halve the per-message memory footprint by eliminating deep clones.

**Not addressed in this PR** (requires separate, wider-scoped changes):
- Apollo InMemoryCache eviction — needs typePolicies configuration in the shared Apollo factory
- Thread-level family state atoms (10 per thread) — small scalar values, low priority
2026-04-16 15:09:00 +00:00

63 lines
2.3 KiB
TypeScript

import { useCallback, useEffect, useRef } from 'react';
import { AGENT_CHAT_INSTANCE_ID } from '@/ai/constants/AgentChatInstanceId';
import { useUpdateStreamingPartsWithDiff } from '@/ai/hooks/useUpdateStreamingPartsWithDiff';
import { agentChatLastDiffSyncedThreadState } from '@/ai/states/agentChatLastDiffSyncedThreadState';
import { agentChatMessageComponentFamilyState } from '@/ai/states/agentChatMessageComponentFamilyState';
import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMessagesComponentFamilyState';
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
export const AgentChatStreamingPartsDiffSyncEffect = () => {
const currentAIChatThread = useAtomStateValue(currentAIChatThreadState);
const agentChatMessages = useAtomComponentFamilyStateValue(
agentChatMessagesComponentFamilyState,
{ threadId: currentAIChatThread },
);
const { updateStreamingPartsWithDiff } = useUpdateStreamingPartsWithDiff();
const setAgentChatLastDiffSyncedThread = useSetAtomState(
agentChatLastDiffSyncedThreadState,
);
const trackedMessageIdsRef = useRef<Set<string>>(new Set());
const evictTrackedMessageAtoms = useCallback(() => {
for (const messageId of trackedMessageIdsRef.current) {
agentChatMessageComponentFamilyState.evictFamilyKey({
instanceId: AGENT_CHAT_INSTANCE_ID,
familyKey: messageId,
});
}
trackedMessageIdsRef.current.clear();
}, []);
useEffect(() => {
return () => {
evictTrackedMessageAtoms();
};
}, [currentAIChatThread, evictTrackedMessageAtoms]);
useEffect(() => {
if (agentChatMessages.length === 0) {
return;
}
const currentIds = new Set(agentChatMessages.map((m) => m.id));
trackedMessageIdsRef.current = currentIds;
updateStreamingPartsWithDiff(agentChatMessages);
setAgentChatLastDiffSyncedThread(currentAIChatThread);
}, [
agentChatMessages,
updateStreamingPartsWithDiff,
currentAIChatThread,
setAgentChatLastDiffSyncedThread,
]);
return null;
};