修复UI编辑器预览作用域清理
- 在资源切换和卸载时取消原生图片预览作用域 - 增加作用域切换、卸载和加载前清理回归测试 - 更新UI编辑器预览生命周期技术文档 Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
@@ -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` 结果;未返回的节点状态不变。
|
||||
|
||||
Reference in New Issue
Block a user