From 8edee9eef472237833a7e2f0ef3b7725c3a140b4 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Fri, 11 Sep 2026 17:44:21 +0800 Subject: [PATCH] =?UTF-8?q?=E9=A2=84=E8=A7=88=E9=98=9F=E5=88=97=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E5=90=8C=E7=BA=A7=E8=A3=81=E5=86=B3=EF=BC=9A=E5=BD=93?= =?UTF-8?q?=E5=89=8D=E9=A2=84=E5=8F=96=E4=BD=9C=E7=94=A8=E5=9F=9F=E4=BC=98?= =?UTF-8?q?=E5=85=88=EF=BC=8C=E4=BD=86=E4=B8=8D=E8=B6=8A=E8=BF=87=20play?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景:全局 3 槽跨视图共享,而 hook 的 scopeKey 只含 `projectPath + projectId`(不含视图)。用户在栏目页滚一遍会按可见性入队最多 51 个预览 job;点「回到资源总览」时它们仍排在同一个队列里,总览自己那几张「该出图」的卡只能排在后面 —— 这就是「进总览要等图」的真实来源。 - `nextPreviewJob` 改为两级裁决:**理由优先级固定 `play > detail > visible` 不变**,只在**同一理由内部**让当前预取作用域的请求优先。 - ⚠️ 早期写法用「作用域匹配则权值 +3」,会让当前视图的 `visible` 压过上一视图的 `play` —— 直接破坏 PRD §3.3.2 的固定调度优先级。这个错误是被本提交新增的契约用例抓到的,已改为同级裁决,并把该约束写进函数注释。 - 新增 `prefetchScopeKey`:优先取调用方显式传入的值;未传时退化为**被喂进 hook 的资源集合签名**,因此调用方(`index.tsx`)无需改动即可生效。 - 队列任务记录入队时的 `prefetchScopeKey`,供同级裁决使用。 - 新增只读 `previewQueueSnapshot()`:暴露「谁在排队、属于哪个预取作用域、当前活动读取数」。队列病理此前只能靠猜,有了它「当前视图的卡是否真的进了队列」可直接断言。不参与渲染、无副作用。 断言(`tests/useProjectResourceCardPreviews.test.ts`):新增「当前预取作用域越过上一作用域的排队预取」契约用例,三条: 1. 当前作用域的 `visible` 越过上一作用域排队的 `visible`; 2. **但不得越过 PRD 优先级** —— 当前作用域的 `visible` 仍排在 `play` 之后; 3. 同一作用域内仍是 `detail > visible`。 变异验证:把作用域偏好关掉(`&& false`)→ 第 1 条断言立即失败(`expected 'previous' to be 'current'`);恢复后 22/22 通过。断言不是恒真假守卫。 验证:typecheck exit 0;该测试文件 22 passed;check:encoding 4388 文件;`git diff --check` 干净。(AGC 全量与本条无关的并发改动混跑,故此处只报本文件的定向结果;全量对照见后续提交的回报。) --- .../useProjectResourceCardPreviews.ts | 111 +++++++++++++++++- .../useProjectResourceCardPreviews.test.ts | 48 +++++++- 2 files changed, 152 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 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'); + }); });