[Chat] Fix optimistic updates by using partial keys (#10838)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-06-10 13:56:54 +03:00
committed by GitHub
parent 9a2e636c17
commit 7379ad3c3b
3 changed files with 191 additions and 143 deletions
@@ -1,6 +1,6 @@
import { import {
type ChatBskyConvoAcceptConvo, type ChatBskyConvoAcceptConvo,
type ChatBskyConvoListConvos, type ChatBskyConvoDefs,
} from '@atproto/api' } from '@atproto/api'
import {useMutation, useQueryClient} from '@tanstack/react-query' import {useMutation, useQueryClient} from '@tanstack/react-query'
@@ -13,7 +13,11 @@ import {
RQKEY_ROOT as REQUESTS_RQKEY_ROOT, RQKEY_ROOT as REQUESTS_RQKEY_ROOT,
} from './list-conversation-requests' } from './list-conversation-requests'
import { import {
RQKEY as CONVO_LIST_KEY, type ConvoListQueryData,
convoListQueryPredicate,
getConvoFromQueryData,
optimisticDelete,
RQKEY_PARTIAL as CONVO_LIST_PARTIAL_KEY,
RQKEY_ROOT as CONVO_LIST_ROOT_KEY, RQKEY_ROOT as CONVO_LIST_ROOT_KEY,
} from './list-conversations' } from './list-conversations'
@@ -42,65 +46,49 @@ export function useAcceptConversation(
return data return data
}, },
onMutate: () => { onMutate: () => {
let prevAcceptedPages: ChatBskyConvoListConvos.OutputSchema[] = [] // snapshot every convo-list cache up front so onError can restore them
let prevInboxPages: ChatBskyConvoListConvos.OutputSchema[] = [] // all by their exact keys
let convoBeingAccepted: const prevConvoListQueries =
| ChatBskyConvoListConvos.OutputSchema['convos'][number] queryClient.getQueriesData<ConvoListQueryData>({
| undefined queryKey: [CONVO_LIST_ROOT_KEY],
queryClient.setQueryData( })
CONVO_LIST_KEY('request'), let convoBeingAccepted: ChatBskyConvoDefs.ConvoView | null = null
(old?: { for (const [_key, data] of queryClient.getQueriesData<ConvoListQueryData>(
pageParams: Array<string | undefined> {queryKey: CONVO_LIST_PARTIAL_KEY('request')},
pages: Array<ChatBskyConvoListConvos.OutputSchema> )) {
}) => { if (!data) continue
if (!old) return old convoBeingAccepted = getConvoFromQueryData(convoId, data)
prevInboxPages = old.pages if (convoBeingAccepted) break
return { }
...old, queryClient.setQueriesData(
pages: old.pages.map(page => { {queryKey: CONVO_LIST_PARTIAL_KEY('request')},
const found = page.convos.find(convo => convo.id === convoId) (old?: ConvoListQueryData) => optimisticDelete(convoId, old),
if (found) {
convoBeingAccepted = found
return {
...page,
convos: page.convos.filter(convo => convo.id !== convoId),
}
}
return page
}),
}
},
) )
queryClient.setQueryData( if (convoBeingAccepted) {
CONVO_LIST_KEY('accepted'), const acceptedConvo: ChatBskyConvoDefs.ConvoView = {
(old?: { ...convoBeingAccepted,
pageParams: Array<string | undefined> status: 'accepted',
pages: Array<ChatBskyConvoListConvos.OutputSchema> }
}) => { queryClient.setQueriesData(
if (!old) return old {
prevAcceptedPages = old.pages queryKey: CONVO_LIST_PARTIAL_KEY('accepted'),
if (convoBeingAccepted) { predicate: convoListQueryPredicate(acceptedConvo),
},
(old?: ConvoListQueryData) => {
if (!old) return old
return { return {
...old, ...old,
pages: [ pages: old.pages.map((page, i) => {
{ const convos = page.convos.filter(c => c.id !== convoId)
...old.pages[0], if (i === 0) {
convos: [ return {...page, convos: [acceptedConvo, ...convos]}
{ }
...convoBeingAccepted, return {...page, convos}
status: 'accepted', }),
},
...old.pages[0].convos,
],
},
...old.pages.slice(1),
],
} }
} else { },
return old )
} }
},
)
const prevRequestsQueries = const prevRequestsQueries =
queryClient.getQueriesData<ConvoRequestListQueryData>({ queryClient.getQueriesData<ConvoRequestListQueryData>({
queryKey: [REQUESTS_RQKEY_ROOT], queryKey: [REQUESTS_RQKEY_ROOT],
@@ -110,41 +98,20 @@ export function useAcceptConversation(
old => optimisticDeleteRequest(convoId, old), old => optimisticDeleteRequest(convoId, old),
) )
onMutate?.() onMutate?.()
return {prevAcceptedPages, prevInboxPages, prevRequestsQueries} return {prevConvoListQueries, prevRequestsQueries}
}, },
onSuccess: data => { onSuccess: data => {
void queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]}) void queryClient.invalidateQueries({queryKey: [CONVO_LIST_ROOT_KEY]})
void queryClient.invalidateQueries({queryKey: [REQUESTS_RQKEY_ROOT]}) void queryClient.invalidateQueries({queryKey: [REQUESTS_RQKEY_ROOT]})
onSuccess?.(data) onSuccess?.(data)
}, },
onError: (error, _, context) => { onError: (error, _, context) => {
logger.error(error) logger.error(error)
queryClient.setQueryData( if (context?.prevConvoListQueries) {
CONVO_LIST_KEY('accepted'), for (const [queryKey, prevData] of context.prevConvoListQueries) {
(old?: { queryClient.setQueryData(queryKey, prevData)
pageParams: Array<string | undefined> }
pages: Array<ChatBskyConvoListConvos.OutputSchema> }
}) => {
if (!old) return old
return {
...old,
pages: context?.prevAcceptedPages || old.pages,
}
},
)
queryClient.setQueryData(
CONVO_LIST_KEY('request'),
(old?: {
pageParams: Array<string | undefined>
pages: Array<ChatBskyConvoListConvos.OutputSchema>
}) => {
if (!old) return old
return {
...old,
pages: context?.prevInboxPages || old.pages,
}
},
)
if (context?.prevRequestsQueries) { if (context?.prevRequestsQueries) {
for (const [queryKey, prevData] of context.prevRequestsQueries) { for (const [queryKey, prevData] of context.prevRequestsQueries) {
queryClient.setQueryData(queryKey, prevData) queryClient.setQueryData(queryKey, prevData)
+107 -21
View File
@@ -8,7 +8,9 @@ import {
} from '@atproto/api' } from '@atproto/api'
import { import {
type InfiniteData, type InfiniteData,
type Query,
type QueryClient, type QueryClient,
type QueryKey,
useInfiniteQuery, useInfiniteQuery,
useQueryClient, useQueryClient,
} from '@tanstack/react-query' } from '@tanstack/react-query'
@@ -48,7 +50,60 @@ export const RQKEY = (
| 'locked-permanently' | 'locked-permanently'
| undefined = undefined, | undefined = undefined,
limit?: number, limit?: number,
) => [RQKEY_ROOT, status, readState, kind, lockStatus, limit] ) => [RQKEY_ROOT, status, readState, kind, lockStatus, limit] as const
/**
* Prefix key matching every convo-list query with the given status (and
* optionally readState), regardless of the remaining params (kind,
* lockStatus, limit). Only valid with prefix-matching APIs (setQueriesData,
* getQueriesData, invalidateQueries) - exact-match APIs (getQueryData,
* setQueryData) hash the full key and will never match a prefix.
*/
export const RQKEY_PARTIAL = (
status: 'accepted' | 'request' | 'all',
readState?: 'all' | 'unread',
) => (readState ? [RQKEY_ROOT, status, readState] : [RQKEY_ROOT, status])
/**
* Whether a convo satisfies the filters encoded in a convo-list query key.
* Caches are server-filtered, so optimistic inserts must apply the same
* filters client-side or convos leak into lists that should exclude them.
*/
export function convoMatchesQueryKey(
convo: ChatBskyConvoDefs.ConvoView,
queryKey: QueryKey,
): boolean {
const [, status, readState, kind, lockStatus] = queryKey as ReturnType<
typeof RQKEY
>
if (status !== 'all' && status !== convo.status) return false
if (readState === 'unread' && convo.unreadCount === 0) return false
if (ChatBskyConvoDefs.isGroupConvo(convo.kind)) {
if (kind === 'direct') return false
if (lockStatus && convo.kind.lockStatus !== lockStatus) return false
} else {
if (kind === 'group') return false
// direct convos are never locked
if (lockStatus && lockStatus !== 'unlocked') return false
}
return true
}
/**
* Query predicate for optimistically upserting a convo into convo-list
* caches. Targets caches whose filters the convo satisfies, plus caches the
* convo is already in - those get updated in place even if the convo no
* longer matches (e.g. unreadCount dropped to 0), mirroring how read/mute
* log events update convos in place everywhere.
*/
export function convoListQueryPredicate(convo: ChatBskyConvoDefs.ConvoView) {
return (query: Query): boolean => {
const data = query.state.data as ConvoListQueryData | undefined
if (data && getConvoFromQueryData(convo.id, data)) return true
return convoMatchesQueryKey(convo, query.queryKey)
}
}
type RQPageParam = string | undefined type RQPageParam = string | undefined
export function useListConvosQuery({ export function useListConvosQuery({
@@ -347,9 +402,12 @@ export function ListConvosProviderInner({
}), }),
} }
} }
// always update the unread one // always update the unread ones, where the convo qualifies
queryClient.setQueriesData( queryClient.setQueriesData(
{queryKey: RQKEY('all', 'unread')}, {
queryKey: RQKEY_PARTIAL('all', 'unread'),
predicate: convoListQueryPredicate(updatedConvo),
},
(old?: ConvoListQueryData) => (old?: ConvoListQueryData) =>
old old
? updateFn(old) ? updateFn(old)
@@ -361,11 +419,20 @@ export function ListConvosProviderInner({
// update the other ones based on status of the incoming message // update the other ones based on status of the incoming message
if (updatedConvo.status === 'accepted') { if (updatedConvo.status === 'accepted') {
queryClient.setQueriesData( queryClient.setQueriesData(
{queryKey: RQKEY('accepted')}, {
queryKey: RQKEY_PARTIAL('accepted'),
predicate: convoListQueryPredicate(updatedConvo),
},
updateFn, updateFn,
) )
} else if (updatedConvo.status === 'request') { } else if (updatedConvo.status === 'request') {
queryClient.setQueriesData({queryKey: RQKEY('request')}, updateFn) queryClient.setQueriesData(
{
queryKey: RQKEY_PARTIAL('request'),
predicate: convoListQueryPredicate(updatedConvo),
},
updateFn,
)
// also move-to-top in the new requests cache // also move-to-top in the new requests cache
queryClient.setQueriesData<ConvoRequestListQueryData>( queryClient.setQueriesData<ConvoRequestListQueryData>(
{queryKey: [REQUESTS_RQKEY_ROOT]}, {queryKey: [REQUESTS_RQKEY_ROOT]},
@@ -385,20 +452,26 @@ export function ListConvosProviderInner({
rev: log.rev, rev: log.rev,
})) }))
} else if (ChatBskyConvoDefs.isLogAcceptConvo(log)) { } else if (ChatBskyConvoDefs.isLogAcceptConvo(log)) {
const requests = queryClient.getQueryData<ConvoListQueryData>( const requestQueries =
RQKEY('request'), queryClient.getQueriesData<ConvoListQueryData>({
) queryKey: RQKEY_PARTIAL('request'),
if (!requests) { })
let foundConvo: ChatBskyConvoDefs.ConvoView | null = null
for (const [_key, data] of requestQueries) {
if (!data) continue
foundConvo = getConvoFromQueryData(log.convoId, data)
if (foundConvo) break
}
if (!foundConvo) {
debouncedRefetch() debouncedRefetch()
return return
} }
const acceptedConvo = getConvoFromQueryData(log.convoId, requests) const acceptedConvo: ChatBskyConvoDefs.ConvoView = {
if (!acceptedConvo) { ...foundConvo,
debouncedRefetch() status: 'accepted',
return
} }
queryClient.setQueryData( queryClient.setQueriesData(
RQKEY('request'), {queryKey: RQKEY_PARTIAL('request')},
(old?: ConvoListQueryData) => optimisticDelete(log.convoId, old), (old?: ConvoListQueryData) => optimisticDelete(log.convoId, old),
) )
// also remove from the new requests cache // also remove from the new requests cache
@@ -407,7 +480,10 @@ export function ListConvosProviderInner({
old => optimisticDeleteRequest(log.convoId, old), old => optimisticDeleteRequest(log.convoId, old),
) )
queryClient.setQueriesData( queryClient.setQueriesData(
{queryKey: RQKEY('accepted')}, {
queryKey: RQKEY_PARTIAL('accepted'),
predicate: convoListQueryPredicate(acceptedConvo),
},
(old?: ConvoListQueryData) => { (old?: ConvoListQueryData) => {
if (!old) { if (!old) {
debouncedRefetch() debouncedRefetch()
@@ -420,12 +496,15 @@ export function ListConvosProviderInner({
return { return {
...page, ...page,
convos: [ convos: [
{...acceptedConvo, status: 'accepted'}, acceptedConvo,
...page.convos, ...page.convos.filter(c => c.id !== log.convoId),
], ],
} }
} }
return page return {
...page,
convos: page.convos.filter(c => c.id !== log.convoId),
}
}), }),
} }
}, },
@@ -723,7 +802,14 @@ export function useUnreadMessageCount() {
hasNew: false, hasNew: false,
} }
} }
}, [accepted, request, currentAccount?.did, currentConvoId, moderationOpts]) }, [
accepted,
request,
currentAccount?.did,
currentConvoId,
moderationOpts,
aa.flags,
])
} }
function calculateCount( function calculateCount(
@@ -911,7 +997,7 @@ function addMemberToConvoView(
} }
} }
function optimisticDelete(chatId: string, old?: ConvoListQueryData) { export function optimisticDelete(chatId: string, old?: ConvoListQueryData) {
if (!old) return old if (!old) return old
return { return {
+32 -37
View File
@@ -1,4 +1,3 @@
import {type ChatBskyConvoListConvos} from '@atproto/api'
import {useMutation, useQueryClient} from '@tanstack/react-query' import {useMutation, useQueryClient} from '@tanstack/react-query'
import {DM_SERVICE_HEADERS} from '#/lib/constants' import {DM_SERVICE_HEADERS} from '#/lib/constants'
@@ -9,7 +8,11 @@ import {
markAllRead as markAllRequestsRead, markAllRead as markAllRequestsRead,
RQKEY_ROOT as REQUESTS_RQKEY_ROOT, RQKEY_ROOT as REQUESTS_RQKEY_ROOT,
} from './list-conversation-requests' } from './list-conversation-requests'
import {RQKEY as CONVO_LIST_KEY} from './list-conversations' import {
type ConvoListQueryData,
RQKEY_PARTIAL as CONVO_LIST_PARTIAL_KEY,
RQKEY_ROOT as CONVO_LIST_ROOT_KEY,
} from './list-conversations'
export function useUpdateAllRead( export function useUpdateAllRead(
status: 'accepted' | 'request', status: 'accepted' | 'request',
@@ -36,18 +39,19 @@ export function useUpdateAllRead(
return data return data
}, },
onMutate: () => { onMutate: () => {
let prevPages: ChatBskyConvoListConvos.OutputSchema[] = [] // snapshot every convo-list cache up front so onError can restore them
// all by their exact keys
const prevConvoListQueries =
queryClient.getQueriesData<ConvoListQueryData>({
queryKey: [CONVO_LIST_ROOT_KEY],
})
let prevRequestsQueries: Array< let prevRequestsQueries: Array<
[readonly unknown[], ConvoRequestListQueryData | undefined] [readonly unknown[], ConvoRequestListQueryData | undefined]
> = [] > = []
queryClient.setQueryData( queryClient.setQueriesData(
CONVO_LIST_KEY(status), {queryKey: CONVO_LIST_PARTIAL_KEY(status)},
(old?: { (old?: ConvoListQueryData) => {
pageParams: Array<string | undefined>
pages: Array<ChatBskyConvoListConvos.OutputSchema>
}) => {
if (!old) return old if (!old) return old
prevPages = old.pages
return { return {
...old, ...old,
pages: old.pages.map(page => { pages: old.pages.map(page => {
@@ -64,13 +68,10 @@ export function useUpdateAllRead(
} }
}, },
) )
// remove unread convos from the badge query // remove unread convos from the badge queries
queryClient.setQueryData( queryClient.setQueriesData(
CONVO_LIST_KEY('all', 'unread'), {queryKey: CONVO_LIST_PARTIAL_KEY('all', 'unread')},
(old?: { (old?: ConvoListQueryData) => {
pageParams: Array<string | undefined>
pages: Array<ChatBskyConvoListConvos.OutputSchema>
}) => {
if (!old) return old if (!old) return old
return { return {
...old, ...old,
@@ -94,10 +95,15 @@ export function useUpdateAllRead(
) )
} }
onMutate?.() onMutate?.()
return {prevPages, prevRequestsQueries} return {prevConvoListQueries, prevRequestsQueries}
}, },
onSuccess: () => { onSuccess: () => {
void queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY(status)}) void queryClient.invalidateQueries({
queryKey: CONVO_LIST_PARTIAL_KEY(status),
})
void queryClient.invalidateQueries({
queryKey: CONVO_LIST_PARTIAL_KEY('all', 'unread'),
})
if (status === 'request') { if (status === 'request') {
void queryClient.invalidateQueries({queryKey: [REQUESTS_RQKEY_ROOT]}) void queryClient.invalidateQueries({queryKey: [REQUESTS_RQKEY_ROOT]})
} }
@@ -105,28 +111,17 @@ export function useUpdateAllRead(
}, },
onError: (error, _, context) => { onError: (error, _, context) => {
logger.error(error) logger.error(error)
queryClient.setQueryData( if (context?.prevConvoListQueries) {
CONVO_LIST_KEY(status), for (const [queryKey, prevData] of context.prevConvoListQueries) {
(old?: { queryClient.setQueryData(queryKey, prevData)
pageParams: Array<string | undefined> }
pages: Array<ChatBskyConvoListConvos.OutputSchema> }
}) => { if (context?.prevRequestsQueries) {
if (!old) return old
return {
...old,
pages: context?.prevPages || old.pages,
}
},
)
if (status === 'request' && context?.prevRequestsQueries) {
for (const [queryKey, prevData] of context.prevRequestsQueries) { for (const [queryKey, prevData] of context.prevRequestsQueries) {
queryClient.setQueryData(queryKey, prevData) queryClient.setQueryData(queryKey, prevData)
} }
} }
void queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY(status)}) void queryClient.invalidateQueries({queryKey: [CONVO_LIST_ROOT_KEY]})
void queryClient.invalidateQueries({
queryKey: CONVO_LIST_KEY('all', 'unread'),
})
if (status === 'request') { if (status === 'request') {
void queryClient.invalidateQueries({queryKey: [REQUESTS_RQKEY_ROOT]}) void queryClient.invalidateQueries({queryKey: [REQUESTS_RQKEY_ROOT]})
} }