forked from boos/BoosAPI
perf: defer token-candidates loading and lazy-compute heavy route indices
The routes page loaded ~924KB of API data upfront (summary 366KB + token-candidates 558KB), but token-candidates was only needed when expanding cards, opening panels, or using filters. This caused a ~3s element render delay blocking LCP. Changes: - Split load() so first paint only fetches routesSummary (366KB) - Preload token-candidates in background via requestIdleCallback - Wrap candidates state updates in startTransition to avoid UI jank - Convert 3 heavy O(routes×models) useMemo indices to lazy per-route computation with ref-based cache (candidateIndex, missingTokenIndex, missingTokenGroupIndex) - Guard 4 ManualRoutePanel-only useMemos with showManual check - Convert 2 derived useMemos to per-route getter functions - Add loadCandidates() calls at 7 interaction trigger points
This commit is contained in:
+120
-49
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { arrayMove } from '@dnd-kit/sortable';
|
||||
import type { DragEndEvent } from '@dnd-kit/core';
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
buildRouteMissingTokenIndex,
|
||||
normalizeMissingTokenModels,
|
||||
type MissingTokenModelsByName,
|
||||
type RouteMissingTokenHint,
|
||||
} from './helpers/routeMissingTokenHints.js';
|
||||
import { buildVisibleRouteList } from './helpers/routeListVisibility.js';
|
||||
import { buildZeroChannelPlaceholderRoutes } from './helpers/zeroChannelRoutes.js';
|
||||
@@ -156,6 +157,8 @@ export default function TokenRoutes() {
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const candidatesLoadedRef = useRef(false);
|
||||
|
||||
const loadRouteDecisions = async (
|
||||
routeRows: RouteSummaryRow[],
|
||||
options?: { force?: boolean; refreshPricingCatalog?: boolean; persistSnapshots?: boolean },
|
||||
@@ -223,14 +226,12 @@ export default function TokenRoutes() {
|
||||
}
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
const [summaryRows, candidateRows] = await Promise.all([
|
||||
api.getRoutesSummary(),
|
||||
api.getModelTokenCandidates(),
|
||||
]);
|
||||
|
||||
const summaries = (summaryRows || []) as RouteSummaryRow[];
|
||||
setRouteSummaries(summaries);
|
||||
const loadCandidates = async (force?: boolean) => {
|
||||
if (candidatesLoadedRef.current && !force) return;
|
||||
candidatesLoadedRef.current = true;
|
||||
try {
|
||||
const candidateRows = await api.getModelTokenCandidates();
|
||||
startTransition(() => {
|
||||
setModelCandidates((candidateRows?.models || {}) as RouteModelCandidatesByModelName);
|
||||
setMissingTokenModelsByName(
|
||||
normalizeMissingTokenModels((candidateRows?.modelsWithoutToken || {}) as MissingTokenModelsByName),
|
||||
@@ -239,6 +240,17 @@ export default function TokenRoutes() {
|
||||
normalizeMissingTokenModels((candidateRows?.modelsMissingTokenGroups || {}) as MissingTokenModelsByName),
|
||||
);
|
||||
setEndpointTypesByModel(candidateRows?.endpointTypesByModel || {});
|
||||
});
|
||||
} catch {
|
||||
candidatesLoadedRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
const summaryRows = await api.getRoutesSummary();
|
||||
|
||||
const summaries = (summaryRows || []) as RouteSummaryRow[];
|
||||
setRouteSummaries(summaries);
|
||||
const decisionPlaceholder: Record<number, RouteDecision | null> = {};
|
||||
for (const route of summaries) {
|
||||
decisionPlaceholder[route.id] = route.decisionSnapshot || null;
|
||||
@@ -247,6 +259,11 @@ export default function TokenRoutes() {
|
||||
setDecisionAutoSkipped(
|
||||
summaries.some((route) => isRouteExactModel(route) && !route.decisionSnapshot),
|
||||
);
|
||||
|
||||
// Silently refresh candidates in the background if already loaded
|
||||
if (candidatesLoadedRef.current) {
|
||||
loadCandidates(true);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -256,6 +273,9 @@ export default function TokenRoutes() {
|
||||
} catch {
|
||||
toast.error('加载路由配置失败');
|
||||
}
|
||||
// Preload candidates in background after first paint
|
||||
const scheduleIdle = typeof requestIdleCallback === 'function' ? requestIdleCallback : (cb: () => void) => setTimeout(cb, 0);
|
||||
scheduleIdle(() => loadCandidates());
|
||||
})();
|
||||
}, []);
|
||||
|
||||
@@ -313,6 +333,7 @@ export default function TokenRoutes() {
|
||||
}, [form.displayName, form.modelPattern, form.routeMode, form.sourceRouteIds.length, saving]);
|
||||
|
||||
const previewModelSamples = useMemo(() => {
|
||||
if (!showManual) return [];
|
||||
const names = new Set<string>();
|
||||
for (const modelName of Object.keys(modelCandidates || {})) {
|
||||
const normalized = modelName.trim();
|
||||
@@ -326,7 +347,7 @@ export default function TokenRoutes() {
|
||||
return Array.from(names)
|
||||
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
|
||||
.slice(0, 800);
|
||||
}, [modelCandidates, routeSummaries]);
|
||||
}, [showManual, modelCandidates, routeSummaries]);
|
||||
|
||||
const exactSourceRouteOptions = useMemo(
|
||||
() => routeSummaries.filter((route) => isRouteExactModel(route)),
|
||||
@@ -395,6 +416,7 @@ export default function TokenRoutes() {
|
||||
};
|
||||
|
||||
const handleEditRoute = (route: RouteSummaryRow) => {
|
||||
loadCandidates();
|
||||
setEditingRouteId(route.id);
|
||||
const routeMode = normalizeRouteMode(route.routeMode);
|
||||
setForm({
|
||||
@@ -583,15 +605,17 @@ export default function TokenRoutes() {
|
||||
}, [listVisibleRoutes, routeEndpointTypesByRouteId]);
|
||||
|
||||
const sourceEndpointTypesByRouteId = useMemo(() => {
|
||||
if (!showManual) return {};
|
||||
const next: Record<number, string[]> = {};
|
||||
for (const route of exactSourceRouteOptions) {
|
||||
next[route.id] = Array.from(routeEndpointTypesByRouteId[route.id] || new Set<string>())
|
||||
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
|
||||
}
|
||||
return next;
|
||||
}, [exactSourceRouteOptions, routeEndpointTypesByRouteId]);
|
||||
}, [showManual, exactSourceRouteOptions, routeEndpointTypesByRouteId]);
|
||||
|
||||
const routeBrandIconCandidates = useMemo(() => {
|
||||
if (!showManual) return [];
|
||||
const byIcon = new Map<string, BrandInfo>();
|
||||
|
||||
for (const route of visibleRouteRows) {
|
||||
@@ -606,7 +630,7 @@ export default function TokenRoutes() {
|
||||
|
||||
return Array.from(byIcon.values())
|
||||
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
|
||||
}, [visibleRouteRows, modelCandidates]);
|
||||
}, [showManual, visibleRouteRows, modelCandidates]);
|
||||
|
||||
const routeIconSelectOptions = useMemo<RouteIconOption[]>(() => ([
|
||||
...ROUTE_ICON_OPTIONS,
|
||||
@@ -780,22 +804,61 @@ export default function TokenRoutes() {
|
||||
[filteredRoutes, visibleRouteCount],
|
||||
);
|
||||
|
||||
const routeModelCandidateIndex = useMemo(
|
||||
() => buildRouteModelCandidatesIndex(routePatterns, modelCandidates, matchesModelPattern),
|
||||
[routePatterns, modelCandidates],
|
||||
);
|
||||
|
||||
const routeMissingTokenIndex = useMemo(
|
||||
() => buildRouteMissingTokenIndex(routePatterns, missingTokenModelsByName, matchesModelPattern),
|
||||
[routePatterns, missingTokenModelsByName],
|
||||
);
|
||||
const routeMissingTokenGroupIndex = useMemo(
|
||||
() => buildRouteMissingTokenIndex(routePatterns, missingTokenGroupModelsByName, matchesModelPattern),
|
||||
[routePatterns, missingTokenGroupModelsByName],
|
||||
);
|
||||
// Lazy per-route candidate index — only computes for routes actually accessed
|
||||
const candidateIndexCacheRef = useRef<{ key: string; cache: Map<number, RouteCandidateView> }>({ key: '', cache: new Map() });
|
||||
const candidateIndexCacheKey = `${routePatterns.length}:${Object.keys(modelCandidates).length}`;
|
||||
if (candidateIndexCacheRef.current.key !== candidateIndexCacheKey) {
|
||||
candidateIndexCacheRef.current = { key: candidateIndexCacheKey, cache: new Map() };
|
||||
}
|
||||
|
||||
const getRouteCandidateView = (routeId: number): RouteCandidateView => {
|
||||
return routeModelCandidateIndex[routeId] || EMPTY_ROUTE_CANDIDATE_VIEW;
|
||||
const cache = candidateIndexCacheRef.current.cache;
|
||||
const cached = cache.get(routeId);
|
||||
if (cached) return cached;
|
||||
const route = routePatterns.find((r) => r.id === routeId);
|
||||
if (!route) return EMPTY_ROUTE_CANDIDATE_VIEW;
|
||||
const index = buildRouteModelCandidatesIndex([route], modelCandidates, matchesModelPattern);
|
||||
const view = index[routeId] || EMPTY_ROUTE_CANDIDATE_VIEW;
|
||||
cache.set(routeId, view);
|
||||
return view;
|
||||
};
|
||||
|
||||
// Lazy per-route missing token index
|
||||
const missingTokenCacheRef = useRef<{ key: string; cache: Map<number, RouteMissingTokenHint[]> }>({ key: '', cache: new Map() });
|
||||
const missingTokenCacheKey = `${routePatterns.length}:${Object.keys(missingTokenModelsByName).length}`;
|
||||
if (missingTokenCacheRef.current.key !== missingTokenCacheKey) {
|
||||
missingTokenCacheRef.current = { key: missingTokenCacheKey, cache: new Map() };
|
||||
}
|
||||
|
||||
const getRouteMissingTokenHints = (routeId: number): RouteMissingTokenHint[] => {
|
||||
const cache = missingTokenCacheRef.current.cache;
|
||||
const cached = cache.get(routeId);
|
||||
if (cached) return cached;
|
||||
const route = routePatterns.find((r) => r.id === routeId);
|
||||
if (!route) return [];
|
||||
const index = buildRouteMissingTokenIndex([route], missingTokenModelsByName, matchesModelPattern);
|
||||
const hints = index[routeId] || [];
|
||||
cache.set(routeId, hints);
|
||||
return hints;
|
||||
};
|
||||
|
||||
// Lazy per-route missing token group index
|
||||
const missingTokenGroupCacheRef = useRef<{ key: string; cache: Map<number, RouteMissingTokenHint[]> }>({ key: '', cache: new Map() });
|
||||
const missingTokenGroupCacheKey = `${routePatterns.length}:${Object.keys(missingTokenGroupModelsByName).length}`;
|
||||
if (missingTokenGroupCacheRef.current.key !== missingTokenGroupCacheKey) {
|
||||
missingTokenGroupCacheRef.current = { key: missingTokenGroupCacheKey, cache: new Map() };
|
||||
}
|
||||
|
||||
const getRouteMissingTokenGroupHints = (routeId: number): RouteMissingTokenHint[] => {
|
||||
const cache = missingTokenGroupCacheRef.current.cache;
|
||||
const cached = cache.get(routeId);
|
||||
if (cached) return cached;
|
||||
const route = routePatterns.find((r) => r.id === routeId);
|
||||
if (!route) return [];
|
||||
const index = buildRouteMissingTokenIndex([route], missingTokenGroupModelsByName, matchesModelPattern);
|
||||
const hints = index[routeId] || [];
|
||||
cache.set(routeId, hints);
|
||||
return hints;
|
||||
};
|
||||
|
||||
const routeById = useMemo(
|
||||
@@ -1000,6 +1063,7 @@ export default function TokenRoutes() {
|
||||
if (isCurrentlyExpanded) {
|
||||
setExpandedRouteIds((prev) => prev.filter((id) => id !== routeId));
|
||||
} else {
|
||||
loadCandidates();
|
||||
setExpandedRouteIds((prev) => [...prev, routeId]);
|
||||
// Load channels on demand
|
||||
const route = routeById.get(routeId) || null;
|
||||
@@ -1014,10 +1078,9 @@ export default function TokenRoutes() {
|
||||
}
|
||||
};
|
||||
|
||||
const missingTokenSiteItemsByRouteId = useMemo(() => {
|
||||
const result: Record<number, MissingTokenRouteSiteActionItem[]> = {};
|
||||
for (const routeId of Object.keys(routeMissingTokenIndex).map(Number)) {
|
||||
const missingTokenHints = routeMissingTokenIndex[routeId] || [];
|
||||
const getMissingTokenSiteItems = (routeId: number): MissingTokenRouteSiteActionItem[] => {
|
||||
const missingTokenHints = getRouteMissingTokenHints(routeId);
|
||||
if (missingTokenHints.length === 0) return EMPTY_MISSING_ITEMS;
|
||||
const siteMap = new Map<string, MissingTokenRouteSiteActionItem>();
|
||||
for (const hint of missingTokenHints) {
|
||||
for (const account of hint.accounts) {
|
||||
@@ -1036,17 +1099,14 @@ export default function TokenRoutes() {
|
||||
}
|
||||
}
|
||||
}
|
||||
result[routeId] = Array.from(siteMap.values()).sort((a, b) => (
|
||||
return Array.from(siteMap.values()).sort((a, b) => (
|
||||
a.siteName.localeCompare(b.siteName, undefined, { sensitivity: 'base' })
|
||||
));
|
||||
}
|
||||
return result;
|
||||
}, [routeMissingTokenIndex]);
|
||||
};
|
||||
|
||||
const missingTokenGroupItemsByRouteId = useMemo(() => {
|
||||
const result: Record<number, MissingTokenGroupRouteSiteActionItem[]> = {};
|
||||
for (const routeId of Object.keys(routeMissingTokenGroupIndex).map(Number)) {
|
||||
const missingGroupHints = routeMissingTokenGroupIndex[routeId] || [];
|
||||
const getMissingTokenGroupItems = (routeId: number): MissingTokenGroupRouteSiteActionItem[] => {
|
||||
const missingGroupHints = getRouteMissingTokenGroupHints(routeId);
|
||||
if (missingGroupHints.length === 0) return EMPTY_MISSING_GROUP_ITEMS;
|
||||
const siteMap = new Map<string, MissingTokenGroupRouteSiteActionItem>();
|
||||
for (const hint of missingGroupHints) {
|
||||
for (const account of hint.accounts) {
|
||||
@@ -1086,12 +1146,10 @@ export default function TokenRoutes() {
|
||||
}
|
||||
}
|
||||
}
|
||||
result[routeId] = Array.from(siteMap.values()).sort((a, b) => (
|
||||
return Array.from(siteMap.values()).sort((a, b) => (
|
||||
a.siteName.localeCompare(b.siteName, undefined, { sensitivity: 'base' })
|
||||
));
|
||||
}
|
||||
return result;
|
||||
}, [routeMissingTokenGroupIndex]);
|
||||
};
|
||||
|
||||
// Stable callbacks for RouteCard memo (use refs to avoid dependency on closure variables)
|
||||
const toggleExpandRef = useRef(toggleExpand);
|
||||
@@ -1116,7 +1174,10 @@ export default function TokenRoutes() {
|
||||
(channelId: number, tokenId: number) => setChannelTokenDraft((prev) => ({ ...prev, [channelId]: tokenId })),
|
||||
[],
|
||||
);
|
||||
const stableAddChannel = useCallback((routeId: number) => setAddChannelModalRouteId(routeId), []);
|
||||
const stableAddChannel = useCallback((routeId: number) => {
|
||||
loadCandidates();
|
||||
setAddChannelModalRouteId(routeId);
|
||||
}, []);
|
||||
const stableToggleSourceGroup = useCallback(
|
||||
(groupKey: string) => setExpandedSourceGroupMap((prev) => ({ ...prev, [groupKey]: !prev[groupKey] })),
|
||||
[],
|
||||
@@ -1247,6 +1308,7 @@ export default function TokenRoutes() {
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
loadCandidates();
|
||||
resetRouteForm();
|
||||
setShowManual(true);
|
||||
}}
|
||||
@@ -1259,7 +1321,10 @@ export default function TokenRoutes() {
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={showZeroChannelRoutes}
|
||||
onClick={() => setShowZeroChannelRoutes((prev) => !prev)}
|
||||
onClick={() => {
|
||||
if (!showZeroChannelRoutes) loadCandidates();
|
||||
setShowZeroChannelRoutes((prev) => !prev);
|
||||
}}
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
|
||||
>
|
||||
@@ -1278,7 +1343,10 @@ export default function TokenRoutes() {
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)', padding: '8px 14px', marginBottom: 12 }}
|
||||
onClick={() => setShowFilters(true)}
|
||||
onClick={() => {
|
||||
loadCandidates();
|
||||
setShowFilters(true);
|
||||
}}
|
||||
>
|
||||
{tr('筛选')}
|
||||
</button>
|
||||
@@ -1324,7 +1392,10 @@ export default function TokenRoutes() {
|
||||
endpointTypeList={endpointTypeList}
|
||||
groupRouteList={groupRouteList}
|
||||
collapsed={filterCollapsed}
|
||||
onToggle={() => setFilterCollapsed((prev) => !prev)}
|
||||
onToggle={() => {
|
||||
if (filterCollapsed) loadCandidates();
|
||||
setFilterCollapsed((prev) => !prev);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1479,8 +1550,8 @@ export default function TokenRoutes() {
|
||||
onDeleteChannel={stableDeleteChannel}
|
||||
onToggleChannelEnabled={stableToggleChannelEnabled}
|
||||
onChannelDragEnd={stableChannelDragEnd}
|
||||
missingTokenSiteItems={missingTokenSiteItemsByRouteId[route.id] || EMPTY_MISSING_ITEMS}
|
||||
missingTokenGroupItems={missingTokenGroupItemsByRouteId[route.id] || EMPTY_MISSING_GROUP_ITEMS}
|
||||
missingTokenSiteItems={getMissingTokenSiteItems(route.id)}
|
||||
missingTokenGroupItems={getMissingTokenGroupItems(route.id)}
|
||||
onCreateTokenForMissing={stableCreateTokenForMissing}
|
||||
onAddChannel={stableAddChannel}
|
||||
onSiteBlockModel={stableSiteBlockModel}
|
||||
@@ -1530,7 +1601,7 @@ export default function TokenRoutes() {
|
||||
routeTitle={resolveRouteTitle(addChannelModalRoute)}
|
||||
candidateView={getRouteCandidateView(addChannelModalRoute.id)}
|
||||
onSuccess={handleAddChannelSuccess}
|
||||
missingTokenHints={routeMissingTokenIndex[addChannelModalRoute.id] || []}
|
||||
missingTokenHints={getRouteMissingTokenHints(addChannelModalRoute.id)}
|
||||
onCreateTokenForMissing={handleCreateTokenForMissingAccount}
|
||||
existingChannelAccountIds={new Set((channelsByRouteId[addChannelModalRoute.id] || []).map((c) => c.accountId))}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user