驱逐仍可见的卡之后补一次可见性复核:图片不再凭空消失
- 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:
+21
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user