修掉导出重读里指纹丢失与读失败不清理两个洞

- useXhsMinitoolExport 加 hashPendingRef:指纹读失败后表单已换、基线还欠着,下一次重读跳过早返回把配套指纹补上,不再把旧 baseHash 留给新表单
- 保存命令的返回值直接落定基线并清掉 pending,不必再补读
- 每次内容读成功都在早返回之前清掉读来源的失败(registryMalformed / exportUnavailable),用函数式 setFailure 避免闭包过期;构建类失败不受影响
- 补两条回归测试:指纹失败后重读补上并带回新 baseHash;读恢复后失败就地清掉
This commit is contained in:
2026-10-06 19:32:35 +08:00
parent 7f9c686b34
commit fba3805af8
2 changed files with 137 additions and 5 deletions
@@ -119,6 +119,14 @@ export function useXhsMinitoolExport({
* 读它。
*/
const baselineRef = useRef('');
/**
* 表单已经换成新的,但配套指纹还没取到(上一次指纹读失败了)。
*
* 指纹与内容必须配套:内容读成功、指纹读失败时,绝不能把旧基线留给新表单,否则下一次保存会
* 拿旧指纹撞出假冲突、或字段恰好相同被静默吸收。置真后轮询不再早返回,直到指纹补上;一次
* 成功保存也会用返回值直接把它落定。
*/
const hashPendingRef = useRef(false);
/** 首次读取是否已经落定。`loading` 只覆盖这一段,自动重读不许再翻它。 */
const initialReadSettledRef = useRef(false);
/** 轮询回调里要判断「用户手上有没有未保存输入」,但它们不该把轮询节拍打乱。 */
@@ -164,7 +172,8 @@ export function useXhsMinitoolExport({
* - 表单是用户的编辑对象,只有用户手上没有未保存输入(也不在冲突里)时才覆盖,否则只更新
* `hasScript`,绝不覆盖正在打的字。
*
* 内容真的变了才去取指纹:指纹与内容必须配套,取早了会把外部改动认成自己的基线。
* 内容真的变了、或上一次指纹读失败还欠着(`hashPendingRef`)时才去取指纹:指纹与内容必须
* 配套,取早了会把外部改动认成自己的基线,欠着不取则会让下一次保存带着旧基线撞出假冲突。
*
* 返回值是**这次现算出来的**「有没有脚本」:`true` / `false` 是宿主的当场结论,`null` 是这次
* 没读到(没有项目路径、没有宿主、注册表坏、认不出形状的拒绝)。「打包」要用这个当场结论决定
@@ -184,10 +193,25 @@ export function useXhsMinitoolExport({
projectPath,
);
setHasScript(content.hasScript);
// 内容读回来了,说明宿主与注册表现在读得动:读来源的失败(注册表坏了 / 宿主不可用)就地
// 清掉,别让弹窗卡在已经好了的现场上。用函数式更新拿最新 `failure`,既不把 `failure` 加进
// `refresh` 的依赖里,也不怕闭包读到旧值。构建类失败不在这里清:它是构建的现场,交给下一次
// 构建处理。
setFailure((previous) =>
previous &&
(previous.type === 'registryMalformed' ||
previous.type === 'exportUnavailable')
? null
: previous,
);
// 首次读取无条件采纳:那时候用户不可能有未保存输入,采纳规则拦在这里只会让面板永远
// 停在「正在读取」(`adoptableRef` 的初值是 false,它是给轮询用的)。
// 上一次指纹读失败时(`hashPendingRef`)也不能早返回:表单虽然没变,但基线还没配套,必须
// 再走一次下面的指纹读;用户正在打字(`adoptableRef` 为 false)时依旧不动表单。
const needsHashRetry = hashPendingRef.current && adoptableRef.current;
if (
!isInitialRead &&
!needsHashRetry &&
(!adoptableRef.current || sameForm(formRef.current, content.form))
) {
return content.hasScript;
@@ -196,10 +220,18 @@ export function useXhsMinitoolExport({
setFieldError(null);
setConflict(null);
setFailure(null);
baselineRef.current = await readXhsMinitoolExportHash(
invoke,
projectPath,
);
try {
baselineRef.current = await readXhsMinitoolExportHash(
invoke,
projectPath,
);
hashPendingRef.current = false;
} catch (error) {
// 指纹没取到:表单已经采纳、基线却还停在旧值。标成 pending,下一次轮询跳过上面的早返回
// 把指纹补上;失败本身照旧交给外层 catch 报给用户。
hashPendingRef.current = true;
throw error;
}
return content.hasScript;
} catch (error) {
const structured = asXhsMinitoolReadFailure(error);
@@ -253,6 +285,8 @@ export function useXhsMinitoolExport({
form: next,
baseHash,
});
// 保存命令的返回值就是当前表单的新指纹:这份表单的基线已经配套,不必再补读。
hashPendingRef.current = false;
setFieldError(null);
setConflict(null);
setFailure(null);
@@ -1243,6 +1243,104 @@ describe('状态 hook', () => {
expect(submitTurn).not.toHaveBeenCalled();
expect(result.current.failure).not.toBeNull();
});
it('指纹读失败后,下一次重读把配套指纹补上,保存不再带陈旧 baseHash', async () => {
// 内容读成功、紧接着的指纹读失败:旧实现会把旧基线留给新表单;又因为表单没变,之后每次
// 轮询都走早返回,指纹永远补不上,保存就会带着空 / 旧 baseHash 撞出假冲突。
let hashCount = 0;
const invoke = vi.fn(async (command: string) => {
if (command === 'read_xhs_minitool_export') {
return { form: FORM, hasScript: true };
}
if (command === 'read_xhs_minitool_export_hash') {
hashCount += 1;
if (hashCount === 1) {
throw { type: 'registryMalformed', cause: '指纹暂时读不到' };
}
return 'hash-2';
}
if (command === 'save_xhs_minitool_export_form') return 'hash-3';
return undefined;
});
installTauriRuntime({ core: { invoke } });
const { result } = renderHook(() =>
useXhsMinitoolExport({
enabled: true,
projectPath: PROJECT_PATH,
submitTurn: vi.fn(async () => true),
}),
);
await waitFor(() => expect(result.current.form).toEqual(FORM));
// 指纹没读到,基线还欠着:失败先报出来。
expect(result.current.failure).toEqual({
type: 'registryMalformed',
cause: '指纹暂时读不到',
});
expect(hashCount).toBe(1);
// 再走一次读:内容没变,但基线欠着,必须补读指纹而不是早返回。
await act(async () => {
await result.current.packageAll();
});
expect(hashCount).toBe(2);
await act(async () => {
result.current.updateField('name', '新名字');
});
await act(async () => {
await result.current.fillForm();
});
// 基线是补读回来的 `hash-2`,不是空字符串、也不是任何旧值。
expect(invoke).toHaveBeenCalledWith('save_xhs_minitool_export_form', {
projectPath: PROJECT_PATH,
form: { ...FORM, name: '新名字' },
baseHash: 'hash-2',
});
});
it('读来源的失败在现场恢复后就地清掉,不必等表单变化', async () => {
// 注册表先坏一次再恢复,而表单值没变:旧实现走早返回、跳过 setFailure(null),弹窗一直卡着。
let readCount = 0;
const invoke = vi.fn(async (command: string) => {
if (command === 'read_xhs_minitool_export') {
readCount += 1;
if (readCount === 2) {
throw { type: 'registryMalformed', cause: 'bad json' };
}
// 第 3 次读恢复,但脚本还没出现:让这次「打包」在读失败清除后就返回,不进入构建
// (构建会自己 setFailure(null),那就分不清失败到底是哪条路清掉的)。
return { form: FORM, hasScript: readCount !== 3 };
}
if (command === 'read_xhs_minitool_export_hash') return CONTENT_HASH;
return undefined;
});
installTauriRuntime({ core: { invoke } });
const { result } = renderHook(() =>
useXhsMinitoolExport({
enabled: true,
projectPath: PROJECT_PATH,
submitTurn: vi.fn(async () => true),
}),
);
await waitFor(() => expect(result.current.form).toEqual(FORM));
expect(result.current.failure).toBeNull();
// 一次读失败:现场挂上。
await act(async () => {
await result.current.packageAll();
});
expect(result.current.failure).toEqual({
type: 'registryMalformed',
cause: 'bad json',
});
// 再读一次就恢复了、表单值一模一样:失败必须在早返回之前清掉。
await act(async () => {
await result.current.packageAll();
});
expect(result.current.failure).toBeNull();
});
});
function renderPane(minitool: XhsMinitoolExportState = fakeMinitool()) {