fix(site): cancel stale refetches before WebSocket cache writes (#23582)

When a chat is created, createChat.onSuccess invalidates the sidebar
list query, triggering a background refetch. The refetch can hit the
server before async title generation finishes, returning the fallback
(truncated) title. If the title_change WebSocket event arrives and
writes the generated title into the cache, the in-flight refetch
response then overwrites it with the stale fallback title.

Cancel any in-flight sidebar-list and per-chat refetches before every
WebSocket-driven cache write. This mirrors the existing pattern in
archiveChat/unarchiveChat, which cancel queries before optimistic
updates for the same reason.
This commit is contained in:
Mathias Fredriksson
2026-03-25 16:18:32 +02:00
committed by GitHub
parent 40395c6e32
commit 7eca33c69b
3 changed files with 121 additions and 0 deletions
+92
View File
@@ -4,6 +4,7 @@ import { QueryClient } from "react-query";
import { describe, expect, it, vi } from "vitest";
import {
archiveChat,
cancelChatListQueries,
chatCostSummary,
chatCostSummaryKey,
chatCostUsers,
@@ -21,6 +22,7 @@ import {
invalidateChatListQueries,
promoteChatQueuedMessage,
unarchiveChat,
updateInfiniteChatsCache,
} from "./chats";
vi.mock("api/api", () => ({
@@ -833,3 +835,93 @@ describe("diff_status_change invalidation scope", () => {
).toBe(true);
});
});
describe("sidebar title race condition", () => {
const readTitle = (
queryClient: QueryClient,
chatId: string,
): string | undefined => {
const data = queryClient.getQueryData<InfiniteData>(infiniteChatsTestKey);
return data?.pages.flat().find((c) => c.id === chatId)?.title;
};
it("in-flight refetch overwrites a WebSocket title update (the bug)", async () => {
const queryClient = createTestQueryClient();
const chatId = "chat-1";
seedInfiniteChats(queryClient, [
makeChat(chatId, { title: "fallback title" }),
]);
// Simulate invalidateChatListQueries triggering a refetch that
// returns stale data (the server hadn't generated the title yet
// when it processed this request).
const fetchDone = queryClient.prefetchQuery({
queryKey: infiniteChatsTestKey,
queryFn: () =>
new Promise<InfiniteData>((resolve) => {
setTimeout(
() =>
resolve({
pages: [[makeChat(chatId, { title: "fallback title" })]],
pageParams: [0],
}),
50,
);
}),
});
// Simulate the title_change WebSocket event arriving while the
// refetch is in flight. This mirrors what AgentsPage does.
updateInfiniteChatsCache(queryClient, (chats) =>
chats.map((c) =>
c.id === chatId ? { ...c, title: "generated title" } : c,
),
);
// The cache shows the generated title immediately.
expect(readTitle(queryClient, chatId)).toBe("generated title");
// After the refetch settles, it overwrites with stale data.
await fetchDone;
expect(readTitle(queryClient, chatId)).toBe("fallback title");
});
it("cancelChatListQueries before the update prevents the overwrite (the fix)", async () => {
const queryClient = createTestQueryClient();
const chatId = "chat-1";
seedInfiniteChats(queryClient, [
makeChat(chatId, { title: "fallback title" }),
]);
const fetchDone = queryClient.prefetchQuery({
queryKey: infiniteChatsTestKey,
queryFn: () =>
new Promise<InfiniteData>((resolve) => {
setTimeout(
() =>
resolve({
pages: [[makeChat(chatId, { title: "fallback title" })]],
pageParams: [0],
}),
50,
);
}),
});
// Cancel, then write. Matches the new WebSocket handler code.
await cancelChatListQueries(queryClient);
updateInfiniteChatsCache(queryClient, (chats) =>
chats.map((c) =>
c.id === chatId ? { ...c, title: "generated title" } : c,
),
);
expect(readTitle(queryClient, chatId)).toBe("generated title");
await fetchDone;
expect(readTitle(queryClient, chatId)).toBe("generated title");
});
});
+14
View File
@@ -104,6 +104,20 @@ export const invalidateChatListQueries = (queryClient: QueryClient) => {
});
};
/**
* Cancel in-flight refetches for sidebar chat-list queries.
* Call this before writing WebSocket-driven cache updates so a
* concurrent refetch (e.g. from createChat.onSuccess or the
* watchChats onOpen handler) cannot overwrite the update with
* stale server data that predates async title generation.
*/
export const cancelChatListQueries = (queryClient: QueryClient) => {
return queryClient.cancelQueries({
queryKey: chatsKey,
predicate: isChatListQuery,
});
};
const DEFAULT_CHAT_PAGE_LIMIT = 50;
export const infiniteChats = (opts?: { q?: string; archived?: boolean }) => {
+15
View File
@@ -2,6 +2,7 @@ import { API, watchChats } from "api/api";
import { getErrorMessage } from "api/errors";
import {
archiveChat,
cancelChatListQueries,
chatDiffContentsKey,
chatKey,
chatModelConfigs,
@@ -411,6 +412,20 @@ const AgentsPage: FC = () => {
const isStatusEvent = chatEvent.kind === "status_change";
const isDiffStatusEvent = chatEvent.kind === "diff_status_change";
// Cancel in-flight list and per-chat refetches so
// they cannot overwrite the cache update below with
// stale server data. This matters when a title_change
// event races with a refetch triggered by
// createChat.onSuccess or the onOpen invalidation:
// the refetch may have been issued before the async
// title generation finished, so its response carries
// the fallback title.
void cancelChatListQueries(queryClient);
void queryClient.cancelQueries({
queryKey: chatKey(updatedChat.id),
exact: true,
});
// For "created" events, use a cross-page existence
// check and prepend only to the first page.
// updateInfiniteChatsCache runs the updater per