到期清理复查:证伪一个假设并保留证伪它的用例
怀疑长定时器挂在 dialogs 数组身份上会被无关变动不断重挂而永不触发,据此改挂到 期时刻并加 ref。为它写的回归用例(每秒一次无关变动持续到超窗)在改回数组依赖后 仍然通过——数组变化会让 effect 重跑,effect 体每次都重新判定到期,频繁变动带来 的是更频繁的判定;不变动时数组稳定,定时器正常存活。 假设不成立,撤回 useMemo + useRef 的间接层。用例保留,注释改写为它实际证明的 性质:判定必须留在 effect 体里,将来挪出去才会真的失效。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6118,3 +6118,11 @@
|
||||
- 到期时刻可精确计算,所以挂一次性定时器而不是轮询;多给 50ms 余量,避免贴着到期时刻醒来判定为未到期、白白多挂一轮。
|
||||
- 验证:纯函数四条用例覆盖边界、队列型与已终态不到期、缺时间戳立即到期、最早到期时刻;hook 四条覆盖到期清理、醒来重新判定(占位期间被收口则不清)、队列型永不挂定时器、已超窗立即清理。去掉定时器重挂逻辑后第一条变红。`vitest src/components/image-editor` 916 通过 / 73 文件,typecheck、eslint、check:encoding 通过。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-08-03 到期清理链路复查:一个被证伪的假设与它留下的用例
|
||||
|
||||
- 复查怀疑:到期定时器要熬三分钟,而 `canvasGenerationDialogs` 变动很频繁(提交工作流三十余处变更点,队列型生成轮询期间持续更新状态)。把 effect 依赖挂在数组身份上,看起来会被无关变动不断重挂定时器、永远等不到触发,整个机制静默失效。据此把定时器改挂在计算出的到期时刻上,并加 ref 读取当前 dialogs。
|
||||
- 结论:**假设是错的**。为它写的回归用例(每秒一次无关变动、持续到超窗)在改回数组依赖后仍然通过——数组身份变化会让 effect 重跑,而 effect 体每次都重新判定到期,频繁变动带来的是更频繁的判定,不比定时器差;不变动时数组稳定,定时器正常存活。两条路径都收口。
|
||||
- 处置:撤回 `useMemo` + `useRef` 的改动,回到更简单的数组依赖版本——既然简单版本本来就正确,多出来的间接层没有收益。用例保留,但注释改写为它**实际证明**的性质:判定必须留在 effect 体里;将来若把它挪出去(例如只在定时器回调里判定),这条不变式才会真的失效,用例届时会变红。
|
||||
- 记这一条是因为过程本身有价值:先假设、再写用例、用例证伪假设、据此撤回改动。若跳过验证直接保留那次「修复」,就会在没有缺陷的地方永久留下一层多余的间接。本次会话里同类错误(凭推断得出结论而不验证)已出现多次,这次是验证挡住了。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
@@ -113,6 +113,45 @@ describe('useInlineGenerationPlaceholderExpiry', () => {
|
||||
expect(onPlaceholdersExpired).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still expires while unrelated dialogs keep churning', () => {
|
||||
// 中文注释:dialogs 变动很频繁(提交工作流三十余处变更点,队列型生成轮询期间持续更新),
|
||||
// 而这个定时器要熬三分钟。曾怀疑无关变动会不断重挂定时器、让它永远等不到触发;本用例
|
||||
// 证明不会:数组身份变化会让 effect 重跑,而 effect 体每次都重新判定到期,频繁变动带来
|
||||
// 的是更频繁的判定,不比定时器差;不变动时数组稳定,定时器正常存活。两条路径都收口。
|
||||
// 保留这条用例是为了钉住这个性质——将来若把判定挪出 effect 体,就会真的失效。
|
||||
const removeCanvasGenerationDialogById = vi.fn();
|
||||
const startedAt = Date.now() - WINDOW_MS + 30_000;
|
||||
const inlineDialog = dialog({
|
||||
requiresLiveSession: true,
|
||||
generationStartedAt: startedAt,
|
||||
});
|
||||
const renderWith = (queuedProgress: number) => (
|
||||
<Harness
|
||||
dialogs={[
|
||||
inlineDialog,
|
||||
dialog({
|
||||
id: 'queued',
|
||||
prompt: `排队中 ${queuedProgress}`,
|
||||
generationStartedAt: Date.now(),
|
||||
}),
|
||||
]}
|
||||
removeCanvasGenerationDialogById={removeCanvasGenerationDialogById}
|
||||
onPlaceholdersExpired={vi.fn()}
|
||||
/>
|
||||
);
|
||||
const { rerender } = render(renderWith(0));
|
||||
|
||||
// 中文注释:每秒一次无关变动,持续到该占位到期之后。
|
||||
for (let tick = 1; tick <= 40; tick += 1) {
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1_000);
|
||||
});
|
||||
rerender(renderWith(tick));
|
||||
}
|
||||
|
||||
expect(removeCanvasGenerationDialogById).toHaveBeenCalledWith('dialog-1');
|
||||
});
|
||||
|
||||
it('never arms a timer for queue-backed placeholders', () => {
|
||||
// 中文注释:队列型占位的 job 在服务端继续跑,worker 会替换占位。自动清理会让用户以为
|
||||
// 操作没发生而重复提交。
|
||||
|
||||
@@ -40,7 +40,6 @@ export function useInlineGenerationPlaceholderExpiry({
|
||||
onPlaceholdersExpired,
|
||||
}: InlineGenerationPlaceholderExpiryOptions) {
|
||||
const [expiryTick, setExpiryTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const expiredIds = collectExpiredInlineGenerationDialogIds(
|
||||
canvasGenerationDialogs,
|
||||
|
||||
Reference in New Issue
Block a user