预览门禁去掉单点依赖:observer 必定建出,并加可见性兜底扫描

真机现场:整个「UI 交互」栏(51 项)全部只剩占位图标。根因链是**同一对文件里的两个缺陷叠加**,因此作为一个原子提交收口(拆成两半任一半都不足以独立成立:光修 observer 仍可能被回调时序漏掉,光加扫描则 observer 根本不存在时的判定精度无从恢复)。

一、确认 2a157ea6f 引入的回归:observer 可能永远不建
- 那次改动把「root 为 null」当成「不建 observer」,并指望「卡片注册」作为 root 就绪信号重建。
- 但那个 effect 每个 scope **实际只跑一次**(`requestPreview` 是稳定引用,其余依赖在 scope 内不变),一旦这一轮因 root 为 null 直接返回就再无第二次机会;
- 而「卡片注册」信号只在**后续还有新注册**时才来 —— 初次挂载时 51 张卡一次性注册完,之后再没有新注册,信号永不再来。
- 结果:observer 永不创建、所有卡永不被观察、永远停在 `idle`,整栏只剩占位图标(`categoryIcons['ui-interaction'] = LayoutGrid`)。
- 修复:**绝不拿"root 未就绪"当作"不建 observer"的理由**。拿到容器 root 最好;拿不到就退到 canvas、再退到视口(`root: null`)。视口判定严格优于"没有 observer"——屏幕上真实可见的卡一定落在视口矩形内;代价只是视口外但仍在画布内的卡晚一步由滚动触发。容器 root 迟到时再有界重试(5 次、退避到 512ms)重建一次,恢复按画本容器裁剪的精确判定。

二、系统性防线:可见性兜底扫描(去掉单点依赖)
- 门禁原先只有 `IntersectionObserver` 一条路,observer 没建出来 / 回调没送达 / 注册与创建交错,卡片就再无第二次机会。
- 新增 `sweepVisiblePreviews()`:按**与 observer 对齐的视口几何判据**(含 160px 余量)独立复核登记表,只对 `idle`(从未请求)的卡补一次 `visible`,`loading`/`queued`/`loaded`/`failed` 一律不碰。
- **不是第二条加载通路**:仍经 `requestPreview` 走同一条队列,受同一套去重、优先级、全局 3 槽与 `48` 项 / `64 MiB` 预算约束;**只补"可判定为可见"的卡,不退化成全量预读**(PRD §3.3.2 合同不变)。
- 触发点:scope 变化后 0 / 250 / 1000ms 各扫一次(覆盖晚挂载的卡)、observer 就绪后立即扫一次、每次卡片注册时扫一次、窗口 resize 与页面重新可见时各扫一次。

测试(`tests/useProjectResourceCardPreviews.test.ts`)
- 更新「等真正的 root 就绪」这条契约:它原先断言"root 为 null 时不得建 observer"——那正是本轮回归的语义,改为断言"**仍然必须建出 observer**(`root: null` 视口判定)",并要求容器 root 迟到后重建为容器 root;
- 新增「不在热预取窗口内的可见卡不得停在 idle」(20 张 / 热预取 12);
- 新增「没有任何 observer 回调时,可见卡也必须被放行」——完全不触发回调,只靠几何可见性,专门守住兜底扫描;
- 新增「视口外的已登记卡不得被请求」——守住"不退化成全量预读"。

变异验证:把兜底扫描改成直接 `return 0`(等价于去掉扫描)→ 「没有任何 observer 回调时可见卡也必须被放行」立即失败(`expected undefined to be 'loaded'`);恢复后 21/21 通过。断言不是恒真假守卫。

可观测性:`data-preview-status` / `data-preview-error` 已在位,因此"可见却 idle"这类问题以后读一个 DOM 属性即可判定,不再需要解码图片或猜调度。

