表单保存改用串行写盘锁,写盘期间的新编辑不会被漏掉

view/project-development/export/state/formSaveLock.ts:新增写盘锁,串行执行保存,并按进入时的编辑版本判断这次写的是不是最后一版。
view/project-development/export/state/useXhsMinitoolExport.ts:persist 走锁,写盘期间又有编辑时不清 dirty,让已排队的自动保存接着写最新一版;updateField 与 resolveConflict 记编辑版本。
tests/formSaveLock.test.ts:覆盖串行、被后来的编辑超过、最后一版、失败不卡队列。
tests/xhsMinitoolExport.test.tsx:补「保存期间继续输入,最后一版仍会落盘」的回归用例。
This commit is contained in:
2026-10-06 22:08:30 +08:00
parent 90da09f961
commit b9d0276a46
4 changed files with 228 additions and 33 deletions
@@ -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<boolean>,
) => Promise<{ saved: boolean; superseded: boolean }>;
};
export function createFormSaveLock(): FormSaveLock {
let chain: Promise<unknown> = 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;
},
};
}
@@ -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<boolean> => {
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);
},
@@ -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<void>((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<void>((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,
});
});
});
@@ -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<void>((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) => {