资源卡异步解码、热预取只喂当前栏目,并删掉未接线的预览版本 map

- 资源卡 `<img>` 补 `decoding="async"`,把真机 37.2 MPx 量级的 PNG 解码移出主线程。刻意不加 `loading="lazy"`:卡片已经由资源画本的 `IntersectionObserver`(`rootMargin: 160px`,root 指向真正包含卡片的容器)精确门禁,只对进入可见区及预取边界的卡片发 IPC;再叠一层浏览器视口懒加载会让「预取边界 + 热预取 12 张」读到的视口外卡片被压住不解码,等于白预取。`decoding="async"` 不改变何时加载,与既有调度正交。
- 卡片预览的热预取改为只喂当前分页栏目:原先传的是 61 项全量投影,前 12 张可能落在别的栏目,用户看不到的卡片先占满全局 3 个物理读取槽,看得见的卡片反而要排队。
- 为此把 `projectResourcesByCategory`、`resourcePageCategoryKey`、`resourcePageCategories`、`activePageCategory`、`activePageResources` 整块上移到 `useProjectResourceCardPreviews` 之前并删除原位置定义;`projectResourcesByCategory` 只依赖 `canvasResources` 与 `categoryOrder`,其余只在既有依赖之上派生,上移不引入前向引用。`activePageResources` 合并为单一声明(原先在 `activePageLayoutReady` 之前另有一份同定义局部变量)。
- 已知副作用并接受:Hook 的 `identityByResourceId` 只覆盖当前栏目,因此「A 栏目 → B 栏目 → 回到 A」会重新读取 A 的可见卡。这正是「栏目级热预取」的定义,也符合 PRD §3.3.2「项目 / mode 切换必须清理预览缓存」的口径;代价是来回切换栏目各重读一次可见卡,单张实测 1–7 ms 且仍受全局 3 槽限制。
- 删除 `resourcePreviewVersionByResourceId` 的三处引用与 `useState` 声明。它从未被写入过:只被 `new Map()` 初始化和清空,`previewVersionByResourceId` 恒为空串,属死代码。
- 选择「删」而不是「填真值」:填真 manifest revision 会让每次 manifest 变更把全部缓存 identity 失效并触发全量重读;而现有 identity(`projectPath` / `projectId` / `id` / `category` / `path` / `mediaType`)对不可变素材已经足够——路径变了就是另一个资源。本轮不新增失效判据。
- 遗留:`appSurface > keeps the resource preview version cache wired and cleared on project switch` 断言的是刚被删除的 prop 接线,需要同步删掉该断言;该测试文件当时正被另一个 Agent 在途编辑,按分工不在本次改动内,已上报由主人处理。
This commit is contained in:
2026-09-11 01:04:06 +08:00
parent bdb07c141b
commit 60d8b8fbb2
@@ -666,6 +666,12 @@ const ResourceCard = memo(function ResourceCard({
<img
src={sourceUrl}
alt=""
// 只声明异步解码,刻意不加 `loading="lazy"`:卡片已经由资源画本的
// IntersectionObserverrootMargin 160px)精确门禁并在可见前才发 IPC,
// 再叠一层浏览器视口懒加载会让「预取边界 + 热预取」读到的视口外卡片
// 被压住不解码,等于白预取。`decoding="async"` 不改变何时加载,
// 只把 37.2 MPx 量级的 PNG 解码移出主线程,与既有调度正交。
decoding="async"
onError={() =>
onPreviewDecodeError(
previewIdentity,
@@ -1299,10 +1305,6 @@ export default function ProjectDevelopmentView({
const [hiddenCommittedResourceId, setHiddenCommittedResourceId] = useState<
string | null
>(null);
const [
resourcePreviewVersionByResourceId,
setResourcePreviewVersionByResourceId,
] = useState<Map<string, string>>(() => new Map());
const [approvalMode, setApprovalMode] = useState<ApprovalMode>('strict');
const [approvalDialogOpen, setApprovalDialogOpen] = useState(false);
const [approvalNotice, setApprovalNotice] = useState('');
@@ -1819,16 +1821,50 @@ export default function ProjectDevelopmentView({
),
};
}, [resourceGraph.referenceEdges, resourceGraph.taskFlows, resources]);
// 栏目级资源分组必须早于卡片预览 Hook:当前分页栏目、以及热预取要喂的资源集合
// 都由它派生。它只依赖 `canvasResources` 与 `categoryOrder`,上移不引入前向引用。
const projectResourcesByCategory = useMemo(
() =>
new Map(
categoryOrder.map((category) => [
category,
canvasResources.filter((resource) => resource.category === category),
]),
),
[canvasResources],
);
// 当前分页栏目必须早于卡片预览 Hook 求出:热预取只喂当前栏目。
// 传全量投影时前 12 张可能落在别的栏目,用户看不到的卡片先占满读取槽,
// 看得见的卡片反而要排队。
const resourcePageCategoryKey = useMemo(
() => createResourceCanvasPageCategorySignature(canvasResources),
[canvasResources],
);
const resourcePageCategories = useMemo(
() => (resourcePageCategoryKey ? [...categoryOrder] : []),
[resourcePageCategoryKey],
);
const activePageCategory =
resourcePageCategories.find(
(category) => category === activeResourceCategory,
) ??
resourcePageCategories.find(
(category) => (projectResourcesByCategory.get(category)?.length ?? 0) > 0,
) ??
resourcePageCategories[0] ??
null;
const activePageResources = activePageCategory
? (projectResourcesByCategory.get(activePageCategory) ?? [])
: [];
const resourceCardPreviews = useProjectResourceCardPreviews({
projectPath,
projectId: manifest.projectId,
mode: sortMode,
resources: canvasResources,
resources: activePageResources,
canvasRef: resourceCanvasRef,
// 资源卡片渲染在资源画本场景里,可见性判定要用真正包含它们的容器。
intersectionRootRef: resourceBookManagerRef,
eagerPreviewLimit: 12,
previewVersionByResourceId: resourcePreviewVersionByResourceId,
});
const resourceCardSizeByResourceId = useMemo(
() =>
@@ -1948,16 +1984,6 @@ export default function ProjectDevelopmentView({
),
[visibleResources],
);
const projectResourcesByCategory = useMemo(
() =>
new Map(
categoryOrder.map((category) => [
category,
canvasResources.filter((resource) => resource.category === category),
]),
),
[canvasResources],
);
const resourceIdsByCategory = useMemo(
() =>
new Map(
@@ -1986,23 +2012,6 @@ export default function ProjectDevelopmentView({
token: transitionToken,
});
}, [resourceCategoryScopeKey]);
const resourcePageCategoryKey = useMemo(
() => createResourceCanvasPageCategorySignature(canvasResources),
[canvasResources],
);
const resourcePageCategories = useMemo(
() => (resourcePageCategoryKey ? [...categoryOrder] : []),
[resourcePageCategoryKey],
);
const activePageCategory =
resourcePageCategories.find(
(category) => category === activeResourceCategory,
) ??
resourcePageCategories.find(
(category) => (projectResourcesByCategory.get(category)?.length ?? 0) > 0,
) ??
resourcePageCategories[0] ??
null;
const registerResourceBookThumbnail = useCallback(
(category: ResourceCategory, element: HTMLElement | null) => {
@@ -2287,9 +2296,6 @@ export default function ProjectDevelopmentView({
activePageExtent.y,
],
);
const activePageResources = activePageCategory
? (projectResourcesByCategory.get(activePageCategory) ?? [])
: [];
const activePageLayoutReady = activePageResources.every((resource) =>
resourcePositionById.has(resource.id),
);
@@ -3236,7 +3242,6 @@ export default function ProjectDevelopmentView({
setPendingResourceEditsError('');
setPendingResourceEditActionErrors(new Map());
setResourceEditServiceIdentityConfirmations(new Map());
setResourcePreviewVersionByResourceId(new Map());
resourceRecoveryPanelEpochRef.current += 1;
setResourceRecoveryPanelOpen(false);
pendingResourceEditActionIdsRef.current = new Set();