视图切换取消排队预取在生产路径上真正生效:视图标识改为必填

- 原缺陷:预览 Hook 的预取作用域 key 缺省退化为"被喂进来的资源集合签名",而宿主按设计要求恒传**全量投影**(传全量才不会让总览里其它栏目只剩栏目标题栏),换栏目/换视图一字不改 ⇒ key 不变 ⇒ 取消上一视图排队预取的 effect 永不执行,"同级裁决"一并退化为 no-op。已有用例用"改 resources"模拟切视图,测的是生产不存在的调用方形状。
- resourceCanvasController.ts 新增 createResourceCanvasPrefetchScopeKey({ view, target, mode }):由视图身份(main / child:<栏目>)加排序模式算出标识。
- index.tsx 在资源画布处显式算出该标识并传给预览 Hook(栏目取 resourceBookState.view / resourceBookState.category,排序取 sortMode)。
- useProjectResourceCardPreviews.ts 把 prefetchScopeKey 由可选改为**必填**并删掉那段兜底:它能"看起来还在工作",正是它掩盖了漏接线;现在漏传是类型错误而不是静默少取消一次。
- 断言:resourceCanvasController.test.ts 新增标识派生两组用例(换栏目/换视图/换排序模式必须变,同一视图必须稳定);useProjectResourceCardPreviews.test.ts 改写两条既有切视图用例为生产形状(resources 恒为全量、只改视图标识与 eagerResources),并在用例内前置断言"换栏目必须换标识";其余既有用例经用例侧包装补齐标识,形状与生产一致,断言一条未放宽。
- 变异验证(提交前已跑):把 createResourceCanvasPrefetchScopeKey 的栏目分量去掉(只留 view + mode)→ 3 条用例变红(标识派生 / 取消上一视图排队预取 / 取消后重新可见再入队),还原后复跑 34 passed。
- 门禁:`npx vitest run` 上述两文件 34 passed;typecheck 见随后整轮。
This commit is contained in:
2026-09-11 21:31:30 +08:00
parent 4175d82fa1
commit 09ed7b099b
5 changed files with 205 additions and 44 deletions
@@ -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,
@@ -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}`;
}
@@ -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();
// 下掉之后立刻按几何复核一次:仍然可见的卡重新入队,不可见的自然不再请求。
@@ -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',
}),
);
});
});
@@ -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<PreviewHookInput, 'prefetchScopeKey'> & {
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<ReturnType<typeof preview>>();
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<ReturnType<typeof preview>>();
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));
});
});
});