diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 9c2645cf3..000beaee9 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -187,7 +187,10 @@ import { resolveResourceBookWheelViewport, zoomResourceBookViewport, } from './resourceBookViewport'; -import { createResourceCanvasPageCategorySignature } from './resourceCanvasController'; +import { + createResourceCanvasPageCategorySignature, + createResourceCanvasPrefetchScopeKey, +} from './resourceCanvasController'; import { buildResourceFilterTagOptions, filterCanvasResources, @@ -2128,6 +2131,17 @@ export default function ProjectDevelopmentView({ const activePageResources = activePageCategory ? (projectResourcesByCategory.get(activePageCategory) ?? []) : []; + /** + * 预览 Hook 的「当前为谁预取」标识:**视图身份**,不是资源集合签名。 + * + * `resources` 按上面的要求恒为全量投影,换栏目不会改它,所以这里必须把视图 + * 显式算出来交给 Hook;否则取消上一视图排队预取的 effect 永不触发。 + */ + const resourceCardPrefetchScopeKey = createResourceCanvasPrefetchScopeKey({ + view: resourceBookState.view, + target: resourceBookState.category, + mode: sortMode, + }); const resourceCardPreviews = useProjectResourceCardPreviews({ projectPath, projectId: manifest.projectId, @@ -2139,6 +2153,7 @@ export default function ProjectDevelopmentView({ // 真正要收窄的是热预取:只让当前分页栏目先占读取槽。传全量时前 12 张可能落在 // 用户看不到的栏目,看得见的卡片反而要排队。 eagerResources: activePageResources, + prefetchScopeKey: resourceCardPrefetchScopeKey, canvasRef: resourceCanvasRef, // 资源卡片渲染在资源画本场景里,可见性判定要用真正包含它们的容器。 intersectionRootRef: resourceBookManagerRef, diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceCanvasController.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceCanvasController.ts index ce1ea27c4..4248acece 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceCanvasController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceCanvasController.ts @@ -6,3 +6,22 @@ export function createResourceCanvasPageCategorySignature< .sort() .join('|'); } + +/** + * 「当前为谁预取」的视图标识(`useProjectResourceCardPreviews` 的 `prefetchScopeKey`)。 + * + * 它必须由**视图身份**算出,不能拿喂给预览 Hook 的资源集合签名代替:那个集合是 + * **全量投影**(`index.tsx` 的注释写明收窄它会让总览里其它栏目只剩栏目标题栏), + * 换栏目/换视图**一字不改**,于是"资源 id 签名"恒不变 —— 视图切换时取消上一视图 + * 排队预取的逻辑就成了一条永不执行的死代码,"同级裁决"也一并退化为 no-op。 + * + * 组成:视图(`main` 总览 / `child:<栏目>`)+ 排序模式。同一栏目换排序会整体重排卡片, + * 对用户就是换了一次视图,因此也纳入标识。 + */ +export function createResourceCanvasPrefetchScopeKey(input: { + view: 'main' | 'child'; + target: string | null; + mode: string; +}) { + return `${input.view}:${input.target ?? ''}|${input.mode}`; +} 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 82cbe9b42..15c09ee6c 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 @@ -281,8 +281,14 @@ export function useProjectResourceCardPreviews(input: { * 切回原视图不会重读已经拿到的图; * - **不留"取消后永不重试"的死角**:被取消的卡如果在几何上仍然可见, * 兜底扫描会重新按 `visible` 入队(扫描是"什么算可见"的仲裁者)。 + * + * ⚠️ **必填,且没有默认值**。曾经它有一个"缺省退化为被喂进来的资源集合签名"的兜底, + * 结果是调用方不接线时取消逻辑静默失效:`resources` 按设计要求恒为**全量投影**, + * 换栏目不改变它的 id 签名 ⇒ key 不变 ⇒ 取消 effect 根本不跑。那种"看起来还在工作" + * 的兜底只会掩盖漏接线,所以这里要求调用方显式给出视图身份 + * (资源画布用 `createResourceCanvasPrefetchScopeKey`),漏传直接是类型错误。 */ - prefetchScopeKey?: string; + prefetchScopeKey: string; }) { const scopeKey = JSON.stringify([input.projectPath, input.projectId]); const identityByResourceId = useMemo( @@ -316,14 +322,7 @@ export function useProjectResourceCardPreviews(input: { > >(new Map()); const [initialScopeId] = useState(createProjectResourcePreviewScopeId); - /** - * 当前预取作用域:优先用调用方显式传入的 `prefetchScopeKey`;没有传时, - * 退化为**被喂进本 hook 的资源集合签名** —— 资源集合变了就等价于"换了要预取的对象", - * 因此不强制调用方多接一根线(`index.tsx` 不必改)。 - */ - const prefetchScopeKey = - input.prefetchScopeKey ?? - JSON.stringify(input.resources.map((resource) => resource.id)); + const prefetchScopeKey = input.prefetchScopeKey; const prefetchScopeKeyRef = useRef(prefetchScopeKey); prefetchScopeKeyRef.current = prefetchScopeKey; const previewsRef = useRef(previews); @@ -953,8 +952,8 @@ export function useProjectResourceCardPreviews(input: { // 视图切换即下掉上一视图的排队预取。修法 4 的"不再为离开的视图补发"也由它收口: // 离开后卡片卸载、observer 注销,任何新注册都会立即被这里清掉,除非该卡在几何上仍可见。 // - // 依赖**派生后的** `prefetchScopeKey`(而不是可能为 undefined 的 `input.prefetchScopeKey`): - // 调用方不传时它退化为资源集合签名,取消逻辑必须同样生效,否则就是一条永远不跑的死代码。 + // 触发条件只有 `prefetchScopeKey`(调用方给的视图身份)。调用方漏接线时这里是**静默**的, + // 所以该参数现为必填,漏传是类型错误而不是"少取消一次"。 useEffect(() => { cancelQueuedVisiblePrefetches(); // 下掉之后立刻按几何复核一次:仍然可见的卡重新入队,不可见的自然不再请求。 diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasController.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasController.test.ts index e90517b23..63f9863b6 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasController.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasController.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { createResourceCanvasPageCategorySignature } from '../src/view/project-development/resourceCanvasController'; +import { + createResourceCanvasPageCategorySignature, + createResourceCanvasPrefetchScopeKey, +} from '../src/view/project-development/resourceCanvasController'; describe('resource canvas page wheel controller', () => { it('invalidates page categories for same-count category changes without reacting to resource order', () => { @@ -16,3 +19,58 @@ describe('resource canvas page wheel controller', () => { ); }); }); + +describe('资源画布预取作用域标识', () => { + /** + * 标识必须由**视图身份**算出:喂给预览 Hook 的资源集合是全量投影,换栏目一字不改, + * 因此"资源 id 签名"做不到区分视图 —— 用它当标识会让取消上一视图排队预取永不触发。 + */ + it('changes for every view identity change that a resource-set signature cannot see', () => { + const childScene = createResourceCanvasPrefetchScopeKey({ + view: 'child', + target: 'scene', + mode: 'dependency', + }); + + // 换栏目:同一批全量资源下的两个不同视图,标识必须不同。 + expect( + createResourceCanvasPrefetchScopeKey({ + view: 'child', + target: 'character', + mode: 'dependency', + }), + ).not.toBe(childScene); + // 换视图:栏目页 → 总览。 + expect( + createResourceCanvasPrefetchScopeKey({ + view: 'main', + target: null, + mode: 'dependency', + }), + ).not.toBe(childScene); + // 同一栏目内换排序模式:卡片整体重排,对用户就是换了一次视图。 + expect( + createResourceCanvasPrefetchScopeKey({ + view: 'child', + target: 'scene', + mode: 'type', + }), + ).not.toBe(childScene); + }); + + it('stays stable for an unchanged view so unrelated renders never cancel prefetches', () => { + expect( + createResourceCanvasPrefetchScopeKey({ + view: 'child', + target: 'scene', + mode: 'dependency', + }), + ).toBe( + createResourceCanvasPrefetchScopeKey({ + view: 'child', + target: 'scene', + mode: 'dependency', + }), + ); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts index 3f8447669..885e826d8 100644 --- a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts +++ b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts @@ -11,6 +11,7 @@ import React from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { isProjectResourcePreviewCancellation } from '../src/services/projectResourcePreviewTransport'; +import { createResourceCanvasPrefetchScopeKey } from '../src/view/project-development/resourceCanvasController'; import { PROJECT_RESOURCE_CARD_PREVIEW_CACHE_BYTE_LIMIT, PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT, @@ -20,9 +21,35 @@ import { import type { ProjectResource } from '../src/view/project-development/resourceProjectionModel'; import { nextPreviewJob, - useProjectResourceCardPreviews, + useProjectResourceCardPreviews as useProjectResourceCardPreviewsUnderTest, } from '../src/view/project-development/useProjectResourceCardPreviews'; +type PreviewHookInput = Parameters< + typeof useProjectResourceCardPreviewsUnderTest +>[0]; + +/** + * 生产里 `prefetchScopeKey` 是**必填**的视图标识,由 `index.tsx` 用 + * `createResourceCanvasPrefetchScopeKey` 按「视图 + 栏目 + 排序模式」算出。 + * + * 绝大多数用例只关心同一条视图内的加载行为,所以这里统一补同一个标识 —— + * 但**要断言"换视图取消上一视图排队预取"的用例必须直接用 + * `useProjectResourceCardPreviewsUnderTest` 并显式传不同的标识**:那正是这条参数 + * 存在的理由,用包装的常量会让用例失去判别力(换视图与不换视图表现完全一样)。 + */ +const TEST_PREFETCH_SCOPE_KEY = 'test:single-view|dependency'; + +function useProjectResourceCardPreviews( + input: Omit & { + prefetchScopeKey?: string; + }, +) { + return useProjectResourceCardPreviewsUnderTest({ + prefetchScopeKey: TEST_PREFETCH_SCOPE_KEY, + ...input, + }); +} + const originalCreateObjectUrl = Object.getOwnPropertyDescriptor( URL, 'createObjectURL', @@ -1484,34 +1511,58 @@ describe('useProjectResourceCardPreviews', () => { ).toBe('opened'); }); - it('cancels only the previous scope queued prefetch when the view changes', async () => { + it('cancels the previous view queued prefetch when only the view identity 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')]; + // + // 生产形状:`resources` **恒为全量投影**(`index.tsx` 的注释写明收窄它会让总览里其它 + // 栏目只剩栏目标题栏),换栏目只改 `eagerResources` 与视图标识 —— 所以"切视图"这条 + // 路径**不可能**靠资源集合变化触发,取消 effect 依赖的只能是显式传入的视图标识。 + const full = [ + ...Array.from({ length: 6 }, (_, index) => + resource(`view-prev-${index + 1}`), + ), + resource('view-next-1'), + ]; + const previousEager = full.slice(0, 6); + const nextEager = [full[6]!]; + const previousScopeKey = createResourceCanvasPrefetchScopeKey({ + view: 'child', + target: 'scene', + mode: 'dependency', + }); + const nextScopeKey = createResourceCanvasPrefetchScopeKey({ + view: 'child', + target: 'character', + mode: 'dependency', + }); + // 判据前置:换栏目必须换标识。曾经这里没有这层保证 —— 标识由"喂进来的资源集合签名" + // 兜底派生,而那个集合恒为全量、换栏目一字不改 ⇒ 标识不变 ⇒ 整段取消逻辑是 no-op。 + expect(nextScopeKey).not.toBe(previousScopeKey); + 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({ + ({ scopeKey, eager }: { scopeKey: string; eager: ProjectResource[] }) => + useProjectResourceCardPreviewsUnderTest({ projectPath: '/tmp/preview-cancel-visible', projectId: 'preview-cancel-visible', mode: 'dependency', - resources, + resources: full, + eagerResources: eager, + prefetchScopeKey: scopeKey, canvasRef, eagerPreviewLimit: 0, }), - { initialProps: { resources: previousScope } }, + { initialProps: { scopeKey: previousScopeKey, eager: previousEager } }, ); // 6 张全部请求:3 张在途占槽,3 张留在队列。其中一张用 detail 理由。 act(() => { - previousScope.forEach((item, index) => { + previousEager.forEach((item, index) => { result.current.requestPreview( item, result.current.identityByResourceId.get(item.id)!, @@ -1526,53 +1577,72 @@ describe('useProjectResourceCardPreviews', () => { expect(before).toHaveLength(3); expect(before.some((job) => job.reason === 'detail')).toBe(true); - // 切视图(等价于进入总览):队列里上一作用域的 visible 必须被下掉,detail 必须留下。 - rerender({ resources: nextScope }); + // 换栏目:全量资源集合一字未动,只有视图标识变。队列里上一视图的 visible + // 必须被下掉,detail 必须留下。 + rerender({ scopeKey: nextScopeKey, eager: nextEager }); 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)。 + // 队列里的 job 带着它入队时那个视图的标识(这里是上一视图),不是资源集合签名。 + expect(after[0]!.prefetchScopeKey).toBe(previousScopeKey); + // 身份没有被整体清空:全量投影里的身份一直可用(未触发 disposeAllCachedPreviews)。 expect(result.current.identityByResourceId.has('view-next-1')).toBe(true); - expect(result.current.identityByResourceId.size).toBe(1); + expect(result.current.identityByResourceId.size).toBe(full.length); // 在途请求不被取消:占槽数仍是 3(取消只针对"排队中"的预取)。 expect(result.current.previewQueueSnapshot().activeReadCount).toBe(3); act(() => { - held.resolve(preview(previousScope[0]!.path)); + held.resolve(preview(previousEager[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 full = [ + ...Array.from({ length: 6 }, (_, index) => + resource(`retry-prev-${index + 1}`), + ), + resource('retry-next-1'), + ]; + const previousEager = full.slice(0, 6); + const nextEager = [full[6]!]; + const previousScopeKey = createResourceCanvasPrefetchScopeKey({ + view: 'child', + target: 'scene', + mode: 'dependency', + }); + const nextScopeKey = createResourceCanvasPrefetchScopeKey({ + view: 'child', + target: 'character', + mode: 'dependency', + }); 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({ + ({ scopeKey, eager }: { scopeKey: string; eager: ProjectResource[] }) => + useProjectResourceCardPreviewsUnderTest({ projectPath: '/tmp/preview-cancel-retry', projectId: 'preview-cancel-retry', mode: 'dependency', - resources, + resources: full, + eagerResources: eager, + prefetchScopeKey: scopeKey, canvasRef, eagerPreviewLimit: 0, }), - { initialProps: { resources: previousScope } }, + { initialProps: { scopeKey: previousScopeKey, eager: previousEager } }, ); - const target = previousScope[5]!; + const target = previousEager[5]!; act(() => { - previousScope.forEach((item) => { + previousEager.forEach((item) => { result.current.requestPreview( item, result.current.identityByResourceId.get(item.id)!, @@ -1589,14 +1659,14 @@ describe('useProjectResourceCardPreviews', () => { .queue.some((job) => job.identity.includes(target.id)), ).toBe(true); - // 切视图:排队被下掉。 - rerender({ resources: nextScope }); + // 换栏目:排队被下掉。 + rerender({ scopeKey: nextScopeKey, eager: nextEager }); await waitFor(() => expect(result.current.previewQueueSnapshot().queue).toHaveLength(0), ); // 切回原视图:卡片重新注册,兜底扫描按几何判定可见后必须重新入队。 - rerender({ resources: previousScope }); + rerender({ scopeKey: previousScopeKey, eager: previousEager }); act(() => { const element = document.createElement('div'); element.getBoundingClientRect = () => @@ -1616,7 +1686,7 @@ describe('useProjectResourceCardPreviews', () => { ); act(() => { - held.resolve(preview(previousScope[0]!.path)); + held.resolve(preview(previousEager[0]!.path)); }); }); });