diff --git a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx index c464da785..d83c30f8c 100644 --- a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx +++ b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx @@ -5215,8 +5215,13 @@ export default function CanvasEditorLegacyPage({ return; } + const beforeRead = useCanvasStore.getState(); const result = await canvasService.getProject(id); - if (cancelled || canvasRemoteOperationActive.current || !result.success || !result.data) return; + const current = useCanvasStore.getState(); + if ( + cancelled || canvasRemoteOperationActive.current || !result.success || !result.data || + current.projectId !== id || current.nodes !== beforeRead.nodes || current.edges !== beforeRead.edges + ) return; useCanvasStore.getState().markAgentOperationRefresh(); hydrateCanvasProject(result.data); lastLocalCanvasMutationAtRef.current = Date.now(); diff --git a/app/canvas/[id]/style-pro/page.tsx b/app/canvas/[id]/style-pro/page.tsx index 0ba03f4aa..2e91c6521 100644 --- a/app/canvas/[id]/style-pro/page.tsx +++ b/app/canvas/[id]/style-pro/page.tsx @@ -48,8 +48,13 @@ export default function CanvasStyleProPage() { const canvasData = result.data.canvasData; const latestState = useCanvasStore.getState(); setProject(result.data.id, result.data.title); - if (latestState.nodes.length === 0 && latestState.edges.length === 0) { + if ( + latestState.projectId !== id || + (latestState.nodes.length === 0 && latestState.edges.length === 0) + ) { loadCanvas(canvasData); + const revision = String(result.data.updatedAt || ""); + useCanvasStore.getState().setCanvasRevision(revision, revision); return; } diff --git a/services/canvas/index.ts b/services/canvas/index.ts index 597f7c623..8fd455d9d 100644 --- a/services/canvas/index.ts +++ b/services/canvas/index.ts @@ -95,18 +95,19 @@ function normalizeRevisionText(value: unknown) { return String(value || "").trim(); } -function syncCanvasStoreRevisionFromUpdatedAt(updatedAt: unknown): void { +function syncCanvasStoreRevisionFromUpdatedAt(projectId: string, updatedAt: unknown): void { if (typeof window === "undefined") return; const revision = normalizeRevisionText(updatedAt); if (!revision) return; - useCanvasStore.getState().setCanvasRevision(revision, revision); + const current = useCanvasStore.getState(); + if (current.projectId === projectId) current.setCanvasRevision(revision, revision); } function syncCanvasStoreRevisionFromProject( - project?: { canvasData?: CanvasState | null; updatedAt?: Date | string } | null + project?: { id: string; canvasData?: CanvasState | null; updatedAt?: Date | string } | null ): void { if (!project?.canvasData) return; - syncCanvasStoreRevisionFromUpdatedAt(project.updatedAt); + syncCanvasStoreRevisionFromUpdatedAt(project.id, project.updatedAt); } export interface NormalizedCropRect { @@ -203,9 +204,6 @@ export const canvasService = { try { const response = await fetch(`/api/canvas/${id}`); const data = await response.json(); - if (data?.success) { - syncCanvasStoreRevisionFromProject(data.data); - } return data; } catch (error) { return { success: false, error: '获取画布失败' }; @@ -335,7 +333,7 @@ export const canvasService = { }; } if (data?.success) { - syncCanvasStoreRevisionFromUpdatedAt(data?.data?.updatedAt); + syncCanvasStoreRevisionFromUpdatedAt(id, data?.data?.updatedAt); } return data as ApiResponse; } catch (error) { diff --git a/tests/routes/canvas-chat-helpers.test.ts b/tests/routes/canvas-chat-helpers.test.ts index b874a2f38..cc1fa25e0 100644 --- a/tests/routes/canvas-chat-helpers.test.ts +++ b/tests/routes/canvas-chat-helpers.test.ts @@ -808,6 +808,7 @@ test.describe("canvas chat pure helpers", () => { }); test("canvas service save uses store revision fallback when options are omitted", async () => { + useCanvasStore.getState().setProject("11111111-1111-4111-8111-111111111111"); const canvasData = { nodes: [], edges: [], @@ -885,7 +886,7 @@ test.describe("canvas chat pure helpers", () => { }); }); - test("canvas service keeps store revision aligned after project load and save", async () => { + test("canvas revision advances after applying the fetched snapshot and saving the current project", async () => { const canvasData = { nodes: [], edges: [], @@ -915,6 +916,7 @@ test.describe("canvas chat pure helpers", () => { }, ]; + useCanvasStore.getState().setProject("11111111-1111-4111-8111-111111111111"); useCanvasStore.getState().setCanvasRevision(null, null); Object.defineProperty(globalThis, "window", { configurable: true, @@ -929,7 +931,11 @@ test.describe("canvas chat pure helpers", () => { }) as Response) as typeof fetch; try { - await canvasService.getProject("11111111-1111-4111-8111-111111111111"); + const result = await canvasService.getProject("11111111-1111-4111-8111-111111111111"); + expect(useCanvasStore.getState().canvasRevision).toBeNull(); + useCanvasStore.getState().loadCanvas(result.data!.canvasData); + const revision = String(result.data!.updatedAt); + useCanvasStore.getState().setCanvasRevision(revision, revision); expect(useCanvasStore.getState().canvasRevision).toBe( "2026-06-22T00:01:00.000Z" ); diff --git a/tests/routes/canvas-toolbar-capability.test.ts b/tests/routes/canvas-toolbar-capability.test.ts index 6622813c3..38d49dbd0 100644 --- a/tests/routes/canvas-toolbar-capability.test.ts +++ b/tests/routes/canvas-toolbar-capability.test.ts @@ -136,6 +136,71 @@ test("metadata changes never adopt an unseen remote canvas revision", async () = expect(useCanvasStore.getState().canvasRevision).toBe("r1"); }); +test("reading a result without applying it cannot authorize an old snapshot to overwrite the result", async () => { + const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window"); + Object.defineProperty(globalThis, "window", { configurable: true, value: {} }); + let remoteNodes = [node("source"), node("result")]; + globalThis.fetch = (async (_url, options) => { + if (options?.method === "PUT") { + const body = JSON.parse(String(options.body)); + if (body.baseCanvasRevision !== "r2") return Response.json({ success: false, error: "画布已更新" }, { status: 409 }); + remoteNodes = body.canvasData.nodes; + return Response.json({ success: true, data: { updatedAt: "r3" } }); + } + return Response.json({ success: true, data: { id: "project-a", updatedAt: "r2", canvasData: { nodes: remoteNodes, edges: [] } } }); + }) as typeof fetch; + try { + await canvasService.getProject("project-a"); + const saved = await canvasService.saveCanvasState("project-a", useCanvasStore.getState().getCanvasState()); + expect(saved).toMatchObject({ success: false, status: 409 }); + expect(remoteNodes.map(n => n.id)).toEqual(["source", "result"]); + expect(useCanvasStore.getState().canvasRevision).toBe("r1"); + } finally { + if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow); + else Reflect.deleteProperty(globalThis, "window"); + } +}); + +test("a delayed read cannot change the revision of a newly selected project", async () => { + const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window"); + Object.defineProperty(globalThis, "window", { configurable: true, value: {} }); + let complete!: (response: Response) => void; + globalThis.fetch = (() => new Promise(resolve => { complete = resolve; })) as typeof fetch; + try { + const pending = canvasService.getProject("project-a"); + useCanvasStore.getState().setProject("project-b"); + useCanvasStore.getState().loadCanvas({ nodes: [node("other") as any], edges: [] }); + useCanvasStore.getState().setCanvasRevision("r9", "r9"); + complete(Response.json({ success: true, data: { id: "project-a", updatedAt: "r2", canvasData: { nodes: [node("result")], edges: [] } } })); + await pending; + expect(useCanvasStore.getState().canvasRevision).toBe("r9"); + expect(useCanvasStore.getState().nodes.map(n => n.id)).toEqual(["other"]); + } finally { + if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow); + else Reflect.deleteProperty(globalThis, "window"); + } +}); + +test("a delayed save cannot change the revision of a newly selected project", async () => { + const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window"); + Object.defineProperty(globalThis, "window", { configurable: true, value: {} }); + let complete!: (response: Response) => void; + globalThis.fetch = (() => new Promise(resolve => { complete = resolve; })) as typeof fetch; + try { + const pending = canvasService.saveCanvasState("project-a", useCanvasStore.getState().getCanvasState()); + useCanvasStore.getState().setProject("project-b"); + useCanvasStore.getState().loadCanvas({ nodes: [node("other") as any], edges: [] }); + useCanvasStore.getState().setCanvasRevision("r9", "r9"); + complete(Response.json({ success: true, data: { updatedAt: "r2" } })); + await pending; + expect(useCanvasStore.getState().canvasRevision).toBe("r9"); + expect(useCanvasStore.getState().nodes.map(n => n.id)).toEqual(["other"]); + } finally { + if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow); + else Reflect.deleteProperty(globalThis, "window"); + } +}); + test("a result merge conflict retains both the local edit and its original revision", async () => { globalThis.fetch = (async (url) => { if (String(url).endsWith("/capabilities")) {