修复内联生成占位归属清理
引入可观察的占位归属注册表,让归属释放可靠触发到期清理。 为完美像素首次提交和人工重试路径配对登记与释放,并统一恢复查询。 补充固定输入与 StrictMode 覆盖,移除依赖 rerender 的假触发。 同步图片画布技术方案与项目决策记录。
This commit is contained in:
@@ -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`。
|
||||
|
||||
@@ -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` 提交给后端。
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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<string>());
|
||||
//
|
||||
// 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<string, AbortController>(),
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
<Harness
|
||||
dialogs={[dialog({ requiresLiveSession: true, generationStartedAt: startedAt })]}
|
||||
dialogs={[
|
||||
dialog({ requiresLiveSession: true, generationStartedAt: startedAt }),
|
||||
]}
|
||||
removeCanvasGenerationDialogById={removeCanvasGenerationDialogById}
|
||||
onPlaceholdersExpired={onPlaceholdersExpired}
|
||||
/>,
|
||||
@@ -267,23 +268,35 @@ describe('useInlineGenerationPlaceholderExpiry', () => {
|
||||
// 在状态上完全一致——只能靠显式登记归属区分。删掉自己正在用的占位会让随后的 POST 因
|
||||
// 占位不存在返回 409。
|
||||
const removeCanvasGenerationDialogById = vi.fn();
|
||||
render(
|
||||
<Harness
|
||||
dialogs={[
|
||||
dialog({
|
||||
requiresLiveSession: true,
|
||||
generationStartedAt: Date.now() - WINDOW_MS - 60_000,
|
||||
}),
|
||||
]}
|
||||
activeDialogIds={['dialog-1']}
|
||||
removeCanvasGenerationDialogById={removeCanvasGenerationDialogById}
|
||||
onPlaceholdersExpired={vi.fn()}
|
||||
/>,
|
||||
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(
|
||||
<Harness
|
||||
dialogs={[expired]}
|
||||
activeDialogIds={['dialog-1']}
|
||||
removeCanvasGenerationDialogById={removeCanvasGenerationDialogById}
|
||||
onPlaceholdersExpired={vi.fn()}
|
||||
/>,
|
||||
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(
|
||||
<Harness
|
||||
dialogs={[expired]}
|
||||
activeDialogIds={[]}
|
||||
removeCanvasGenerationDialogById={removeCanvasGenerationDialogById}
|
||||
onPlaceholdersExpired={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -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<string>());
|
||||
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<Set<string>>;
|
||||
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,
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user