diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts index e4094c7fb..da5894660 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts @@ -42,6 +42,7 @@ import { useUiEditorState, } from '../../features/ui-editor/useUiEditorState'; import { + cancelLocalProjectResourcePreviewScope, createProjectResourcePreviewRequestId, createProjectResourcePreviewScopeId, } from '../../services/projectResourcePreviewTransport'; @@ -242,6 +243,7 @@ export function useUiEditorSession( return; } let cancelled = false; + let previewScopeId: string | null = null; setIsLoading(true); setLoadError(null); setPersistedRevision(null); @@ -263,7 +265,8 @@ export function useUiEditorSession( setSavedStateSignature(JSON.stringify(state)); setSaveError(null); if (projectPath) { - const scopeId = createProjectResourcePreviewScopeId(); + previewScopeId = createProjectResourcePreviewScopeId(); + const scopeId = previewScopeId; const resources = [ ...Object.entries(state.ui_design_images).map(([id, image]) => ({ id, @@ -320,6 +323,9 @@ export function useUiEditorSession( }); return () => { cancelled = true; + if (previewScopeId) { + cancelLocalProjectResourcePreviewScope(previewScopeId); + } }; }, [projectPath, replaceEditorState, resourceId, stateStore]); diff --git a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts index 832dbc3ca..2bfe26d0f 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts +++ b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts @@ -145,6 +145,93 @@ async function renderLoadedSession(state: State) { } describe('UiEditorPage', () => { + it('cancels image preview scopes when the resource changes and on unmount', async () => { + vi.mocked(invoke) + .mockReset() + .mockImplementation(async (command) => { + if (command === 'read_local_project_image_preview') { + return { dataUrl: 'data:image/png;base64,cHJldmlldw==' }; + } + return undefined; + }); + Object.defineProperty(window, '__TAURI__', { + configurable: true, + value: { core: { invoke } }, + }); + const stateStore: UiDesignStateStore = { + load: vi.fn().mockResolvedValue({ + revision: 0, + state: stateWithPages(['page']), + }), + save: vi.fn(), + }; + const hook = renderHook( + ({ resourceId }) => + useUiEditorSession('/tmp/ui-editor', resourceId, stateStore), + { initialProps: { resourceId: 'ui-resource-a' } }, + ); + + await waitFor(() => + expect(invoke).toHaveBeenCalledWith( + 'read_local_project_image_preview', + expect.objectContaining({ scopeId: expect.any(String) }), + ), + ); + const firstScopeId = vi + .mocked(invoke) + .mock.calls.find( + ([command]) => command === 'read_local_project_image_preview', + )?.[1]?.scopeId; + + hook.rerender({ resourceId: 'ui-resource-b' }); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith( + 'cancel_local_project_resource_preview_scope', + { scopeId: firstScopeId }, + ), + ); + await waitFor(() => + expect( + vi + .mocked(invoke) + .mock.calls.filter( + ([command]) => command === 'read_local_project_image_preview', + ), + ).toHaveLength(4), + ); + const secondScopeId = vi + .mocked(invoke) + .mock.calls.filter( + ([command]) => command === 'read_local_project_image_preview', + ) + .at(-1)?.[1]?.scopeId; + + hook.unmount(); + + expect(invoke).toHaveBeenCalledWith( + 'cancel_local_project_resource_preview_scope', + { scopeId: secondScopeId }, + ); + }); + + it('does not cancel a preview scope before state loading creates one', () => { + vi.mocked(invoke).mockClear(); + const stateStore: UiDesignStateStore = { + load: vi.fn(() => new Promise(() => undefined)), + save: vi.fn(), + }; + const hook = renderHook(() => + useUiEditorSession('/tmp/ui-editor', 'ui-resource', stateStore), + ); + + hook.unmount(); + + expect(invoke).not.toHaveBeenCalledWith( + 'cancel_local_project_resource_preview_scope', + expect.anything(), + ); + }); + it('uses the durable Tauri store for a project UI resource', async () => { vi.mocked(invoke) .mockResolvedValueOnce({ diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 60470067e..26a608321 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1171,6 +1171,10 @@ game-project/ - UI State 原子安装保留最近一个可解析、canonical 的 `.previous` 恢复候选,作为最佳努力恢复来源;写入主文件前不把完整 State 语义校验重复执行一遍。主文件损坏时,恢复候选仍必须通过同一严格 schema、project/asset identity、revision、引用和 State 校验后才能安装;恢复安装与保存共用项目写锁,并在持锁后重新读取主文件,已有并发保存的有效新版本时直接返回而不安装旧副本。任一候选均不可信则停在加载错误,前端禁编辑和保存。新建 UI 资源先登记并安装合法 envelope,任一步失败补偿 manifest/文件,避免把空 JSON 留给资源卡。 - 图片路径只需是安全项目相对路径,不要求外部图片仍存在或已登记为 manifest asset;缺失媒体只导致 preview 占位。`imageOrder`、当前选择、缩放、面板开关和 preview URL 不写入 State,加载后由 State 派生。保存冻结提交快照,保存期间的新编辑继续保持 dirty;AI state lock 和加载期间禁保存。 +## 2026-08-20 UI Editor 本地图片预览作用域处置 + +- UI Editor 每次成功加载 State 后为当前本地图片预览批次创建独立 `scopeId`。切换项目、切换 UI 资源或卸载时,清理逻辑必须先失效前端 ownership fence,再调用 `cancel_local_project_resource_preview_scope` 取消该批次已排队或在途的原生读取;加载完成前尚未创建 scope 时不得发出空取消。旧 scope 的迟到结果不得更新当前 preview URL,回归测试同时覆盖资源切换与卸载。 + ## 2026-08-18 UI Editor 新建节点组件状态 - 所有非组件绑定流程创建的 UI Editor 节点,`components_status` 与 `layout_status` 一致初始化为 `NoProblem`,覆盖前端新建页面根 / 人工节点,以及 Rust 结构识别与合并产生的节点。组件绑定命令仍只对 LLM 显式返回的节点写入其 `NoProblem` 或 `NeedReview` 结果;未返回的节点状态不变。