inline 占位补到期清理,收掉存活窗口引入的回归
存活窗口把「立即刷新也能清掉孤儿占位」换掉了:剥离只在首次加载跑一次,窗口内 被保留的占位再没有东西会重新判定,页面开着就一直转。 复查发现缺的不是删除手段——requestRemoveCanvasGenerationDialog 已接入快捷键, 对 generating 会弹确认再删——而是「它已经死了」这个信号。 加一次性到期定时器,到点走与加载期完全一致的处置:移除 + 同一条文案。未采纳 标记 failed,那会被持久化且剥离只处理 generating,卡片跨刷新长期存在,与当初 选「刷新后占位消失」的用意相反。 三条实现约束:到期回调只推进 tick、判定在 effect 里用当前值重做;清理用底层 removeCanvasGenerationDialogById 而非 View 的版本(后者写历史、清选中、切工具, 是用户主动删除的语义);本会话在途占位不靠推理豁免,靠重新判定兜住。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6107,3 +6107,14 @@
|
||||
- 复查中核过、确认不是问题的两点:其一,网关放宽的作用域正确——`/api/*` 走 `is_generic_api_proxy_path`,读超时默认 `3600` 秒,远高于服务端 90 秒预算与客户端 120 秒,次序是 `90 < 120 < 3600`,网关不会在服务端合法工作期间截断,它合成 502/504 只可能是连接失败或进程不可用,确属未知结果;唯一变数是有人把 `GENARRATIVE_PINGORA_GATEWAY_UPSTREAM_API_READ_TIMEOUT_SECONDS` 调到 90 秒以下。其二,`projectHasUnresolvedGenerationDialog` 由 `some(...)` 改为「取首个匹配再判」存在极低风险的语义收窄,同 id 多 dialog 时行为不同,但 id 唯一,实际不可达。
|
||||
- 验证:`vitest src/components/image-editor` 908 通过 / 72 文件,typecheck、eslint、check:encoding 通过。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-08-03 inline 占位补到期清理,收掉存活窗口引入的回归
|
||||
|
||||
- 缺陷:存活窗口把「立即刷新也能清掉孤儿占位」这条旧行为换掉了。剥离只在项目首次加载执行一次,窗口内被保留的占位再没有任何东西会重新判定——页面保持打开就一直转,必须等到用户下一次加载且距创建已满窗口才收口。这是引入 TTL 时的已知取舍,本次补上。
|
||||
- 复查中发现的关键事实:**手动收口入口本来就存在**——`requestRemoveCanvasGenerationDialog` 已接入键盘快捷键,对 `generating` 占位会先弹确认再删。所以缺的从来不是删除手段,而是「它已经死了」这个信号;占位看起来和正在干活一模一样。
|
||||
- 决策:加一次性到期定时器,到点走与加载期**完全一致**的处置——移除 + 同一条文案(抽成 `DEAD_INLINE_PLACEHOLDER_NOTICE` 共用)。未采纳「标记 failed 而不移除」:`failed` 会被自动保存持久化,而剥离只处理 `generating`,那张卡片会跨刷新长期存在,与当初选「刷新后占位消失」的用意相反,把一次性噪音变成永久残留。
|
||||
- 三条必须守住的实现约束,都写进了 hook 的文档注释:其一,到期回调只推进 tick 让 effect 重跑,判定始终在 effect 体里用当前 dialogs 和当前时间做——挂上定时器之后占位可能已被拥有者会话正常收口,按闭包旧值行动会清掉一个已完成的占位;其二,清理用底层 `removeCanvasGenerationDialogById` 而不是 View 的 `removeCanvasGenerationDialog`,后者是用户主动删除的语义(写 `delete-generation-result` 历史、清空选中、切回选择工具),自动清理记用户没做过的历史、抢走当前选中态都是错的,加载期剥离同样不做这些;其三,本会话自己在途的占位不会被误清(客户端 120 秒就 abort,catch 会把它推离 `generating`),但不依赖该推理,靠第一条的重新判定兜住。
|
||||
- 作用面划分:`dropDeadInlineGenerationPlaceholders` 跑在**快照**上、只在加载时执行;`collectExpiredInlineGenerationDialogIds` / `resolveNextInlineGenerationDialogExpiryAt` 跑在**内存 dialog** 上、供页面打开期间使用。同一条规则、两种数据形状,边界(到期时刻含等号不算过期)与缺时间戳的兜底方向都保持一致。
|
||||
- 到期时刻可精确计算,所以挂一次性定时器而不是轮询;多给 50ms 余量,避免贴着到期时刻醒来判定为未到期、白白多挂一轮。
|
||||
- 验证:纯函数四条用例覆盖边界、队列型与已终态不到期、缺时间戳立即到期、最早到期时刻;hook 四条覆盖到期清理、醒来重新判定(占位期间被收口则不清)、队列型永不挂定时器、已超窗立即清理。去掉定时器重挂逻辑后第一条变红。`vitest src/components/image-editor` 916 通过 / 73 文件,typecheck、eslint、check:encoding 通过。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
CANVAS_WORLD_ORIGIN,
|
||||
canvasDisplayScaleToViewportScale,
|
||||
canvasDisplayViewportToViewport,
|
||||
collectExpiredInlineGenerationDialogIds,
|
||||
createLayerFromAsset,
|
||||
DEFAULT_CANVAS_BACKGROUND_COLOR,
|
||||
dropDeadInlineGenerationPlaceholders,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
hydrateLayer,
|
||||
normalizeAssetLibrary,
|
||||
normalizeCanvasBackgroundHex,
|
||||
resolveNextInlineGenerationDialogExpiryAt,
|
||||
resolveSnappedLayerPosition,
|
||||
serializeCanvasLayout,
|
||||
serializeLayer,
|
||||
@@ -999,6 +1001,73 @@ describe('ImageCanvasEditorModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('inline placeholder expiry helpers', () => {
|
||||
const dialog = (
|
||||
overrides: Partial<CanvasGenerationDialogState>,
|
||||
): CanvasGenerationDialogState =>
|
||||
({
|
||||
id: 'dialog-1',
|
||||
mode: 'quick-edit',
|
||||
prompt: '完美像素',
|
||||
status: 'generating',
|
||||
...overrides,
|
||||
}) as CanvasGenerationDialogState;
|
||||
|
||||
it('reports an inline placeholder as expired only after the window elapses', () => {
|
||||
// 中文注释:与 dropDeadInlineGenerationPlaceholders 是同一条规则的两个作用面,边界
|
||||
// 必须一致——正好到期不算过期,超过一毫秒才算。
|
||||
const now = 1_700_000_000_000;
|
||||
const live = [dialog({ requiresLiveSession: true, generationStartedAt: now - 180_000 })];
|
||||
const stale = [dialog({ requiresLiveSession: true, generationStartedAt: now - 180_001 })];
|
||||
|
||||
expect(collectExpiredInlineGenerationDialogIds(live, now)).toEqual([]);
|
||||
expect(collectExpiredInlineGenerationDialogIds(stale, now)).toEqual([
|
||||
'dialog-1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('never expires queue-backed or settled placeholders', () => {
|
||||
// 中文注释:队列型占位的 job 在服务端继续跑,误清会让用户以为没发生而重复提交;
|
||||
// 已终态的占位也不该被自动清掉,那是用户要自己处置的失败卡片。
|
||||
const now = 1_700_000_000_000;
|
||||
const dialogs = [
|
||||
dialog({ id: 'queued', generationStartedAt: now - 999_999 }),
|
||||
dialog({
|
||||
id: 'settled',
|
||||
requiresLiveSession: true,
|
||||
status: 'failed',
|
||||
generationStartedAt: now - 999_999,
|
||||
}),
|
||||
];
|
||||
|
||||
expect(collectExpiredInlineGenerationDialogIds(dialogs, now)).toEqual([]);
|
||||
expect(resolveNextInlineGenerationDialogExpiryAt(dialogs)).toBeNull();
|
||||
});
|
||||
|
||||
it('expires a live-session placeholder that carries no usable timestamp', () => {
|
||||
// 中文注释:兜底方向与快照侧一致——按保留会让这类占位永久转下去。
|
||||
expect(
|
||||
collectExpiredInlineGenerationDialogIds(
|
||||
[dialog({ requiresLiveSession: true })],
|
||||
1_700_000_000_000,
|
||||
),
|
||||
).toEqual(['dialog-1']);
|
||||
});
|
||||
|
||||
it('resolves the earliest expiry so callers can arm a single timer', () => {
|
||||
// 中文注释:到期时刻可以精确算出来,调用方据此挂一次性定时器而不是轮询。
|
||||
const now = 1_700_000_000_000;
|
||||
const dialogs = [
|
||||
dialog({ id: 'later', requiresLiveSession: true, generationStartedAt: now - 10_000 }),
|
||||
dialog({ id: 'sooner', requiresLiveSession: true, generationStartedAt: now - 60_000 }),
|
||||
];
|
||||
|
||||
expect(resolveNextInlineGenerationDialogExpiryAt(dialogs)).toBe(
|
||||
now - 60_000 + 180_000,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dropDeadInlineGenerationPlaceholders', () => {
|
||||
const buildProject = (
|
||||
layers: EditorProjectLayerSnapshot[],
|
||||
|
||||
@@ -563,6 +563,61 @@ export function isUnresolvedCanvasGenerationDialogRecord(
|
||||
*/
|
||||
const INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS = 180_000;
|
||||
|
||||
function inlineGenerationPlaceholderExpiryAt(
|
||||
dialog: CanvasGenerationDialogState,
|
||||
): number | null {
|
||||
if (dialog.requiresLiveSession !== true || dialog.status !== 'generating') {
|
||||
return null;
|
||||
}
|
||||
const startedAt = dialog.generationStartedAt;
|
||||
// 中文注释:缺时间戳按「立即到期」处理,与 dropDeadInlineGenerationPlaceholders 的兜底
|
||||
// 方向一致——按保留会让这类占位永久转下去。
|
||||
if (typeof startedAt !== 'number' || !Number.isFinite(startedAt)) {
|
||||
return Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
return startedAt + INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 中文注释:内存态里已经越过存活窗口的 inline 占位 id。
|
||||
*
|
||||
* 与 `dropDeadInlineGenerationPlaceholders` 是同一条规则的两个作用面:那个跑在**快照**上、
|
||||
* 只在项目加载时执行一次;这个跑在**内存 dialog** 上,供页面打开期间的到期清理使用。
|
||||
* 少了后者,加载时因未到期而被保留的孤儿占位就再没有任何东西会重新判定,只能转到用户
|
||||
* 下一次加载——那正是引入存活窗口带来的回归。
|
||||
*/
|
||||
export function collectExpiredInlineGenerationDialogIds(
|
||||
dialogs: readonly CanvasGenerationDialogState[],
|
||||
now: number = Date.now(),
|
||||
): string[] {
|
||||
return dialogs
|
||||
.filter((dialog) => {
|
||||
const expiryAt = inlineGenerationPlaceholderExpiryAt(dialog);
|
||||
return expiryAt !== null && now > expiryAt;
|
||||
})
|
||||
.map((dialog) => dialog.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 中文注释:下一个 inline 占位到期的绝对时刻,没有则返回 null。调用方据此挂一次性定时器,
|
||||
* 而不是轮询——到期时刻是可以精确算出来的。
|
||||
*/
|
||||
export function resolveNextInlineGenerationDialogExpiryAt(
|
||||
dialogs: readonly CanvasGenerationDialogState[],
|
||||
): number | null {
|
||||
let earliest: number | null = null;
|
||||
for (const dialog of dialogs) {
|
||||
const expiryAt = inlineGenerationPlaceholderExpiryAt(dialog);
|
||||
if (expiryAt === null || !Number.isFinite(expiryAt)) {
|
||||
continue;
|
||||
}
|
||||
if (earliest === null || expiryAt < earliest) {
|
||||
earliest = expiryAt;
|
||||
}
|
||||
}
|
||||
return earliest;
|
||||
}
|
||||
|
||||
/**
|
||||
* 中文注释:剥离「只能由已死会话收口」的 generating 占位。
|
||||
*
|
||||
|
||||
@@ -97,6 +97,7 @@ import {
|
||||
DEFAULT_IMAGE_CANVAS_VIEWPORT,
|
||||
useImageCanvasViewportControls,
|
||||
} from './useImageCanvasViewportControls';
|
||||
import { useInlineGenerationPlaceholderExpiry } from './useInlineGenerationPlaceholderExpiry';
|
||||
|
||||
const TASK_FOCUS_HORIZONTAL_INSET = 28;
|
||||
const TASK_FOCUS_TOP_INSET = 82;
|
||||
@@ -295,6 +296,13 @@ function createAssetActionLayer(asset: EditorAsset): CanvasLayer {
|
||||
};
|
||||
}
|
||||
|
||||
// 中文注释:服务端持久化顺序是 OSS PUT → asset object → project resource → editor asset
|
||||
// → 画布收口,非事务。看到孤儿 generating 占位只能说明最后一步没做完,前面几步可能已经
|
||||
// 成功。所以不能断言「什么都没发生」,只能指路让用户自己核对素材库。
|
||||
// 加载期剥离与页面打开期的到期清理共用这一条,两条路径对用户完全一致。
|
||||
const DEAD_INLINE_PLACEHOLDER_NOTICE =
|
||||
'上次的完美像素处理未完成,画布占位已清理。请确认素材库是否已生成派生图。';
|
||||
|
||||
export function ImageCanvasEditorView({
|
||||
onProjectAccessLost,
|
||||
}: ImageCanvasEditorViewProps = {}) {
|
||||
@@ -1508,13 +1516,18 @@ export function ImageCanvasEditorView({
|
||||
if (deadInlinePlaceholderDropCount === 0) {
|
||||
return;
|
||||
}
|
||||
// 中文注释:服务端持久化顺序是 OSS PUT → asset object → project resource → editor asset
|
||||
// → 画布收口,非事务。加载时还看到 generating 只能说明最后一步没做完,前面几步可能已经
|
||||
// 成功。所以不能断言「什么都没发生」,只能指路让用户自己核对素材库。
|
||||
showGenerationWarning(
|
||||
'上次的完美像素处理未完成,画布占位已清理。请确认素材库是否已生成派生图。',
|
||||
);
|
||||
showGenerationWarning(DEAD_INLINE_PLACEHOLDER_NOTICE);
|
||||
}, [deadInlinePlaceholderDropCount, showGenerationWarning]);
|
||||
const handleInlinePlaceholdersExpired = useCallback(() => {
|
||||
showGenerationWarning(DEAD_INLINE_PLACEHOLDER_NOTICE);
|
||||
}, [showGenerationWarning]);
|
||||
// 中文注释:加载期的剥离只跑一次,当时未到期而被保留的孤儿占位需要这里补上到期清理,
|
||||
// 否则它会一直转到用户下一次加载。两条路径共用同一条文案,用户感知一致。
|
||||
useInlineGenerationPlaceholderExpiry({
|
||||
canvasGenerationDialogs,
|
||||
removeCanvasGenerationDialogById,
|
||||
onPlaceholdersExpired: handleInlinePlaceholdersExpired,
|
||||
});
|
||||
const handleExternalGenerationTasksCompleted = useCallback(
|
||||
(tasks: ExternalGenerationTaskRecord[]) => {
|
||||
if (!projectId || tasks.length === 0) {
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, render } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { CanvasGenerationDialogState } from './ImageCanvasEditorTypes';
|
||||
import { useInlineGenerationPlaceholderExpiry } from './useInlineGenerationPlaceholderExpiry';
|
||||
|
||||
const WINDOW_MS = 180_000;
|
||||
|
||||
function dialog(
|
||||
overrides: Partial<CanvasGenerationDialogState>,
|
||||
): CanvasGenerationDialogState {
|
||||
return {
|
||||
id: 'dialog-1',
|
||||
mode: 'quick-edit',
|
||||
prompt: '完美像素',
|
||||
status: 'generating',
|
||||
...overrides,
|
||||
} as CanvasGenerationDialogState;
|
||||
}
|
||||
|
||||
function Harness({
|
||||
dialogs,
|
||||
removeCanvasGenerationDialogById,
|
||||
onPlaceholdersExpired,
|
||||
}: {
|
||||
dialogs: CanvasGenerationDialogState[];
|
||||
removeCanvasGenerationDialogById: (dialogId: string) => void;
|
||||
onPlaceholdersExpired: (expiredCount: number) => void;
|
||||
}) {
|
||||
useInlineGenerationPlaceholderExpiry({
|
||||
canvasGenerationDialogs: dialogs,
|
||||
removeCanvasGenerationDialogById,
|
||||
onPlaceholdersExpired,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('useInlineGenerationPlaceholderExpiry', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-08-03T00:00:00.000Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('clears an inline placeholder once its live window elapses', () => {
|
||||
// 中文注释:加载期剥离只跑一次,当时未到期而被保留的孤儿占位靠这里补收口,否则会一直
|
||||
// 转到用户下一次加载——那正是引入存活窗口带来的回归。
|
||||
const removeCanvasGenerationDialogById = vi.fn();
|
||||
const onPlaceholdersExpired = vi.fn();
|
||||
render(
|
||||
<Harness
|
||||
dialogs={[
|
||||
dialog({
|
||||
requiresLiveSession: true,
|
||||
generationStartedAt: Date.now() - WINDOW_MS + 1_000,
|
||||
}),
|
||||
]}
|
||||
removeCanvasGenerationDialogById={removeCanvasGenerationDialogById}
|
||||
onPlaceholdersExpired={onPlaceholdersExpired}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(removeCanvasGenerationDialogById).not.toHaveBeenCalled();
|
||||
|
||||
// 中文注释:定时器触发的是 setState,必须包 act 才会同步刷新出重新判定那一轮。
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2_000);
|
||||
});
|
||||
|
||||
expect(removeCanvasGenerationDialogById).toHaveBeenCalledWith('dialog-1');
|
||||
expect(onPlaceholdersExpired).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('re-evaluates on wake instead of acting on the armed snapshot', () => {
|
||||
// 中文注释:挂上定时器之后占位可能已被拥有者会话正常收口。到期回调若按闭包里的旧值行动,
|
||||
// 就会清掉一个已经完成的占位——判定必须在醒来那一刻用当前 dialogs 重做。
|
||||
const removeCanvasGenerationDialogById = vi.fn();
|
||||
const onPlaceholdersExpired = vi.fn();
|
||||
const startedAt = Date.now() - WINDOW_MS + 1_000;
|
||||
const { rerender } = render(
|
||||
<Harness
|
||||
dialogs={[dialog({ requiresLiveSession: true, generationStartedAt: startedAt })]}
|
||||
removeCanvasGenerationDialogById={removeCanvasGenerationDialogById}
|
||||
onPlaceholdersExpired={onPlaceholdersExpired}
|
||||
/>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<Harness
|
||||
dialogs={[
|
||||
dialog({
|
||||
requiresLiveSession: true,
|
||||
generationStartedAt: startedAt,
|
||||
status: 'idle',
|
||||
generatedLayerId: 'layer-perfect-pixel-1',
|
||||
}),
|
||||
]}
|
||||
removeCanvasGenerationDialogById={removeCanvasGenerationDialogById}
|
||||
onPlaceholdersExpired={onPlaceholdersExpired}
|
||||
/>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(10_000);
|
||||
});
|
||||
|
||||
expect(removeCanvasGenerationDialogById).not.toHaveBeenCalled();
|
||||
expect(onPlaceholdersExpired).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never arms a timer for queue-backed placeholders', () => {
|
||||
// 中文注释:队列型占位的 job 在服务端继续跑,worker 会替换占位。自动清理会让用户以为
|
||||
// 操作没发生而重复提交。
|
||||
const removeCanvasGenerationDialogById = vi.fn();
|
||||
render(
|
||||
<Harness
|
||||
dialogs={[dialog({ generationStartedAt: Date.now() - WINDOW_MS * 10 })]}
|
||||
removeCanvasGenerationDialogById={removeCanvasGenerationDialogById}
|
||||
onPlaceholdersExpired={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(WINDOW_MS * 20);
|
||||
});
|
||||
|
||||
expect(removeCanvasGenerationDialogById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears an already-expired placeholder without waiting for a timer', () => {
|
||||
// 中文注释:加载时就已超窗的情形由快照侧剥离处理,但内存态也可能直接拿到超窗占位
|
||||
// (例如会话内切换项目),此时不该再等一轮定时器。
|
||||
const removeCanvasGenerationDialogById = vi.fn();
|
||||
render(
|
||||
<Harness
|
||||
dialogs={[
|
||||
dialog({
|
||||
requiresLiveSession: true,
|
||||
generationStartedAt: Date.now() - WINDOW_MS - 1,
|
||||
}),
|
||||
]}
|
||||
removeCanvasGenerationDialogById={removeCanvasGenerationDialogById}
|
||||
onPlaceholdersExpired={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(removeCanvasGenerationDialogById).toHaveBeenCalledWith('dialog-1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
collectExpiredInlineGenerationDialogIds,
|
||||
resolveNextInlineGenerationDialogExpiryAt,
|
||||
} from './ImageCanvasEditorModel';
|
||||
import type { CanvasGenerationDialogState } from './ImageCanvasEditorTypes';
|
||||
|
||||
type InlineGenerationPlaceholderExpiryOptions = {
|
||||
canvasGenerationDialogs: CanvasGenerationDialogState[];
|
||||
removeCanvasGenerationDialogById: (dialogId: string) => void;
|
||||
onPlaceholdersExpired: (expiredCount: number) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 中文注释:页面打开期间的 inline 占位到期清理。
|
||||
*
|
||||
* 加载期的 `dropDeadInlineGenerationPlaceholders` 只在项目首次加载执行一次,所以当时还没
|
||||
* 越过存活窗口、被保留下来的孤儿占位再没有任何东西会重新判定——它会一直转到用户下一次
|
||||
* 加载为止。本 hook 补上这一段:算出下一个到期时刻挂一次性定时器,到点按同一条规则处置。
|
||||
*
|
||||
* 三个必须守住的点:
|
||||
*
|
||||
* 一、到期回调**不做判定**,只推进 tick 让 effect 重跑,判定始终在 effect 体里用当前的
|
||||
* dialogs 和当前时间做。挂上定时器之后占位可能已被拥有者会话正常收口、或被用户删掉,
|
||||
* 按闭包里的旧值行动会清掉一个已经完成的占位。
|
||||
*
|
||||
* 二、清理用底层的 `removeCanvasGenerationDialogById`,不是 View 的
|
||||
* `removeCanvasGenerationDialog`。后者是用户主动删除的语义:写一条
|
||||
* `delete-generation-result` 历史、清空选中、切回选择工具。自动清理记用户没做过的历史、
|
||||
* 抢走用户当前的选中态和工具,都是错的;加载期剥离同样不做这些。
|
||||
*
|
||||
* 三、本会话自己在途的占位不会被误清。客户端 120 秒就会 abort,catch 会把它推离
|
||||
* `generating`,180 秒时它不可能还处于 `generating`。但这里不依赖该推理——第一点的
|
||||
* 「到期重新判定」本身就兜住了:条件不成立就什么都不做。
|
||||
*/
|
||||
export function useInlineGenerationPlaceholderExpiry({
|
||||
canvasGenerationDialogs,
|
||||
removeCanvasGenerationDialogById,
|
||||
onPlaceholdersExpired,
|
||||
}: InlineGenerationPlaceholderExpiryOptions) {
|
||||
const [expiryTick, setExpiryTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const expiredIds = collectExpiredInlineGenerationDialogIds(
|
||||
canvasGenerationDialogs,
|
||||
);
|
||||
if (expiredIds.length > 0) {
|
||||
for (const dialogId of expiredIds) {
|
||||
removeCanvasGenerationDialogById(dialogId);
|
||||
}
|
||||
onPlaceholdersExpired(expiredIds.length);
|
||||
return undefined;
|
||||
}
|
||||
const nextExpiryAt = resolveNextInlineGenerationDialogExpiryAt(
|
||||
canvasGenerationDialogs,
|
||||
);
|
||||
if (nextExpiryAt === null) {
|
||||
return undefined;
|
||||
}
|
||||
// 中文注释:多给 50ms 余量。定时器只保证「不早于」,贴着到期时刻醒来会让 effect 重跑一次
|
||||
// 却判定为未到期,白白多挂一轮。
|
||||
const delayMs = Math.max(0, nextExpiryAt - Date.now()) + 50;
|
||||
const timer = window.setTimeout(() => {
|
||||
setExpiryTick((currentTick) => currentTick + 1);
|
||||
}, delayMs);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [
|
||||
canvasGenerationDialogs,
|
||||
expiryTick,
|
||||
onPlaceholdersExpired,
|
||||
removeCanvasGenerationDialogById,
|
||||
]);
|
||||
}
|
||||
Reference in New Issue
Block a user