到期清理复查:证伪一个假设并保留证伪它的用例

怀疑长定时器挂在 dialogs 数组身份上会被无关变动不断重挂而永不触发,据此改挂到
期时刻并加 ref。为它写的回归用例(每秒一次无关变动持续到超窗)在改回数组依赖后
仍然通过——数组变化会让 effect 重跑,effect 体每次都重新判定到期,频繁变动带来
的是更频繁的判定;不变动时数组稳定,定时器正常存活。

假设不成立,撤回 useMemo + useRef 的间接层。用例保留,注释改写为它实际证明的
性质:判定必须留在 effect 体里,将来挪出去才会真的失效。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 10:26:05 +00:00
parent cf41e5ae76
commit 6b1fe0b829
3 changed files with 47 additions and 1 deletions
@@ -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,