表单保存改用串行写盘锁,写盘期间的新编辑不会被漏掉
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:
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
+48
-33
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user