视图切换时只取消上一视图排队中的可见性预取

用户离开某个视图后,继续为它排队读图没有任何收益,而这些排队的预取会**排在新视图按需请求的前面**(3 槽跨视图共享),把"进入总览后等图"变成纯等待。这是"进总览要等图"的主因,单张读取本身只有 5–15ms。

- `cancelQueuedVisiblePrefetches()`:只下掉**还在队列里**的 `visible` 理由任务。语义边界刻意收窄 ——
  - `detail` / `play` 理由的排队**保留**;
  - **在途请求不打断**(只占 3 槽中的 1 个,打断它拿不回已花的读盘成本,且切回来要重读);
  - **缓存与身份不失效**(不触发 `disposeAllCachedPreviews`),切回原视图不重读已拿到的图。
- 修法 4 一并收口:视图切换 effect 在取消后**立即按几何复核一次**(`sweepVisiblePreviews`),仍然可见的卡重新入队、不可见的自然不再请求 —— 因此不存在"取消后永不重试"的死角。
- 依赖使用**派生后的** `prefetchScopeKey`(不是可能为 `undefined` 的 `input.prefetchScopeKey`)。这修掉了本轮自查发现的一个真 bug:早期写法在调用方未传该 prop 时直接 `return`,整段取消逻辑成了**永不执行的死代码**;由下面的断言暴露。

断言(`tests/useProjectResourceCardPreviews.test.ts` 新增 2 条):
1. 「切视图只取消上一作用域排队中的可见性预取」:登记 6 张(3 在途占满槽 + 3 排队,其中 1 张为 `detail`),切视图后断言队列从 3 → 1、**留下的正是 `detail`**、且 `activeReadCount` 仍为 3(在途不被取消)、新视图 identity 立刻可用;
2. 「取消后再次可见会重新入队」:切走再切回,卡片重新注册且几何可见后,断言该卡**重新进入队列** —— 直接守住"不留永不重试死角"这条硬要求。

变异验证:把取消那一步去掉(effect 内只保留复核)→ 第 1 条断言立即失败(`expected [ …(3) ] to have a length of 1 but got 3`);恢复后 24/24 通过。

