fix(canvas): prevent stale autosaves from overwriting generated images

This commit is contained in:
134 2026-10-15 14:50:43 +08:00
parent e6dbb612ce
commit 86c565d34c
3 changed files with 45 additions and 32 deletions

View File

@ -15320,17 +15320,40 @@ export function CanvasChatPanel({
}, [activeChatSessionMode, startNewChatSession]);
const switchToSession = useCallback((sessionId: string) => {
if (!sessionId || sessionId === activeChatId) {
if (!sessionId) {
setIsSessionMenuOpen(false);
return;
}
if (sessionId === activeChatId) {
setIsSessionMenuOpen(false);
if (status === "streaming" || status === "submitted" || isClawdSubmitting) return;
// Reopening the current conversation must recover an empty/stale view too.
void mutateHistoryData().then((fresh) => {
if (
activeChatIdRef.current !== sessionId ||
inFlightChatIdRef.current === sessionId ||
!Array.isArray(fresh?.messages)
) return;
loadedHistorySignatureRef.current = buildCanvasHistoryMessagesSignature(
toRawCanvasChatId(sessionId), fresh.messages
);
processedMessageIds.current.clear();
hasHydratedHistoryRef.current = true;
setMessages(sortCanvasMessagesByCreatedAt(fresh.messages));
setMessageViewportKey((previous) => previous + 1);
}).catch((error) => {
console.error("[CanvasChat] Failed to reopen current conversation:", error);
toast.error("聊天记录暂时读取失败,请重试");
});
return;
}
forceHistoryHydrationRef.current = toRawCanvasChatId(sessionId);
releaseSendGuard(activeChatId);
setActiveChatId(sessionId);
replaceChatSessionQuery(sessionId);
setIsSessionMenuOpen(false);
toast.success("已切换聊天窗口");
}, [activeChatId, releaseSendGuard, replaceChatSessionQuery]);
}, [activeChatId, isClawdSubmitting, mutateHistoryData, releaseSendGuard, replaceChatSessionQuery, setMessages, status]);
const switchToClawdHistorySession = useCallback(
(historyItem: ClawdHistoryItem) => {

View File

@ -306,26 +306,10 @@ export const canvasService = {
return response;
};
let response = await doSaveWithRateLimitRetry(buildRequestBody(options));
let data = await response.json().catch(() => ({}));
const serverRevision = normalizeRevisionText(data?.canvasUpdatedAt);
if (
response.status === 409 &&
data?.code === "STALE_CANVAS_REVISION" &&
serverRevision
) {
response = await doSaveWithRateLimitRetry(
buildRequestBody(
{
baseCanvasRevision: serverRevision,
baseCanvasUpdatedAt: serverRevision,
},
false
)
);
data = await response.json().catch(() => ({}));
}
const response = await doSaveWithRateLimitRetry(buildRequestBody(options));
const data = await response.json().catch(() => ({}));
// A conflict means this snapshot missed a remote edit. Reusing the newer
// revision with the same old nodes would overwrite generated results.
if (!response.ok) {
return {

View File

@ -896,12 +896,16 @@ test.describe("canvas chat pure helpers", () => {
}
});
test("canvas service automatically retries stale save conflicts", async () => {
test("stale autosaves cannot overwrite a remotely generated image by adopting its revision", async () => {
const originalFetch = globalThis.fetch;
const hadWindow = Object.prototype.hasOwnProperty.call(globalThis, "window");
const originalWindow = (globalThis as typeof globalThis & { window?: unknown })
.window;
const bodies: Array<Record<string, unknown>> = [];
let remoteNodes = [{ id: "new-generated-image", type: "image" }];
useCanvasStore.getState().setCanvasRevision(
"2026-06-22T00:01:00.000Z", "2026-06-22T00:01:00.000Z"
);
const responses = [
{
ok: false,
@ -929,7 +933,9 @@ test.describe("canvas chat pure helpers", () => {
writable: true,
});
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
bodies.push(JSON.parse(String(init?.body || "{}")));
const body = JSON.parse(String(init?.body || "{}"));
bodies.push(body);
if (bodies.length > 1) remoteNodes = body.canvasData.nodes;
return responses.shift() as Response;
}) as typeof fetch;
@ -946,16 +952,15 @@ test.describe("canvas chat pure helpers", () => {
);
expect(result).toMatchObject({
success: true,
data: { updatedAt: "2026-06-22T00:02:01.000Z" },
});
expect(bodies).toHaveLength(2);
expect(bodies[1]).toMatchObject({
baseCanvasRevision: "2026-06-22T00:02:00.000Z",
baseCanvasUpdatedAt: "2026-06-22T00:02:00.000Z",
success: false,
status: 409,
code: "STALE_CANVAS_REVISION",
canvasUpdatedAt: "2026-06-22T00:02:00.000Z",
});
expect(bodies).toHaveLength(1);
expect(remoteNodes).toEqual([{ id: "new-generated-image", type: "image" }]);
expect(useCanvasStore.getState().canvasRevision).toBe(
"2026-06-22T00:02:01.000Z"
"2026-06-22T00:01:00.000Z"
);
} finally {
globalThis.fetch = originalFetch;
@ -968,6 +973,7 @@ test.describe("canvas chat pure helpers", () => {
} else {
Reflect.deleteProperty(globalThis, "window");
}
useCanvasStore.getState().setCanvasRevision(null, null);
}
});