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 e28dfa751..4dc751f6f 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 @@ -405,6 +405,7 @@ export function useProjectResourceCardPreviews(input: { cacheOrderRef.current.delete(identity); disposeCachedPreview(identity); next.set(identity, state); + let evictedCount = 0; if (state.status === 'loaded' || state.status === 'failed') { const cached = materialized ? { @@ -427,10 +428,30 @@ export function useProjectResourceCardPreviews(input: { cacheOrderRef.current.delete(evictedIdentity); disposeCachedPreview(evictedIdentity); next.delete(evictedIdentity); + evictedCount += 1; } } previewsRef.current = next; setPreviews(next); + if (evictedCount > 0) { + /** + * 驱逐之后立刻补一次可见性兜底扫描。 + * + * 被驱逐的 identity 从 `previews` 里被删掉,状态正好回到"从未请求"(`undefined`), + * 而 [`sweepVisiblePreviews`] **只对 `undefined` 的卡重新入队** —— 也就是说 + * 驱逐后仍停在视口里的卡只差这一次调用:不补,它的图片就凭空消失、退回占位图标, + * 而且不会自己回来(`IntersectionObserver` 对一直相交的元素没有二次回调, + * 下一次扫描只等 scope 变化的 0/250/1000ms、resize 或 visibilitychange)。 + * + * 调用点必须在 `previewsRef.current = next` **之后**:扫描读的是 `previewsRef`, + * 早一步调用会看到被驱逐的卡仍是 `loaded`,于是什么都不做。 + * + * 重入性:重新入队走 `requestPreview` → `drainQueue` → `publishPreview({status:'loading'})`, + * 而 `loading` 发布**不进**上面这个 `loaded` / `failed` 分支,因此不会再触发驱逐、 + * 也不会再走到这里 —— 递归深度恒为 1,不自激。 + */ + sweepVisiblePreviewsRef.current(); + } }, [disposeCachedPreview], ); diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts index 3e4223f4a..d5d1ad9ab 100644 --- a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts +++ b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts @@ -1612,3 +1612,69 @@ describe('useProjectResourceCardPreviews', () => { }); }); }); + +describe('预览缓存驱逐后的可见性复核', () => { + it('re-requests a card that is still visible right after the LRU evicts it', async () => { + // 现场:LRU 只按条数/字节驱逐,不看可见性 —— 当前屏幕上那张卡会被后加载的图挤掉, + // 图片凭空消失、退回占位图标,而且不会自己回来:`IntersectionObserver` 对一直相交的 + // 元素没有二次回调,兜底扫描的下一个触发点要等 scope 变化 / resize / 切回页面。 + const target = resource('evicted-visible-art'); + const fillers = Array.from( + { length: PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT }, + (_, index) => resource(`eviction-filler-${index}`), + ); + const resources = [target, ...fillers]; + const invoke = vi.fn( + async (_command: string, args?: Record) => + preview(String(args?.relativePath ?? '')), + ); + window.__TAURI__ = { core: { invoke } }; + const canvasRef = { current: document.createElement('div') }; + + const { result } = renderHook(() => + useProjectResourceCardPreviews({ + projectPath: '/tmp/preview-evict-visible', + projectId: 'preview-evict-visible', + mode: 'dependency', + resources, + canvasRef, + // 关掉热预取:这张卡只能靠可见性门禁放行。 + eagerPreviewLimit: 0, + }), + ); + const targetIdentity = result.current.identityByResourceId.get(target.id)!; + const targetReads = () => + previewReadCalls(invoke).filter( + ([, args]) => + (args as { relativePath?: string } | undefined)?.relativePath === + target.path, + ); + + // 目标卡落在视口里:注册即被兜底扫描放行,拿到图。 + const card = document.createElement('div'); + card.getBoundingClientRect = () => + ({ top: 10, left: 10, bottom: 60, right: 60 }) as DOMRect; + act(() => { + result.current.observePreview(card, target, targetIdentity); + }); + await waitFor(() => + expect(result.current.previews.get(targetIdentity)?.status).toBe( + 'loaded', + ), + ); + expect(targetReads()).toHaveLength(1); + + // 灌满缓存:第 49 张落地时把最早的、仍然可见的这张挤出去。 + for (const filler of fillers) { + const identity = result.current.identityByResourceId.get(filler.id)!; + act(() => result.current.requestPreview(filler, identity, 'visible')); + await waitFor(() => + expect(result.current.previews.get(identity)?.status).toBe('loaded'), + ); + } + + // 驱逐后必须补一次可见性复核:仍可见的卡要重新入队并重新读到图。 + await waitFor(() => expect(targetReads()).toHaveLength(2)); + expect(result.current.previews.get(targetIdentity)?.status).toBe('loaded'); + }); +});