diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 50edaa61f..5b6b35fe6 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6213,3 +6213,11 @@ - 文档收口:后端数据契约、连接池 Drop 说明、图片画布方案、decision log 与 pitfalls 同步撤回“相对断言可消除并行竞态”的错误保证。连接池 lease 的 Drop 只保证本地 slot / permit 可回收,不表示 handler timeout/drop 能取消或回滚已经发出的远端 procedure。 - 验证结果:`cargo test --manifest-path server-rs/Cargo.toml -p api-server pixel_art_snap` 为 17 通过 / 0 失败;`npm run check:rustfmt`、`npm run check:encoding`(5153 个文件)与 `git diff --check` 通过。 - 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-04 inline 占位 ownership 释放改为可观察信号 + +- 覆盖缺口:2026-08-03 的“释放归属后同一超窗占位立即清理”测试在 `rerender` 时同时创建了新的 dialogs 数组和 callback,effect 实际由这些依赖变化唤醒;它没有证明 `finally` 里单独执行 `Set.delete()` 会重新判定。生产实现把稳定 ref 对象放进依赖,但 React 不观察 `.current` 内容变化,因此旧测试与旧实现之间存在同一个盲区。 +- 决策:Set 继续作为首个 await 前同步可见的 ownership 真值,但封装进 `useInlineGenerationPlaceholderOwnership`,不再向 View 暴露可变 ref。`claim / release` 只有在 membership 真变化时才推进 version;到期 effect 同时依赖稳定 `has` 和 version。首次提交与人工 exact replay 通过同一份 ownership 登记 / 释放,hydrate GET-only 恢复只查询这份 ownership 来避开本页 live Promise,不能在 View 创建第二份 registry。 +- 时序边界:`claim` 仍紧挨占位创建且早于任何 await;`release` 仍位于 `finally`。version 只负责 React 通知,不替代同步 Set,也不清理 observed recovery key;否则可能在 live Promise 尚未退出时启动第二条 GET。重复 claim / release 为幂等 no-op,不额外触发 effect。 +- 验证:hook 定向测试使用生产 ownership hook、固定 dialogs 数组和固定 callbacks,并包在 StrictMode 中;单次 render 后先 claim 取消到期 timer,推进到超窗仍不清理,再仅 release 唤醒 effect并清理一次。重复 claim / release 分别保持 version `1 / 2`,删除与通知均只发生一次。hook 定向测试 10 条、generation workflow 定向测试 81 条通过;`npm run typecheck`、全仓 `npm run lint:eslint`、`npm run check:encoding` 与 `git diff --check` 通过。未追加其它测试或全量测试套件。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 5cdd1cdce..c60ce384e 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -177,6 +177,7 @@ - 完美像素前端第二批以 durable operation 为提交边界:生成占位必须持久化 `perfectPixelOperation = { version: 1, kind: "perfect-pixel", operationId, taskId, request, submittedAt, reconcileUntil }`,其中 `operationId` 等于规范化 dialog id、`taskId` 固定为 `pixel-art-snap-{operationId}`,`request` 是稳定源引用解析完成后的完整 `EditorPixelArtSnapInput`,`submittedAt / reconcileUntil` 构成从快照写入起算、不得被 POST 回包续期的 75 秒整链绝对窗口。完美像素 dialog id 使用跨标签随机 identity,不能复用每个标签页都会从 1 开始的局部计数器。inline 源图以该 identity 作为稳定 upload ID,只执行 object-only 上传,不等待 signed URL;快照不得包含 Data URL、Blob URL 或 signed URL。POST 前必须取得包含该 dialog 与 operation 快照的布局保存成功确认,保存冲突、鉴权失败、重试耗尽或无法确认时 POST 必须为零。人工重试只能原样重放该快照与同一 operation,不得重新 placement、上传、读取当前图层字段或暗中换 identity;快照缺失、损坏或与 dialog / project / task / completion 不匹配时失败关闭。首次提交或人工重试在途期间若 owner、project 或组件生命周期已经变化,旧响应的素材写入、项目应用、提示与对账副作用必须全部忽略,不能把前一账号的结果写入当前账号状态。 - 完美像素 unknown-result 的 verdict 只来自项目 GET,POST 响应体不得直接判成功:找到唯一稳定 task resource 且 dialog 已收口、结果层精确指向该 resource 时为 `Applied`;resource 存在且 dialog 不存在时为 `DialogMissing`,结果只在素材库;dialog 仍 generating(包括匹配 resource 已先可见)或 dialog 不存在且无匹配 resource 时继续有界轮询;resource 与 dialog / layer 出现原子事务不可能产生的错配时保持待确认并提示冲突,禁止自动 POST。首个 GET 立即执行,此后退避不超过 5 秒;即使绝对窗口已过期也必须读取一次。`refreshAssetLibrary` 只在终态后 best-effort 触发,不进入轮询 deadline、`Promise.all` 或成功判断,同步 throw、异步 reject 和永久挂起均不得阻塞。轮询到期或 GET 失败后 dialog 转 `pending-confirmation`,保留 operation 与请求快照并释放页面 busy,不得伪装成普通失败或声称素材已保存。 - 完美像素恢复只对账、不重新执行:项目 hydrate 后识别带有效 operation 快照的 `generating` / `pending-confirmation` dialog,只按稳定 task/resource 做 GET-only 轮询,绝不 POST、重新上传、重新准备来源或为了恢复而先写布局;owner/project 切换、卸载或更高 revision 到来时旧轮询结果不得生效。新写入的 v1 operation 固定使用 75 秒跨度;为兼容第一批和滚动升级中的旧标签页,hydrate 仍接受跨度及未来时钟偏差不超过 240 秒的旧 v1 journal。若旧 `submittedAt` 位于可接受的未来区间,先把它规范化到当前时间,再把 `reconcileUntil` 压到 `min(持久截止, 规范化 submittedAt + 75 秒, 当前时间 + 75 秒)`;写回形状必须继续满足 `reconcileUntil >= submittedAt`,确保下次 hydrate 仍保留同一 identity。新 durable operation 无论在哪个标签页、是否超过 legacy TTL 都不得被 `requiresLiveSession` 或普通删除路径清理;TTL 只兼容完全没有 operation journal 字段的历史 inline 孤儿,字段存在但内容损坏时必须保留并失败关闭,清理 legacy 孤儿时必须同时更新 `project.layers` 与 `project.canvas.layers`。恢复到期仍持久保持 `pending-confirmation`,只有用户明确点击重试才进入 exact replay。 +- legacy inline 占位的本会话归属必须由同一份封装 ownership 管理:同步 Set 在首个 await 前完成 `claim`,保证到期判定即时可见;`claim / release` 仅在 membership 真变化时推进 React 可观察的 version,`release` 即使发生时 dialogs 与 callbacks identity 都不变,也必须立即唤醒到期 effect 重新判定。禁止重新暴露可变 Set ref 或直接修改 `.current`,React 不会因为 ref 内容变化而重跑 effect。 - 重绘生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图,且重绘面板保持打开。 - 快速编辑 / 重绘站内 public 示例图、历史 generated 图或 OSS generated 图时,优先复用当前图层已有 `objectKey` / `resourceId` / `sourceAssetId`;尚未登记且没有稳定引用的浏览器本地图片或普通 public 图片路径都必须先上传并取得 objectKey。前端不得再把正式对象下载成 `data:image/*;base64,...` 后提交,也不得把 Data URL / Blob URL 写入外部生成持久任务 JSON;后端收到引用后统一做 owner 归属校验并签名读取。 - 快速编辑不保留额外参考图入口;点击修改时只把原图或红框序号标注图作为 `/api/editor/images/edits` 的 `sourceImageSrc` 提交给后端。 diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 116a5214f..38efa392b 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -1633,7 +1633,7 @@ export function ImageCanvasEditorView({ openCropExpandPanel, removeSelectedLayerBackground, snapSelectedLayerToPerfectPixels, - activeInlineGenerationDialogIdsRef, + activeInlineGenerationDialogOwnership, perfectPixelLayerIds, pendingPerfectPixelLayerIds, splitSelectedIconSpritesheet, @@ -1964,7 +1964,7 @@ export function ImageCanvasEditorView({ // 否则它会一直转到用户下一次加载。两条路径共用同一条文案,用户感知一致。 useInlineGenerationPlaceholderExpiry({ canvasGenerationDialogs, - activeInlineGenerationDialogIdsRef, + activeInlineGenerationDialogOwnership, removeCanvasGenerationDialogById, onPlaceholdersExpired: handleInlinePlaceholdersExpired, }); diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index cc4c7179d..2d947bd55 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -122,6 +122,7 @@ import { resolveEditorGenerationMediaReference, useImageCanvasGenerationSubmissionWorkflow, } from './useImageCanvasGenerationSubmissionWorkflow'; +import { useInlineGenerationPlaceholderOwnership } from './useInlineGenerationPlaceholderExpiry'; type CanvasSize = { width: number; height: number }; @@ -988,7 +989,16 @@ export function useImageCanvasGenerationWorkflow({ // 而它无法从 dialog 状态区分「已死会话留下的」和「本会话正在跑的」——两者都是 // requiresLiveSession + generating。占位创建后还要走源图解析/直传和 flush 才轮到 // 受超时保护的 POST,这段慢起来会越过存活窗口,届时定时器会删掉自己正在用的占位。 - const activeInlineGenerationDialogIdsRef = useRef(new Set()); + // + // ownership 内部保留同步 Set,并通过 version 把 claim / release 通知给 React。调用方不得 + // 直接改 Set,否则 finally 释放后到期 effect 不会重新判定。 + const activeInlineGenerationDialogOwnership = + useInlineGenerationPlaceholderOwnership(); + const { + claim: claimActiveInlineGenerationDialog, + release: releaseActiveInlineGenerationDialog, + has: hasActiveInlineGenerationDialog, + } = activeInlineGenerationDialogOwnership; const perfectPixelRecoveryControllersRef = useRef( new Map(), ); @@ -2257,7 +2267,7 @@ export function useImageCanvasGenerationWorkflow({ perfectPixelDialogId = placement.dialogId; // 中文注释:紧挨着创建注册,中间不能有 await——否则会留出一个「占位已存在但尚未 // 登记归属」的窗口,到期清理正好可以在那里把它删掉。 - activeInlineGenerationDialogIdsRef.current.add(perfectPixelDialogId); + claimActiveInlineGenerationDialog(perfectPixelDialogId); if (!placement.placeholder) { throw new Error('无法创建完美像素处理占位'); } @@ -2468,9 +2478,7 @@ export function useImageCanvasGenerationWorkflow({ } } finally { if (perfectPixelDialogId) { - activeInlineGenerationDialogIdsRef.current.delete( - perfectPixelDialogId, - ); + releaseActiveInlineGenerationDialog(perfectPixelDialogId); } perfectPixelLayerIdsRef.current.delete(sourceLayer.id); setPerfectPixelLayerIds((currentLayerIds) => { @@ -2494,6 +2502,8 @@ export function useImageCanvasGenerationWorkflow({ isPerfectPixelAuthorityCurrent, openPlacedCanvasGenerationDialog, projectId, + claimActiveInlineGenerationDialog, + releaseActiveInlineGenerationDialog, setCharacterAnimationPanel, setCropExpandPanel, setImageContextMenu, @@ -2542,26 +2552,26 @@ export function useImageCanvasGenerationWorkflow({ if (perfectPixelLayerIdsRef.current.has(lockKey)) { return; } - perfectPixelLayerIdsRef.current.add(lockKey); - activeInlineGenerationDialogIdsRef.current.add(normalizedDialogId); - if (dialog.sourceLayerId) { - setPerfectPixelLayerIds((currentLayerIds) => { - const nextLayerIds = new Set(currentLayerIds); - nextLayerIds.add(dialog.sourceLayerId!); - return nextLayerIds; - }); - } const retriedOperation = createPerfectPixelReconciliationOperation(operation); - observedPerfectPixelRecoveryKeysRef.current.add( - perfectPixelRecoveryKey( - currentUserId, - normalizedProjectId, - retriedOperation, - ), - ); let postAttempted = false; + perfectPixelLayerIdsRef.current.add(lockKey); + claimActiveInlineGenerationDialog(normalizedDialogId); try { + if (dialog.sourceLayerId) { + setPerfectPixelLayerIds((currentLayerIds) => { + const nextLayerIds = new Set(currentLayerIds); + nextLayerIds.add(dialog.sourceLayerId!); + return nextLayerIds; + }); + } + observedPerfectPixelRecoveryKeysRef.current.add( + perfectPixelRecoveryKey( + currentUserId, + normalizedProjectId, + retriedOperation, + ), + ); updateCanvasGenerationDialogById(normalizedDialogId, (current) => ({ ...current, status: 'generating', @@ -2657,7 +2667,7 @@ export function useImageCanvasGenerationWorkflow({ errorMessage, })); } finally { - activeInlineGenerationDialogIdsRef.current.delete(normalizedDialogId); + releaseActiveInlineGenerationDialog(normalizedDialogId); perfectPixelLayerIdsRef.current.delete(lockKey); if (dialog.sourceLayerId) { setPerfectPixelLayerIds((currentLayerIds) => { @@ -2674,10 +2684,12 @@ export function useImageCanvasGenerationWorkflow({ [ applyProjectSnapshot, canvasGenerationDialogs, + claimActiveInlineGenerationDialog, currentUserId, flushProjectPersistence, isPerfectPixelAuthorityCurrent, projectId, + releaseActiveInlineGenerationDialog, showGenerationWarning, settleLivePerfectPixelVerdict, updateCanvasGenerationDialogById, @@ -2739,7 +2751,7 @@ export function useImageCanvasGenerationWorkflow({ ); // 中文注释:本页首次 POST / 人工重试已有自己的 Promise 收口。effect 会在 operation // 写入后重跑,必须把该 key 记为本会话已观察并跳过,否则会并发启动第二条 GET 轮询。 - if (activeInlineGenerationDialogIdsRef.current.has(dialog.id)) { + if (hasActiveInlineGenerationDialog(dialog.id)) { observedPerfectPixelRecoveryKeysRef.current.add(recoveryKey); continue; } @@ -2877,6 +2889,7 @@ export function useImageCanvasGenerationWorkflow({ canvasGenerationDialogs, currentUserId, flushProjectPersistence, + hasActiveInlineGenerationDialog, hasCanvasGenerationDialogById, projectId, refreshPerfectPixelAssetLibrary, @@ -3740,7 +3753,7 @@ export function useImageCanvasGenerationWorkflow({ removeSelectedLayerBackground, snapSelectedLayerToPerfectPixels, retryPerfectPixelOperation, - activeInlineGenerationDialogIdsRef, + activeInlineGenerationDialogOwnership, perfectPixelLayerIds, pendingPerfectPixelLayerIds, splitSelectedIconSpritesheet, @@ -3820,6 +3833,7 @@ export function useImageCanvasGenerationWorkflow({ isPickingPublicationReferenceFromCanvas, generationWarning, generationWarningVersion, + activeInlineGenerationDialogOwnership, openBackgroundMusicGenerationDialog, openCharacterAnimationPanel, openCharacterGenerationDialog, diff --git a/src/components/image-editor/useInlineGenerationPlaceholderExpiry.test.tsx b/src/components/image-editor/useInlineGenerationPlaceholderExpiry.test.tsx index 698d0a1cf..638a0c19c 100644 --- a/src/components/image-editor/useInlineGenerationPlaceholderExpiry.test.tsx +++ b/src/components/image-editor/useInlineGenerationPlaceholderExpiry.test.tsx @@ -1,14 +1,17 @@ /* @vitest-environment jsdom */ -import { act, render } from '@testing-library/react'; -import { useRef } from 'react'; +import { act, render, renderHook } from '@testing-library/react'; +import { StrictMode } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { CanvasGenerationDialogState, PerfectPixelOperationSnapshot, } from './ImageCanvasEditorTypes'; -import { useInlineGenerationPlaceholderExpiry } from './useInlineGenerationPlaceholderExpiry'; +import { + useInlineGenerationPlaceholderExpiry, + useInlineGenerationPlaceholderOwnership, +} from './useInlineGenerationPlaceholderExpiry'; const WINDOW_MS = 240_000; @@ -60,18 +63,16 @@ function Harness({ dialogs, removeCanvasGenerationDialogById, onPlaceholdersExpired, - activeDialogIds = [], }: { dialogs: CanvasGenerationDialogState[]; removeCanvasGenerationDialogById: (dialogId: string) => void; onPlaceholdersExpired: (expiredCount: number) => void; - activeDialogIds?: string[]; }) { - const activeInlineGenerationDialogIdsRef = useRef(new Set(activeDialogIds)); - activeInlineGenerationDialogIdsRef.current = new Set(activeDialogIds); + const activeInlineGenerationDialogOwnership = + useInlineGenerationPlaceholderOwnership(); useInlineGenerationPlaceholderExpiry({ canvasGenerationDialogs: dialogs, - activeInlineGenerationDialogIdsRef, + activeInlineGenerationDialogOwnership, removeCanvasGenerationDialogById, onPlaceholdersExpired, }); @@ -106,9 +107,7 @@ describe('useInlineGenerationPlaceholderExpiry', () => { perfectPixelOperation: perfectPixelOperation(dialogId), }), ]} - removeCanvasGenerationDialogById={ - removeCanvasGenerationDialogById - } + removeCanvasGenerationDialogById={removeCanvasGenerationDialogById} onPlaceholdersExpired={onPlaceholdersExpired} />, ); @@ -193,7 +192,9 @@ describe('useInlineGenerationPlaceholderExpiry', () => { const startedAt = Date.now() - WINDOW_MS + 1_000; const { rerender } = render( , @@ -267,23 +268,35 @@ describe('useInlineGenerationPlaceholderExpiry', () => { // 在状态上完全一致——只能靠显式登记归属区分。删掉自己正在用的占位会让随后的 POST 因 // 占位不存在返回 409。 const removeCanvasGenerationDialogById = vi.fn(); - render( - , + const onPlaceholdersExpired = vi.fn(); + const dialogs = [ + dialog({ + requiresLiveSession: true, + generationStartedAt: Date.now() - WINDOW_MS + 1_000, + }), + ]; + const { result } = renderHook( + () => { + const ownership = useInlineGenerationPlaceholderOwnership(); + useInlineGenerationPlaceholderExpiry({ + canvasGenerationDialogs: dialogs, + activeInlineGenerationDialogOwnership: ownership, + removeCanvasGenerationDialogById, + onPlaceholdersExpired, + }); + return ownership; + }, + { wrapper: StrictMode }, ); - // 中文注释:不仅不能删,还不能给它挂定时器。归属过滤若只作用于「已到期」而不作用于 - // 「下一到期时刻」,这个超窗占位仍会被算出一个已经过去的到期时刻,delayMs 塌成 50ms, - // 触发 → tick → 重跑 → 再挂,变成每 50 毫秒一次 setState 的忙等。 + expect(vi.getTimerCount()).toBe(1); + act(() => { + expect(result.current.claim('dialog-1')).toBe(true); + }); + expect(result.current.version).toBe(1); + + // 中文注释:claim 只改变 ownership 的私有 Set 与 version;dialogs 和两个 callback + // identity 始终不变。归属过滤必须同时取消下一到期 timer,不能在超窗后进入 50ms 忙等。 expect(vi.getTimerCount()).toBe(0); act(() => { @@ -291,37 +304,60 @@ describe('useInlineGenerationPlaceholderExpiry', () => { }); expect(removeCanvasGenerationDialogById).not.toHaveBeenCalled(); + expect(onPlaceholdersExpired).not.toHaveBeenCalled(); expect(vi.getTimerCount()).toBe(0); }); it('expires the placeholder once the session releases ownership', () => { - // 中文注释:归属在 finally 里释放。释放之后同一个超窗占位必须能被清掉,否则失败退出的 - // 会话会留下永久转圈的占位。 + // 中文注释:归属在 finally 里释放。此用例不 rerender,也不更换 dialogs / callbacks; + // 唯一唤醒 effect 的必须是生产 ownership hook 在 release 时推进的 version。 const removeCanvasGenerationDialogById = vi.fn(); - const expired = dialog({ - requiresLiveSession: true, - generationStartedAt: Date.now() - WINDOW_MS - 60_000, - }); - const { rerender } = render( - , + const onPlaceholdersExpired = vi.fn(); + const dialogs = [ + dialog({ + requiresLiveSession: true, + generationStartedAt: Date.now() - WINDOW_MS + 1_000, + }), + ]; + const { result } = renderHook( + () => { + const ownership = useInlineGenerationPlaceholderOwnership(); + useInlineGenerationPlaceholderExpiry({ + canvasGenerationDialogs: dialogs, + activeInlineGenerationDialogOwnership: ownership, + removeCanvasGenerationDialogById, + onPlaceholdersExpired, + }); + return ownership; + }, + { wrapper: StrictMode }, ); + + act(() => { + expect(result.current.claim('dialog-1')).toBe(true); + expect(result.current.claim('dialog-1')).toBe(false); + }); + expect(result.current.version).toBe(1); + act(() => { + vi.advanceTimersByTime(2_000); + }); expect(removeCanvasGenerationDialogById).not.toHaveBeenCalled(); - rerender( - , - ); - + act(() => { + expect(result.current.release('dialog-1')).toBe(true); + }); + expect(result.current.version).toBe(2); expect(removeCanvasGenerationDialogById).toHaveBeenCalledWith('dialog-1'); + expect(removeCanvasGenerationDialogById).toHaveBeenCalledOnce(); + expect(onPlaceholdersExpired).toHaveBeenCalledWith(1); + expect(onPlaceholdersExpired).toHaveBeenCalledOnce(); + + act(() => { + expect(result.current.release('dialog-1')).toBe(false); + }); + expect(result.current.version).toBe(2); + expect(removeCanvasGenerationDialogById).toHaveBeenCalledOnce(); + expect(onPlaceholdersExpired).toHaveBeenCalledOnce(); }); it('never arms a timer for queue-backed placeholders', () => { diff --git a/src/components/image-editor/useInlineGenerationPlaceholderExpiry.ts b/src/components/image-editor/useInlineGenerationPlaceholderExpiry.ts index 14687eaa9..7e1f07921 100644 --- a/src/components/image-editor/useInlineGenerationPlaceholderExpiry.ts +++ b/src/components/image-editor/useInlineGenerationPlaceholderExpiry.ts @@ -1,4 +1,4 @@ -import { type RefObject, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { collectExpiredInlineGenerationDialogIds, @@ -6,9 +6,54 @@ import { } from './ImageCanvasEditorModel'; import type { CanvasGenerationDialogState } from './ImageCanvasEditorTypes'; +export type InlineGenerationPlaceholderOwnership = Readonly<{ + version: number; + claim: (dialogId: string) => boolean; + release: (dialogId: string) => boolean; + has: (dialogId: string) => boolean; +}>; + +/** + * 中文注释:同步 Set 是本会话 ownership 的即时真值;version 只负责把 membership 变化 + * 通知给 React。所有写入都必须经过 claim / release,避免直接修改 ref 后 effect 无法观察。 + */ +export function useInlineGenerationPlaceholderOwnership(): InlineGenerationPlaceholderOwnership { + const activeDialogIdsRef = useRef(new Set()); + const [version, setVersion] = useState(0); + const claim = useCallback((dialogId: string) => { + if (activeDialogIdsRef.current.has(dialogId)) { + return false; + } + activeDialogIdsRef.current.add(dialogId); + setVersion((currentVersion) => currentVersion + 1); + return true; + }, []); + const release = useCallback((dialogId: string) => { + if (!activeDialogIdsRef.current.delete(dialogId)) { + return false; + } + setVersion((currentVersion) => currentVersion + 1); + return true; + }, []); + const has = useCallback( + (dialogId: string) => activeDialogIdsRef.current.has(dialogId), + [], + ); + + return useMemo( + () => ({ + version, + claim, + release, + has, + }), + [claim, has, release, version], + ); +} + type InlineGenerationPlaceholderExpiryOptions = { canvasGenerationDialogs: CanvasGenerationDialogState[]; - activeInlineGenerationDialogIdsRef: RefObject>; + activeInlineGenerationDialogOwnership: InlineGenerationPlaceholderOwnership; removeCanvasGenerationDialogById: (dialogId: string) => void; onPlaceholdersExpired: (expiredCount: number) => void; }; @@ -31,17 +76,21 @@ type InlineGenerationPlaceholderExpiryOptions = { * `delete-generation-result` 历史、清空选中、切回选择工具。自动清理记用户没做过的历史、 * 抢走用户当前的选中态和工具,都是错的;加载期剥离同样不做这些。 * - * 三、本会话自己在途的占位不会被误清。客户端 120 秒就会 abort,catch 会把它推离 - * `generating`,180 秒时它不可能还处于 `generating`。但这里不依赖该推理——第一点的 - * 「到期重新判定」本身就兜住了:条件不成立就什么都不做。 + * 三、本会话自己在途的占位不会被误清。ownership 的同步 Set 负责在首个 await 前立即 + * 挡住清理;claim / release 同时推进可观察 version,让 membership 变化必然触发重新判定。 + * 不能把可变 ref 对象本身放进依赖后直接修改 `.current`,React 不会观察这种变化。 */ export function useInlineGenerationPlaceholderExpiry({ canvasGenerationDialogs, - activeInlineGenerationDialogIdsRef, + activeInlineGenerationDialogOwnership, removeCanvasGenerationDialogById, onPlaceholdersExpired, }: InlineGenerationPlaceholderExpiryOptions) { const [expiryTick, setExpiryTick] = useState(0); + const { + has: isDialogOwnedByCurrentSession, + version: activeInlineGenerationOwnershipVersion, + } = activeInlineGenerationDialogOwnership; useEffect(() => { // 中文注释:本会话仍在执行的占位一律跳过。到期清理只针对已死会话留下的孤儿,而 // dialog 状态区分不出这两者——本会话在源图直传或布局保存阶段慢起来时,它的占位同样 @@ -55,9 +104,8 @@ export function useInlineGenerationPlaceholderExpiry({ // 到期时刻,delayMs 塌成 50ms,定时器触发 → tick → 重跑 → 状态没变 → 再挂 50ms, // 变成每 50 毫秒一次 setState 的忙等。可达路径是真实的:前置 90 秒 + POST 120 秒之后 // catch 里还要做对账 GET,而归属要到 finally 才释放。 - const activeDialogIds = activeInlineGenerationDialogIdsRef.current; const unownedDialogs = canvasGenerationDialogs.filter( - (dialog) => !activeDialogIds?.has(dialog.id), + (dialog) => !isDialogOwnedByCurrentSession(dialog.id), ); const expiredIds = collectExpiredInlineGenerationDialogIds(unownedDialogs); if (expiredIds.length > 0) { @@ -82,9 +130,10 @@ export function useInlineGenerationPlaceholderExpiry({ window.clearTimeout(timer); }; }, [ - activeInlineGenerationDialogIdsRef, + activeInlineGenerationOwnershipVersion, canvasGenerationDialogs, expiryTick, + isDialogOwnedByCurrentSession, onPlaceholdersExpired, removeCanvasGenerationDialogById, ]);