fix(site): prevent sticky message cycling when submitting edited message (#24292)

This commit is contained in:
Danielle Maywood
2026-04-23 12:09:54 +01:00
committed by GitHub
parent be011b210b
commit 6edb49dcfa
5 changed files with 228 additions and 27 deletions
+49 -8
View File
@@ -823,7 +823,7 @@ describe("mutation invalidation scope", () => {
}
});
it("editChatMessage invalidates chat detail, messages, and debug runs", async () => {
it("editChatMessage invalidates chat detail and debug runs, not messages", async () => {
const queryClient = createTestQueryClient();
const chatId = "chat-1";
seedAllActiveQueries(queryClient, chatId);
@@ -833,19 +833,22 @@ describe("mutation invalidation scope", () => {
await new Promise((r) => setTimeout(r, 0));
// These queries should be invalidated -- editing changes
// message content, may update the chat record, and can start
// a new debug run.
// Chat metadata and debug runs should be invalidated because
// editing changes the chat's updated_at and can start a new
// debug run.
const chatState = queryClient.getQueryState(chatKey(chatId));
expect(chatState?.isInvalidated, "chatKey should be invalidated").toBe(
true,
);
// Messages are NOT invalidated. The per-chat WebSocket handles
// post-edit message delivery, making REST invalidation
// unnecessary.
const messagesState = queryClient.getQueryState(chatMessagesKey(chatId));
expect(
messagesState?.isInvalidated,
"chatMessagesKey should be invalidated",
).toBe(true);
"chatMessagesKey should not be invalidated",
).not.toBe(true);
expect(
queryClient.getQueryState(chatDebugRunsKey(chatId))?.isInvalidated,
@@ -853,6 +856,37 @@ describe("mutation invalidation scope", () => {
).toBe(true);
});
it("editChatMessage onError invalidates messages", async () => {
const queryClient = createTestQueryClient();
const chatId = "chat-1";
const messages = [3, 2, 1].map((id) => makeMsg(chatId, id));
queryClient.setQueryData<InfMessages>(chatMessagesKey(chatId), {
pages: [{ messages, queued_messages: [], has_more: false }],
pageParams: [undefined],
});
const mutation = editChatMessage(queryClient, chatId);
mutation.onError(
new Error("fail"),
{ messageId: 2, req: editReq },
{
previousData: {
pages: [{ messages, queued_messages: [], has_more: false }],
pageParams: [undefined],
},
},
);
await new Promise((r) => setTimeout(r, 0));
const messagesState = queryClient.getQueryState(chatMessagesKey(chatId));
expect(
messagesState?.isInvalidated,
"chatMessagesKey should be invalidated on error",
).toBe(true);
});
// Shared type for the infinite messages cache shape used by
// editChatMessage tests below.
type InfMessages = {
@@ -1083,7 +1117,7 @@ describe("mutation invalidation scope", () => {
const mutation = editChatMessage(queryClient, chatId);
// Pass undefined context — simulates onMutate throwing before
// Pass undefined context. This simulates onMutate throwing before
// it could return a snapshot.
mutation.onError(
new Error("fail"),
@@ -1091,9 +1125,16 @@ describe("mutation invalidation scope", () => {
undefined,
);
// Cache should be untouched — no crash, no corruption.
// Cache should be untouched: no crash, no corruption.
const data = queryClient.getQueryData<InfMessages>(chatMessagesKey(chatId));
expect(data?.pages[0]?.messages.map((m) => m.id)).toEqual([3, 2, 1]);
await new Promise((r) => setTimeout(r, 0));
const messagesState = queryClient.getQueryState(chatMessagesKey(chatId));
expect(
messagesState?.isInvalidated,
"chatMessagesKey should be invalidated even without context",
).toBe(true);
});
it("editChatMessage onMutate updates the first page and preserves older pages", async () => {
+15 -10
View File
@@ -1010,6 +1010,13 @@ export const editChatMessage = (queryClient: QueryClient, chatId: string) => ({
if (context?.previousData) {
queryClient.setQueryData(chatMessagesKey(chatId), context.previousData);
}
// Invalidate messages as a safety net: the restored snapshot
// may be missing WebSocket-delivered messages that arrived
// during the mutation's flight time.
void queryClient.invalidateQueries({
queryKey: chatMessagesKey(chatId),
exact: true,
});
},
onSuccess: (
response: TypesGen.EditChatMessageResponse,
@@ -1026,20 +1033,18 @@ export const editChatMessage = (queryClient: QueryClient, chatId: string) => ({
);
},
onSettled: () => {
// Always reconcile with the server regardless of whether
// the mutation succeeded or failed. On success this picks
// up the replacement message; on failure it confirms the
// restore from onError matches the server state. Use exact
// matching to avoid cascading to unrelated queries
// (diff-status, diff-contents, cost summaries, etc.).
// Refresh chat metadata (status, title, etc.). The messages
// query is intentionally NOT invalidated here. The per-chat
// WebSocket handles post-edit message delivery via
// FullRefresh, making REST invalidation unnecessary.
// Invalidating chatMessagesKey would trigger a redundant
// refetch that causes extra store mutations while the
// sticky user message is settling after the optimistic
// truncation.
void queryClient.invalidateQueries({
queryKey: chatKey(chatId),
exact: true,
});
void queryClient.invalidateQueries({
queryKey: chatMessagesKey(chatId),
exact: true,
});
void invalidateChatDebugRuns(queryClient, chatId);
},
});