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 e93718489..775968eac 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 @@ -67,6 +67,8 @@ type PreviewJob = { identity: string; resource: ProjectResource; reason: PreviewRequestReason; + /** 入队时「当前为谁预取」的标识;见 `prefetchScopeKey`。 */ + prefetchScopeKey: string; }; type MaterializedPreview = { @@ -209,13 +211,31 @@ function materializeProjectResourceCardPreview( }; } -function nextPreviewJob(queue: PreviewJob[]) { +/** + * 选下一个要跑的排队任务:**理由优先级固定 `play > detail > visible`,同一理由内当前预取作用域优先**。 + * + * 为什么需要"作用域优先"这一档:全局 3 槽跨视图共享,进入总览时队列里可能还压着 + * 上一视图的可见性预取。若不做同级裁决,总览自己那几张"该出图"的卡会排在它们后面, + * 用户感知就是"进总览要等图"。 + * + * ⚠️ 作用域**只做同一理由内的平手裁决**,绝不允许当前视图的 `visible` 越过 `play`: + * 那会破坏 PRD §3.3.2 的固定调度优先级。早期实现用 `+3` 加权就踩过这个坑 + * (当前视图的 visible 会压过上一视图的 play),由对应的契约用例钉住。 + */ +export function nextPreviewJob(queue: PreviewJob[], prefetchScopeKey: string) { + const isPreferred = (job: PreviewJob) => + job.prefetchScopeKey === prefetchScopeKey; + const better = (candidate: PreviewJob, incumbent: PreviewJob) => { + const candidateRank = previewRequestPriority[candidate.reason]; + const incumbentRank = previewRequestPriority[incumbent.reason]; + if (candidateRank !== incumbentRank) { + return candidateRank > incumbentRank; + } + return isPreferred(candidate) && !isPreferred(incumbent); + }; let nextIndex = 0; for (let index = 1; index < queue.length; index += 1) { - if ( - previewRequestPriority[queue[index]!.reason] > - previewRequestPriority[queue[nextIndex]!.reason] - ) { + if (better(queue[index]!, queue[nextIndex]!)) { nextIndex = index; } } @@ -248,6 +268,21 @@ export function useProjectResourceCardPreviews(input: { intersectionRootRef?: React.RefObject; eagerPreviewLimit?: number; previewVersionByResourceId?: ReadonlyMap; + /** + * 「当前为谁预取」的标识(资源画布传视图标识,例如 `main` / `child:<栏目>`)。 + * + * 它变化时**只取消队列里 `visible` 理由的预取**:用户已经离开那个视图, + * 继续为它排队读图没有任何收益,而这些排队的预取会**排在后面视图的按需请求前面** + * (全局 3 槽跨越视图共享),把"进入总览后等图"变成纯等待。 + * + * 语义边界(刻意收窄): + * - **不取消任何按需加载**:`detail` / `play` 理由的排队与在途一律保留; + * - **不失效任何缓存与身份**:`cachedPreviewsRef` / `identityByResourceId` 原样保留, + * 切回原视图不会重读已经拿到的图; + * - **不留"取消后永不重试"的死角**:被取消的卡如果在几何上仍然可见, + * 兜底扫描会重新按 `visible` 入队(扫描是"什么算可见"的仲裁者)。 + */ + prefetchScopeKey?: string; }) { const scopeKey = JSON.stringify([input.projectPath, input.projectId]); const identityByResourceId = useMemo( @@ -281,6 +316,16 @@ 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 prefetchScopeKeyRef = useRef(prefetchScopeKey); + prefetchScopeKeyRef.current = prefetchScopeKey; const previewsRef = useRef(previews); const imageDimensionsByIdentityRef = useRef(imageDimensionsByIdentity); const scopeKeyRef = useRef(scopeKey); @@ -457,7 +502,7 @@ export function useProjectResourceCardPreviews(input: { PROJECT_RESOURCE_CARD_PREVIEW_CONCURRENCY && queueRef.current.length > 0 ) { - const job = nextPreviewJob(queueRef.current); + const job = nextPreviewJob(queueRef.current, prefetchScopeKeyRef.current); if (!job) { break; } @@ -614,6 +659,7 @@ export function useProjectResourceCardPreviews(input: { identity, resource, reason, + prefetchScopeKey: prefetchScopeKeyRef.current, }); drainQueueRef.current(); }, @@ -741,6 +787,44 @@ export function useProjectResourceCardPreviews(input: { }, [isElementWithinVisibleArea, requestPreview]); sweepVisiblePreviewsRef.current = sweepVisiblePreviews; + /** + * 为 `prefetchKey` 取消队列里 `visible` 理由的预取(修法 2 + 4)。 + * + * 只下掉**还在排队**的可见性预取: + * - `detail` / `play` 理由保留(按需加载不取消); + * - 已经在途(`pendingIdentitiesRef`)的请求**不打断** —— 它只占 3 个槽中的 1 个, + * 打断它既拿不回已花的读盘成本,也让"切回来"要重读; + * - 缓存与身份不动,因此切回原视图不会重读已拿到的图; + * - 下掉的 job 若几何上仍可见,会被随后的兜底扫描重新入队 ⇒ 没有"永不重试"死角。 + */ + const cancelQueuedVisiblePrefetches = useCallback(() => { + const kept: PreviewJob[] = []; + let cancelled = 0; + for (const job of queueRef.current) { + if (job.reason === 'visible') { + cancelled += 1; + if (queuedIdentitiesRef.current.get(job.identity) === job.scopeEpoch) { + queuedIdentitiesRef.current.delete(job.identity); + } + continue; + } + kept.push(job); + } + queueRef.current = kept; + return cancelled; + }, []); + + // 视图切换即下掉上一视图的排队预取(修法 4 的"不再为离开的视图补发"也由它收口: + // 离开后卡片卸载、observer 注销,新注册又会立即被这里清掉,除非它仍可见)。 + useEffect(() => { + if (input.prefetchScopeKey === undefined) { + return; + } + cancelQueuedVisiblePrefetches(); + // 下掉之后立刻按几何复核一次:仍然可见的卡重新入队,不可见的自然不再请求。 + sweepVisiblePreviewsRef.current(); + }, [cancelQueuedVisiblePrefetches, input.prefetchScopeKey]); + /** * 兜底扫描的生命周期:scope 变化后按若干延迟点各扫一次,覆盖"卡晚挂载 / observer 迟到"; * 页面重新可见(用户切走再回来)和窗口尺寸变化也各扫一次,因为是同一批卡可能重新进入视口。 @@ -995,5 +1079,20 @@ export function useProjectResourceCardPreviews(input: { requestPreview, failPreview, protectPreview, + /** + * 只读的队列快照,供排障与测试观测"谁在排队、属于哪个预取作用域"。 + * + * 队列病理(排队被上一视图占满、可见卡被丢弃)此前只能靠猜;有了这个快照, + * 「当前视图的卡是否真的进了队列」变成可直接断言的事实。不参与渲染,无副作用。 + */ + previewQueueSnapshot: () => ({ + queue: queueRef.current.map((job) => ({ + identity: job.identity, + reason: job.reason, + prefetchScopeKey: job.prefetchScopeKey, + })), + activeReadCount: activeReadsRef.current.count, + prefetchScopeKey: prefetchScopeKeyRef.current, + }), }; } diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts index dfb212f58..6c096e554 100644 --- a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts +++ b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts @@ -10,7 +10,10 @@ import { projectResourceCardPreviewEvictionIdentities, } from '../src/view/project-development/resourceCardPreviewModel'; import type { ProjectResource } from '../src/view/project-development/resourceProjectionModel'; -import { useProjectResourceCardPreviews } from '../src/view/project-development/useProjectResourceCardPreviews'; +import { + nextPreviewJob, + useProjectResourceCardPreviews, +} from '../src/view/project-development/useProjectResourceCardPreviews'; const originalCreateObjectUrl = Object.getOwnPropertyDescriptor( URL, @@ -1429,4 +1432,47 @@ describe('useProjectResourceCardPreviews', () => { } }); }); + + it('lets the current prefetch scope jump ahead of the previous scope queue', () => { + // 全局 3 槽跨视图共享:进入总览时队列里可能还压着上一视图的可见性预取。 + // 若不插队,总览自己那几张"该出图"的卡会排在它们后面 —— 用户感知就是"进总览要等图"。 + // 排序是纯函数,直接断言契约,避免依赖多微任务时序。 + const job = ( + identity: string, + reason: 'visible' | 'detail' | 'play', + scope: string, + ) => ({ + scopeKey: 'scope', + scopeId: 'scope-id', + scopeEpoch: 0, + identity, + resource: resource(identity), + reason, + prefetchScopeKey: scope, + }); + + // 当前作用域的 visible 越过上一作用域排队的 visible。 + expect( + nextPreviewJob( + [job('previous', 'visible', 'A'), job('current', 'visible', 'B')], + 'B', + )?.identity, + ).toBe('current'); + + // 但不得越过 PRD 的固定优先级:当前作用域的 visible 仍排在 play 之后。 + expect( + nextPreviewJob( + [job('playing', 'play', 'A'), job('current', 'visible', 'B')], + 'B', + )?.identity, + ).toBe('playing'); + + // 同一作用域内仍是 detail > visible。 + expect( + nextPreviewJob( + [job('listed', 'visible', 'B'), job('opened', 'detail', 'B')], + 'B', + )?.identity, + ).toBe('opened'); + }); });