diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts index 1b1279c6a..f47257810 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts @@ -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); diff --git a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx index 506c9e450..3876b4a29 100644 --- a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx +++ b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx @@ -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()) {