Skip to content

Commit df430e7

Browse files
committed
fix(chats): stop reading a deleted chat while its view is still mounted
Removing the detail entry under a live observer rebuilt it on the next render and fetched the chat that was just deleted: a 404 and a load error until the navigation landed. Cancel what is in flight and remove the entry when its last reader leaves.
1 parent ad94fcd commit df430e7

2 files changed

Lines changed: 66 additions & 2 deletions

File tree

‎apps/sim/hooks/queries/mothership-chats.test.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ vi.mock('@/lib/terminal/transport', () => ({
2727
import type { MothershipEffort } from '@/lib/mothership/model-options'
2828
import {
2929
fetchMothershipChatHistory,
30+
mothershipChatHistoryQueryOptions,
31+
useDeleteMothershipChat,
3032
useDeleteMothershipChats,
3133
useSetMothershipChatEffort,
3234
} from '@/hooks/queries/mothership-chats'
@@ -101,6 +103,46 @@ describe('tasks query boundary parsing', () => {
101103
})
102104
})
103105

106+
it('reads a deleted chat no more while a view holds it, and forgets it once the view leaves', async () => {
107+
const tanstack =
108+
await vi.importActual<typeof import('@tanstack/react-query')>('@tanstack/react-query')
109+
const client = new tanstack.QueryClient()
110+
reactQueryMockFns.mockUseQueryClient.mockReturnValueOnce(
111+
client as unknown as typeof queryClient
112+
)
113+
const mutation = useDeleteMothershipChat({ organizationId: 'org-1' }) as unknown as {
114+
onSuccess: (data: undefined, chatId: string) => Promise<void>
115+
}
116+
const options = mothershipChatHistoryQueryOptions('chat-open')
117+
vi.mocked(fetch).mockResolvedValueOnce(
118+
jsonResponse({
119+
success: true,
120+
chat: {
121+
id: 'chat-open',
122+
title: null,
123+
mode: 'assistant',
124+
messages: [],
125+
activeStreamId: null,
126+
resources: [],
127+
},
128+
})
129+
)
130+
await client.prefetchQuery(options)
131+
vi.mocked(fetch).mockClear()
132+
const view = new tanstack.QueryObserver(client, options)
133+
const leave = view.subscribe(() => {})
134+
135+
await mutation.onSuccess(undefined, 'chat-open')
136+
view.getOptimisticResult(client.defaultQueryOptions(options))
137+
view.setOptions(options)
138+
139+
expect(fetch).not.toHaveBeenCalled()
140+
expect(view.getCurrentResult().isSuccess).toBe(true)
141+
leave()
142+
expect(client.getQueryCache().find({ queryKey: options.queryKey })).toBeUndefined()
143+
client.clear()
144+
})
145+
104146
it('loads a chat from a server that predates the effort field', async () => {
105147
vi.mocked(fetch).mockResolvedValueOnce(
106148
jsonResponse({

‎apps/sim/hooks/queries/mothership-chats.ts‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,28 @@ async function deleteChat(chatId: string): Promise<void> {
516516
})
517517
}
518518

519+
/**
520+
* Forgets a deleted chat's transcript. Removing an entry a mounted view still reads makes its next
521+
* render rebuild the entry and read a chat that is gone, so that view keeps what it has until its
522+
* last reader leaves, which leaving the deleted chat is about to do.
523+
*/
524+
function forgetChatHistory(queryClient: QueryClient, chatId: string) {
525+
const queryKey = mothershipChatKeys.detail(chatId)
526+
const cache = queryClient.getQueryCache()
527+
const query = cache.find({ queryKey, exact: true })
528+
if (!query?.getObserversCount()) {
529+
queryClient.removeQueries({ queryKey })
530+
return
531+
}
532+
void queryClient.cancelQueries({ queryKey, exact: true })
533+
const unsubscribe = cache.subscribe((event) => {
534+
if (event.type !== 'observerRemoved' || event.query !== query) return
535+
if (query.getObserversCount() > 0) return
536+
unsubscribe()
537+
queryClient.removeQueries({ queryKey })
538+
})
539+
}
540+
519541
/**
520542
* Soft-deletes a mothership chat and invalidates both the active and archived
521543
* chat lists — the chat moves from the sidebar into Recently Deleted.
@@ -526,7 +548,7 @@ export function useDeleteMothershipChat(owner?: MothershipChatOwner) {
526548
mutationFn: deleteChat,
527549
onSuccess: async (_data, chatId) => {
528550
await suspendDesktopChatScopes(chatId)
529-
queryClient.removeQueries({ queryKey: mothershipChatKeys.detail(chatId) })
551+
forgetChatHistory(queryClient, chatId)
530552
useMothershipQueueStore.getState().clearChat(chatId)
531553
},
532554
onSettled: () => {
@@ -575,7 +597,7 @@ export function useDeleteMothershipChats(owner?: MothershipChatOwner) {
575597
chatIds.map(async (chatId) => {
576598
await deleteChat(chatId)
577599
await suspendDesktopChatScopes(chatId)
578-
queryClient.removeQueries({ queryKey: mothershipChatKeys.detail(chatId) })
600+
forgetChatHistory(queryClient, chatId)
579601
useMothershipQueueStore.getState().clearChat(chatId)
580602
})
581603
)

0 commit comments

Comments
 (0)