Files
Genarrative/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts
T
suzmii 5cd02aa8b0
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 2m44s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 2m45s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 2m46s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 2m49s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m53s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m18s
Project CI / Frontend tests (pull_request) Failing after 4m6s
Project CI / Native shell tests (pull_request) Successful in 6m12s
Project CI / Repository checks (pull_request) Failing after 3m48s
Project CI / Backend tests (pull_request) Successful in 7m8s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m59s
统一智能体对话层级并完善素材导出与文档预览
当前Agent与策划Agent共用正文和过程表现组件,统一思考及工具调用的小字号浅色样式
素材工具栏按实际动作分组,导出紧邻删除并修复空分组重复分隔线
文档与代码使用共享Markdown预览和代码高亮,复用按需读取与缓存
移除未调用的活跃回合查询命令,保留正式快照恢复链路
补充回归用例并同步技术方案和团队约定
2026-09-16 03:59:27 +08:00

1329 lines
52 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,
projectResourceCardPreviewReadsContent,
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(kind: ProjectResourceCardPreviewKind) {
if (kind === 'document') {
return '文档';
}
if (kind === 'code') {
return '游戏代码';
}
return '资源';
}
function previewReadErrorMessage(
kind: ProjectResourceCardPreviewKind,
error: unknown,
): { error: string; retryable: boolean } {
const message = error instanceof Error ? error.message : String(error);
const kindLabel = resourceReadKindLabel(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,
hasAlpha: transport.hasAlpha,
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,
// 头部级 alpha 判据随图像预览 payload 一起透传:卡面棋盘格底只认它。
hasAlpha: transport.hasAlpha,
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` 入队(扫描是"什么算可见"的仲裁者)。
*
* ⚠️ **必填,且没有默认值**。曾经它有一个"缺省退化为被喂进来的资源集合签名"的兜底,
* 结果是调用方不接线时取消逻辑静默失效:`resources` 按设计要求恒为**全量投影**,
* 换栏目不改变它的 id 签名 ⇒ key 不变 ⇒ 取消 effect 根本不跑。那种"看起来还在工作"
* 的兜底只会掩盖漏接线,所以这里要求调用方显式给出视图身份
* (资源画布用 `createResourceCanvasPrefetchScopeKey`),漏传直接是类型错误。
*/
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);
const prefetchScopeKey = input.prefetchScopeKey;
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);
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);
}
}
previewsRef.current = next;
setPreviews(next);
/**
* 这里**刻意不补扫**。(曾经有过一次"驱逐后立刻补一遍可见性扫描",已删除。)
*
* 那段的注释宣称"递归深度恒为 1、不自激",这个不变量是**假的**,三个理由:
*
* 1. 它的触发条件是"本轮驱逐了任意条目",而第一轮只淘汰**视口外**条目 —— 那种情况下
* 根本没有可见卡丢状态,补扫却照样把别的"空闲且可见"的卡重新入队,等于凭空加压;
* 2. 真正驱逐到可见卡的情况(全表都在视口内且仍超预算,第二轮全表 LRU 回退)下,
* 补扫把刚被驱逐的卡重新入队 → 读回来 → 缓存又超预算 → 再驱逐 → 再走到这里:
* 每个周期跨一次异步读取,**这不是"递归深度 1",是一条不收敛的自激回路**,
* 会一直占着那 3 个物理读取槽;
* 3. 而那一轮回读**换不来稳定结果**:缓存仍然超预算,读回来的卡会立刻被下一轮 LRU
* 淘汰(第二轮回退是全表 LRU,刚读回的卡恰好是最新的),循环只在可见集合里轮转。
*
* 正确反应是接受这次**有界**淘汰(由「全可见且超预算时仍必须淘汰」用例钉住),
* 而不是反复重读。可见卡本来就不会再被"先淘汰可见卡"的 LRU 选中(见上面的可见性优先
* 淘汰),所以补扫既不必要又有害;确实需要重试时由 scope 变化 / resize /
* visibilitychange 那几条**有界**扫描负责。
*
* 实测(变异验证):把"驱逐后补扫"加回来跑
* 「settles instead of re-reading evicted visible cards forever」,它不是"断言变红"而是
* **worker 直接被撑爆**`ERR_WORKER_OUT_OF_MEMORY`,约 74 秒后终止)—— 回路真的不收敛,
* 在客户端里对应的就是持续重读直到内存耗尽。
*/
},
[disposeCachedPreview],
);
const readPreview = useCallback(
async (
job: PreviewJob,
): Promise<ProjectResourceCardPreviewTransportPayload> => {
const kind = projectResourceCardPreviewKind(job.resource);
// 内联文档和按需打开的代码详情共用现有预览结果,不另建读取或缓存链路。
if (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(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 (
!projectResourceCardPreviewReadsContent(resource) &&
reason !== 'detail'
) {
return;
}
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`(调用方给的视图身份)。调用方漏接线时这里是**静默**的,
// 所以该参数现为必填,漏传是类型错误而不是"少取消一次"。
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,
/**
* 可见性兜底扫描(判据见 `viewportBandOfElement`:视口 ± 160px 内的 `idle` 卡补一次
* `visible`)。注册(`observePreview`)时本就会扫一次,这里把它暴露出来的唯一原因是
* **portal 宿主的几何变化事件 observer 收不到**
*
* 弹窗内容挂在 `document.body` 下,不在资源画本 observer 的 `root` 子树里 ⇒ observer
* 永远把它们报成"不相交";而扫描用的是**视口几何**,与 observer 的 root 无关,所以这类
* 宿主自己注册元素后,只要在「弹窗打开」「候选区滚动」这两个几何变化点上显式扫一次,
* 走的仍是同一条队列与同一条判据,不新增第二条加载通路。
*/
sweepVisiblePreviews,
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,
}),
};
}