修掉导出重读里指纹丢失与读失败不清理两个洞
- useXhsMinitoolExport 加 hashPendingRef:指纹读失败后表单已换、基线还欠着,下一次重读跳过早返回把配套指纹补上,不再把旧 baseHash 留给新表单 - 保存命令的返回值直接落定基线并清掉 pending,不必再补读 - 每次内容读成功都在早返回之前清掉读来源的失败(registryMalformed / exportUnavailable),用函数式 setFailure 避免闭包过期;构建类失败不受影响 - 补两条回归测试:指纹失败后重读补上并带回新 baseHash;读恢复后失败就地清掉
This commit is contained in:
+39
-5
@@ -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()) {
|
||||
|
||||
Reference in New Issue
Block a user