导出 hook 换项目时整套重置并丢弃过时结果

view/project-development/export/state/useXhsMinitoolExport.ts:新增 projectPath 重置效果,把表单/判据/冲突/产物状态与全部本地基线清回初值;读内容、取指纹、保存、构建各处在 await 回来后用 projectPathRef 校验结果仍属于当前项目,过时就丢弃,不再写回新项目。
tests/xhsMinitoolExport.test.tsx:补换项目后表单与产物判据清空、并读回新项目内容的用例。
This commit is contained in:
2026-10-06 22:04:07 +08:00
parent 26120af5ac
commit 90da09f961
2 changed files with 103 additions and 4 deletions
@@ -134,6 +134,11 @@ export function useXhsMinitoolExport({
/** 上一次抛进错误池的故障指纹:同一个只抛一次,见 [useXhsMinitoolExport.throwToErrorPool]。 */
const reportedFailureRef = useRef('');
const formRef = useRef<XHSMiniToolExportForm | null>(null);
/**
* 当前项目路径的即时值。异步结果(读内容、取指纹、保存、构建)回来时用它判断结果还属不属于
* 当前项目:面板根跨项目不卸载,旧请求回来时绝不能把上一个项目的表单 / 基线写进新项目。
*/
const projectPathRef = useRef(projectPath);
useEffect(() => {
formRef.current = form;
@@ -143,6 +148,36 @@ export function useXhsMinitoolExport({
adoptableRef.current = !dirty && !conflict && !fieldError;
}, [dirty, conflict, fieldError]);
/**
* 换项目时把整套状态与本地判据清回初值。
*
* 面板根跨项目不卸载,而这个 hook 的状态默认都属于「当前项目」:不清就会拿 A 的表单、写回基线
* 与产物判据去操作 B。清完之后,下面依赖 `projectPath` 的轮询效果会立刻为新项目重读一次。
*
* `initialReadSettledRef` 必须一起复位:它决定 `loading` 与首次采纳,留着 true 会让新项目的
* 第一次读取被当成「非首次轮询」而直接早返回,旧表单就会继续留在界面上。
*/
useEffect(() => {
projectPathRef.current = projectPath;
setForm(null);
setHasScript(false);
setFieldError(null);
setConflict(null);
setFailure(null);
setDismissedFailure('');
setLoading(false);
setReadSettled(false);
setRunning(false);
setArtifactReady(false);
setDirty(false);
setAdaptPromptDismissed(false);
baselineRef.current = '';
hashPendingRef.current = false;
initialReadSettledRef.current = false;
reportedFailureRef.current = '';
formRef.current = null;
}, [projectPath]);
// 脚本出现之后就把提示重新武装:它要是又被删掉,用户该再看到一次阻断提示。
useEffect(() => {
if (hasScript) setAdaptPromptDismissed(false);
@@ -192,6 +227,8 @@ export function useXhsMinitoolExport({
invoke,
projectPath,
);
// 读回来之前换了项目:这份内容属于上一个项目,不能写进新项目的状态。
if (projectPathRef.current !== projectPath) return null;
setHasScript(content.hasScript);
// 内容读回来了,说明宿主与注册表现在读得动:读来源的失败(注册表坏了 / 宿主不可用)就地
// 清掉,别让弹窗卡在已经好了的现场上。用函数式更新拿最新 `failure`,既不把 `failure` 加进
@@ -221,19 +258,25 @@ export function useXhsMinitoolExport({
setConflict(null);
setFailure(null);
try {
baselineRef.current = await readXhsMinitoolExportHash(
const contentHash = await readXhsMinitoolExportHash(
invoke,
projectPath,
);
// 基线属于项目:换过项目就不能拿这份指纹去当新项目的写回基线。
if (projectPathRef.current !== projectPath) return null;
baselineRef.current = contentHash;
hashPendingRef.current = false;
} catch (error) {
// 指纹没取到:表单已经采纳、基线却还停在旧值。标成 pending,下一次轮询跳过上面的早返回
// 把指纹补上;失败本身照旧交给外层 catch 报给用户。
if (projectPathRef.current !== projectPath) return null;
hashPendingRef.current = true;
throw error;
}
return content.hasScript;
} catch (error) {
// 旧项目的失败不该记到新项目头上(也不该为新项目弹卡片)。
if (projectPathRef.current !== projectPath) return null;
const structured = asXhsMinitoolReadFailure(error);
if (!structured) {
// 认不出形状的拒绝(Tauri 传输失败 / 命令 panic / 参数序列化失败):现场原样抛给错误池
@@ -249,7 +292,8 @@ export function useXhsMinitoolExport({
setFailure(structured);
return null;
} finally {
if (isInitialRead) {
// 旧项目的收尾不翻新项目的「首次读取」判据:新项目那次 refresh 会自己收尾。
if (isInitialRead && projectPathRef.current === projectPath) {
initialReadSettledRef.current = true;
setLoading(false);
setReadSettled(true);
@@ -281,12 +325,15 @@ export function useXhsMinitoolExport({
const invoke = resolveTauriInvoke();
if (!invoke) return false;
try {
baselineRef.current = await saveXhsMinitoolExportForm(invoke, {
const nextBaseline = await saveXhsMinitoolExportForm(invoke, {
projectPath,
form: next,
baseHash,
});
// 保存期间换了项目:这份返回值属于上一个项目,不能拿来当新项目的写回基线。
if (projectPathRef.current !== projectPath) return false;
// 保存命令的返回值就是当前表单的新指纹:这份表单的基线已经配套,不必再补读。
baselineRef.current = nextBaseline;
hashPendingRef.current = false;
setFieldError(null);
setConflict(null);
@@ -294,6 +341,8 @@ export function useXhsMinitoolExport({
setDirty(false);
return true;
} catch (error) {
// 旧项目的失败不该记到新项目头上。
if (projectPathRef.current !== projectPath) return false;
// 三种结果都要停下重试,等用户或下一次输入推着走:字段不合法等用户改、冲突等用户选、
// 其余按 typed 失败给建议。共同点是 `dirty` 归零,避免同一份输入反复打宿主。
setDirty(false);
@@ -358,8 +407,12 @@ export function useXhsMinitoolExport({
setFailure(null);
try {
await runXhsMinitoolExportBuild(invoke, projectPath);
// 构建期间换了项目:这次产物属于上一个项目,不能让新项目出现可下载的按钮。
if (projectPathRef.current !== projectPath) return;
setArtifactReady(true);
} catch (error) {
// 旧项目的构建失败不该记到新项目头上。
if (projectPathRef.current !== projectPath) return;
setArtifactReady(false);
const structured = asXhsMinitoolBuildFailure(error);
if (!structured) {
@@ -393,6 +446,8 @@ export function useXhsMinitoolExport({
setRunning(true);
try {
const hasScriptNow = await refresh();
// 读期间换了项目:这次「有没有脚本」的结论属于上一个项目。
if (projectPathRef.current !== projectPath) return;
if (hasScriptNow !== true) {
// 弹窗重新武装起来就是给用户的交代,状态行不再复述一遍。
setAdaptPromptDismissed(false);
@@ -407,7 +462,8 @@ export function useXhsMinitoolExport({
if (conflict || fieldError) return;
await buildOnce();
} finally {
setRunning(false);
// 旧项目那一次打包的收尾不解新项目的锁。
if (projectPathRef.current === projectPath) setRunning(false);
}
}, [
projectPath,
@@ -798,6 +798,49 @@ describe('状态 hook', () => {
expect(result.current.form).toEqual(FORM);
});
it('换项目时清掉上一份表单与产物判据,并读回新项目的内容', async () => {
const OTHER_PATH = '/tmp/export-panel-project-2';
const OTHER_FORM = {
name: '另一个小工具',
introduction: '另一个简介',
iconPath: 'assets/other.png',
};
const invoke = vi.fn(
async (command: string, args?: { projectPath?: string }) => {
if (command === 'read_xhs_minitool_export') {
return args?.projectPath === PROJECT_PATH
? { form: FORM, hasScript: true }
: { form: OTHER_FORM, hasScript: false };
}
if (command === 'read_xhs_minitool_export_hash') return CONTENT_HASH;
return { outputTail: 'zip 已生成', omittedCharacters: 0 };
},
);
installTauriRuntime({ core: { invoke } });
const { result, rerender } = renderHook(
({ projectPath }: { projectPath: string }) =>
useXhsMinitoolExport({
enabled: true,
projectPath,
submitTurn: vi.fn(async () => true),
}),
{ initialProps: { projectPath: PROJECT_PATH } },
);
await waitFor(() => expect(result.current.form).toEqual(FORM));
await act(async () => {
await result.current.packageAll();
});
expect(result.current.artifactReady).toBe(true);
rerender({ projectPath: OTHER_PATH });
// 新项目第一次读取落定之前,界面不能还显示上一个项目的表单与可下载产物。
expect(result.current.form).toBeNull();
expect(result.current.artifactReady).toBe(false);
await waitFor(() => expect(result.current.form).toEqual(OTHER_FORM));
expect(result.current.hasScript).toBe(false);
});
it('打包成功才有下载入口,失败会收回去', async () => {
let fail = false;
const invoke = vi.fn(async (command: string) => {