From 3de2ec734bcfab02242bc98472a74ea0207b6e38 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Fri, 11 Sep 2026 16:49:09 +0800 Subject: [PATCH] =?UTF-8?q?=E9=A2=84=E8=A7=88=E9=97=A8=E7=A6=81=E5=8E=BB?= =?UTF-8?q?=E6=8E=89=E5=8D=95=E7=82=B9=E4=BE=9D=E8=B5=96=EF=BC=9Aobserver?= =?UTF-8?q?=20=E5=BF=85=E5=AE=9A=E5=BB=BA=E5=87=BA=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E5=8A=A0=E5=8F=AF=E8=A7=81=E6=80=A7=E5=85=9C=E5=BA=95=E6=89=AB?= =?UTF-8?q?=E6=8F=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真机现场:整个「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` 干净。 --- .../useProjectResourceCardPreviews.ts | 164 +++++++- .../useProjectResourceCardPreviews.test.ts | 355 +++++++++++------- 2 files changed, 376 insertions(+), 143 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts index c0d9301aa..e93718489 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts @@ -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(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 | 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; diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts index adf84f209..dfb212f58 100644 --- a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts +++ b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts @@ -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) => - 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) => + 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(); - 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(); + 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) => + 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) => + 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(); } - } + }); }); });