fix(canvas): 画布同步 - 避免版本冲突循环并合并重复提示
This commit is contained in:
@@ -521,10 +521,14 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
|
||||
}).catch((error) => {
|
||||
autoSavedTaskIdsRef.current.delete(saveKey);
|
||||
if (error instanceof Error && error.name === "AbortError") return;
|
||||
message.warning(error instanceof Error ? `生成结果已保留,但项目资产同步失败:${error.message}` : "生成结果已保留,但项目资产同步失败");
|
||||
message.warning({
|
||||
key: `canvas-asset-sync:${projectId}`,
|
||||
content: error instanceof Error ? `生成结果已保留,但项目资产同步失败:${error.message}` : "生成结果已保留,但项目资产同步失败",
|
||||
duration: 4,
|
||||
});
|
||||
});
|
||||
});
|
||||
}, [domainProjectId, message, nodes, projectLoaded, saveGeneratedAsset]);
|
||||
}, [domainProjectId, message, nodes, projectId, projectLoaded, saveGeneratedAsset]);
|
||||
|
||||
return {
|
||||
applyGenerationTaskResult,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { lazy, Suspense, type ReactNode } from "react";
|
||||
import { createBrowserRouter, Navigate, Outlet } from "react-router";
|
||||
import { createBrowserRouter, Navigate, Outlet, useLocation } from "react-router";
|
||||
|
||||
import { RequireAuth } from "@/components/auth/require-auth";
|
||||
import { FullScreenLoader, WorkspaceRouteLoader } from "@/components/ui/aceternity/full-screen-loader";
|
||||
import { loadAssetsPage, loadCanvasPage, loadCanvasProjectPage, loadCreatePage, loadProjectDetailPage, loadProjectsPage, loadWalletPage } from "@/lib/workspace-route-modules";
|
||||
import { CanvasRefreshShell } from "@/pages/canvas/canvas-refresh-shell";
|
||||
import { AuthScene } from "@/pages/auth/auth-scene";
|
||||
import RouteErrorPage from "@/pages/route-error";
|
||||
|
||||
@@ -62,7 +63,10 @@ function fullScreenDeferred(element: ReactNode) {
|
||||
}
|
||||
|
||||
function AuthenticatedWorkspaceLayout() {
|
||||
return <RequireAuth>{fullScreenDeferred(<UserLayout><Outlet /></UserLayout>)}</RequireAuth>;
|
||||
const { pathname } = useLocation();
|
||||
const isCanvasProjectRoute = pathname.startsWith("/canvas/");
|
||||
const fallback = isCanvasProjectRoute ? <CanvasRefreshShell /> : <FullScreenLoader label="正在打开创作空间" detail="准备当前页面" />;
|
||||
return <RequireAuth><Suspense fallback={fallback}><UserLayout><Outlet /></UserLayout></Suspense></RequireAuth>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -177,7 +181,7 @@ export const router = createBrowserRouter([
|
||||
),
|
||||
},
|
||||
{ path: "/canvas", element: <RequireAuth>{deferred(<CanvasPage />)}</RequireAuth> },
|
||||
{ path: "/canvas/:id", element: <RequireAuth>{deferred(<CanvasProjectPage />)}</RequireAuth> },
|
||||
{ path: "/canvas/:id", element: <RequireAuth><CanvasProjectPage /></RequireAuth> },
|
||||
{
|
||||
path: "/admin",
|
||||
element: <RequireAuth>{deferred(<AdminPage />)}</RequireAuth>,
|
||||
|
||||
@@ -29,6 +29,7 @@ let incrementalSession = false;
|
||||
let sessionEpoch = 0;
|
||||
const verifiedProjects = new Set<string>();
|
||||
const verifiedAssets = new Set<string>();
|
||||
const remoteProjectLoadPromises = new Map<string, Promise<CanvasProject | undefined>>();
|
||||
|
||||
export async function initializeRemoteUserDataSession(userId: string) {
|
||||
await withRemoteUserDataSyncExclusive(async () => {
|
||||
@@ -42,8 +43,10 @@ export async function initializeRemoteUserDataSession(userId: string) {
|
||||
}
|
||||
|
||||
export async function loadCanvasProjectForEditing(id: string) {
|
||||
const pending = remoteProjectLoadPromises.get(id);
|
||||
if (pending) return pending;
|
||||
const epoch = sessionEpoch;
|
||||
return withRemoteUserDataSyncExclusive(async () => {
|
||||
const request = withRemoteUserDataSyncExclusive(async () => {
|
||||
if (epoch !== sessionEpoch) throw new Error("账号已切换,请重新打开画布");
|
||||
const local = useCanvasStore.getState().projects.find((project) => project.id === id);
|
||||
if (!activeRemoteUserId || verifiedProjects.has(id)) return local;
|
||||
@@ -51,8 +54,10 @@ export async function loadCanvasProjectForEditing(id: string) {
|
||||
await loadReferencedAssets(collectAssetIds(project));
|
||||
const current = useCanvasStore.getState().projects.find((candidate) => candidate.id === id);
|
||||
if (current && !sameEntitySnapshot(acknowledgedProjects.get(id), current)) {
|
||||
const baseline = acknowledgedProjects.get(id);
|
||||
if (baseline && Date.parse(baseline.updatedAt) !== Date.parse(project.updatedAt)) throw new Error("画布已在其他端修改,请先导出本地修改再重新加载");
|
||||
// 当前页面已经开始编辑本地缓存时,远端详情只建立新的冲突基线;
|
||||
// 保留当前画布内容,后续保存由当前打开的画布显式覆盖远端版本,
|
||||
// 避免旧缓存被永久卡在“自动重试但永远冲突”的状态。
|
||||
acknowledgedProjects.set(id, project);
|
||||
verifiedProjects.add(id);
|
||||
return current;
|
||||
}
|
||||
@@ -61,6 +66,17 @@ export async function loadCanvasProjectForEditing(id: string) {
|
||||
useCanvasStore.setState((state) => ({ projects: [...state.projects.filter((candidate) => candidate.id !== id), project] }));
|
||||
return project;
|
||||
});
|
||||
remoteProjectLoadPromises.set(id, request);
|
||||
const clearPending = () => {
|
||||
if (remoteProjectLoadPromises.get(id) === request) remoteProjectLoadPromises.delete(id);
|
||||
};
|
||||
void request.then(clearPending, clearPending);
|
||||
return request;
|
||||
}
|
||||
|
||||
async function waitForRemoteProjectLoads() {
|
||||
const pending = [...remoteProjectLoadPromises.values()];
|
||||
if (pending.length) await Promise.all(pending);
|
||||
}
|
||||
|
||||
export async function loadAssetLibraryPage(options: Parameters<typeof listRemoteAssetsPage>[0]) {
|
||||
@@ -169,6 +185,7 @@ export function resetRemoteUserDataSync() {
|
||||
incrementalSession = false;
|
||||
verifiedProjects.clear();
|
||||
verifiedAssets.clear();
|
||||
remoteProjectLoadPromises.clear();
|
||||
activeRemoteUserId = "";
|
||||
remoteUserDataPhase = "inactive";
|
||||
acknowledgedAssets.clear();
|
||||
@@ -376,6 +393,8 @@ export async function saveRemoteUserDataNow() {
|
||||
const epoch = sessionEpoch;
|
||||
if (!activeRemoteUserId) return;
|
||||
requireRemoteUserDataBaseline();
|
||||
// 画布先用本地缓存秒开时,远端详情校验可能仍在进行;写入必须等待校验结果。
|
||||
await waitForRemoteProjectLoads();
|
||||
if (syncPromise) {
|
||||
syncQueued = true;
|
||||
return syncPromise;
|
||||
@@ -416,7 +435,11 @@ async function saveRemoteUserDataBatch(uploaded: Map<string, string>) {
|
||||
const baseline = acknowledgedProjects.get(source.id);
|
||||
if (!baseline || verifiedProjects.has(source.id)) continue;
|
||||
const { project } = await getRemoteCanvasProject(source.id);
|
||||
if (Date.parse(project.updatedAt) !== Date.parse(baseline.updatedAt)) throw new Error("画布远端版本已变化,已停止覆盖,请重新打开画布");
|
||||
if (Date.parse(project.updatedAt) !== Date.parse(baseline.updatedAt)) {
|
||||
// 以远端当前版本作为新的校验基线,继续提交当前打开画布的完整快照。
|
||||
// 这是画布编辑态的显式覆盖策略,避免资产同步被旧缓存冲突永久阻塞。
|
||||
acknowledgedProjects.set(source.id, project);
|
||||
}
|
||||
verifiedProjects.add(source.id);
|
||||
}
|
||||
for (const source of dirtyAssets) {
|
||||
|
||||
@@ -4451,6 +4451,78 @@ test("incremental sessions leave cached entities untouched and fetch only the op
|
||||
}
|
||||
});
|
||||
|
||||
test("concurrent canvas opens share one remote detail request", async () => {
|
||||
const originalWindow = (globalThis as { window?: unknown }).window;
|
||||
const previousAdapter = apiClient.defaults.adapter;
|
||||
const previousProjects = useCanvasStore.getState().projects;
|
||||
const requests: string[] = [];
|
||||
const project = storedCanvasProject("concurrent-open", "Concurrent");
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { setTimeout: () => 1, clearTimeout: () => undefined, localStorage: { getItem: () => null, setItem: () => undefined, removeItem: () => undefined } },
|
||||
});
|
||||
apiClient.defaults.adapter = async (config) => {
|
||||
requests.push(`${config.method} ${config.url}`);
|
||||
throw new Error("detail unavailable");
|
||||
};
|
||||
try {
|
||||
resetRemoteUserDataSync();
|
||||
useCanvasStore.setState({ projects: [project] });
|
||||
await initializeRemoteUserDataSession("concurrent-open-owner");
|
||||
const first = loadCanvasProjectForEditing(project.id).catch((error: unknown) => error);
|
||||
const second = loadCanvasProjectForEditing(project.id).catch((error: unknown) => error);
|
||||
const firstError = await first;
|
||||
const secondError = await second;
|
||||
expect(firstError).toBeInstanceOf(Error);
|
||||
expect(secondError).toBeInstanceOf(Error);
|
||||
expect((firstError as Error).message).toContain("detail unavailable");
|
||||
expect((secondError as Error).message).toContain("detail unavailable");
|
||||
expect(requests).toEqual(["get /canvas-projects/concurrent-open"]);
|
||||
} finally {
|
||||
resetRemoteUserDataSync();
|
||||
useCanvasStore.setState({ projects: previousProjects });
|
||||
apiClient.defaults.adapter = previousAdapter;
|
||||
if (originalWindow === undefined) delete (globalThis as { window?: unknown }).window;
|
||||
else Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow });
|
||||
}
|
||||
});
|
||||
|
||||
test("remote canvas conflict refreshes the baseline and overwrites once without repeating detail reads", async () => {
|
||||
const originalWindow = (globalThis as { window?: unknown }).window;
|
||||
const previousAdapter = apiClient.defaults.adapter;
|
||||
const previousProjects = useCanvasStore.getState().projects;
|
||||
const requests: string[] = [];
|
||||
const cached = storedCanvasProject("latched-conflict", "Cached");
|
||||
const remote = { ...cached, updatedAt: "2026-09-11T00:00:00.000Z" };
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { setTimeout: () => 1, clearTimeout: () => undefined, localStorage: { getItem: () => null, setItem: () => undefined, removeItem: () => undefined } },
|
||||
});
|
||||
apiClient.defaults.adapter = async (config) => {
|
||||
requests.push(`${config.method} ${config.url}`);
|
||||
const project = config.method === "put" ? { ...remote, ...cached, title: "Edited locally" } : remote;
|
||||
return { data: { code: 0, data: { project }, msg: "" }, status: 200, statusText: "OK", headers: {}, config };
|
||||
};
|
||||
try {
|
||||
resetRemoteUserDataSync();
|
||||
useCanvasStore.setState({ projects: [cached] });
|
||||
await initializeRemoteUserDataSession("latched-conflict-owner");
|
||||
useCanvasStore.getState().renameProject(cached.id, "Edited locally");
|
||||
await expect(saveRemoteUserDataNow()).resolves.toBeUndefined();
|
||||
await expect(saveRemoteUserDataNow()).resolves.toBeUndefined();
|
||||
expect(requests).toEqual([
|
||||
"get /canvas-projects/latched-conflict",
|
||||
"put /canvas-projects/latched-conflict",
|
||||
]);
|
||||
} finally {
|
||||
resetRemoteUserDataSync();
|
||||
useCanvasStore.setState({ projects: previousProjects });
|
||||
apiClient.defaults.adapter = previousAdapter;
|
||||
if (originalWindow === undefined) delete (globalThis as { window?: unknown }).window;
|
||||
else Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow });
|
||||
}
|
||||
});
|
||||
|
||||
test("a remote asset page rejects incomplete records instead of patching them into the store", async () => {
|
||||
const previousAdapter = apiClient.defaults.adapter;
|
||||
const previousAssets = useAssetStore.getState().assets;
|
||||
|
||||
Reference in New Issue
Block a user