fix(canvas): serialize pre-Agent saves with scheduled autosave
This commit is contained in:
parent
c690c0e370
commit
0272a5a328
@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { createCanvasSaveQueue } from "@/lib/client/canvas-save-queue";
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
@ -5075,59 +5076,84 @@ export default function CanvasEditorLegacyPage({
|
||||
workflowInputs.brand,
|
||||
]);
|
||||
|
||||
const canvasSaveQueue = useRef<ReturnType<typeof createCanvasSaveQueue> | null>(null);
|
||||
if (!canvasSaveQueue.current) canvasSaveQueue.current = createCanvasSaveQueue();
|
||||
const canvasAutosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const canvasPersistenceScope = useRef(id);
|
||||
canvasPersistenceScope.current = id;
|
||||
const persistCanvasState = useCallback(
|
||||
async (reason: "autosave" | "delete" | "beforeunload") => {
|
||||
const canvasData = buildPersistedCanvasData();
|
||||
const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState();
|
||||
const result = await canvasService.saveCanvasState(
|
||||
id,
|
||||
canvasData,
|
||||
activeCanvasWorkspaceType,
|
||||
{
|
||||
baseCanvasRevision: canvasRevision,
|
||||
baseCanvasUpdatedAt: canvasUpdatedAt,
|
||||
}
|
||||
);
|
||||
if (!result.success) {
|
||||
console.error(`[Canvas] ${reason} save failed:`, result.error);
|
||||
if (result.status === 409) {
|
||||
externalCanvasConflictRef.current = true;
|
||||
console.warn("[Canvas] stale save revision handled without user prompt", {
|
||||
reason,
|
||||
canvasUpdatedAt: result.canvasUpdatedAt,
|
||||
});
|
||||
if (!externalCanvasConflictNotifiedRef.current) {
|
||||
externalCanvasConflictNotifiedRef.current = true;
|
||||
toast.warning("画布已被外部操作更新,本地改动未覆盖远端;请刷新后继续编辑");
|
||||
(reason: "autosave" | "delete" | "beforeunload") => canvasSaveQueue.current!.enqueue(async () => {
|
||||
if (canvasPersistenceScope.current !== id) return false;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const canvasData = buildPersistedCanvasData();
|
||||
const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState();
|
||||
const result = await canvasService.saveCanvasState(
|
||||
id,
|
||||
canvasData,
|
||||
activeCanvasWorkspaceType,
|
||||
{
|
||||
baseCanvasRevision: canvasRevision,
|
||||
baseCanvasUpdatedAt: canvasUpdatedAt,
|
||||
}
|
||||
);
|
||||
if (canvasPersistenceScope.current !== id) return false;
|
||||
if (!result.success) {
|
||||
console.error(`[Canvas] ${reason} save failed:`, result.error);
|
||||
if (result.status === 409) {
|
||||
externalCanvasConflictRef.current = true;
|
||||
console.warn("[Canvas] stale save revision handled without user prompt", {
|
||||
reason,
|
||||
canvasUpdatedAt: result.canvasUpdatedAt,
|
||||
});
|
||||
if (!externalCanvasConflictNotifiedRef.current) {
|
||||
externalCanvasConflictNotifiedRef.current = true;
|
||||
toast.warning("画布已被外部操作更新,本地改动未覆盖远端;请刷新后继续编辑");
|
||||
}
|
||||
}
|
||||
} else if (result.data?.updatedAt) {
|
||||
externalCanvasConflictRef.current = false;
|
||||
externalCanvasConflictNotifiedRef.current = false;
|
||||
useCanvasStore
|
||||
.getState()
|
||||
.setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt));
|
||||
}
|
||||
} else if (result.data?.updatedAt) {
|
||||
externalCanvasConflictRef.current = false;
|
||||
externalCanvasConflictNotifiedRef.current = false;
|
||||
useCanvasStore
|
||||
.getState()
|
||||
.setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt));
|
||||
return result.success;
|
||||
} finally {
|
||||
if (canvasPersistenceScope.current === id) setIsSaving(false);
|
||||
}
|
||||
return result.success;
|
||||
},
|
||||
}),
|
||||
[activeCanvasWorkspaceType, buildPersistedCanvasData, id]
|
||||
);
|
||||
|
||||
const persistCanvasBeforeAgent = useCallback(() => {
|
||||
// Flush the pending local edit once instead of racing its scheduled autosave.
|
||||
if (canvasAutosaveTimer.current) {
|
||||
clearTimeout(canvasAutosaveTimer.current);
|
||||
canvasAutosaveTimer.current = null;
|
||||
}
|
||||
return persistCanvasState("autosave");
|
||||
}, [persistCanvasState]);
|
||||
|
||||
// 自动保存逻辑
|
||||
useEffect(() => {
|
||||
if (isLoading || isAgentMode || isAgentOperationRefreshActive()) return;
|
||||
|
||||
const saveTimer = setTimeout(async () => {
|
||||
setIsSaving(true);
|
||||
canvasAutosaveTimer.current = null;
|
||||
if (isAgentOperationRefreshActive()) return;
|
||||
try {
|
||||
await persistCanvasState("autosave");
|
||||
} catch (error) {
|
||||
console.error("Auto save failed:", error);
|
||||
}
|
||||
setIsSaving(false);
|
||||
}, 3000); // 3秒自动保存
|
||||
|
||||
return () => clearTimeout(saveTimer);
|
||||
canvasAutosaveTimer.current = saveTimer;
|
||||
return () => {
|
||||
clearTimeout(saveTimer);
|
||||
if (canvasAutosaveTimer.current === saveTimer) canvasAutosaveTimer.current = null;
|
||||
};
|
||||
}, [isAgentMode, isAgentOperationRefreshActive, nodes, isLoading, persistCanvasState]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -11077,7 +11103,7 @@ export default function CanvasEditorLegacyPage({
|
||||
onClose={() => setIsPanelOpen(false)}
|
||||
onOpenFileList={() => setPanelMode("fileList")}
|
||||
onRequireLogin={openLoginDialog}
|
||||
onBeforeAgentRequest={() => persistCanvasState("autosave")}
|
||||
onBeforeAgentRequest={persistCanvasBeforeAgent}
|
||||
selectedAgentPresetId={
|
||||
isCanvasAgentModeEnabled ? selectedAgentPresetId : null
|
||||
}
|
||||
|
||||
11
lib/client/canvas-save-queue.ts
Normal file
11
lib/client/canvas-save-queue.ts
Normal file
@ -0,0 +1,11 @@
|
||||
/** Serialize local saves so each request reads the revision returned by the last. */
|
||||
export function createCanvasSaveQueue() {
|
||||
let pending: Promise<unknown> = Promise.resolve();
|
||||
return {
|
||||
enqueue<T>(save: () => Promise<T>): Promise<T> {
|
||||
const next = pending.then(save, save);
|
||||
pending = next.catch(() => undefined);
|
||||
return next;
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
import { createCanvasSaveQueue } from "@/lib/client/canvas-save-queue";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
@ -41,3 +42,17 @@ test("completed execution details remain expandable in saved assistant history w
|
||||
expect(html).not.toContain("停止后续步骤");expect(html).not.toContain("<button");
|
||||
expect(html).not.toMatch(/<details[^>]+open/);
|
||||
});
|
||||
|
||||
test("pre-Agent flush and autosave read successive revisions instead of racing stale snapshots", async () => {
|
||||
const queue = createCanvasSaveQueue();let release!: () => void;let revision = 1;let title = "before";
|
||||
const gate = new Promise<void>(resolve => { release = resolve; });const sent: Array<{revision:number;title:string}> = [];
|
||||
const first = queue.enqueue(async () => { sent.push({revision,title});await gate;revision=2;return true; });
|
||||
const second = queue.enqueue(async () => { sent.push({revision,title});revision=3;return true; });
|
||||
await Promise.resolve();expect(sent).toEqual([{revision:1,title:"before"}]);title="latest local edit";release();await Promise.all([first,second]);
|
||||
expect(sent).toEqual([{revision:1,title:"before"},{revision:2,title:"latest local edit"}]);
|
||||
});
|
||||
test("a failed local save does not deadlock later saves or replay a failed snapshot", async () => {
|
||||
const queue = createCanvasSaveQueue();let calls=0;
|
||||
await expect(queue.enqueue(async()=>{calls++;throw new Error("revision conflict");})).rejects.toThrow("revision conflict");
|
||||
await expect(queue.enqueue(async()=>{calls++;return true;})).resolves.toBe(true);expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user