驱逐仍可见的卡之后补一次可见性复核:图片不再凭空消失

- publishPreview 记录本轮驱逐条数,驱逐发生后在 previewsRef 落盘之后补一次 sweepVisiblePreviews;
- 被驱逐的 identity 状态正好回到 undefined,而 sweepVisiblePreviews 只对 undefined 的卡重新入队,此前只差不这一次调用;
- 调用点必须在 previewsRef.current = next 之后,否则扫描看到被驱逐卡仍是 loaded 而不做任何事;
- 注释写明重入有界:重新入队走 publishPreview({status:'loading'}),不进 loaded/failed 驱逐分支,递归深度恒为 1;
- 只在真的发生驱逐时补扫,普通 loading/loaded 发布不增加扫描开销;
- 新增用例:灌满 48 张缓存把仍可见的目标卡挤出(LRU 不看可见性),断言该卡被重新请求读成 loaded。
This commit is contained in:
2026-09-11 19:28:47 +08:00
parent f88e6aa5fe
commit 56a8c2c32c
2 changed files with 87 additions and 0 deletions
@@ -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],
);
@@ -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<string, unknown>) =>
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');
});
});