预览门禁去掉单点依赖: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:
+151
-13
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user