修复UI编辑器预览作用域清理

- 在资源切换和卸载时取消原生图片预览作用域
- 增加作用域切换、卸载和加载前清理回归测试
- 更新UI编辑器预览生命周期技术文档

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
2026-08-20 14:29:15 +08:00
parent 6ba55cbaa6
commit edfa7d8ac2
3 changed files with 98 additions and 1 deletions
@@ -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]);
@@ -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({
@@ -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` 结果;未返回的节点状态不变。