From e87fea949eb8a2998c6a3eb92fd3aeb3d852639b Mon Sep 17 00:00:00 2001 From: Sonarly Claude Code Date: Thu, 16 Apr 2026 15:09:00 +0000 Subject: [PATCH] fix(twenty-front): add eviction to atom family cache and deduplicate AI chat message storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../AgentChatStreamingPartsDiffSyncEffect.tsx | 26 ++++++++++++++++++- .../hooks/useUpdateStreamingPartsWithDiff.ts | 4 +-- .../state/jotai/types/ComponentFamilyState.ts | 1 + .../utils/createAtomComponentFamilyState.ts | 12 +++++++++ 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/packages/twenty-front/src/modules/ai/components/AgentChatStreamingPartsDiffSyncEffect.tsx b/packages/twenty-front/src/modules/ai/components/AgentChatStreamingPartsDiffSyncEffect.tsx index 7761ab11ec5..560b776c7f2 100644 --- a/packages/twenty-front/src/modules/ai/components/AgentChatStreamingPartsDiffSyncEffect.tsx +++ b/packages/twenty-front/src/modules/ai/components/AgentChatStreamingPartsDiffSyncEffect.tsx @@ -1,11 +1,14 @@ +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'; -import { useEffect } from 'react'; export const AgentChatStreamingPartsDiffSyncEffect = () => { const currentAIChatThread = useAtomStateValue(currentAIChatThreadState); @@ -21,11 +24,32 @@ export const AgentChatStreamingPartsDiffSyncEffect = () => { agentChatLastDiffSyncedThreadState, ); + const trackedMessageIdsRef = useRef>(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); }, [ diff --git a/packages/twenty-front/src/modules/ai/hooks/useUpdateStreamingPartsWithDiff.ts b/packages/twenty-front/src/modules/ai/hooks/useUpdateStreamingPartsWithDiff.ts index 07dede10f86..63e1ec4fdfa 100644 --- a/packages/twenty-front/src/modules/ai/hooks/useUpdateStreamingPartsWithDiff.ts +++ b/packages/twenty-front/src/modules/ai/hooks/useUpdateStreamingPartsWithDiff.ts @@ -36,11 +36,9 @@ export const useUpdateStreamingPartsWithDiff = () => { continue; } - const clonedMessage = structuredClone(incomingMessage); - jotaiStore.set( agentChatMessageFamilyCallbackState(incomingMessage.id), - clonedMessage, + incomingMessage, ); processStreamingMessageUpdate(incomingMessage); diff --git a/packages/twenty-front/src/modules/ui/utilities/state/jotai/types/ComponentFamilyState.ts b/packages/twenty-front/src/modules/ui/utilities/state/jotai/types/ComponentFamilyState.ts index 1eb3702bcb4..7a8d0c3c992 100644 --- a/packages/twenty-front/src/modules/ui/utilities/state/jotai/types/ComponentFamilyState.ts +++ b/packages/twenty-front/src/modules/ui/utilities/state/jotai/types/ComponentFamilyState.ts @@ -18,4 +18,5 @@ export type ComponentFamilyState = { atomFamily: ( key: ComponentFamilyStateKey, ) => JotaiWritableAtom; + evictFamilyKey: (key: ComponentFamilyStateKey) => void; }; diff --git a/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/createAtomComponentFamilyState.ts b/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/createAtomComponentFamilyState.ts index fe056f51e81..f5245e1bd56 100644 --- a/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/createAtomComponentFamilyState.ts +++ b/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/createAtomComponentFamilyState.ts @@ -49,9 +49,21 @@ export const createAtomComponentFamilyState = ({ return baseAtom; }; + const evictFunction = ({ + instanceId, + familyKey, + }: ComponentFamilyStateKey): void => { + const familyKeyStr = + typeof familyKey === 'string' ? familyKey : JSON.stringify(familyKey); + + const cacheKey = `${instanceId}__${familyKeyStr}`; + atomCache.delete(cacheKey); + }; + return { type: 'ComponentFamilyState', key, atomFamily: familyFunction, + evictFamilyKey: evictFunction, }; };