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)); + }); + }); });