From f5b381dbd800cbe8f029c393213de8be526f6bba Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Fri, 11 Sep 2026 17:55:22 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A7=86=E5=9B=BE=E5=88=87=E6=8D=A2=E6=97=B6?= =?UTF-8?q?=E5=8F=AA=E5=8F=96=E6=B6=88=E4=B8=8A=E4=B8=80=E8=A7=86=E5=9B=BE?= =?UTF-8?q?=E6=8E=92=E9=98=9F=E4=B8=AD=E7=9A=84=E5=8F=AF=E8=A7=81=E6=80=A7?= =?UTF-8?q?=E9=A2=84=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户离开某个视图后,继续为它排队读图没有任何收益,而这些排队的预取会**排在新视图按需请求的前面**(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` 干净。 --- .../useProjectResourceCardPreviews.ts | 14 +- .../useProjectResourceCardPreviews.test.ts | 136 ++++++++++++++++++ 2 files changed, 143 insertions(+), 7 deletions(-) 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 775968eac..e28dfa751 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 @@ -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 迟到"; diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts index 6c096e554..3e4223f4a 100644 --- a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts +++ b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts @@ -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>(); + 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>(); + 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)); + }); + }); });