预览队列改为同级裁决:当前预取作用域优先,但不越过 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:
+105
-6
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user