diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index df71d5b83..2f50a2e5b 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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`。 diff --git a/src/components/image-editor/useInlineGenerationPlaceholderExpiry.test.tsx b/src/components/image-editor/useInlineGenerationPlaceholderExpiry.test.tsx index 04aae2db3..f227e9d61 100644 --- a/src/components/image-editor/useInlineGenerationPlaceholderExpiry.test.tsx +++ b/src/components/image-editor/useInlineGenerationPlaceholderExpiry.test.tsx @@ -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) => ( + + ); + 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 会替换占位。自动清理会让用户以为 // 操作没发生而重复提交。 diff --git a/src/components/image-editor/useInlineGenerationPlaceholderExpiry.ts b/src/components/image-editor/useInlineGenerationPlaceholderExpiry.ts index 7ac3f8631..9974964a4 100644 --- a/src/components/image-editor/useInlineGenerationPlaceholderExpiry.ts +++ b/src/components/image-editor/useInlineGenerationPlaceholderExpiry.ts @@ -40,7 +40,6 @@ export function useInlineGenerationPlaceholderExpiry({ onPlaceholdersExpired, }: InlineGenerationPlaceholderExpiryOptions) { const [expiryTick, setExpiryTick] = useState(0); - useEffect(() => { const expiredIds = collectExpiredInlineGenerationDialogIds( canvasGenerationDialogs,