修复内联生成占位归属清理
引入可观察的占位归属注册表,让归属释放可靠触发到期清理。 为完美像素首次提交和人工重试路径配对登记与释放,并统一恢复查询。 补充固定输入与 StrictMode 覆盖,移除依赖 rerender 的假触发。 同步图片画布技术方案与项目决策记录。
This commit is contained in:
@@ -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