From 56a8c2c32c95b59a5dd874c562b49d826fcca794 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Fri, 11 Sep 2026 19:28:47 +0800 Subject: [PATCH] =?UTF-8?q?=E9=A9=B1=E9=80=90=E4=BB=8D=E5=8F=AF=E8=A7=81?= =?UTF-8?q?=E7=9A=84=E5=8D=A1=E4=B9=8B=E5=90=8E=E8=A1=A5=E4=B8=80=E6=AC=A1?= =?UTF-8?q?=E5=8F=AF=E8=A7=81=E6=80=A7=E5=A4=8D=E6=A0=B8=EF=BC=9A=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E4=B8=8D=E5=86=8D=E5=87=AD=E7=A9=BA=E6=B6=88=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - publishPreview 记录本轮驱逐条数,驱逐发生后在 previewsRef 落盘之后补一次 sweepVisiblePreviews; - 被驱逐的 identity 状态正好回到 undefined,而 sweepVisiblePreviews 只对 undefined 的卡重新入队,此前只差不这一次调用; - 调用点必须在 previewsRef.current = next 之后,否则扫描看到被驱逐卡仍是 loaded 而不做任何事; - 注释写明重入有界:重新入队走 publishPreview({status:'loading'}),不进 loaded/failed 驱逐分支,递归深度恒为 1; - 只在真的发生驱逐时补扫,普通 loading/loaded 发布不增加扫描开销; - 新增用例:灌满 48 张缓存把仍可见的目标卡挤出(LRU 不看可见性),断言该卡被重新请求读成 loaded。 --- .../useProjectResourceCardPreviews.ts | 21 ++++++ .../useProjectResourceCardPreviews.test.ts | 66 +++++++++++++++++++ 2 files changed, 87 insertions(+) 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'); + }); +});