Files
Genarrative/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts
T
suzmii 2ea3bcbb07
Project CI / Repository checks (pull_request) Failing after 13s
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / Frontend tests (pull_request) Successful in 3m14s
Project CI / Native shell tests (pull_request) Successful in 19m24s
驱逐改为可见性优先,条目上限 48→72:图片不再自己消失又回来
用户报「图片会自己消失然后重新加载」。根因是**淘汰完全按 LRU 插入顺序、且完全不看"是否仍然可见"**:缓存条目顺序是「最近一次被请求」的顺序,而停在屏幕上不动的卡片不会产生新的请求 —— 于是恰恰是用户正看着的那几张排在队首被首选淘汰,`disposeCachedPreview` 释放 Blob 后图片凭空消失,再被兜底扫描重新读回来,表现为闪烁。上一轮「驱逐后补一次可见性复核」只是把"掉了不回来"变成"掉了再加载",是治标。

- `projectResourceCardPreviewEvictionIdentities` 新增 `visibleIdentities` 参数,淘汰改两轮:**第一轮只淘汰视口外的条目**(可见卡一律跳过);**第二轮才回退** —— 只有"剩余条目全部仍在视口内且依旧超预算"时才按全表 LRU 淘汰。回退不可省略,否则"可见即永不淘汰"会造成无界内存,该回退由用例钉住。
- `useProjectResourceCardPreviews` 在淘汰前按几何算出可见集合(复用既有的视口档位判据 `viewportBandOfElement <= 1`,经 ref 转发生效,避免定义顺序耦合)。
- `PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT` 由 `48` 提到 `72`。依据是真机实测:该项目「UI 交互」栏目有 **51 张**可预览卡,而原上限 48 **小于一栏的规模** ⇒ 滚满该栏目必然驱逐;取 72 覆盖 51 张并留约 40% 余量,按真机单张均值 591 KB 外推 ≈ **43 MiB**,**仍在既有 64 MiB 字节预算之内**(真机 52 张 blob 合计 29.32 MiB,仅用掉 45.8%)。**字节预算未动,也不是把上限放大到任意大。**

断言(`tests/useProjectResourceCardPreviews.test.ts`):
1. 「可见卡不得成为首选淘汰对象」:最老的 3 张都在屏幕上时,淘汰必须跳过它们、改淘汰视口外的第 4 张;对照用例同时钉住"不给可见信息时退化为纯 LRU";
2. 「全可见且超预算时仍必须淘汰」(防无界内存),条目上限与字节上限两侧各一条;
3. 「51 张整栏零淘汰」:真机栏目规模下 `projectResourceCardPreviewEvictionIdentities` 必须返回空数组,且断言字节侧余量。
另把原先守旧行为的用例改写为守新契约:可见卡被后续加载挤出缓存上限时**必须仍保持 `loaded` 且不产生第二次读取**(不再依赖"掉了再补读")。既有用例一条未放宽。

变异验证:
- 去掉可见性过滤(第一轮不再跳过可见卡)→ 断言 1 所属用例立即失败(`expected [ [ …(2) ], [ …(2) ] ] to have a length of 1 but got 2`,即目标卡被驱逐并重读);
- 去掉"全可见时回退全表 LRU"→ 断言 2 立即失败(`expected [] to deeply equal [ 'item-0' ]`,即缓存无界)。

验证:定向 `useProjectResourceCardPreviews` 31 passed;typecheck exit 0;prettier 干净。(全量 AGC 子集的前后对照见随后的回报。)
2026-09-11 20:39:59 +08:00