验证(同一时刻、同一命令的前后对照;当时树上有并发改动:`src-tauri/.../direct_project_history.rs`):
- 改动前(只 stash 我的两个文件):`86 files / 1236 passed / 4 skipped / 0 failed`
- 改动后:`86 files / 1238 passed / 4 skipped / 0 failed`(多出的 2 条为本提交新增用例)
- `src/components/image-editor` 1387 passed;typecheck exit 0;check:encoding 4388 文件;prettier 与 eslint 干净;`git diff --check` 干净。
This commit is contained in:
2026-09-11 17:55:22 +08:00
parent 8edee9eef4
commit f5b381dbd8
2 changed files with 143 additions and 7 deletions
@@ -814,16 +814,16 @@ export function useProjectResourceCardPreviews(input: {
return cancelled;
}, []);
// 视图切换即下掉上一视图的排队预取修法 4 的"不再为离开的视图补发"也由它收口:
// 离开后卡片卸载、observer 注销,新注册会立即被这里清掉,除非仍可见
// 视图切换即下掉上一视图的排队预取修法 4 的"不再为离开的视图补发"也由它收口:
// 离开后卡片卸载、observer 注销,任何新注册会立即被这里清掉,除非该卡在几何上仍可见。
//
// 依赖**派生后的** `prefetchScopeKey`(而不是可能为 undefined 的 `input.prefetchScopeKey`):
// 调用方不传时它退化为资源集合签名,取消逻辑必须同样生效,否则就是一条永远不跑的死代码。
useEffect(() => {
if (input.prefetchScopeKey === undefined) {
return;
}
cancelQueuedVisiblePrefetches();
// 下掉之后立刻按几何复核一次:仍然可见的卡重新入队,不可见的自然不再请求。
sweepVisiblePreviewsRef.current();
}, [cancelQueuedVisiblePrefetches, input.prefetchScopeKey]);
sweepVisiblePreviews();
}, [cancelQueuedVisiblePrefetches, prefetchScopeKey, sweepVisiblePreviews]);
/**
* 兜底扫描的生命周期:scope 变化后按若干延迟点各扫一次,覆盖"卡晚挂载 / observer 迟到"
@@ -1475,4 +1475,140 @@ describe('useProjectResourceCardPreviews', () => {
)?.identity,
).toBe('opened');
});
it('cancels only the previous scope queued prefetch when the view changes', async () => {
// 修法 2 + 4:用户离开某个视图后,继续为它排队读图没有收益,而它会挡住新视图的按需请求。
// 只取消**排队中的** `visible``detail` / `play` 与在途请求一律保留。
const previousScope = Array.from({ length: 6 }, (_, index) =>
resource(`view-prev-${index + 1}`),
);
const nextScope = [resource('view-next-1')];
const held = deferred<ReturnType<typeof preview>>();
const invoke = vi.fn(async () => held.promise);
window.__TAURI__ = { core: { invoke } };
const canvasRef = { current: document.createElement('div') };
const { result, rerender } = renderHook(
({ resources }: { resources: ProjectResource[] }) =>
useProjectResourceCardPreviews({
projectPath: '/tmp/preview-cancel-visible',
projectId: 'preview-cancel-visible',
mode: 'dependency',
resources,
canvasRef,
eagerPreviewLimit: 0,
}),
{ initialProps: { resources: previousScope } },
);
// 6 张全部请求:3 张在途占槽,3 张留在队列。其中一张用 detail 理由。
act(() => {
previousScope.forEach((item, index) => {
result.current.requestPreview(
item,
result.current.identityByResourceId.get(item.id)!,
index === 5 ? 'detail' : 'visible',
);
});
});
await waitFor(() =>
expect(result.current.previewQueueSnapshot().activeReadCount).toBe(3),
);
const before = result.current.previewQueueSnapshot().queue;
expect(before).toHaveLength(3);
expect(before.some((job) => job.reason === 'detail')).toBe(true);
// 切视图(等价于进入总览):队列里上一作用域的 visible 必须被下掉,detail 必须留下。
rerender({ resources: nextScope });
await waitFor(() =>
expect(result.current.previewQueueSnapshot().queue).toHaveLength(1),
);
const after = result.current.previewQueueSnapshot().queue;
expect(after[0]!.reason).toBe('detail');
expect(after[0]!.prefetchScopeKey).toContain('view-prev-6');
// 身份没有被整体清空:新视图的 identity 立刻可用(未触发 disposeAllCachedPreviews)。
expect(result.current.identityByResourceId.has('view-next-1')).toBe(true);
expect(result.current.identityByResourceId.size).toBe(1);
// 在途请求不被取消:占槽数仍是 3(取消只针对"排队中"的预取)。
expect(result.current.previewQueueSnapshot().activeReadCount).toBe(3);
act(() => {
held.resolve(preview(previousScope[0]!.path));
});
});
it('re-enqueues a cancelled prefetch when its card becomes visible again', async () => {
// 硬要求:取消不得留下"永不重试"的死角。切回原视图时卡片重新挂载/注册,
// 兜底扫描按几何判定它可见,必须重新入队。
const previousScope = Array.from({ length: 6 }, (_, index) =>
resource(`retry-prev-${index + 1}`),
);
const nextScope = [resource('retry-next-1')];
const held = deferred<ReturnType<typeof preview>>();
const invoke = vi.fn(async () => held.promise);
window.__TAURI__ = { core: { invoke } };
const canvasRef = { current: document.createElement('div') };
const { result, rerender } = renderHook(
({ resources }: { resources: ProjectResource[] }) =>
useProjectResourceCardPreviews({
projectPath: '/tmp/preview-cancel-retry',
projectId: 'preview-cancel-retry',
mode: 'dependency',
resources,
canvasRef,
eagerPreviewLimit: 0,
}),
{ initialProps: { resources: previousScope } },
);
const target = previousScope[5]!;
act(() => {
previousScope.forEach((item) => {
result.current.requestPreview(
item,
result.current.identityByResourceId.get(item.id)!,
'visible',
);
});
});
await waitFor(() =>
expect(result.current.previewQueueSnapshot().activeReadCount).toBe(3),
);
expect(
result.current
.previewQueueSnapshot()
.queue.some((job) => job.identity.includes(target.id)),
).toBe(true);
// 切视图:排队被下掉。
rerender({ resources: nextScope });
await waitFor(() =>
expect(result.current.previewQueueSnapshot().queue).toHaveLength(0),
);
// 切回原视图:卡片重新注册,兜底扫描按几何判定可见后必须重新入队。
rerender({ resources: previousScope });
act(() => {
const element = document.createElement('div');
element.getBoundingClientRect = () =>
({ top: 10, left: 10, bottom: 60, right: 60 }) as DOMRect;
result.current.observePreview(
element,
target,
result.current.identityByResourceId.get(target.id)!,
);
});
await waitFor(() =>
expect(
result.current
.previewQueueSnapshot()
.queue.some((job) => job.identity.includes(target.id)),
).toBe(true),
);
act(() => {
held.resolve(preview(previousScope[0]!.path));
});
});
});