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 bef0eac06..c0d9301aa 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 @@ -595,10 +595,48 @@ export function useProjectResourceCardPreviews(input: { [touchCachedPreview], ); + /** + * 把当前登记表里的全部卡片元素补挂到 observer 上。 + * + * 注册(`observePreview`)与 observer 的创建分属不同 effect,两者存在时序窗口: + * 在 observer 还没建好时注册的卡只能靠 observer effect 里的一次性补挂兜住, + * 一旦那个补挂发生在注册之前、或 observer 之后被重建,卡就会**停留在登记表里 + * 却从未被观察** —— 它永不触发可见性回调,也就永远停在 `idle`,卡面只剩占位图标。 + * 所以每次 observer 就绪或重建后都要按登记表重新补挂一遍。 + */ + const attachObservedCards = useCallback(() => { + const observer = observerRef.current; + if (!observer) { + return; + } + for (const element of observedCardsRef.current.keys()) { + observer.observe(element); + } + }, []); + + /** + * 创建 observer 时的 root,用来判断它是否变了。 + * + * `null` 有特殊含义:**还没有可用的 root**。注册比本 effect 先跑,所以第一轮这里 + * 拿到 null 是正常时序,此时不能建 observer。 + */ + const observerRootRef = useRef(null); + const [rootEpoch, setRootEpoch] = useState(0); + const observePreview = useCallback( (element: HTMLElement, resource: ProjectResource, identity: string) => { const binding = { resource, identity }; observedCardsRef.current.set(element, binding); + if (!observerRef.current) { + // 卡片一定渲染在资源画本容器内部,所以「有卡片注册」本身就是「root 已就绪」 + // 的信号。observer 还没建好时用它触发一次重建,避免卡死在未观察状态。 + // 在测试等没有 IntersectionObserver 的环境里保持静默,不制造多余渲染。 + const root = + input.intersectionRootRef?.current ?? input.canvasRef.current; + if (root && window.IntersectionObserver) { + setRootEpoch((epoch) => epoch + 1); + } + } observerRef.current?.observe(element); return () => { observerRef.current?.unobserve(element); @@ -607,7 +645,7 @@ export function useProjectResourceCardPreviews(input: { } }; }, - [], + [input.canvasRef, input.intersectionRootRef], ); const failPreview = useCallback( @@ -755,6 +793,21 @@ export function useProjectResourceCardPreviews(input: { if (!IntersectionObserverClass) { return undefined; } + // root 是资源画本容器;它由被观察卡片所在的那棵子树持有。如果这里为 null, + // 浏览器会退回按视口判定,而真正裁剪卡片的是画本容器 —— 被祖先裁掉的卡片 + // 会永远报「不可见」,可见性门禁再也不会放行它们。此时**不建 observer**, + // 等卡片注册触发的 `rootEpoch` 让本 effect 重跑再建;期间 `attachObservedCards` + // 会把登记表里的卡一次性补挂齐,避免"登记了却没被观察"。 + const root = input.intersectionRootRef?.current ?? input.canvasRef.current; + if (!root) { + return undefined; + } + if (observerRef.current && observerRootRef.current === root) { + // root 没变且 observer 已在:只补挂新注册的卡,不做无谓重建。 + attachObservedCards(); + return undefined; + } + observerRef.current?.disconnect(); const observer = new IntersectionObserverClass( (entries) => { for (const entry of entries) { @@ -770,21 +823,30 @@ export function useProjectResourceCardPreviews(input: { } }, { - root: input.intersectionRootRef?.current ?? input.canvasRef.current, + root, rootMargin: '160px', }, ); observerRef.current = observer; - for (const element of observedCardsRef.current.keys()) { - observer.observe(element); - } + observerRootRef.current = root; + attachObservedCards(); return () => { observer.disconnect(); if (observerRef.current === observer) { observerRef.current = null; } + if (observerRootRef.current === root) { + observerRootRef.current = null; + } }; - }, [input.canvasRef, input.intersectionRootRef, requestPreview, scopeKey]); + }, [ + attachObservedCards, + input.canvasRef, + input.intersectionRootRef, + requestPreview, + rootEpoch, + scopeKey, + ]); return { identityByResourceId, diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts index 7b20d89f3..adf84f209 100644 --- a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts +++ b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts @@ -986,4 +986,352 @@ describe('useProjectResourceCardPreviews', () => { } } }); + + /** + * 可见性门禁的时机契约:observer 必须**等 root 真正就绪**才建,且建好后要把 + * 登记表里的卡一次性补挂齐。 + * + * 背景:root 是资源画本容器(卡片渲染在它内部)。若在 root 还是 null 时就建 observer, + * 浏览器会退回按视口判定,被画本容器裁掉的卡片永远报「不可见」,可见性门禁再也不放行, + * 卡片就停在 `idle`、卡面只剩占位图标 —— 真机现场出现的正是「同一屏里 idle 与 loaded 交错」。 + */ + describe('可见性门禁的 root 时机契约', () => { + type ObserverHarness = { + callbacks: IntersectionObserverCallback[]; + roots: (Element | Document | null)[]; + observed: Set; + restore: () => void; + }; + + function installIntersectionObserver(): ObserverHarness { + const callbacks: IntersectionObserverCallback[] = []; + const roots: (Element | Document | null)[] = []; + const observed = new Set(); + const original = 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); + } + + 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, + }); + return { + callbacks, + roots, + observed, + restore: () => { + if (original) { + Object.defineProperty(window, 'IntersectionObserver', original); + } else { + Reflect.deleteProperty(window, 'IntersectionObserver'); + } + }, + }; + } + + function fireVisible(harness: ObserverHarness, targets: Iterable) { + act(() => { + harness.callbacks.at(-1)!( + [...targets].map( + (target) => + ({ + target, + isIntersecting: true, + intersectionRatio: 1, + }) as IntersectionObserverEntry, + ), + {} as IntersectionObserver, + ); + }); + } + + it('waits for a real root, then observes and loads the cards', async () => { + const art = resource('late-root-art'); + const invoke = vi.fn( + async (_command: string, args?: Record) => + preview(String(args?.relativePath ?? art.path)), + ); + window.__TAURI__ = { core: { invoke } }; + // root 在首次渲染时还没有挂上(等价于画本容器尚未就绪)。 + const canvasRef: { current: HTMLDivElement | null } = { current: null }; + const harness = installIntersectionObserver(); + + try { + const { result } = renderHook(() => + useProjectResourceCardPreviews({ + projectPath: '/tmp/preview-late-root', + projectId: 'preview-late-root', + mode: 'dependency', + resources: [art], + canvasRef, + // 关掉热预取,逼这张卡只能靠可见性放行。 + eagerPreviewLimit: 0, + }), + ); + const identity = result.current.identityByResourceId.get(art.id)!; + + // root 还没就绪时不得建 observer —— 否则会按视口判定,被容器裁掉的卡永远不可见。 + expect(harness.callbacks).toHaveLength(0); + + const root = document.createElement('div'); + const card = document.createElement('div'); + root.append(card); + canvasRef.current = root; + let unregister: () => void = () => undefined; + act(() => { + 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); + expect(harness.observed.has(card)).toBe(true); + expect(result.current.previews.get(identity)).toBeUndefined(); + + fireVisible(harness, [card]); + + await waitFor(() => + expect(result.current.previews.get(identity)?.status).toBe('loaded'), + ); + unregister(); + } finally { + harness.restore(); + } + }); + + it('re-observes cards registered before the observer existed', async () => { + const resources = Array.from({ length: 3 }, (_, index) => + resource(`early-register-${index + 1}`), + ); + const invoke = vi.fn( + async (_command: string, args?: Record) => + preview(String(args?.relativePath ?? '')), + ); + window.__TAURI__ = { core: { invoke } }; + const canvasRef: { current: HTMLDivElement | null } = { current: null }; + const harness = installIntersectionObserver(); + + try { + const { result } = renderHook(() => + useProjectResourceCardPreviews({ + projectPath: '/tmp/preview-early-register', + projectId: 'preview-early-register', + mode: 'dependency', + resources, + canvasRef, + eagerPreviewLimit: 0, + }), + ); + + const root = document.createElement('div'); + canvasRef.current = root; + const elements = resources.map(() => document.createElement('div')); + act(() => { + resources.forEach((item, index) => { + result.current.observePreview( + elements[index]!, + item, + result.current.identityByResourceId.get(item.id)!, + ); + }); + }); + + // 登记与建 observer 的先后顺序无关:建好之后登记表里的每一张都必须被挂上。 + await waitFor(() => + expect(harness.observed.size).toBe(elements.length), + ); + for (const element of elements) { + expect(harness.observed.has(element)).toBe(true); + } + + fireVisible(harness, elements); + + await waitFor(() => { + for (const item of resources) { + const identity = result.current.identityByResourceId.get(item.id)!; + expect(result.current.previews.get(identity)?.status).toBe( + 'loaded', + ); + } + }); + } finally { + 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') }; + + 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); + } + + 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, + }); + + try { + const { result } = renderHook(() => + useProjectResourceCardPreviews({ + projectPath: '/tmp/preview-visibility', + projectId: 'preview-visibility', + mode: 'dependency', + resources, + canvasRef, + // 热预取只覆盖前 12 张:第 13 张起完全依赖可见性门禁。 + eagerPreviewLimit: 12, + }), + ); + + // 模拟卡片挂载后的注册:按计划顺序,前 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), + ); + } + }); + + // 注册的每个元素都必须真的进了 observer:漏 observe 的卡永远不会被放行。 + expect(observed.size).toBe(unregister.length); + expect(roots.at(-1)).toBe(canvasRef.current); + + 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'); + } + } + }); });