1315 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { ProjectResourceCanvasLayoutMode } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import {
cancelLocalProjectResourcePreviewScope,
createProjectResourcePreviewRequestId,
createProjectResourcePreviewScopeId,
isProjectResourcePreviewCancellation,
} from '../../services/projectResourcePreviewTransport';
import {
IDLE_PROJECT_RESOURCE_CARD_PREVIEW,
PROJECT_RESOURCE_CARD_PREVIEW_ACTIVE_QUEUE_RESERVE,
PROJECT_RESOURCE_CARD_PREVIEW_CACHE_BYTE_LIMIT,
PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT,
PROJECT_RESOURCE_CARD_PREVIEW_CONCURRENCY,
PROJECT_RESOURCE_CARD_PREVIEW_QUEUE_LIMIT,
projectResourceCardPreviewEvictionIdentities,
projectResourceCardPreviewIdentity,
projectResourceCardPreviewImageDimensions,
type ProjectResourceCardPreviewKind,
projectResourceCardPreviewKind,
type ProjectResourceCardPreviewPayload,
type ProjectResourceCardPreviewState,
type ProjectResourceCardPreviewTransportPayload,
projectResourceMediaPreviewCategory,
} from './resourceCardPreviewModel';
import type { ProjectResource } from './resourceProjectionModel';
/**
* 容器 root 迟迟拿不到时的有界重试:5 次、1.6s 内退避到 512ms。
*
* 重试只用于「把视口判定换回画本容器判定」这一次精度提升,不是门禁可用性的前提 ——
* observer 在没有容器 root 时也会按视口建出来(见 observer effect 的注释),
* 所以重试用尽也不会让任何卡片停在未观察状态。
*/
const RESOURCE_PREVIEW_ROOT_RETRY_LIMIT = 5;
const RESOURCE_PREVIEW_ROOT_RETRY_BASE_MS = 32;
/**
* 可见性兜底扫描的延迟点(毫秒)。
*
* 门禁原先只有 `IntersectionObserver` 一条路:observer 没建出来、回调没送达、
* 或注册与创建交错,卡片就会**再也没有第二次机会**,表现为"可见卡停在 `idle`、
* 卡面只剩占位图标"。兜底扫描按**同一个可见性判据**独立复核一遍登记表,
* 给这些时序缺陷一个恢复窗口。
*
* 多个时间点而不是单次定时器:晚挂载的卡(分页切换、懒挂载)也要被覆盖到。
*/
const RESOURCE_PREVIEW_VISIBLE_SWEEP_DELAYS_MS = [0, 250, 1000] as const;
/** 视口判定允许的额外边界,与 observer 的 `rootMargin: '160px'` 对齐。 */
const RESOURCE_PREVIEW_VIEWPORT_MARGIN_PX = 160;
type PreviewRequestReason = 'visible' | 'detail' | 'play';
type PreviewJob = {
scopeKey: string;
scopeId: string;
scopeEpoch: number;
identity: string;
resource: ProjectResource;
reason: PreviewRequestReason;
/** 入队时「当前为谁预取」的标识;见 `prefetchScopeKey`。 */
prefetchScopeKey: string;
};
type MaterializedPreview = {
preview: ProjectResourceCardPreviewPayload;
retainedBytes: number;
objectUrl: string | null;
};
type CachedPreview = {
retainedBytes: number;
objectUrl: string | null;
};
type ObservedPreviewCard = {
identity: string;
resource: ProjectResource;
};
function resourceReadKindLabel(
resource: ProjectResource,
kind: ProjectResourceCardPreviewKind,
) {
if (kind === 'document') {
return '文档';
}
if (kind === 'code') {
return '游戏代码';
}
return '资源';
}
function previewReadErrorMessage(
resource: ProjectResource,
kind: ProjectResourceCardPreviewKind,
error: unknown,
): { error: string; retryable: boolean } {
const message = error instanceof Error ? error.message : String(error);
const kindLabel = resourceReadKindLabel(resource, kind);
if (message.includes('项目权限策略要求用户确认')) {
return {
error: `当前项目策略要求先确认读取${kindLabel},确认后请关闭详情并重试`,
retryable: true,
};
}
if (message.includes('项目权限策略拒绝执行')) {
return {
error: `当前项目策略不允许读取${kindLabel},调整策略后请关闭详情并重试`,
retryable: true,
};
}
if (message.includes('发生漂移') || message.includes('发生替换')) {
return {
error: '资源读取期间发生变化,请关闭详情并重试',
retryable: true,
};
}
if (
message.includes('不能超过') ||
message.includes('UTF-8') ||
message.includes('只支持') ||
message.includes('脚本或外部资源引用') ||
message.includes('图片尺寸过大') ||
message.includes('图片尺寸溢出') ||
message.includes('图片结构无效') ||
message.includes('内容无效') ||
message.includes('文件为空') ||
message.includes('图片不能为空') ||
message.includes('签名与登记类型不一致')
) {
return {
error: message
.replace('image.inspect', '图片预览')
.replace(',暂时无法在客户端预览', ',无法在客户端预览'),
retryable: false,
};
}
return {
error: '资源暂时无法读取,请关闭详情并重试',
retryable: true,
};
}
const previewRequestPriority: Record<PreviewRequestReason, number> = {
visible: 0,
detail: 1,
play: 2,
};
function materializeProjectResourceCardPreview(
transport: ProjectResourceCardPreviewTransportPayload,
): MaterializedPreview {
if (!transport.dataUrl) {
return {
preview: {
path: transport.path,
mediaType: transport.mediaType,
byteLen: transport.byteLen,
pixelWidth: transport.pixelWidth,
pixelHeight: transport.pixelHeight,
content: transport.content,
},
retainedBytes:
transport.content === undefined
? 0
: new TextEncoder().encode(transport.content).byteLength,
objectUrl: null,
};
}
const imageDimensions = projectResourceCardPreviewImageDimensions(transport);
const prefix = `data:${transport.mediaType};base64,`;
if (!transport.dataUrl.startsWith(prefix)) {
throw new Error('媒体预览内容无效:data URL 类型与媒体类型不一致');
}
let decoded: string;
try {
decoded = window.atob(transport.dataUrl.slice(prefix.length));
} catch {
throw new Error('媒体预览内容无效:base64 解码失败');
}
const bytes = new Uint8Array(decoded.length);
for (let index = 0; index < decoded.length; index += 1) {
bytes[index] = decoded.charCodeAt(index);
}
const blob = new Blob([bytes], { type: transport.mediaType });
if (blob.size > PROJECT_RESOURCE_CARD_PREVIEW_CACHE_BYTE_LIMIT) {
throw new Error('媒体预览不能超过客户端缓存字节预算');
}
const objectUrl = URL.createObjectURL(blob);
return {
preview: {
path: transport.path,
mediaType: transport.mediaType,
byteLen: transport.byteLen,
pixelWidth: imageDimensions?.pixelWidth,
pixelHeight: imageDimensions?.pixelHeight,
sourceUrl: objectUrl,
},
retainedBytes: blob.size,
objectUrl,
};
}
/**
* 选下一个要跑的排队任务:**理由优先级固定 `play > detail > visible`,同一理由内当前预取作用域优先**。
*
* 为什么需要"作用域优先"这一档:全局 3 槽跨视图共享,进入总览时队列里可能还压着
* 上一视图的可见性预取。若不做同级裁决,总览自己那几张"该出图"的卡会排在它们后面,
* 用户感知就是"进总览要等图"。
*
* ⚠️ 作用域**只做同一理由内的平手裁决**,绝不允许当前视图的 `visible` 越过 `play`
* 那会破坏 PRD §3.3.2 的固定调度优先级。早期实现用 `+3` 加权就踩过这个坑
* (当前视图的 visible 会压过上一视图的 play),由对应的契约用例钉住。
*/
export function nextPreviewJob(queue: PreviewJob[], prefetchScopeKey: string) {
const isPreferred = (job: PreviewJob) =>
job.prefetchScopeKey === prefetchScopeKey;
const better = (candidate: PreviewJob, incumbent: PreviewJob) => {
const candidateRank = previewRequestPriority[candidate.reason];
const incumbentRank = previewRequestPriority[incumbent.reason];
if (candidateRank !== incumbentRank) {
return candidateRank > incumbentRank;
}
return isPreferred(candidate) && !isPreferred(incumbent);
};
let nextIndex = 0;
for (let index = 1; index < queue.length; index += 1) {
if (better(queue[index]!, queue[nextIndex]!)) {
nextIndex = index;
}
}
return queue.splice(nextIndex, 1)[0];
}
export function useProjectResourceCardPreviews(input: {
projectPath: string;
projectId: string;
mode: ProjectResourceCanvasLayoutMode;
/**
* 身份与渲染口径,**必须传全量资源投影**。
*
* 这里同时决定 `identityByResourceId`,而调用方在身份缺失时会直接不挂载卡片
* `ProjectDevelopmentView` 的 `renderResourceBookCard` 对未知身份 `return null`)。
* 因此把本参数收窄成「当前分页栏目」会让总览里其它栏目只剩栏目标题栏和空的缩略卡片。
* 需要缩小预热范围请用 [`eagerResources`],不要改这里。
*/
resources: ProjectResource[];
/**
* 只用于热预取(`eagerPreviewLimit` 计数的那个集合),不影响身份 / 缓存 / 渲染。
*
* 缺省回退到 `resources`。资源画布传「当前分页栏目」,避免用户看不到的栏目先占满
* 全局物理读取槽;传全量时前若干张可能落在别的栏目,看得见的卡片反而要排队。
*/
eagerResources?: ProjectResource[];
canvasRef: React.RefObject<HTMLDivElement | null>;
// 可见性判定容器。卡片渲染在资源画本场景里时,传真正包含卡片的容器;
// 缺省回退到 canvasRef。
intersectionRootRef?: React.RefObject<HTMLDivElement | null>;
eagerPreviewLimit?: number;
previewVersionByResourceId?: ReadonlyMap<string, string>;
/**
* 「当前为谁预取」的标识(资源画布传视图标识,例如 `main` / `child:<栏目>`)。
*
* 它变化时**只取消队列里 `visible` 理由的预取**:用户已经离开那个视图,
* 继续为它排队读图没有任何收益,而这些排队的预取会**排在后面视图的按需请求前面**
* (全局 3 槽跨越视图共享),把"进入总览后等图"变成纯等待。
*
* 语义边界(刻意收窄):
* - **不取消任何按需加载**`detail` / `play` 理由的排队与在途一律保留;
* - **不失效任何缓存与身份**`cachedPreviewsRef` / `identityByResourceId` 原样保留,
* 切回原视图不会重读已经拿到的图;
* - **不留"取消后永不重试"的死角**:被取消的卡如果在几何上仍然可见,
* 兜底扫描会重新按 `visible` 入队(扫描是"什么算可见"的仲裁者)。
*/
prefetchScopeKey?: string;
}) {
const scopeKey = JSON.stringify([input.projectPath, input.projectId]);
const identityByResourceId = useMemo(
() =>
new Map(
input.resources.map((resource) => [
resource.id,
projectResourceCardPreviewIdentity({
projectPath: input.projectPath,
projectId: input.projectId,
previewVersion:
input.previewVersionByResourceId?.get(resource.id) ?? '',
resource,
}),
]),
),
[
input.previewVersionByResourceId,
input.projectId,
input.projectPath,
input.resources,
],
);
const [previews, setPreviews] = useState<
Map<string, ProjectResourceCardPreviewState>
>(new Map());
const [imageDimensionsByIdentity, setImageDimensionsByIdentity] = useState<
Map<
string,
NonNullable<ReturnType<typeof projectResourceCardPreviewImageDimensions>>
>
>(new Map());
const [initialScopeId] = useState(createProjectResourcePreviewScopeId);
/**
* 当前预取作用域:优先用调用方显式传入的 `prefetchScopeKey`;没有传时,
* 退化为**被喂进本 hook 的资源集合签名** —— 资源集合变了就等价于"换了要预取的对象",
* 因此不强制调用方多接一根线(`index.tsx` 不必改)。
*/
const prefetchScopeKey =
input.prefetchScopeKey ??
JSON.stringify(input.resources.map((resource) => resource.id));
const prefetchScopeKeyRef = useRef(prefetchScopeKey);
prefetchScopeKeyRef.current = prefetchScopeKey;
const previewsRef = useRef(previews);
const imageDimensionsByIdentityRef = useRef(imageDimensionsByIdentity);
const scopeKeyRef = useRef(scopeKey);
const scopeIdRef = useRef(initialScopeId);
const scopeInitializedRef = useRef(false);
const scopeEpochRef = useRef(0);
const identityByResourceIdRef = useRef(identityByResourceId);
const validIdentitiesRef = useRef(new Set(identityByResourceId.values()));
const observerRef = useRef<IntersectionObserver | null>(null);
const observedCardsRef = useRef(new Map<HTMLElement, ObservedPreviewCard>());
const queueRef = useRef<PreviewJob[]>([]);
const queuedIdentitiesRef = useRef(new Map<string, number>());
/**
* 被队列上限吞掉的**按需**请求(`detail` / `play`)。
*
* 这类丢弃此前完全不可见:用户在可见卡上点播放,没有请求、没有报错、没有 loading。
* 记为单调计数 + 最后一条明细,供排障与用例观测;`visible` 的背压不计入(那是设计内的)。
*/
const droppedRequestsRef = useRef<{
count: number;
last: {
identity: string;
reason: PreviewRequestReason;
queueLength: number;
at: number;
} | null;
}>({ count: 0, last: null });
const pendingIdentitiesRef = useRef(new Map<string, number>());
const cacheOrderRef = useRef(new Map<string, true>());
const cachedPreviewsRef = useRef(new Map<string, CachedPreview>());
const cacheBytesRef = useRef(0);
const activeReadsRef = useRef({ scopeEpoch: 0, count: 0 });
const protectedIdentityRef = useRef<string | null>(null);
const drainQueueRef = useRef<() => void>(() => undefined);
scopeKeyRef.current = scopeKey;
identityByResourceIdRef.current = identityByResourceId;
validIdentitiesRef.current = new Set(identityByResourceId.values());
const isCurrentJob = useCallback((job: PreviewJob) => {
return (
scopeEpochRef.current === job.scopeEpoch &&
scopeKeyRef.current === job.scopeKey &&
scopeIdRef.current === job.scopeId &&
identityByResourceIdRef.current.get(job.resource.id) === job.identity
);
}, []);
const disposeCachedPreview = useCallback((identity: string) => {
const cached = cachedPreviewsRef.current.get(identity);
if (!cached) {
return;
}
cachedPreviewsRef.current.delete(identity);
cacheBytesRef.current = Math.max(
0,
cacheBytesRef.current - cached.retainedBytes,
);
if (cached.objectUrl) {
URL.revokeObjectURL(cached.objectUrl);
}
}, []);
const disposeAllCachedPreviews = useCallback(() => {
for (const identity of Array.from(cachedPreviewsRef.current.keys())) {
disposeCachedPreview(identity);
}
cacheOrderRef.current.clear();
cacheBytesRef.current = 0;
}, [disposeCachedPreview]);
const publishPreview = useCallback(
(
identity: string,
state: ProjectResourceCardPreviewState,
materialized?: MaterializedPreview,
) => {
const next = new Map(previewsRef.current);
if (state.status === 'loaded') {
const dimensions = projectResourceCardPreviewImageDimensions(
state.preview,
);
if (dimensions) {
const nextImageDimensions = new Map(
imageDimensionsByIdentityRef.current,
);
nextImageDimensions.set(identity, dimensions);
imageDimensionsByIdentityRef.current = nextImageDimensions;
setImageDimensionsByIdentity(nextImageDimensions);
}
}
cacheOrderRef.current.delete(identity);
disposeCachedPreview(identity);
next.set(identity, state);
let evictedCount = 0;
if (state.status === 'loaded' || state.status === 'failed') {
const cached = materialized
? {
retainedBytes: materialized.retainedBytes,
objectUrl: materialized.objectUrl,
}
: { retainedBytes: 0, objectUrl: null };
cachedPreviewsRef.current.set(identity, cached);
cacheBytesRef.current += cached.retainedBytes;
cacheOrderRef.current.set(identity, true);
/**
* 淘汰必须知道"谁还在屏幕上"。
*
* 缓存条目的顺序是「最近一次被请求」,而**停在屏幕上不动的卡片不会产生新的请求**
* —— 于是恰恰是用户正看着的那几张排在队首:纯 LRU 淘汰会首选它们,表现为
* "图片自己消失又回来"。这里把当前可见集合交给淘汰函数,让它先只淘汰视口外的条目。
*/
const visibleIdentities = new Set<string>();
for (const [element, binding] of observedCardsRef.current) {
if (viewportBandRef.current(element) <= 1) {
visibleIdentities.add(binding.identity);
}
}
const evictedIdentities = projectResourceCardPreviewEvictionIdentities(
Array.from(cacheOrderRef.current.keys()).map((cachedIdentity) => ({
identity: cachedIdentity,
retainedBytes:
cachedPreviewsRef.current.get(cachedIdentity)?.retainedBytes ?? 0,
})),
protectedIdentityRef.current,
visibleIdentities,
);
for (const evictedIdentity of evictedIdentities) {
cacheOrderRef.current.delete(evictedIdentity);
disposeCachedPreview(evictedIdentity);
next.delete(evictedIdentity);
evictedCount += 1;
}
}
previewsRef.current = next;
setPreviews(next);
if (evictedCount > 0) {
/**
* 驱逐之后立刻补一次可见性兜底扫描。
*
* 被驱逐的 identity 从 `previews` 里被删掉,状态正好回到"从未请求"`undefined`),
* 而 [`sweepVisiblePreviews`] **只对 `undefined` 的卡重新入队** —— 也就是说
* 驱逐后仍停在视口里的卡只差这一次调用:不补,它的图片就凭空消失、退回占位图标,
* 而且不会自己回来(`IntersectionObserver` 对一直相交的元素没有二次回调,
* 下一次扫描只等 scope 变化的 0/250/1000ms、resize 或 visibilitychange)。
*
* 调用点必须在 `previewsRef.current = next` **之后**:扫描读的是 `previewsRef`
* 早一步调用会看到被驱逐的卡仍是 `loaded`,于是什么都不做。
*
* 重入性:重新入队走 `requestPreview` → `drainQueue` → `publishPreview({status:'loading'})`
* 而 `loading` 发布**不进**上面这个 `loaded` / `failed` 分支,因此不会再触发驱逐、
* 也不会再走到这里 —— 递归深度恒为 1,不自激。
*/
sweepVisiblePreviewsRef.current();
}
},
[disposeCachedPreview],
);
const readPreview = useCallback(
async (
job: PreviewJob,
): Promise<ProjectResourceCardPreviewTransportPayload> => {
const kind = projectResourceCardPreviewKind(job.resource);
if (
(kind === 'document' || kind === 'code') &&
job.resource.content !== undefined
) {
return {
path: job.resource.path,
mediaType: job.resource.mediaType,
byteLen: new TextEncoder().encode(job.resource.content).byteLength,
content: job.resource.content,
};
}
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
throw new Error(
kind === 'document' || kind === 'code'
? '文档预览需要在客户端内打开'
: '媒体预览需要在客户端内打开',
);
}
if (kind === 'raster-image') {
return invoke<ProjectResourceCardPreviewTransportPayload>(
'read_local_project_image_preview',
{
projectPath: input.projectPath,
relativePath: job.resource.path,
scopeId: job.scopeId,
requestId: createProjectResourcePreviewRequestId(),
},
);
}
if (kind === 'document' || kind === 'code') {
return invoke<ProjectResourceCardPreviewTransportPayload>(
'read_local_project_text_preview',
{
projectPath: input.projectPath,
relativePath: job.resource.path,
scopeId: job.scopeId,
requestId: createProjectResourcePreviewRequestId(),
},
);
}
return invoke<ProjectResourceCardPreviewTransportPayload>(
'read_local_project_media_preview',
{
projectPath: input.projectPath,
relativePath: job.resource.path,
category: projectResourceMediaPreviewCategory(job.resource),
scopeId: job.scopeId,
requestId: createProjectResourcePreviewRequestId(),
},
);
},
[input.projectPath],
);
const drainQueue = useCallback(() => {
while (
activeReadsRef.current.scopeEpoch === scopeEpochRef.current &&
activeReadsRef.current.count <
PROJECT_RESOURCE_CARD_PREVIEW_CONCURRENCY &&
queueRef.current.length > 0
) {
const job = nextPreviewJob(queueRef.current, prefetchScopeKeyRef.current);
if (!job) {
break;
}
if (queuedIdentitiesRef.current.get(job.identity) === job.scopeEpoch) {
queuedIdentitiesRef.current.delete(job.identity);
}
if (!isCurrentJob(job)) {
continue;
}
activeReadsRef.current.count += 1;
pendingIdentitiesRef.current.set(job.identity, job.scopeEpoch);
const jobPreviewKind = projectResourceCardPreviewKind(job.resource);
publishPreview(job.identity, { status: 'loading' });
void readPreview(job)
.then((transport) => {
if (isCurrentJob(job)) {
const materialized =
materializeProjectResourceCardPreview(transport);
if (!isCurrentJob(job)) {
if (materialized.objectUrl) {
URL.revokeObjectURL(materialized.objectUrl);
}
return;
}
publishPreview(
job.identity,
{ status: 'loaded', preview: materialized.preview },
materialized,
);
}
})
.catch((error: unknown) => {
if (
isCurrentJob(job) &&
!isProjectResourcePreviewCancellation(error)
) {
const failure = previewReadErrorMessage(
job.resource,
jobPreviewKind,
error,
);
publishPreview(job.identity, {
status: 'failed',
...failure,
});
}
})
.finally(() => {
if (
pendingIdentitiesRef.current.get(job.identity) === job.scopeEpoch
) {
pendingIdentitiesRef.current.delete(job.identity);
}
if (activeReadsRef.current.scopeEpoch === job.scopeEpoch) {
activeReadsRef.current.count = Math.max(
0,
activeReadsRef.current.count - 1,
);
drainQueueRef.current();
}
});
}
}, [isCurrentJob, publishPreview, readPreview]);
drainQueueRef.current = drainQueue;
const touchCachedPreview = useCallback((identity: string) => {
if (!cacheOrderRef.current.has(identity)) {
return;
}
cacheOrderRef.current.delete(identity);
cacheOrderRef.current.set(identity, true);
}, []);
const requestPreview = useCallback(
(
resource: ProjectResource,
identity: string,
reason: PreviewRequestReason,
) => {
if (identityByResourceIdRef.current.get(resource.id) !== identity) {
return;
}
const kind = projectResourceCardPreviewKind(resource);
if (
kind === 'version' ||
kind === 'placeholder' ||
(kind === 'audio' && reason !== 'play')
) {
return;
}
const current = previewsRef.current.get(identity);
if (current?.status === 'loaded') {
touchCachedPreview(identity);
return;
}
if (current?.status === 'failed') {
if (reason === 'visible' || !current.retryable) {
touchCachedPreview(identity);
return;
}
}
if (
current?.status === 'loading' ||
pendingIdentitiesRef.current.get(identity) === scopeEpochRef.current
) {
return;
}
const queuedJob =
queuedIdentitiesRef.current.get(identity) === scopeEpochRef.current
? queueRef.current.find((job) => job.identity === identity)
: undefined;
if (queuedJob) {
if (
previewRequestPriority[reason] >
previewRequestPriority[queuedJob.reason]
) {
queuedJob.reason = reason;
}
return;
}
const visibleQueueLimit =
PROJECT_RESOURCE_CARD_PREVIEW_QUEUE_LIMIT -
PROJECT_RESOURCE_CARD_PREVIEW_ACTIVE_QUEUE_RESERVE;
if (
reason === 'visible' &&
queueRef.current.length >= visibleQueueLimit
) {
return;
}
if (
queueRef.current.length >= PROJECT_RESOURCE_CARD_PREVIEW_QUEUE_LIMIT
) {
let replaceIndex = -1;
for (let index = queueRef.current.length - 1; index >= 0; index -= 1) {
if (queueRef.current[index]?.reason === 'visible') {
replaceIndex = index;
break;
}
}
if (reason === 'visible' || replaceIndex < 0) {
/*
* 队列被占满、又找不到可以顶掉的可见性预取时,这次请求只能丢掉。
*
* `visible` 撞上限是设计内的背压(下一轮兜底扫描会把仍可见的卡补回来),
* 但 **`detail` / `play` 是按需请求**:用户在可见卡上点了播放却什么都不发生,
* 此前连一条痕迹都没有 —— 这里必须留下可观测标记(快照 + 日志)。
* 队列上限、3 个并发槽与优先级顺序都不放宽,只是把"被吞掉的那次"记下来。
*/
if (reason !== 'visible') {
droppedRequestsRef.current = {
count: droppedRequestsRef.current.count + 1,
last: {
identity,
reason,
queueLength: queueRef.current.length,
at: Date.now(),
},
};
console.warn(
`[preview-queue] 按需请求被队列上限丢弃 reason=${reason} queueLength=${queueRef.current.length}`,
);
}
return;
}
const [replacedJob] = queueRef.current.splice(replaceIndex, 1);
if (replacedJob) {
queuedIdentitiesRef.current.delete(replacedJob.identity);
}
}
cacheOrderRef.current.delete(identity);
queuedIdentitiesRef.current.set(identity, scopeEpochRef.current);
queueRef.current.push({
scopeKey: scopeKeyRef.current,
scopeId: scopeIdRef.current,
scopeEpoch: scopeEpochRef.current,
identity,
resource,
reason,
prefetchScopeKey: prefetchScopeKeyRef.current,
});
drainQueueRef.current();
},
[touchCachedPreview],
);
/**
* 把当前登记表里的全部卡片元素补挂到 observer 上。
*
* 注册(`observePreview`)与 observer 的创建分属不同 effect,两者存在时序窗口:
* 在 observer 还没建好时注册的卡只能靠 observer effect 里的一次性补挂兜住,
* 一旦那个补挂发生在注册之前、或 observer 之后被重建,卡就会**停留在登记表里
* 却从未被观察** —— 它永不触发可见性回调,也就永远停在 `idle`,卡面只剩占位图标。
* 所以每次 observer 就绪或重建后都要按登记表重新补挂一遍。
*/
const attachObservedCards = useCallback(() => {
const observer = observerRef.current;
if (!observer) {
return;
}
for (const element of observedCardsRef.current.keys()) {
observer.observe(element);
}
}, []);
/**
* 创建 observer 时用的 root,用来判断它是否变了。
*
* 取值只用于"要不要重建"的比较:`null` 表示**当时没有拿到容器 root**,
* 这时仍然要按视口建 observer(见下),不能把"没有 root"当成"不建 observer"。
*/
const observerRootRef = useRef<HTMLElement | null>(null);
const [rootEpoch, setRootEpoch] = useState(0);
/**
* 本 scope 是否已经把 observer 建起来过。
*
* 用来把"注册时触发的重建"限制在 observer 尚未就绪的阶段:一旦建好,
* 后续注册只走 `observe` 快路径,不再触发重渲染。
*/
const observerReadyRef = useRef(false);
/** root 尚未就绪时的有界重试序号,避免"信号永不再来"导致 observer 永不创建。 */
const rootRetryRef = useRef(0);
/**
* 兜底扫描的转发 ref`observePreview` 与 observer effect 都可能在
* `sweepVisiblePreviews` 定义之前建立,用 ref 避免把回调顺序写成隐式契约。
*/
const sweepVisiblePreviewsRef = useRef<() => number>(() => 0);
/**
* 可见性档位判据的转发 ref`publishPreview` 在它定义之前就要用它算"谁还在屏幕上",
* 同样用 ref 避免把定义顺序写成隐式契约。
*/
const viewportBandRef = useRef<(element: HTMLElement) => 0 | 1 | 2>(() => 2);
const observePreview = useCallback(
(element: HTMLElement, resource: ProjectResource, identity: string) => {
const binding = { resource, identity };
observedCardsRef.current.set(element, binding);
if (!observerReadyRef.current) {
// 卡片一定渲染在资源画本容器内部,所以「有卡片注册」本身就是「root 已就绪」
// 的信号。observer 还没建好时用它触发一次重建,避免卡死在未观察状态。
// 在测试等没有 IntersectionObserver 的环境里保持静默,不制造多余渲染。
const root =
input.intersectionRootRef?.current ?? input.canvasRef.current;
if (root && window.IntersectionObserver) {
setRootEpoch((epoch) => epoch + 1);
}
}
observerRef.current?.observe(element);
// 注册即复核:observer 回调可能还没送达,此时先按几何判一次可见性。
sweepVisiblePreviewsRef.current();
return () => {
observerRef.current?.unobserve(element);
if (observedCardsRef.current.get(element) === binding) {
observedCardsRef.current.delete(element);
}
};
},
[input.canvasRef, input.intersectionRootRef],
);
/**
* 把一个卡元素判到三个档位:
* `0` 落在视口内、`1` 只落在 `rootMargin`160px)那一圈里、`2` 在放行范围之外。
*
* 判据与 observer 的 `rootMargin: 160px` 对齐,但它是**独立复核**:门禁漏放行要修的
* 就是"trust observer 报过什么",所以不能拿同一条可能失效的通路当判据。
*
* 两个用途共用同一套几何口径,避免"扫描一套、热预取另一套":
* - `2` 是**放行门禁**(外圈不得请求,也不得进热预取,因此不会被全量预读吃掉预算);
* - `0` / `1` 是**放行顺序**(先视口内、再余量圈,见 `requestPreviewCardsByViewportBand`)。
*
* 尺寸量不出来(未布局、`0×0`)时判 `2`:宁可等一次 observer 回调或下一轮扫描,
* 也不能凭"量不到"就当可见。
*/
const viewportBandOfElement = useCallback(
(element: HTMLElement): 0 | 1 | 2 => {
const rect = element.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) {
return 2;
}
const viewportWidth =
window.innerWidth || document.documentElement?.clientWidth || 0;
const viewportHeight =
window.innerHeight || document.documentElement?.clientHeight || 0;
if (viewportWidth <= 0 || viewportHeight <= 0) {
return 2;
}
const withinBand = (margin: number) =>
rect.right >= -margin &&
rect.left <= viewportWidth + margin &&
rect.bottom >= -margin &&
rect.top <= viewportHeight + margin;
if (withinBand(0)) {
return 0;
}
return withinBand(RESOURCE_PREVIEW_VIEWPORT_MARGIN_PX) ? 1 : 2;
},
[],
);
viewportBandRef.current = viewportBandOfElement;
/**
* 按**两档**放行一批已确认可见的卡:先视口内的(档 0),再 `rootMargin` 圈里的(档 1)。
*
* 冷启动首屏一次可能同时有十几二十张卡够格(真机口径:可见 ≈15、加上 160px 余量后
* 相交 ≈21),而物理读取只有 3 个槽(`PROJECT_RESOURCE_CARD_PREVIEW_CONCURRENCY`)。
* 一次全部入队时,排在队列后面的"其实就在屏幕里"的卡要等前面那些最多 160px 外的卡读完
* —— 用户感知就是"首屏等图"。两档放行**只改入队顺序、不改总量**:档 1 的卡在同一次调用里
* 一样会被放行,所以兜底扫描不会退化成"只补第一档"3 槽 / 72 项 / 64 MiB 合同也不动。
*/
const requestPreviewCardsByViewportBand = useCallback(
(
cards: readonly {
element: HTMLElement;
resource: ProjectResource;
identity: string;
}[],
options: { onlyIdle: boolean; limit?: number },
) => {
const limit = options.limit ?? Number.POSITIVE_INFINITY;
const requestedIdentities = new Set<string>();
let requested = 0;
for (const band of [0, 1] as const) {
for (const card of cards) {
if (requested >= limit) {
return requested;
}
if (requestedIdentities.has(card.identity)) {
continue;
}
if (viewportBandOfElement(card.element) !== band) {
continue;
}
if (
options.onlyIdle &&
previewsRef.current.get(card.identity)?.status !== undefined
) {
continue;
}
requestedIdentities.add(card.identity);
requestPreview(card.resource, card.identity, 'visible');
requested += 1;
}
}
return requested;
},
[requestPreview, viewportBandOfElement],
);
/**
* 可见性兜底扫描:把「已登记、确实在视口内、但还没有任何请求」的卡补一次 `visible`。
*
* 它**不是第二条加载通路**`requestPreview` 仍然只做去重、排优先级、走同一条队列),
* 而是给"门禁漏放行"一个可恢复窗口。只对 `idle`(从未请求)的卡生效:
* `loading` / `queued` / `loaded` / `failed` 一律不碰,所以不会绕过热预取合同、
* 也不会突破全局 3 槽与 LRU 预算。
*
* 放行顺序按视口内 / 余量圈两档(见 `requestPreviewCardsByViewportBand`)。
*/
const sweepVisiblePreviews = useCallback(() => {
return requestPreviewCardsByViewportBand(
Array.from(observedCardsRef.current, ([element, binding]) => ({
element,
resource: binding.resource,
identity: binding.identity,
})),
{ onlyIdle: true },
);
}, [requestPreviewCardsByViewportBand]);
sweepVisiblePreviewsRef.current = sweepVisiblePreviews;
/**
* 为 `prefetchKey` 取消队列里 `visible` 理由的预取(修法 2 + 4)。
*
* 只下掉**还在排队**的可见性预取:
* - `detail` / `play` 理由保留(按需加载不取消);
* - 已经在途(`pendingIdentitiesRef`)的请求**不打断** —— 它只占 3 个槽中的 1 个,
* 打断它既拿不回已花的读盘成本,也让"切回来"要重读;
* - 缓存与身份不动,因此切回原视图不会重读已拿到的图;
* - 下掉的 job 若几何上仍可见,会被随后的兜底扫描重新入队 ⇒ 没有"永不重试"死角。
*/
const cancelQueuedVisiblePrefetches = useCallback(() => {
const kept: PreviewJob[] = [];
let cancelled = 0;
for (const job of queueRef.current) {
if (job.reason === 'visible') {
cancelled += 1;
if (queuedIdentitiesRef.current.get(job.identity) === job.scopeEpoch) {
queuedIdentitiesRef.current.delete(job.identity);
}
continue;
}
kept.push(job);
}
queueRef.current = kept;
return cancelled;
}, []);
// 视图切换即下掉上一视图的排队预取。修法 4 的"不再为离开的视图补发"也由它收口:
// 离开后卡片卸载、observer 注销,任何新注册都会立即被这里清掉,除非该卡在几何上仍可见。
//
// 依赖**派生后的** `prefetchScopeKey`(而不是可能为 undefined 的 `input.prefetchScopeKey`):
// 调用方不传时它退化为资源集合签名,取消逻辑必须同样生效,否则就是一条永远不跑的死代码。
useEffect(() => {
cancelQueuedVisiblePrefetches();
// 下掉之后立刻按几何复核一次:仍然可见的卡重新入队,不可见的自然不再请求。
sweepVisiblePreviews();
}, [cancelQueuedVisiblePrefetches, prefetchScopeKey, sweepVisiblePreviews]);
/**
* 兜底扫描的生命周期:scope 变化后按若干延迟点各扫一次,覆盖"卡晚挂载 / observer 迟到"
* 页面重新可见(用户切走再回来)和窗口尺寸变化也各扫一次,因为是同一批卡可能重新进入视口。
*/
useEffect(() => {
const timers = RESOURCE_PREVIEW_VISIBLE_SWEEP_DELAYS_MS.map((delay) =>
setTimeout(() => sweepVisiblePreviewsRef.current(), delay),
);
const handleVisibility = () => {
if (!document.hidden) {
sweepVisiblePreviewsRef.current();
}
};
window.addEventListener('resize', handleVisibility);
document.addEventListener('visibilitychange', handleVisibility);
return () => {
for (const timer of timers) {
clearTimeout(timer);
}
window.removeEventListener('resize', handleVisibility);
document.removeEventListener('visibilitychange', handleVisibility);
};
}, [scopeKey]);
const failPreview = useCallback(
(identity: string, error: string, retryable = false) => {
if (!validIdentitiesRef.current.has(identity)) {
return;
}
publishPreview(identity, { status: 'failed', error, retryable });
},
[publishPreview],
);
const protectPreview = useCallback((identity: string | null) => {
protectedIdentityRef.current = identity;
}, []);
useLayoutEffect(() => {
if (scopeInitializedRef.current) {
const previousScopeId = scopeIdRef.current;
scopeIdRef.current = createProjectResourcePreviewScopeId();
cancelLocalProjectResourcePreviewScope(previousScopeId);
} else {
scopeInitializedRef.current = true;
}
scopeEpochRef.current += 1;
activeReadsRef.current = {
scopeEpoch: scopeEpochRef.current,
count: 0,
};
queueRef.current = [];
queuedIdentitiesRef.current.clear();
pendingIdentitiesRef.current.clear();
disposeAllCachedPreviews();
cacheOrderRef.current.clear();
protectedIdentityRef.current = null;
previewsRef.current = new Map();
setPreviews(new Map());
imageDimensionsByIdentityRef.current = new Map();
setImageDimensionsByIdentity(new Map());
}, [disposeAllCachedPreviews, scopeKey]);
useEffect(() => {
const validIdentities = new Set(identityByResourceId.values());
const current = previewsRef.current;
const next = new Map(
Array.from(current).filter(([identity]) => validIdentities.has(identity)),
);
for (const identity of Array.from(cacheOrderRef.current.keys())) {
if (!validIdentitiesRef.current.has(identity)) {
cacheOrderRef.current.delete(identity);
disposeCachedPreview(identity);
}
}
if (next.size !== current.size) {
previewsRef.current = next;
setPreviews(next);
}
const currentImageDimensions = imageDimensionsByIdentityRef.current;
const nextImageDimensions = new Map(
Array.from(currentImageDimensions).filter(([identity]) =>
validIdentities.has(identity),
),
);
if (nextImageDimensions.size !== currentImageDimensions.size) {
imageDimensionsByIdentityRef.current = nextImageDimensions;
setImageDimensionsByIdentity(nextImageDimensions);
}
}, [disposeCachedPreview, identityByResourceId]);
useEffect(() => {
const eagerPreviewLimit = Math.max(
0,
Math.min(
PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT,
Math.floor(input.eagerPreviewLimit ?? 0),
),
);
// 只预热 `eagerResources`(缺省回退 `resources`):身份与缓存仍然覆盖全量投影,
// 这里收窄的仅仅是「谁先占读取槽」。
const eagerResources = input.eagerResources ?? input.resources;
const elementsByIdentity = new Map<string, HTMLElement>();
for (const [element, binding] of observedCardsRef.current) {
elementsByIdentity.set(binding.identity, element);
}
/**
* **热预取也要过可见性判据**:此前它按投影顺序盲取前 `eagerPreviewLimit` 张,
* 条件里只有身份与 kind 过滤 —— 于是排在前面的视口外卡片先占满 3 个读取槽,
* 屏幕里那些卡反而要排队,冷启动首屏就是"等图片"。
*
* 现在只预取**几何上可见**(档 0 / 档 1)且已登记的卡,并按"先视口内、再余量圈"
* 两档入队;`eagerPreviewLimit` 仍是硬上限,3 槽 / 48 项 / 64 MiB 合同不变。
*/
const visibleCards: {
element: HTMLElement;
resource: ProjectResource;
identity: string;
}[] = [];
for (const resource of eagerResources) {
const identity = identityByResourceId.get(resource.id);
if (!identity) {
continue;
}
const kind = projectResourceCardPreviewKind(resource);
if (kind === 'version' || kind === 'placeholder' || kind === 'audio') {
continue;
}
const element = elementsByIdentity.get(identity);
if (!element) {
continue;
}
visibleCards.push({ element, resource, identity });
}
const requested = requestPreviewCardsByViewportBand(visibleCards, {
onlyIdle: true,
limit: eagerPreviewLimit,
});
if (requested > 0) {
return;
}
/**
* 一张可见卡都判不出来时(卡片尚未注册、容器还没布局出尺寸)保留原来的顺序预取,
* **绝不把首屏热预取削成 0**:这时按投影顺序预热前若干张,至少保证首屏有图可读。
* 真正的放行顺序与去重仍然只有一条通路(`requestPreview`),这里不是第二条加载通路。
*/
let fallbackRequested = 0;
for (const resource of eagerResources) {
if (fallbackRequested >= eagerPreviewLimit) {
break;
}
const identity = identityByResourceId.get(resource.id);
const kind = projectResourceCardPreviewKind(resource);
if (
identity &&
kind !== 'version' &&
kind !== 'placeholder' &&
kind !== 'audio'
) {
requestPreview(resource, identity, 'visible');
fallbackRequested += 1;
}
}
}, [
identityByResourceId,
input.eagerPreviewLimit,
input.eagerResources,
input.resources,
requestPreview,
requestPreviewCardsByViewportBand,
]);
const imageDimensionsByResourceId = useMemo(() => {
const result = new Map<
string,
NonNullable<ReturnType<typeof projectResourceCardPreviewImageDimensions>>
>();
for (const resource of input.resources) {
const identity = identityByResourceId.get(resource.id);
const kind = projectResourceCardPreviewKind(resource);
if (kind !== 'raster-image' && kind !== 'media-image') {
continue;
}
const dimensions = identity
? imageDimensionsByIdentity.get(identity)
: undefined;
if (dimensions) {
result.set(resource.id, dimensions);
}
}
return result;
}, [identityByResourceId, imageDimensionsByIdentity, input.resources]);
useEffect(
() => () => {
cancelLocalProjectResourcePreviewScope(scopeIdRef.current);
scopeEpochRef.current += 1;
queueRef.current = [];
queuedIdentitiesRef.current.clear();
pendingIdentitiesRef.current.clear();
activeReadsRef.current = {
scopeEpoch: scopeEpochRef.current,
count: 0,
};
disposeAllCachedPreviews();
},
[disposeAllCachedPreviews],
);
useEffect(() => {
const IntersectionObserverClass = window.IntersectionObserver;
if (!IntersectionObserverClass) {
return undefined;
}
// root 是资源画本容器;它由被观察卡片所在的那棵子树持有。
//
// 这里**绝不能用"root 还没就绪"当作"不建 observer"的理由**:本 effect 每个 scope
// 实际只跑一次(`requestPreview` 是稳定引用,其余依赖在 scope 内不变),一旦这一轮
// 因为 root 为 null 直接返回,就再也没有第二次机会 —— 所有卡片永远不被观察、
// 永远停在 `idle`,整个栏目只剩占位图标。这是此前真实发生过的回归。
// 因此:拿到容器 root 最好,拿不到就退到 canvas、再退到视口(root: null),
// 并且只要 root 还没就绪就有界重试,直到拿到容器 root 再重建一次。
const root = input.intersectionRootRef?.current ?? input.canvasRef.current;
if (observerRef.current && observerRootRef.current === root) {
// root 没变且 observer 已在:只补挂新注册的卡,不做无谓重建。
observerReadyRef.current = true;
attachObservedCards();
return undefined;
}
observerRef.current?.disconnect();
const observer = new IntersectionObserverClass(
(entries) => {
/*
* 一次回调往往同时报来十几二十张相交的卡(真机首屏口径:可见 ≈15、
* 加 160px 余量后 ≈21),而读取只有 3 个槽:谁先进队列,谁就先出图。
*
* 这里**只改入队顺序**:视口内的(档 0)先入队,剩下报相交的(档 1,或几何上
* 量不出尺寸的卡)随后入队 —— 一张都不丢,所以既有"相交即放行"的语义不变,
* 只是先看到的那几张先出图。判据复用 `viewportBandOfElement`,不做第二次门禁:
* observer 已经报过相交,这里没有理由因为量不到尺寸而拒收。
*/
const intersectingCards: {
element: HTMLElement;
resource: ProjectResource;
identity: string;
}[] = [];
const deferredCards: typeof intersectingCards = [];
for (const entry of entries) {
if (!entry.isIntersecting) {
continue;
}
const element = entry.target as HTMLElement;
const binding = observedCardsRef.current.get(element);
if (!binding) {
continue;
}
const card = {
element,
resource: binding.resource,
identity: binding.identity,
};
if (viewportBandOfElement(element) === 0) {
intersectingCards.push(card);
} else {
deferredCards.push(card);
}
}
for (const card of intersectingCards) {
requestPreview(card.resource, card.identity, 'visible');
}
for (const card of deferredCards) {
requestPreview(card.resource, card.identity, 'visible');
}
},
{
// `root: null` 表示按视口判定。它比"没有 observer"严格更好:屏幕上真实可见的卡
// 一定在视口矩形内、一定会被报为相交;代价只是视口外但仍在画布内的卡会晚一步
// 由滚动触发。拿不到容器 root 时宁可先用它,也不要让门禁整体失效。
root: root ?? null,
rootMargin: '160px',
},
);
observerRef.current = observer;
observerReadyRef.current = true;
observerRootRef.current = root;
attachObservedCards();
// observer 刚就绪就立刻复核一遍登记表:注册与创建交错时漏掉的卡在这里被补上。
sweepVisiblePreviewsRef.current();
// 容器 root 迟到时重建一次,恢复"按画本容器裁剪"的精确判定。
let retryTimer: ReturnType<typeof setTimeout> | undefined;
if (!root && rootRetryRef.current < RESOURCE_PREVIEW_ROOT_RETRY_LIMIT) {
const attempt = rootRetryRef.current;
rootRetryRef.current = attempt + 1;
retryTimer = setTimeout(
() => setRootEpoch((epoch) => epoch + 1),
RESOURCE_PREVIEW_ROOT_RETRY_BASE_MS * 2 ** attempt,
);
}
return () => {
if (retryTimer !== undefined) {
clearTimeout(retryTimer);
}
observer.disconnect();
if (observerRef.current === observer) {
observerRef.current = null;
observerReadyRef.current = false;
}
if (observerRootRef.current === root) {
observerRootRef.current = null;
}
};
}, [
attachObservedCards,
input.canvasRef,
input.intersectionRootRef,
requestPreview,
rootEpoch,
scopeKey,
viewportBandOfElement,
]);
return {
identityByResourceId,
previews,
imageDimensionsByResourceId,
idlePreview: IDLE_PROJECT_RESOURCE_CARD_PREVIEW,
observePreview,
requestPreview,
failPreview,
protectPreview,
/**
* 只读的队列快照,供排障与测试观测"谁在排队、属于哪个预取作用域"。
*
* 队列病理(排队被上一视图占满、可见卡被丢弃)此前只能靠猜;有了这个快照,
* 「当前视图的卡是否真的进了队列」变成可直接断言的事实。不参与渲染,无副作用。
*/
previewQueueSnapshot: () => ({
queue: queueRef.current.map((job) => ({
identity: job.identity,
reason: job.reason,
prefetchScopeKey: job.prefetchScopeKey,
})),
activeReadCount: activeReadsRef.current.count,
prefetchScopeKey: prefetchScopeKeyRef.current,
/** 被队列上限吞掉的按需请求条数;`visible` 的背压不计入。 */
droppedRequestCount: droppedRequestsRef.current.count,
/** 最近一次被吞掉的按需请求明细,从未发生时为 `null`。 */
lastDroppedRequest: droppedRequestsRef.current.last,
}),
};
}