Skip to content

Commit edeede4

Browse files
committed
fix(chats): let an invalidated list read survive marking a chat read
Mark read and unread cancel list reads to protect their optimistic flag, and the flag's write clears isInvalidated, so a read owed by an invalidation never ran again. A conversation mounting just after its chat was deleted did exactly that and left the deleted row in the sidebar. Read the list again when one was cancelled.
1 parent 2ac2dd2 commit edeede4

2 files changed

Lines changed: 90 additions & 7 deletions

File tree

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

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { flushMicrotasks } from '@sim/testing/helpers/async'
2+
import { createDeferred } from '@sim/testing/helpers/deferred'
13
import { jsonResponse } from '@sim/testing/helpers/http'
24
import { reactQueryMock, reactQueryMockFns } from '@sim/testing/mocks/react-query.mock'
35
import { sleep } from '@sim/utils/helpers'
@@ -27,7 +29,10 @@ vi.mock('@/lib/terminal/transport', () => ({
2729
import type { MothershipEffort } from '@/lib/mothership/model-options'
2830
import {
2931
fetchMothershipChatHistory,
32+
mothershipChatKeys,
3033
useDeleteMothershipChats,
34+
useMarkMothershipChatRead,
35+
useMarkMothershipChatUnread,
3136
useSetMothershipChatEffort,
3237
} from '@/hooks/queries/mothership-chats'
3338
import { useMothershipEffortStore } from '@/stores/mothership-effort/store'
@@ -101,6 +106,61 @@ describe('tasks query boundary parsing', () => {
101106
})
102107
})
103108

109+
it.each([
110+
['read', useMarkMothershipChatRead],
111+
['unread', useMarkMothershipChatUnread],
112+
] as const)(
113+
'marking a chat %s still removes a row an invalidated list read would drop',
114+
async (_name, useMark) => {
115+
const tanstack =
116+
await vi.importActual<typeof import('@tanstack/react-query')>('@tanstack/react-query')
117+
const client = new tanstack.QueryClient()
118+
const owner = { organizationId: 'org-1' }
119+
const queryKey = mothershipChatKeys.ownerList(owner)
120+
const rows = [{ id: 'chat-gone', name: 'Deleted', isUnread: false }]
121+
const staleRead = createDeferred<typeof rows>()
122+
const queryFn = vi
123+
.fn<() => Promise<typeof rows>>()
124+
.mockReturnValueOnce(staleRead.promise)
125+
.mockResolvedValue([])
126+
client.setQueryData(queryKey, rows)
127+
const list = new tanstack.QueryObserver(client, { queryKey, queryFn, staleTime: 60_000 })
128+
const stop = list.subscribe(() => {})
129+
reactQueryMockFns.mockUseQueryClient.mockReturnValueOnce(
130+
client as unknown as typeof queryClient
131+
)
132+
const mutation = useMark(owner) as unknown as {
133+
onMutate: (chatId: string) => Promise<unknown>
134+
onSettled: (data: undefined, error: null, chatId: string, context: unknown) => void
135+
}
136+
137+
void client.invalidateQueries({ queryKey })
138+
const context = await mutation.onMutate('chat-gone')
139+
mutation.onSettled(undefined, null, 'chat-gone', context)
140+
await flushMicrotasks(10)
141+
142+
expect(queryFn).toHaveBeenCalledTimes(2)
143+
expect(list.getCurrentResult().data).toEqual([])
144+
stop()
145+
client.clear()
146+
}
147+
)
148+
149+
it('leaves the list alone when marking a chat with no invalidated read to redo', async () => {
150+
const owner = { organizationId: 'org-1' }
151+
queryClient.getQueryData.mockReturnValueOnce([])
152+
queryClient.getQueryState.mockReturnValueOnce({ isInvalidated: false })
153+
const mutation = useMarkMothershipChatRead(owner) as unknown as {
154+
onMutate: (chatId: string) => Promise<unknown>
155+
onSettled: (data: undefined, error: null, chatId: string, context: unknown) => void
156+
}
157+
158+
const context = await mutation.onMutate('chat-1')
159+
mutation.onSettled(undefined, null, 'chat-1', context)
160+
161+
expect(queryClient.invalidateQueries).not.toHaveBeenCalled()
162+
})
163+
104164
it('loads a chat from a server that predates the effort field', async () => {
105165
vi.mocked(fetch).mockResolvedValueOnce(
106166
jsonResponse({

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

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -660,6 +660,20 @@ function applyUnreadFlag(
660660
)
661661
}
662662

663+
/**
664+
* Stops the list reads that could overwrite an optimistic flag. Answers whether one was owed by an
665+
* invalidation: the flag's write clears that mark, so the caller has to read the list again.
666+
*/
667+
async function holdChatListReads(
668+
queryClient: ReturnType<typeof useQueryClient>,
669+
owner: MothershipChatOwner | undefined
670+
): Promise<boolean> {
671+
const queryKey = mothershipChatKeys.ownerList(owner)
672+
const owesRead = queryClient.getQueryState(queryKey)?.isInvalidated === true
673+
await queryClient.cancelQueries({ queryKey })
674+
return owesRead
675+
}
676+
663677
/**
664678
* Marks a chat as read with optimistic update.
665679
*
@@ -670,7 +684,8 @@ function applyUnreadFlag(
670684
* If there is no cached list yet (initial fetch still in flight, e.g. on
671685
* chat-page refresh), we skip cancellation entirely so the in-flight fetch
672686
* can resolve normally — otherwise it would be orphaned and never refetched.
673-
* `onSuccess` then reconciles whichever state the fetch produced.
687+
* `onSuccess` then reconciles whichever state the fetch produced. A read an invalidation
688+
* asked for still runs: cancelling one for good would keep a deleted chat in the sidebar.
674689
*/
675690
export function useMarkMothershipChatRead(owner?: MothershipChatOwner) {
676691
const queryClient = useQueryClient()
@@ -680,12 +695,12 @@ export function useMarkMothershipChatRead(owner?: MothershipChatOwner) {
680695
const previousChats = queryClient.getQueryData<MothershipChatMetadata[]>(
681696
mothershipChatKeys.ownerList(owner)
682697
)
683-
if (!previousChats) return { previousChats }
698+
if (!previousChats) return { previousChats, owesRead: false }
684699

685-
await queryClient.cancelQueries({ queryKey: mothershipChatKeys.ownerList(owner) })
700+
const owesRead = await holdChatListReads(queryClient, owner)
686701
applyUnreadFlag(queryClient, owner, chatId, false)
687702

688-
return { previousChats }
703+
return { previousChats, owesRead }
689704
},
690705
onSuccess: (_data, chatId) => {
691706
applyUnreadFlag(queryClient, owner, chatId, false)
@@ -695,6 +710,10 @@ export function useMarkMothershipChatRead(owner?: MothershipChatOwner) {
695710
queryClient.setQueryData(mothershipChatKeys.ownerList(owner), context.previousChats)
696711
}
697712
},
713+
onSettled: (_data, _error, _variables, context) => {
714+
if (context?.owesRead)
715+
queryClient.invalidateQueries({ queryKey: mothershipChatKeys.ownerList(owner) })
716+
},
698717
})
699718
}
700719

@@ -712,12 +731,12 @@ export function useMarkMothershipChatUnread(owner?: MothershipChatOwner) {
712731
const previousChats = queryClient.getQueryData<MothershipChatMetadata[]>(
713732
mothershipChatKeys.ownerList(owner)
714733
)
715-
if (!previousChats) return { previousChats }
734+
if (!previousChats) return { previousChats, owesRead: false }
716735

717-
await queryClient.cancelQueries({ queryKey: mothershipChatKeys.ownerList(owner) })
736+
const owesRead = await holdChatListReads(queryClient, owner)
718737
applyUnreadFlag(queryClient, owner, chatId, true)
719738

720-
return { previousChats }
739+
return { previousChats, owesRead }
721740
},
722741
onSuccess: (_data, chatId) => {
723742
applyUnreadFlag(queryClient, owner, chatId, true)
@@ -727,6 +746,10 @@ export function useMarkMothershipChatUnread(owner?: MothershipChatOwner) {
727746
queryClient.setQueryData(mothershipChatKeys.ownerList(owner), context.previousChats)
728747
}
729748
},
749+
onSettled: (_data, _error, _variables, context) => {
750+
if (context?.owesRead)
751+
queryClient.invalidateQueries({ queryKey: mothershipChatKeys.ownerList(owner) })
752+
},
730753
})
731754
}
732755

0 commit comments

Comments
 (0)