冷启动首屏:热预取过可见性判据,相交卡按视口内 / 余量圈两档放行
- A 热预取不再按投影顺序盲取前 N:eager effect 现在只预取已登记且几何上可见(档 0 / 档 1)的卡,按两档顺序入队,eagerPreviewLimit 仍是硬上限; - A 保留首屏兜底:一张可见卡都判不出来时(卡片尚未注册 / 容器还没布局出尺寸)退回原顺序预取,绝不把首屏热预取削成 0; - B 新增 viewportBandOfElement:0 = 视口内、1 = 只落在 rootMargin(160px) 圈里、2 = 放行范围外(含量不出尺寸);门禁与放行顺序共用同一套几何口径; - B 新增 requestPreviewCardsByViewportBand:先档 0 再档 1,只改入队顺序、不改总量(档 1 在同一次调用里一样放行),3 槽 / 48 项 / 64 MiB 合同不动; - B 兜底扫描改用两档放行(门禁语义不变:只有档 0 / 档 1 才放行),不会退化成"只补第一档"; - B observer 回调一次报来十几二十张相交卡时,只把"视口内"的插到前面,其余一张不丢,既有"相交即放行"语义不变; - 新增用例:视口外卡片不得进入热预取(改前首屏 distinct 读 20 张、含 8 张视口外;改后 12 张全部可见);相交一次性放行时先视口内后余量圈且总量不变(6 张全放行); - 变异验证:整份还原成改前版本 → 两条用例分别红(20 vs 12、放行顺序反了);只把 observer 回调改回单趟 → 只有两档那条红。
This commit is contained in:
+179
-45
@@ -794,32 +794,92 @@ export function useProjectResourceCardPreviews(input: {
|
||||
);
|
||||
|
||||
/**
|
||||
* 与 observer 的 `rootMargin: 160px` 对齐的视口判定。
|
||||
* 把一个卡元素判到三个档位:
|
||||
* `0` 落在视口内、`1` 只落在 `rootMargin`(160px)那一圈里、`2` 在放行范围之外。
|
||||
*
|
||||
* 兜底扫描用它**独立复核可见性**,而不是信任 observer 报过什么 —— 要修的是
|
||||
* "门禁漏放行",所以不能拿同一条可能失效的通路当判据。只认"确实落在视口矩形
|
||||
* (含 160px 余量)内"的卡,**不会退化成全量预读**。
|
||||
* 判据与 observer 的 `rootMargin: 160px` 对齐,但它是**独立复核**:门禁漏放行要修的
|
||||
* 就是"trust observer 报过什么",所以不能拿同一条可能失效的通路当判据。
|
||||
*
|
||||
* 两个用途共用同一套几何口径,避免"扫描一套、热预取另一套":
|
||||
* - `2` 是**放行门禁**(外圈不得请求,也不得进热预取,因此不会被全量预读吃掉预算);
|
||||
* - `0` / `1` 是**放行顺序**(先视口内、再余量圈,见 `requestPreviewCardsByViewportBand`)。
|
||||
*
|
||||
* 尺寸量不出来(未布局、`0×0`)时判 `2`:宁可等一次 observer 回调或下一轮扫描,
|
||||
* 也不能凭"量不到"就当可见。
|
||||
*/
|
||||
const isElementWithinVisibleArea = useCallback((element: HTMLElement) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.width <= 0 || rect.height <= 0) {
|
||||
return false;
|
||||
}
|
||||
const margin = RESOURCE_PREVIEW_VIEWPORT_MARGIN_PX;
|
||||
const viewportWidth =
|
||||
window.innerWidth || document.documentElement?.clientWidth || 0;
|
||||
const viewportHeight =
|
||||
window.innerHeight || document.documentElement?.clientHeight || 0;
|
||||
if (viewportWidth <= 0 || viewportHeight <= 0) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
rect.right >= -margin &&
|
||||
rect.left <= viewportWidth + margin &&
|
||||
rect.bottom >= -margin &&
|
||||
rect.top <= viewportHeight + margin
|
||||
);
|
||||
}, []);
|
||||
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;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* 按**两档**放行一批已确认可见的卡:先视口内的(档 0),再 `rootMargin` 圈里的(档 1)。
|
||||
*
|
||||
* 冷启动首屏一次可能同时有十几二十张卡够格(真机口径:可见 ≈15、加上 160px 余量后
|
||||
* 相交 ≈21),而物理读取只有 3 个槽(`PROJECT_RESOURCE_CARD_PREVIEW_CONCURRENCY`)。
|
||||
* 一次全部入队时,排在队列后面的"其实就在屏幕里"的卡要等前面那些最多 160px 外的卡读完
|
||||
* —— 用户感知就是"首屏等图"。两档放行**只改入队顺序、不改总量**:档 1 的卡在同一次调用里
|
||||
* 一样会被放行,所以兜底扫描不会退化成"只补第一档",3 槽 / 48 项 / 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`。
|
||||
@@ -828,21 +888,19 @@ export function useProjectResourceCardPreviews(input: {
|
||||
* 而是给"门禁漏放行"一个可恢复窗口。只对 `idle`(从未请求)的卡生效:
|
||||
* `loading` / `queued` / `loaded` / `failed` 一律不碰,所以不会绕过热预取合同、
|
||||
* 也不会突破全局 3 槽与 LRU 预算。
|
||||
*
|
||||
* 放行顺序按视口内 / 余量圈两档(见 `requestPreviewCardsByViewportBand`)。
|
||||
*/
|
||||
const sweepVisiblePreviews = useCallback(() => {
|
||||
let requested = 0;
|
||||
for (const [element, binding] of observedCardsRef.current) {
|
||||
if (previewsRef.current.get(binding.identity)?.status !== undefined) {
|
||||
continue;
|
||||
}
|
||||
if (!isElementWithinVisibleArea(element)) {
|
||||
continue;
|
||||
}
|
||||
requestPreview(binding.resource, binding.identity, 'visible');
|
||||
requested += 1;
|
||||
}
|
||||
return requested;
|
||||
}, [isElementWithinVisibleArea, requestPreview]);
|
||||
return requestPreviewCardsByViewportBand(
|
||||
Array.from(observedCardsRef.current, ([element, binding]) => ({
|
||||
element,
|
||||
resource: binding.resource,
|
||||
identity: binding.identity,
|
||||
})),
|
||||
{ onlyIdle: true },
|
||||
);
|
||||
}, [requestPreviewCardsByViewportBand]);
|
||||
sweepVisiblePreviewsRef.current = sweepVisiblePreviews;
|
||||
|
||||
/**
|
||||
@@ -985,9 +1043,53 @@ export function useProjectResourceCardPreviews(input: {
|
||||
// 只预热 `eagerResources`(缺省回退 `resources`):身份与缓存仍然覆盖全量投影,
|
||||
// 这里收窄的仅仅是「谁先占读取槽」。
|
||||
const eagerResources = input.eagerResources ?? input.resources;
|
||||
let requested = 0;
|
||||
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) {
|
||||
if (requested >= eagerPreviewLimit) {
|
||||
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);
|
||||
@@ -999,7 +1101,7 @@ export function useProjectResourceCardPreviews(input: {
|
||||
kind !== 'audio'
|
||||
) {
|
||||
requestPreview(resource, identity, 'visible');
|
||||
requested += 1;
|
||||
fallbackRequested += 1;
|
||||
}
|
||||
}
|
||||
}, [
|
||||
@@ -1008,6 +1110,7 @@ export function useProjectResourceCardPreviews(input: {
|
||||
input.eagerResources,
|
||||
input.resources,
|
||||
requestPreview,
|
||||
requestPreviewCardsByViewportBand,
|
||||
]);
|
||||
|
||||
const imageDimensionsByResourceId = useMemo(() => {
|
||||
@@ -1070,16 +1173,46 @@ export function useProjectResourceCardPreviews(input: {
|
||||
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 binding = observedCardsRef.current.get(
|
||||
entry.target as HTMLElement,
|
||||
);
|
||||
if (binding) {
|
||||
requestPreview(binding.resource, binding.identity, 'visible');
|
||||
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');
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1126,6 +1259,7 @@ export function useProjectResourceCardPreviews(input: {
|
||||
requestPreview,
|
||||
rootEpoch,
|
||||
scopeKey,
|
||||
viewportBandOfElement,
|
||||
]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, cleanup, renderHook, waitFor } from '@testing-library/react';
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
render,
|
||||
renderHook,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { isProjectResourcePreviewCancellation } from '../src/services/projectResourcePreviewTransport';
|
||||
@@ -1696,6 +1703,288 @@ describe('预览队列上限吞掉按需请求时的可观测性', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('冷启动首屏的放行范围与放行顺序', () => {
|
||||
type PreviewController = ReturnType<typeof useProjectResourceCardPreviews>;
|
||||
|
||||
const VIEWPORT_MARGIN = 160;
|
||||
|
||||
/** 只补用例关心的字段:几何判据只读 width/height/left/right/top/bottom。 */
|
||||
function cardRect(input: {
|
||||
top: number;
|
||||
bottom: number;
|
||||
left?: number;
|
||||
right?: number;
|
||||
}): DOMRect {
|
||||
const left = input.left ?? 10;
|
||||
const right = input.right ?? 60;
|
||||
return {
|
||||
x: left,
|
||||
y: input.top,
|
||||
top: input.top,
|
||||
bottom: input.bottom,
|
||||
left,
|
||||
right,
|
||||
width: right - left,
|
||||
height: input.bottom - input.top,
|
||||
} as DOMRect;
|
||||
}
|
||||
|
||||
/** 视口内(档 0) */
|
||||
const IN_VIEWPORT_RECT = () => cardRect({ top: 10, bottom: 60 });
|
||||
/** 只在 160px 余量圈里(档 1):整卡在视口上沿之外,但仍落在 rootMargin 内。 */
|
||||
const IN_MARGIN_ONLY_RECT = () =>
|
||||
cardRect({
|
||||
top: -(VIEWPORT_MARGIN / 2),
|
||||
bottom: -(VIEWPORT_MARGIN / 2) + 50,
|
||||
});
|
||||
/** 放行范围之外(档 2) */
|
||||
const OFFSCREEN_RECT = () => cardRect({ top: 2000, bottom: 2060 });
|
||||
|
||||
function PreviewBandCard(input: {
|
||||
controller: PreviewController;
|
||||
resource: ProjectResource;
|
||||
rect: DOMRect;
|
||||
}) {
|
||||
const elementRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const { identityByResourceId, observePreview } = input.controller;
|
||||
React.useLayoutEffect(() => {
|
||||
const element = elementRef.current;
|
||||
const identity = identityByResourceId.get(input.resource.id);
|
||||
if (!element || !identity) {
|
||||
return undefined;
|
||||
}
|
||||
element.getBoundingClientRect = () => input.rect;
|
||||
return observePreview(element, input.resource, identity);
|
||||
}, [identityByResourceId, input.rect, input.resource, observePreview]);
|
||||
return React.createElement('div', {
|
||||
ref: elementRef,
|
||||
'data-preview-band-card': input.resource.id,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 真机形状的最小复刻:卡片在子组件的 layout effect 里注册(早于本 hook 的被动 effect,
|
||||
* 与生产里"卡片是画本容器的子组件"同一时序)。
|
||||
*/
|
||||
function PreviewBandHarness(input: {
|
||||
projectPath: string;
|
||||
resources: ProjectResource[];
|
||||
rects: DOMRect[];
|
||||
eagerPreviewLimit: number;
|
||||
controllerRef?: { current: PreviewController | null };
|
||||
}) {
|
||||
const canvasRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const controller = useProjectResourceCardPreviews({
|
||||
projectPath: input.projectPath,
|
||||
projectId: input.projectPath,
|
||||
mode: 'dependency',
|
||||
resources: input.resources,
|
||||
canvasRef,
|
||||
eagerPreviewLimit: input.eagerPreviewLimit,
|
||||
});
|
||||
if (input.controllerRef) {
|
||||
input.controllerRef.current = controller;
|
||||
}
|
||||
return React.createElement(
|
||||
'div',
|
||||
{ ref: canvasRef },
|
||||
input.resources.map((item, index) =>
|
||||
React.createElement(PreviewBandCard, {
|
||||
key: item.id,
|
||||
controller,
|
||||
resource: item,
|
||||
rect: input.rects[index]!,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function readPaths(invoke: ReturnType<typeof vi.fn>) {
|
||||
return previewReadCalls(invoke).map(
|
||||
([, args]) =>
|
||||
(args as { relativePath?: string } | undefined)?.relativePath ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
function installRecordingIntersectionObserver() {
|
||||
const callbacks: IntersectionObserverCallback[] = [];
|
||||
const observed = new Set<Element>();
|
||||
const original = Object.getOwnPropertyDescriptor(
|
||||
window,
|
||||
'IntersectionObserver',
|
||||
);
|
||||
class RecordingIntersectionObserver {
|
||||
readonly root = null;
|
||||
readonly rootMargin = '160px';
|
||||
readonly thresholds = [0];
|
||||
|
||||
constructor(callback: IntersectionObserverCallback) {
|
||||
callbacks.push(callback);
|
||||
}
|
||||
|
||||
observe(element: Element) {
|
||||
observed.add(element);
|
||||
}
|
||||
|
||||
unobserve(element: Element) {
|
||||
observed.delete(element);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
observed.clear();
|
||||
}
|
||||
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
Object.defineProperty(window, 'IntersectionObserver', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: RecordingIntersectionObserver,
|
||||
});
|
||||
return {
|
||||
callbacks,
|
||||
observed,
|
||||
fire: (targets: readonly Element[]) => {
|
||||
act(() => {
|
||||
callbacks.at(-1)!(
|
||||
targets.map(
|
||||
(target) =>
|
||||
({
|
||||
target,
|
||||
isIntersecting: true,
|
||||
intersectionRatio: 1,
|
||||
}) as IntersectionObserverEntry,
|
||||
),
|
||||
{} as IntersectionObserver,
|
||||
);
|
||||
});
|
||||
},
|
||||
restore: () => {
|
||||
if (original) {
|
||||
Object.defineProperty(window, 'IntersectionObserver', original);
|
||||
} else {
|
||||
Reflect.deleteProperty(window, 'IntersectionObserver');
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it('prefetches only cards that are actually in view, and still fills the first screen', async () => {
|
||||
// A:热预取此前按投影顺序盲取前 12 张,条件里没有几何判断 —— 排在前面的视口外卡片
|
||||
// 先占满 3 个读取槽,屏幕里的卡反而排队,首屏就是"等图片"。
|
||||
const offscreen = Array.from({ length: 8 }, (_, index) =>
|
||||
resource(`eager-offscreen-${index}`),
|
||||
);
|
||||
const marginOnly = Array.from({ length: 4 }, (_, index) =>
|
||||
resource(`eager-margin-${index}`),
|
||||
);
|
||||
const inViewport = Array.from({ length: 8 }, (_, index) =>
|
||||
resource(`eager-in-viewport-${index}`),
|
||||
);
|
||||
// 投影顺序故意把"视口外"排在前面:旧的顺序预取会先读它们。
|
||||
const resources = [...offscreen, ...marginOnly, ...inViewport];
|
||||
const rects = [
|
||||
...offscreen.map(OFFSCREEN_RECT),
|
||||
...marginOnly.map(IN_MARGIN_ONLY_RECT),
|
||||
...inViewport.map(IN_VIEWPORT_RECT),
|
||||
];
|
||||
const invoke = vi.fn(
|
||||
async (_command: string, args?: Record<string, unknown>) =>
|
||||
preview(String(args?.relativePath ?? '')),
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
|
||||
render(
|
||||
React.createElement(PreviewBandHarness, {
|
||||
projectPath: '/tmp/preview-eager-visibility',
|
||||
resources,
|
||||
rects,
|
||||
eagerPreviewLimit: 12,
|
||||
}),
|
||||
);
|
||||
|
||||
// 首屏请求量:仍是 eagerPreviewLimit = 12 张,但 12 张全部落在可见卡上。
|
||||
await waitFor(() =>
|
||||
expect(new Set(readPaths(invoke)).size).toBe(
|
||||
resources.length - offscreen.length,
|
||||
),
|
||||
);
|
||||
const readPathsSet = new Set(readPaths(invoke));
|
||||
for (const item of offscreen) {
|
||||
expect(readPathsSet.has(item.path)).toBe(false);
|
||||
}
|
||||
for (const item of [...inViewport, ...marginOnly]) {
|
||||
expect(readPathsSet.has(item.path)).toBe(true);
|
||||
}
|
||||
// 首屏要保证有图可读:可见的 12 张一张都不能少。
|
||||
expect(readPathsSet.size).toBe(resources.length - offscreen.length);
|
||||
});
|
||||
|
||||
it('releases intersecting cards in two bands: viewport first, margin second', async () => {
|
||||
// B:一次 observer 回调可能同时报来 ~21 张相交卡,物理读取只有 3 个槽。
|
||||
// 两档放行 = 先入队视口内、再入队余量圈,**总量不变**(不是丢掉第二档)。
|
||||
const marginOnly = Array.from({ length: 3 }, (_, index) =>
|
||||
resource(`band-margin-${index}`),
|
||||
);
|
||||
const inViewport = Array.from({ length: 3 }, (_, index) =>
|
||||
resource(`band-in-viewport-${index}`),
|
||||
);
|
||||
// 登记顺序故意先余量圈、后视口内:单趟入队会把这个顺序原样写进队列。
|
||||
const resources = [...marginOnly, ...inViewport];
|
||||
const invoke = vi.fn(() => new Promise(() => undefined));
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
const harness = installRecordingIntersectionObserver();
|
||||
const controllerRef: { current: PreviewController | null } = {
|
||||
current: null,
|
||||
};
|
||||
|
||||
try {
|
||||
render(
|
||||
React.createElement(PreviewBandHarness, {
|
||||
projectPath: '/tmp/preview-band-order',
|
||||
resources,
|
||||
rects: resources.map(OFFSCREEN_RECT),
|
||||
eagerPreviewLimit: 0,
|
||||
controllerRef,
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(harness.callbacks.length).toBeGreaterThan(0));
|
||||
// 注册时全在放行范围之外:登记扫描一张都不该放行。
|
||||
expect(previewReadCalls(invoke)).toHaveLength(0);
|
||||
|
||||
const elements = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[data-preview-band-card]'),
|
||||
);
|
||||
expect(elements).toHaveLength(resources.length);
|
||||
elements.forEach((element, index) => {
|
||||
element.getBoundingClientRect =
|
||||
index < marginOnly.length ? IN_MARGIN_ONLY_RECT : IN_VIEWPORT_RECT;
|
||||
});
|
||||
|
||||
harness.fire(elements);
|
||||
|
||||
// 3 个槽先给视口内那批:一次回调里先看到的那几张先出图。
|
||||
expect(readPaths(invoke)).toEqual(inViewport.map((item) => item.path));
|
||||
// 余量圈那批没有被丢掉,只是排在后面(总量不变 = 6 张全部放行)。
|
||||
const queuedIdentities = controllerRef
|
||||
.current!.previewQueueSnapshot()
|
||||
.queue.map((job) => job.identity);
|
||||
expect(queuedIdentities).toEqual(
|
||||
marginOnly.map((item) =>
|
||||
controllerRef.current!.identityByResourceId.get(item.id),
|
||||
),
|
||||
);
|
||||
expect(readPaths(invoke).length + queuedIdentities.length).toBe(
|
||||
resources.length,
|
||||
);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('预览缓存驱逐后的可见性复核', () => {
|
||||
it('re-requests a card that is still visible right after the LRU evicts it', async () => {
|
||||
// 现场:LRU 只按条数/字节驱逐,不看可见性 —— 当前屏幕上那张卡会被后加载的图挤掉,
|
||||
|
||||
Reference in New Issue
Block a user