预览队列改为同级裁决:当前预取作用域优先,但不越过 play

背景:全局 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 全量与本条无关的并发改动混跑,故此处只报本文件的定向结果;全量对照见后续提交的回报。)
This commit is contained in:
2026-09-11 17:44:21 +08:00
parent a4bec3d018
commit 8edee9eef4
2 changed files with 152 additions and 7 deletions
@@ -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<HTMLDivElement | null>;
eagerPreviewLimit?: number;
previewVersionByResourceId?: ReadonlyMap<string, string>;
/**
* 「当前为谁预取」的标识(资源画布传视图标识,例如 `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,
}),
};
}
@@ -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');
});
});