验证(同一时刻、同一命令的前后对照;树上另有并发改动):改动前 `86 files / 1225 passed / 4 skipped / 0 failed`;改动后 `86 files / 1227 passed / 4 skipped / 0 failed`(多出的 2 条为本提交新增用例)。`src/components/image-editor` 1385 passed;typecheck exit 0;check:encoding 4384 文件;prettier 与 eslint 干净;`git diff --check` 干净。
This commit is contained in:
2026-09-11 16:49:09 +08:00
parent e088ede217
commit 3de2ec734b
2 changed files with 376 additions and 143 deletions
@@ -33,6 +33,31 @@ import {
} 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 = {
@@ -615,19 +640,33 @@ export function useProjectResourceCardPreviews(input: {
}, []);
/**
* 创建 observer 时的 root,用来判断它是否变了。
* 创建 observer 时的 root,用来判断它是否变了。
*
* `null` 有特殊含义:**还没有可用的 root**。注册比本 effect 先跑,所以第一轮这里
* 拿到 null 是正常时序,此时不能建 observer。
* 取值只用于"要不要重建"的比较:`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);
const observePreview = useCallback(
(element: HTMLElement, resource: ProjectResource, identity: string) => {
const binding = { resource, identity };
observedCardsRef.current.set(element, binding);
if (!observerRef.current) {
if (!observerReadyRef.current) {
// 卡片一定渲染在资源画本容器内部,所以「有卡片注册」本身就是「root 已就绪」
// 的信号。observer 还没建好时用它触发一次重建,避免卡死在未观察状态。
// 在测试等没有 IntersectionObserver 的环境里保持静默,不制造多余渲染。
@@ -638,6 +677,8 @@ export function useProjectResourceCardPreviews(input: {
}
}
observerRef.current?.observe(element);
// 注册即复核:observer 回调可能还没送达,此时先按几何判一次可见性。
sweepVisiblePreviewsRef.current();
return () => {
observerRef.current?.unobserve(element);
if (observedCardsRef.current.get(element) === binding) {
@@ -648,6 +689,82 @@ export function useProjectResourceCardPreviews(input: {
[input.canvasRef, input.intersectionRootRef],
);
/**
* 与 observer 的 `rootMargin: 160px` 对齐的视口判定。
*
* 兜底扫描用它**独立复核可见性**,而不是信任 observer 报过什么 —— 要修的是
* "门禁漏放行",所以不能拿同一条可能失效的通路当判据。只认"确实落在视口矩形
* (含 160px 余量)内"的卡,**不会退化成全量预读**。
*/
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
);
}, []);
/**
* 可见性兜底扫描:把「已登记、确实在视口内、但还没有任何请求」的卡补一次 `visible`。
*
* 它**不是第二条加载通路**`requestPreview` 仍然只做去重、排优先级、走同一条队列),
* 而是给"门禁漏放行"一个可恢复窗口。只对 `idle`(从未请求)的卡生效:
* `loading` / `queued` / `loaded` / `failed` 一律不碰,所以不会绕过热预取合同、
* 也不会突破全局 3 槽与 LRU 预算。
*/
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]);
sweepVisiblePreviewsRef.current = 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)) {
@@ -793,17 +910,18 @@ export function useProjectResourceCardPreviews(input: {
if (!IntersectionObserverClass) {
return undefined;
}
// root 是资源画本容器;它由被观察卡片所在的那棵子树持有。如果这里为 null
// 浏览器会退回按视口判定,而真正裁剪卡片的是画本容器 —— 被祖先裁掉的卡片
// 会永远报「不可见」,可见性门禁再也不会放行它们。此时**不建 observer**
// 等卡片注册触发的 `rootEpoch` 让本 effect 重跑再建;期间 `attachObservedCards`
// 会把登记表里的卡一次性补挂齐,避免"登记了却没被观察"。
// 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 (!root) {
return undefined;
}
if (observerRef.current && observerRootRef.current === root) {
// root 没变且 observer 已在:只补挂新注册的卡,不做无谓重建。
observerReadyRef.current = true;
attachObservedCards();
return undefined;
}
@@ -823,17 +941,37 @@ export function useProjectResourceCardPreviews(input: {
}
},
{
root,
// `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;
@@ -1100,11 +1100,21 @@ describe('useProjectResourceCardPreviews', () => {
);
const identity = result.current.identityByResourceId.get(art.id)!;
// root 还没就绪时不得建 observer —— 否则会按视口判定,被容器裁掉的卡永远不可见
expect(harness.callbacks).toHaveLength(0);
// 关键回归护栏:容器 root 尚未就绪时**仍然必须建出 observer**(退到视口判定)
// 若这里因为 `root === null` 就不建,本 effect 每个 scope 只跑一次、
// `requestPreview` 又是稳定引用,就再也没有第二次机会 —— 所有卡片永远不被观察、
// 永远停在 `idle`,整个栏目只剩占位图标。真机整栏全空正是这个形状。
await waitFor(() =>
expect(harness.callbacks.length).toBeGreaterThan(0),
);
expect(harness.roots.at(-1)).toBeNull();
const root = document.createElement('div');
const card = document.createElement('div');
// 故意让它落在视口之外:本用例要证明的是"observer 回调能放行"这条通路,
// 兜底扫描不该抢先把可见卡加载掉(那由下面两条扫描专项用例覆盖)。
card.getBoundingClientRect = () =>
({ top: 5000, left: 10, bottom: 5060, right: 60 }) as DOMRect;
root.append(card);
canvasRef.current = root;
let unregister: () => void = () => undefined;
@@ -1112,11 +1122,8 @@ describe('useProjectResourceCardPreviews', () => {
unregister = result.current.observePreview(card, art, identity);
});
// 注册本身就是「root 已就绪」的信号:observer 必须以真正的 root 建出来
await waitFor(() =>
expect(harness.callbacks.length).toBeGreaterThan(0),
);
expect(harness.roots.at(-1)).toBe(root);
// 容器 root 迟到后要重建一次,把判定从视口换成画本容器
await waitFor(() => expect(harness.roots.at(-1)).toBe(root));
expect(harness.observed.has(card)).toBe(true);
expect(result.current.previews.get(identity)).toBeUndefined();
@@ -1190,148 +1197,236 @@ describe('useProjectResourceCardPreviews', () => {
harness.restore();
}
});
});
it('does not leave a visible card idle when it is outside the eager window', async () => {
// 真机现场:同一屏里 idle 与 loaded 交错,idle 的那批从未入队。
// 热预取只覆盖前 12 张,其余全部依赖可见性门禁 —— 所以这里必须证明
// 「注册进 observer 的卡,在回调报可见后会真的进入队列」,而不是停在 idle。
const resources = Array.from({ length: 20 }, (_, index) =>
resource(`visibility-${index + 1}`),
);
const invoke = vi.fn(
async (_command: string, args?: Record<string, unknown>) =>
preview(String(args?.relativePath ?? '')),
);
window.__TAURI__ = { core: { invoke } };
const canvasRef = { current: document.createElement('div') };
it('does not leave a visible card idle when it is outside the eager window', async () => {
// 真机现场:同一屏里 idle 与 loaded 交错,idle 的那批从未入队。
// 热预取只覆盖前 12 张,其余全部依赖可见性门禁 —— 所以这里必须证明
// 「注册进 observer 的卡,在回调报可见后会真的进入队列」,而不是停在 idle。
const resources = Array.from({ length: 20 }, (_, index) =>
resource(`visibility-${index + 1}`),
);
const invoke = vi.fn(
async (_command: string, args?: Record<string, unknown>) =>
preview(String(args?.relativePath ?? '')),
);
window.__TAURI__ = { core: { invoke } };
const canvasRef = { current: document.createElement('div') };
const callbacks: IntersectionObserverCallback[] = [];
const roots: (Element | Document | null)[] = [];
const observed = new Set<Element>();
const originalIntersectionObserver = Object.getOwnPropertyDescriptor(
window,
'IntersectionObserver',
);
class TestIntersectionObserver {
readonly root = null;
readonly rootMargin = '160px';
readonly thresholds = [0];
const callbacks: IntersectionObserverCallback[] = [];
const roots: (Element | Document | null)[] = [];
const observed = new Set<Element>();
const originalIntersectionObserver = Object.getOwnPropertyDescriptor(
window,
'IntersectionObserver',
);
class TestIntersectionObserver {
readonly root = null;
readonly rootMargin = '160px';
readonly thresholds = [0];
constructor(
callback: IntersectionObserverCallback,
options?: IntersectionObserverInit,
) {
callbacks.push(callback);
roots.push(options?.root ?? null);
constructor(
callback: IntersectionObserverCallback,
options?: IntersectionObserverInit,
) {
callbacks.push(callback);
roots.push(options?.root ?? null);
}
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: TestIntersectionObserver,
});
observe(element: Element) {
observed.add(element);
}
try {
const { result } = renderHook(() =>
useProjectResourceCardPreviews({
projectPath: '/tmp/preview-visibility',
projectId: 'preview-visibility',
mode: 'dependency',
resources,
canvasRef,
// 热预取只覆盖前 12 张:第 13 张起完全依赖可见性门禁。
eagerPreviewLimit: 12,
}),
);
unobserve(element: Element) {
observed.delete(element);
}
// 模拟卡片挂载后的注册:按计划顺序,前 12 张已进热预取窗口,
// 从第 13 张起是「只能靠可见性放行」的那批。
const unregister: Array<() => void> = [];
act(() => {
for (const item of resources.slice(12)) {
const element = document.createElement('div');
const identity = result.current.identityByResourceId.get(item.id)!;
unregister.push(
result.current.observePreview(element, item, identity),
);
}
});
disconnect() {
observed.clear();
}
// 注册的每个元素都必须真的进了 observer:漏 observe 的卡永远不会被放行。
expect(observed.size).toBe(unregister.length);
expect(roots.at(-1)).toBe(canvasRef.current);
takeRecords() {
return [];
await waitFor(() =>
expect(
result.current.previews.get(
result.current.identityByResourceId.get(resources[0]!.id)!,
)?.status,
).toBe('loaded'),
);
// 报可见之前,未进热预取窗口的卡应仍是「从未请求」。
const registerOrder: Array<[ProjectResource, string]> = resources
.slice(12)
.map((item) => [
item,
result.current.identityByResourceId.get(item.id)!,
]);
for (const [, identity] of registerOrder) {
expect(result.current.previews.get(identity)?.status).toBeUndefined();
}
act(() => {
callbacks.at(-1)!(
[...observed].map(
(target) =>
({
target,
isIntersecting: true,
intersectionRatio: 1,
}) as IntersectionObserverEntry,
),
{} as IntersectionObserver,
);
});
await waitFor(() => {
for (const [, identity] of registerOrder) {
expect(result.current.previews.get(identity)?.status).toBe(
'loaded',
);
}
});
// 契约:可见卡不得停在 idle —— 每张注册过的卡都要落成 loaded。
expect(
registerOrder.filter(
([, identity]) =>
result.current.previews.get(identity) === undefined,
),
).toEqual([]);
} finally {
if (originalIntersectionObserver) {
Object.defineProperty(
window,
'IntersectionObserver',
originalIntersectionObserver,
);
} else {
Reflect.deleteProperty(window, 'IntersectionObserver');
}
}
}
Object.defineProperty(window, 'IntersectionObserver', {
configurable: true,
writable: true,
value: TestIntersectionObserver,
});
try {
const { result } = renderHook(() =>
useProjectResourceCardPreviews({
projectPath: '/tmp/preview-visibility',
projectId: 'preview-visibility',
mode: 'dependency',
resources,
canvasRef,
// 热预取只覆盖前 12 张:第 13 张起完全依赖可见性门禁。
eagerPreviewLimit: 12,
}),
it('requests a visible-but-never-observed card without any observer callback', async () => {
// 兜底扫描的存在理由:门禁原先只有 observer 一条路,observer 回调没送达
// (没建出来、重建中、注册与创建交错)时卡就永远停在 idle。
// 这条用例**完全不触发 observer 回调**,只靠"已登记 + 几何上确实可见"就应被放行。
const art = resource('sweep-art');
const invoke = vi.fn(
async (_command: string, args?: Record<string, unknown>) =>
preview(String(args?.relativePath ?? art.path)),
);
window.__TAURI__ = { core: { invoke } };
const canvasRef: { current: HTMLDivElement | null } = { current: null };
const harness = installIntersectionObserver();
// 模拟卡片挂载后的注册:按计划顺序,前 12 张已进热预取窗口,
// 从第 13 张起是「只能靠可见性放行」的那批。
const unregister: Array<() => void> = [];
act(() => {
for (const item of resources.slice(12)) {
const element = document.createElement('div');
const identity = result.current.identityByResourceId.get(item.id)!;
unregister.push(
result.current.observePreview(element, item, identity),
);
}
});
try {
const { result } = renderHook(() =>
useProjectResourceCardPreviews({
projectPath: '/tmp/preview-sweep',
projectId: 'preview-sweep',
mode: 'dependency',
resources: [art],
canvasRef,
eagerPreviewLimit: 0,
}),
);
const identity = result.current.identityByResourceId.get(art.id)!;
// 注册的每个元素都必须真的进了 observer:漏 observe 的卡永远不会被放行。
expect(observed.size).toBe(unregister.length);
expect(roots.at(-1)).toBe(canvasRef.current);
const root = document.createElement('div');
canvasRef.current = root;
const card = document.createElement('div');
card.getBoundingClientRect = () =>
({ top: 10, left: 10, bottom: 60, right: 60 }) as DOMRect;
act(() => {
result.current.observePreview(card, art, identity);
});
await waitFor(() =>
expect(
result.current.previews.get(
result.current.identityByResourceId.get(resources[0]!.id)!,
)?.status,
).toBe('loaded'),
// 从未调用 harness.callbacks → 可见性只能来自兜底扫描。
await waitFor(() =>
expect(result.current.previews.get(identity)?.status).toBe('loaded'),
);
expect(previewReadCalls(invoke)).toHaveLength(1);
} finally {
harness.restore();
}
});
it('does not request a registered card that is outside the viewport', async () => {
// 兜底扫描只能补"可判定为可见"的卡:不能退化成全量预读。
const art = resource('sweep-offscreen-art');
const invoke = vi.fn(
async (_command: string, args?: Record<string, unknown>) =>
preview(String(args?.relativePath ?? art.path)),
);
window.__TAURI__ = { core: { invoke } };
const canvasRef = { current: document.createElement('div') };
const harness = installIntersectionObserver();
// 报可见之前,未进热预取窗口的卡应仍是「从未请求」。
const registerOrder: Array<[ProjectResource, string]> = resources
.slice(12)
.map((item) => [
item,
result.current.identityByResourceId.get(item.id)!,
]);
for (const [, identity] of registerOrder) {
expect(result.current.previews.get(identity)?.status).toBeUndefined();
}
act(() => {
callbacks.at(-1)!(
[...observed].map(
(target) =>
({
target,
isIntersecting: true,
intersectionRatio: 1,
}) as IntersectionObserverEntry,
),
{} as IntersectionObserver,
try {
const { result } = renderHook(() =>
useProjectResourceCardPreviews({
projectPath: '/tmp/preview-sweep-offscreen',
projectId: 'preview-sweep-offscreen',
mode: 'dependency',
resources: [art],
canvasRef,
eagerPreviewLimit: 0,
}),
);
});
const identity = result.current.identityByResourceId.get(art.id)!;
await waitFor(() => {
for (const [, identity] of registerOrder) {
expect(result.current.previews.get(identity)?.status).toBe('loaded');
}
});
// 契约:可见卡不得停在 idle —— 每张注册过的卡都要落成 loaded。
expect(
registerOrder.filter(
([, identity]) => result.current.previews.get(identity) === undefined,
),
).toEqual([]);
} finally {
if (originalIntersectionObserver) {
Object.defineProperty(
window,
'IntersectionObserver',
originalIntersectionObserver,
);
} else {
Reflect.deleteProperty(window, 'IntersectionObserver');
const card = document.createElement('div');
card.getBoundingClientRect = () =>
({ top: 5000, left: 10, bottom: 5060, right: 60 }) as DOMRect;
act(() => {
result.current.observePreview(card, art, identity);
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(harness.observed.has(card)).toBe(true);
expect(result.current.previews.get(identity)).toBeUndefined();
expect(previewReadCalls(invoke)).toHaveLength(0);
} finally {
harness.restore();
}
}
});
});
});