diff --git a/web/src/pages/canvas/use-canvas-generation.ts b/web/src/pages/canvas/use-canvas-generation.ts
index 0bd146b0d..515abe920 100644
--- a/web/src/pages/canvas/use-canvas-generation.ts
+++ b/web/src/pages/canvas/use-canvas-generation.ts
@@ -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,
diff --git a/web/src/router.tsx b/web/src/router.tsx
index 90660bde9..3bd0395fc 100644
--- a/web/src/router.tsx
+++ b/web/src/router.tsx
@@ -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 {fullScreenDeferred()};
+ const { pathname } = useLocation();
+ const isCanvasProjectRoute = pathname.startsWith("/canvas/");
+ const fallback = isCanvasProjectRoute ? : ;
+ return ;
}
/**
@@ -177,7 +181,7 @@ export const router = createBrowserRouter([
),
},
{ path: "/canvas", element: {deferred()} },
- { path: "/canvas/:id", element: {deferred()} },
+ { path: "/canvas/:id", element: },
{
path: "/admin",
element: {deferred()},
diff --git a/web/src/services/user-data-sync.ts b/web/src/services/user-data-sync.ts
index 5d7821eee..9b03fc3b2 100644
--- a/web/src/services/user-data-sync.ts
+++ b/web/src/services/user-data-sync.ts
@@ -29,6 +29,7 @@ let incrementalSession = false;
let sessionEpoch = 0;
const verifiedProjects = new Set();
const verifiedAssets = new Set();
+const remoteProjectLoadPromises = new Map>();
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[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) {
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) {
diff --git a/web/test/user-scoped-generation-persistence.test.ts b/web/test/user-scoped-generation-persistence.test.ts
index 384b3ac7f..7224acca8 100644
--- a/web/test/user-scoped-generation-persistence.test.ts
+++ b/web/test/user-scoped-generation-persistence.test.ts
@@ -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;