Merge pull request '加载画布内容后同步版本并保留本地编辑' (#43) from codex/canvas-read-revision-20261105 into main
This commit is contained in:
commit
c60585aeca
@ -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();
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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"
|
||||
);
|
||||
|
||||
@ -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<Response>(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<Response>(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")) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user