冷启动首屏:热预取过可见性判据,相交卡按视口内 / 余量圈两档放行

- 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:
2026-09-11 19:58:09 +08:00
parent 1c43f2c2b2
commit 149c0b591a
2 changed files with 469 additions and 46 deletions
@@ -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 只按条数/字节驱逐,不看可见性 —— 当前屏幕上那张卡会被后加载的图挤掉,