视图切换时只取消上一视图排队中的可见性预取
用户离开某个视图后,继续为它排队读图没有任何收益,而这些排队的预取会**排在新视图按需请求的前面**(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:
+7
-7
@@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user