Skip to content

Commit 7379ad3

Browse files
mozziusclaude
andauthored
[Chat] Fix optimistic updates by using partial keys (bluesky-social#10838)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 9a2e636 commit 7379ad3

3 files changed

Lines changed: 191 additions & 143 deletions

File tree

src/state/queries/messages/accept-conversation.ts

Lines changed: 52 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {
22
type ChatBskyConvoAcceptConvo,
3-
type ChatBskyConvoListConvos,
3+
type ChatBskyConvoDefs,
44
} from '@atproto/api'
55
import {useMutation, useQueryClient} from '@tanstack/react-query'
66

@@ -13,7 +13,11 @@ import {
1313
RQKEY_ROOT as REQUESTS_RQKEY_ROOT,
1414
} from './list-conversation-requests'
1515
import {
16-
RQKEY as CONVO_LIST_KEY,
16+
type ConvoListQueryData,
17+
convoListQueryPredicate,
18+
getConvoFromQueryData,
19+
optimisticDelete,
20+
RQKEY_PARTIAL as CONVO_LIST_PARTIAL_KEY,
1721
RQKEY_ROOT as CONVO_LIST_ROOT_KEY,
1822
} from './list-conversations'
1923

@@ -42,65 +46,49 @@ export function useAcceptConversation(
4246
return data
4347
},
4448
onMutate: () => {
45-
let prevAcceptedPages: ChatBskyConvoListConvos.OutputSchema[] = []
46-
let prevInboxPages: ChatBskyConvoListConvos.OutputSchema[] = []
47-
let convoBeingAccepted:
48-
| ChatBskyConvoListConvos.OutputSchema['convos'][number]
49-
| undefined
50-
queryClient.setQueryData(
51-
CONVO_LIST_KEY('request'),
52-
(old?: {
53-
pageParams: Array<string | undefined>
54-
pages: Array<ChatBskyConvoListConvos.OutputSchema>
55-
}) => {
56-
if (!old) return old
57-
prevInboxPages = old.pages
58-
return {
59-
...old,
60-
pages: old.pages.map(page => {
61-
const found = page.convos.find(convo => convo.id === convoId)
62-
if (found) {
63-
convoBeingAccepted = found
64-
return {
65-
...page,
66-
convos: page.convos.filter(convo => convo.id !== convoId),
67-
}
68-
}
69-
return page
70-
}),
71-
}
72-
},
49+
// snapshot every convo-list cache up front so onError can restore them
50+
// all by their exact keys
51+
const prevConvoListQueries =
52+
queryClient.getQueriesData<ConvoListQueryData>({
53+
queryKey: [CONVO_LIST_ROOT_KEY],
54+
})
55+
let convoBeingAccepted: ChatBskyConvoDefs.ConvoView | null = null
56+
for (const [_key, data] of queryClient.getQueriesData<ConvoListQueryData>(
57+
{queryKey: CONVO_LIST_PARTIAL_KEY('request')},
58+
)) {
59+
if (!data) continue
60+
convoBeingAccepted = getConvoFromQueryData(convoId, data)
61+
if (convoBeingAccepted) break
62+
}
63+
queryClient.setQueriesData(
64+
{queryKey: CONVO_LIST_PARTIAL_KEY('request')},
65+
(old?: ConvoListQueryData) => optimisticDelete(convoId, old),
7366
)
74-
queryClient.setQueryData(
75-
CONVO_LIST_KEY('accepted'),
76-
(old?: {
77-
pageParams: Array<string | undefined>
78-
pages: Array<ChatBskyConvoListConvos.OutputSchema>
79-
}) => {
80-
if (!old) return old
81-
prevAcceptedPages = old.pages
82-
if (convoBeingAccepted) {
67+
if (convoBeingAccepted) {
68+
const acceptedConvo: ChatBskyConvoDefs.ConvoView = {
69+
...convoBeingAccepted,
70+
status: 'accepted',
71+
}
72+
queryClient.setQueriesData(
73+
{
74+
queryKey: CONVO_LIST_PARTIAL_KEY('accepted'),
75+
predicate: convoListQueryPredicate(acceptedConvo),
76+
},
77+
(old?: ConvoListQueryData) => {
78+
if (!old) return old
8379
return {
8480
...old,
85-
pages: [
86-
{
87-
...old.pages[0],
88-
convos: [
89-
{
90-
...convoBeingAccepted,
91-
status: 'accepted',
92-
},
93-
...old.pages[0].convos,
94-
],
95-
},
96-
...old.pages.slice(1),
97-
],
81+
pages: old.pages.map((page, i) => {
82+
const convos = page.convos.filter(c => c.id !== convoId)
83+
if (i === 0) {
84+
return {...page, convos: [acceptedConvo, ...convos]}
85+
}
86+
return {...page, convos}
87+
}),
9888
}
99-
} else {
100-
return old
101-
}
102-
},
103-
)
89+
},
90+
)
91+
}
10492
const prevRequestsQueries =
10593
queryClient.getQueriesData<ConvoRequestListQueryData>({
10694
queryKey: [REQUESTS_RQKEY_ROOT],
@@ -110,41 +98,20 @@ export function useAcceptConversation(
11098
old => optimisticDeleteRequest(convoId, old),
11199
)
112100
onMutate?.()
113-
return {prevAcceptedPages, prevInboxPages, prevRequestsQueries}
101+
return {prevConvoListQueries, prevRequestsQueries}
114102
},
115103
onSuccess: data => {
116-
void queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]})
104+
void queryClient.invalidateQueries({queryKey: [CONVO_LIST_ROOT_KEY]})
117105
void queryClient.invalidateQueries({queryKey: [REQUESTS_RQKEY_ROOT]})
118106
onSuccess?.(data)
119107
},
120108
onError: (error, _, context) => {
121109
logger.error(error)
122-
queryClient.setQueryData(
123-
CONVO_LIST_KEY('accepted'),
124-
(old?: {
125-
pageParams: Array<string | undefined>
126-
pages: Array<ChatBskyConvoListConvos.OutputSchema>
127-
}) => {
128-
if (!old) return old
129-
return {
130-
...old,
131-
pages: context?.prevAcceptedPages || old.pages,
132-
}
133-
},
134-
)
135-
queryClient.setQueryData(
136-
CONVO_LIST_KEY('request'),
137-
(old?: {
138-
pageParams: Array<string | undefined>
139-
pages: Array<ChatBskyConvoListConvos.OutputSchema>
140-
}) => {
141-
if (!old) return old
142-
return {
143-
...old,
144-
pages: context?.prevInboxPages || old.pages,
145-
}
146-
},
147-
)
110+
if (context?.prevConvoListQueries) {
111+
for (const [queryKey, prevData] of context.prevConvoListQueries) {
112+
queryClient.setQueryData(queryKey, prevData)
113+
}
114+
}
148115
if (context?.prevRequestsQueries) {
149116
for (const [queryKey, prevData] of context.prevRequestsQueries) {
150117
queryClient.setQueryData(queryKey, prevData)

src/state/queries/messages/list-conversations.tsx

Lines changed: 107 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import {
88
} from '@atproto/api'
99
import {
1010
type InfiniteData,
11+
type Query,
1112
type QueryClient,
13+
type QueryKey,
1214
useInfiniteQuery,
1315
useQueryClient,
1416
} from '@tanstack/react-query'
@@ -48,7 +50,60 @@ export const RQKEY = (
4850
| 'locked-permanently'
4951
| undefined = undefined,
5052
limit?: number,
51-
) => [RQKEY_ROOT, status, readState, kind, lockStatus, limit]
53+
) => [RQKEY_ROOT, status, readState, kind, lockStatus, limit] as const
54+
55+
/**
56+
* Prefix key matching every convo-list query with the given status (and
57+
* optionally readState), regardless of the remaining params (kind,
58+
* lockStatus, limit). Only valid with prefix-matching APIs (setQueriesData,
59+
* getQueriesData, invalidateQueries) - exact-match APIs (getQueryData,
60+
* setQueryData) hash the full key and will never match a prefix.
61+
*/
62+
export const RQKEY_PARTIAL = (
63+
status: 'accepted' | 'request' | 'all',
64+
readState?: 'all' | 'unread',
65+
) => (readState ? [RQKEY_ROOT, status, readState] : [RQKEY_ROOT, status])
66+
67+
/**
68+
* Whether a convo satisfies the filters encoded in a convo-list query key.
69+
* Caches are server-filtered, so optimistic inserts must apply the same
70+
* filters client-side or convos leak into lists that should exclude them.
71+
*/
72+
export function convoMatchesQueryKey(
73+
convo: ChatBskyConvoDefs.ConvoView,
74+
queryKey: QueryKey,
75+
): boolean {
76+
const [, status, readState, kind, lockStatus] = queryKey as ReturnType<
77+
typeof RQKEY
78+
>
79+
if (status !== 'all' && status !== convo.status) return false
80+
if (readState === 'unread' && convo.unreadCount === 0) return false
81+
if (ChatBskyConvoDefs.isGroupConvo(convo.kind)) {
82+
if (kind === 'direct') return false
83+
if (lockStatus && convo.kind.lockStatus !== lockStatus) return false
84+
} else {
85+
if (kind === 'group') return false
86+
// direct convos are never locked
87+
if (lockStatus && lockStatus !== 'unlocked') return false
88+
}
89+
return true
90+
}
91+
92+
/**
93+
* Query predicate for optimistically upserting a convo into convo-list
94+
* caches. Targets caches whose filters the convo satisfies, plus caches the
95+
* convo is already in - those get updated in place even if the convo no
96+
* longer matches (e.g. unreadCount dropped to 0), mirroring how read/mute
97+
* log events update convos in place everywhere.
98+
*/
99+
export function convoListQueryPredicate(convo: ChatBskyConvoDefs.ConvoView) {
100+
return (query: Query): boolean => {
101+
const data = query.state.data as ConvoListQueryData | undefined
102+
if (data && getConvoFromQueryData(convo.id, data)) return true
103+
return convoMatchesQueryKey(convo, query.queryKey)
104+
}
105+
}
106+
52107
type RQPageParam = string | undefined
53108

54109
export function useListConvosQuery({
@@ -347,9 +402,12 @@ export function ListConvosProviderInner({
347402
}),
348403
}
349404
}
350-
// always update the unread one
405+
// always update the unread ones, where the convo qualifies
351406
queryClient.setQueriesData(
352-
{queryKey: RQKEY('all', 'unread')},
407+
{
408+
queryKey: RQKEY_PARTIAL('all', 'unread'),
409+
predicate: convoListQueryPredicate(updatedConvo),
410+
},
353411
(old?: ConvoListQueryData) =>
354412
old
355413
? updateFn(old)
@@ -361,11 +419,20 @@ export function ListConvosProviderInner({
361419
// update the other ones based on status of the incoming message
362420
if (updatedConvo.status === 'accepted') {
363421
queryClient.setQueriesData(
364-
{queryKey: RQKEY('accepted')},
422+
{
423+
queryKey: RQKEY_PARTIAL('accepted'),
424+
predicate: convoListQueryPredicate(updatedConvo),
425+
},
365426
updateFn,
366427
)
367428
} else if (updatedConvo.status === 'request') {
368-
queryClient.setQueriesData({queryKey: RQKEY('request')}, updateFn)
429+
queryClient.setQueriesData(
430+
{
431+
queryKey: RQKEY_PARTIAL('request'),
432+
predicate: convoListQueryPredicate(updatedConvo),
433+
},
434+
updateFn,
435+
)
369436
// also move-to-top in the new requests cache
370437
queryClient.setQueriesData<ConvoRequestListQueryData>(
371438
{queryKey: [REQUESTS_RQKEY_ROOT]},
@@ -385,20 +452,26 @@ export function ListConvosProviderInner({
385452
rev: log.rev,
386453
}))
387454
} else if (ChatBskyConvoDefs.isLogAcceptConvo(log)) {
388-
const requests = queryClient.getQueryData<ConvoListQueryData>(
389-
RQKEY('request'),
390-
)
391-
if (!requests) {
392-
debouncedRefetch()
393-
return
455+
const requestQueries =
456+
queryClient.getQueriesData<ConvoListQueryData>({
457+
queryKey: RQKEY_PARTIAL('request'),
458+
})
459+
let foundConvo: ChatBskyConvoDefs.ConvoView | null = null
460+
for (const [_key, data] of requestQueries) {
461+
if (!data) continue
462+
foundConvo = getConvoFromQueryData(log.convoId, data)
463+
if (foundConvo) break
394464
}
395-
const acceptedConvo = getConvoFromQueryData(log.convoId, requests)
396-
if (!acceptedConvo) {
465+
if (!foundConvo) {
397466
debouncedRefetch()
398467
return
399468
}
400-
queryClient.setQueryData(
401-
RQKEY('request'),
469+
const acceptedConvo: ChatBskyConvoDefs.ConvoView = {
470+
...foundConvo,
471+
status: 'accepted',
472+
}
473+
queryClient.setQueriesData(
474+
{queryKey: RQKEY_PARTIAL('request')},
402475
(old?: ConvoListQueryData) => optimisticDelete(log.convoId, old),
403476
)
404477
// also remove from the new requests cache
@@ -407,7 +480,10 @@ export function ListConvosProviderInner({
407480
old => optimisticDeleteRequest(log.convoId, old),
408481
)
409482
queryClient.setQueriesData(
410-
{queryKey: RQKEY('accepted')},
483+
{
484+
queryKey: RQKEY_PARTIAL('accepted'),
485+
predicate: convoListQueryPredicate(acceptedConvo),
486+
},
411487
(old?: ConvoListQueryData) => {
412488
if (!old) {
413489
debouncedRefetch()
@@ -420,12 +496,15 @@ export function ListConvosProviderInner({
420496
return {
421497
...page,
422498
convos: [
423-
{...acceptedConvo, status: 'accepted'},
424-
...page.convos,
499+
acceptedConvo,
500+
...page.convos.filter(c => c.id !== log.convoId),
425501
],
426502
}
427503
}
428-
return page
504+
return {
505+
...page,
506+
convos: page.convos.filter(c => c.id !== log.convoId),
507+
}
429508
}),
430509
}
431510
},
@@ -723,7 +802,14 @@ export function useUnreadMessageCount() {
723802
hasNew: false,
724803
}
725804
}
726-
}, [accepted, request, currentAccount?.did, currentConvoId, moderationOpts])
805+
}, [
806+
accepted,
807+
request,
808+
currentAccount?.did,
809+
currentConvoId,
810+
moderationOpts,
811+
aa.flags,
812+
])
727813
}
728814

729815
function calculateCount(
@@ -911,7 +997,7 @@ function addMemberToConvoView(
911997
}
912998
}
913999

914-
function optimisticDelete(chatId: string, old?: ConvoListQueryData) {
1000+
export function optimisticDelete(chatId: string, old?: ConvoListQueryData) {
9151001
if (!old) return old
9161002

9171003
return {

0 commit comments

Comments
 (0)