视图切换取消排队预取在生产路径上真正生效:视图标识改为必填
- 原缺陷:预览 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:
@@ -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}`;
|
||||
}
|
||||
|
||||
+10
-11
@@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user