diff --git a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx index 8c9828a62..2069ce994 100644 --- a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx +++ b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx @@ -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 | null>(null); + if (!canvasSaveQueue.current) canvasSaveQueue.current = createCanvasSaveQueue(); + const canvasAutosaveTimer = useRef | 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 } diff --git a/lib/client/canvas-save-queue.ts b/lib/client/canvas-save-queue.ts new file mode 100644 index 000000000..87dbbe791 --- /dev/null +++ b/lib/client/canvas-save-queue.ts @@ -0,0 +1,11 @@ +/** Serialize local saves so each request reads the revision returned by the last. */ +export function createCanvasSaveQueue() { + let pending: Promise = Promise.resolve(); + return { + enqueue(save: () => Promise): Promise { + const next = pending.then(save, save); + pending = next.catch(() => undefined); + return next; + }, + }; +} diff --git a/tests/routes/canvas-agent-runtime-progress.test.tsx b/tests/routes/canvas-agent-runtime-progress.test.tsx index a042ddf74..dc79d291b 100644 --- a/tests/routes/canvas-agent-runtime-progress.test.tsx +++ b/tests/routes/canvas-agent-runtime-progress.test.tsx @@ -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("]+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(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); +});