修复内联生成占位归属清理

引入可观察的占位归属注册表,让归属释放可靠触发到期清理。

为完美像素首次提交和人工重试路径配对登记与释放,并统一恢复查询。

补充固定输入与 StrictMode 覆盖,移除依赖 rerender 的假触发。

同步图片画布技术方案与项目决策记录。
This commit is contained in:
2026-08-04 05:33:52 +00:00
parent 193c17d6ed
commit f6628ead7e
6 changed files with 192 additions and 84 deletions
@@ -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,
]);