diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/formSaveLock.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/formSaveLock.ts new file mode 100644 index 000000000..39de0e89b --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/formSaveLock.ts @@ -0,0 +1,51 @@ +/** + * 表单写盘的串行锁。 + * + * 为什么需要它:写盘要等网络。等待期间用户可能又敲了一版表单,那一版会在 `AUTOSAVE_DELAY_MS` 之后 + * 再排一次写盘。如果先回来的那次写盘无条件清掉 `dirty`,自动保存效果会认为「已经存好了」而把后 + * 一次的定时器取消掉,最后一版输入就永远不落盘。 + * + * 锁管两件事: + * 1. **串行**:同一时刻只跑一次写盘,后来的调用排队,不会并发打宿主; + * 2. **是否被后来的编辑超过**:每次 `run` 记下进入时的编辑版本,写盘回来时再比一次。版本变了, + * 说明这次写的不是最后一版,调用方就不该清 `dirty`,排队的下一次写盘会接着写最新一版。 + * + * 编辑版本由 `markEdited()` 推进:只有用户真的改了表单才加,读回来的内容不在此列。 + */ +export type FormSaveLock = { + /** 记录一次表单编辑(用户在界面上改了字段、或逐字段解冲突)。 */ + markEdited: () => void; + /** + * 串行执行一次写盘。 + * + * `saved` 是任务本身的结果;`superseded` 表示这次写盘期间表单又被编辑过——此时 `saved` 虽然为 + * 真,写的却不是最后一版,调用方不能清 `dirty`。 + */ + run: ( + save: () => Promise, + ) => Promise<{ saved: boolean; superseded: boolean }>; +}; + +export function createFormSaveLock(): FormSaveLock { + let chain: Promise = Promise.resolve(); + let editVersion = 0; + + return { + markEdited() { + editVersion += 1; + }, + run(save) { + const versionAtStart = editVersion; + const task = chain.then(async () => { + const saved = await save(); + return { saved, superseded: editVersion !== versionAtStart }; + }); + // 队列不能因为一次失败就断掉:这次任务的结果照旧返回给调用方,链本身只吞掉拒绝。 + chain = task.then( + () => undefined, + () => undefined, + ); + return task; + }, + }; +} 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 67f404d05..0d071d68c 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 @@ -24,6 +24,7 @@ import type { FormInvalid } from '../generated/FormInvalid'; import type { SaveConflict } from '../generated/SaveConflict'; import type { XHSMiniToolExportForm } from '../generated/XHSMiniToolExportForm'; import type { XHSMiniToolExportState } from '../generated/XHSMiniToolExportState'; +import { createFormSaveLock } from './formSaveLock'; import { asXhsMinitoolBuildFailure, asXhsMinitoolReadFailure, @@ -139,6 +140,8 @@ export function useXhsMinitoolExport({ * 当前项目:面板根跨项目不卸载,旧请求回来时绝不能把上一个项目的表单 / 基线写进新项目。 */ const projectPathRef = useRef(projectPath); + /** 表单写盘的串行锁:一次只写一版,并告诉调用方这次写的是不是最后一版。 */ + const saveLockRef = useRef(createFormSaveLock()); useEffect(() => { formRef.current = form; @@ -318,47 +321,56 @@ export function useXhsMinitoolExport({ * 这是**唯一**做表单硬校验的地方(`validate_form` 在保存命令里):自动保存到点就调它,「打包」 * 要落盘手上没保存的输入也调它。返回 `false` 就是这次输入没能落盘,调用方(打包)停在原地等 * 用户处理,不拿一份没通过校验的表单去构建。 + * + * 写盘经由 [FormSaveLock]:同一时刻只写一版。写盘期间用户又改了表单时,这次的结果虽然成功,也 + * **不能**清 `dirty`——否则自动保存效果会把后一版的定时器当成「已经存好了」取消,最后一版输入 + * 就永远不落盘。 */ const persist = useCallback( async (next: XHSMiniToolExportForm, baseHash: string): Promise => { if (!projectPath) return false; const invoke = resolveTauriInvoke(); if (!invoke) return false; - try { - 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); - setFailure(null); - setDirty(false); - return true; - } catch (error) { - // 旧项目的失败不该记到新项目头上。 - if (projectPathRef.current !== projectPath) return false; - // 三种结果都要停下重试,等用户或下一次输入推着走:字段不合法等用户改、冲突等用户选、 - // 其余按 typed 失败给建议。共同点是 `dirty` 归零,避免同一份输入反复打宿主。 - setDirty(false); - const structured = asXhsMinitoolSaveFailure(error); - if (!structured) { - throwToErrorPool(String(error), error); + const { saved, superseded } = await saveLockRef.current.run(async () => { + try { + 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); + setFailure(null); + return true; + } catch (error) { + // 旧项目的失败不该记到新项目头上。 + if (projectPathRef.current !== projectPath) return false; + // 三种结果都要停下重试,等用户或下一次输入推着走:字段不合法等用户改、冲突等用户选、 + // 其余按 typed 失败给建议。共同点是 `dirty` 归零,避免同一份输入反复打宿主。 + setDirty(false); + const structured = asXhsMinitoolSaveFailure(error); + if (!structured) { + throwToErrorPool(String(error), error); + return false; + } + if (structured.type === 'formInvalid') setFieldError(structured); + else if (structured.type === 'saveConflict') setConflict(structured); + else if (structured.type === 'exportUnavailable') { + // 宿主侧事实故障:不落面板,直接抛给全局错误池上报。 + throwToErrorPool(JSON.stringify(structured), error); + } else setFailure(structured); return false; } - if (structured.type === 'formInvalid') setFieldError(structured); - else if (structured.type === 'saveConflict') setConflict(structured); - else if (structured.type === 'exportUnavailable') { - // 宿主侧事实故障:不落面板,直接抛给全局错误池上报。 - throwToErrorPool(JSON.stringify(structured), error); - } else setFailure(structured); - return false; - } + }); + // 成功且期间没有新编辑,才说明「最后一版已经落盘」;被后来的编辑超过时留着 `dirty`, + // 让已经排上的下一次自动保存接着写。 + if (saved && !superseded) setDirty(false); + return saved; }, [projectPath, throwToErrorPool], ); @@ -374,6 +386,8 @@ export function useXhsMinitoolExport({ /** 改一个字段。改哪个字段就清哪个字段的旧报错——别让已经改好的红字继续挂着。 */ const updateField = useCallback( (field: keyof XHSMiniToolExportForm, value: string): void => { + // 记进写盘锁:正在写的那一版回来时会看到「已被超过」,从而不清 `dirty`。 + saveLockRef.current.markEdited(); setForm((previous) => previous ? { ...previous, [field]: value } : previous, ); @@ -389,6 +403,7 @@ export function useXhsMinitoolExport({ const resolveConflict = useCallback( (resolved: XHSMiniToolExportForm): void => { if (!conflict) return; + saveLockRef.current.markEdited(); setForm(resolved); void persist(resolved, conflict.contentHash); }, diff --git a/apps/ai-game-creator-shell/tests/formSaveLock.test.ts b/apps/ai-game-creator-shell/tests/formSaveLock.test.ts new file mode 100644 index 000000000..308f5a5ad --- /dev/null +++ b/apps/ai-game-creator-shell/tests/formSaveLock.test.ts @@ -0,0 +1,73 @@ +// @vitest-environment node +/** + * 表单写盘锁的行为边界:串行、以及「这次写的是不是最后一版」。 + */ +import { describe, expect, it } from 'vitest'; + +import { createFormSaveLock } from '../src/view/project-development/export/state/formSaveLock'; + +describe('表单写盘锁', () => { + it('串行执行:前一次结束前不会开始下一次', async () => { + const lock = createFormSaveLock(); + const order: string[] = []; + let releaseFirst!: () => void; + const first = lock.run(async () => { + order.push('first:start'); + await new Promise((resolve) => { + releaseFirst = resolve; + }); + order.push('first:end'); + return true; + }); + const second = lock.run(async () => { + order.push('second:start'); + return true; + }); + + // 让第一个任务跑起来并挂住:第二个还没轮到。 + await Promise.resolve(); + expect(order).toEqual(['first:start']); + + releaseFirst(); + await first; + await second; + expect(order).toEqual(['first:start', 'first:end', 'second:start']); + }); + + it('写盘期间又编辑过:这次结果标记为已被超过', async () => { + const lock = createFormSaveLock(); + let release!: () => void; + const task = lock.run(async () => { + await new Promise((resolve) => { + release = resolve; + }); + return true; + }); + await Promise.resolve(); + lock.markEdited(); + release(); + + await expect(task).resolves.toEqual({ saved: true, superseded: true }); + }); + + it('写完没有再编辑:就是最后一版', async () => { + const lock = createFormSaveLock(); + await expect(lock.run(async () => true)).resolves.toEqual({ + saved: true, + superseded: false, + }); + }); + + it('一次任务失败不会卡住后面的写盘', async () => { + const lock = createFormSaveLock(); + await expect( + lock.run(async () => { + throw new Error('写盘失败'); + }), + ).rejects.toThrow('写盘失败'); + await expect(lock.run(async () => true)).resolves.toEqual({ + saved: true, + superseded: false, + }); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx index 19abffa35..ad32a5855 100644 --- a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx +++ b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx @@ -732,6 +732,62 @@ describe('状态 hook', () => { expect(Object.keys(result.current)).not.toContain('dirty'); }); + it('保存期间继续输入时,先回来的那次不清待保存状态,最后一版仍会落盘', async () => { + const savedNames: string[] = []; + let releaseFirstSave!: () => void; + let saveCount = 0; + const invoke = vi.fn( + async (command: string, args?: { form?: { name: string } }) => { + if (command === 'read_xhs_minitool_export') { + return { form: FORM, hasScript: true }; + } + if (command === 'read_xhs_minitool_export_hash') return CONTENT_HASH; + if (command === 'save_xhs_minitool_export_form') { + saveCount += 1; + savedNames.push(args?.form?.name ?? ''); + if (saveCount === 1) { + await new Promise((resolve) => { + releaseFirstSave = resolve; + }); + } + return `hash-${saveCount}`; + } + 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)); + + // 第一版输入:自动保存发出后挂在宿主那一步。 + await act(async () => { + result.current.updateField('name', '第一版'); + }); + await waitFor(() => expect(saveCount).toBe(1), { timeout: 3000 }); + + // 第一次保存还没回来,用户又敲了一版:这一版也必须被写下去,不能因为第一次回来 + // 清掉 `dirty` 就让它的定时器被取消。 + await act(async () => { + result.current.updateField('name', '第二版'); + }); + await act(async () => { + releaseFirstSave(); + }); + // 先让 React 把第一次保存的结果与效果清理跑完,再等第二版的自动保存定时器。 + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 1500)); + }); + + expect(savedNames).toEqual(['第一版', '第二版']); + expect(result.current.form?.name).toBe('第二版'); + }); + it('用户手上有未保存输入时,自动重读只更新脚本判据、不动表单', async () => { let diskForm = { ...FORM }; const invoke = vi.fn(async (command: string) => {