2ea3bcbb07
用户报「图片会自己消失然后重新加载」。根因是**淘汰完全按 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 子集的前后对照见随后的回报。)
1315 lines
50 KiB
TypeScript
1315 lines
50 KiB
TypeScript
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,
|
||
}),
|
||
};
|
||
}
|