mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-15 11:44:10 +00:00
## Root cause `useAssetBrowser`'s `filteredAssets` computed re-ran `.map(transformAssetForDisplay)` over the full result set on every tab switch. `transformAssetForDisplay` allocates fresh `badges`/`stats` objects, walks `tags`, calls `getAssetBaseModels`, and runs i18n date formatting per asset — none of which were memoized. Switching All / Inputs / Outputs forced N transforms per click and produced brand-new `AssetDisplayItem` references, which also defeated `:key`-based diffing in `AssetGrid` / `VirtualGrid` and re-rendered every visible card. ## Fix Memoize `transformAssetForDisplay` at module scope with a `WeakMap<AssetItem, AssetDisplayItem>`. Unchanged assets reuse the same display item across tab switches; the GC reclaims entries when assets are released. ## Before / after (n=200 assets, 6 tab switches: inputs → outputs → all → inputs → outputs → all) | Metric | Before | After | | ------------------------------- | -----: | ----: | | `transformAssetForDisplay` runs | 800 | 0 | | Wall time (Vitest harness) | 13.2 ms | 8.1 ms | | Reused `AssetDisplayItem` refs | 0 | 200 | Measured via `src/platform/assets/composables/useAssetBrowser.perf.test.ts` plus a temporary `process.stderr.write` harness. ## Red / green | Commit | Purpose | | --------- | ------- | |7367fdd60| test: failing perf assertions (transform budget + reused refs) | |021b98ac0| perf: WeakMap memoization of `transformAssetForDisplay` | ## Test plan - [x] `pnpm exec vitest run src/platform/assets/composables/useAssetBrowser` — 42/42 pass (including 2 new perf assertions) - [x] `pnpm typecheck` - [x] `pnpm exec eslint` on touched files - [x] `pnpm exec oxfmt --check` on touched files Fixes [FE-229 ](https://linear.app/comfyorg/issue/FE-229/asset-browser-switching-all-inputs-outputs-tabs-is-slow)Source: https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1776716352588229 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-11491-perf-memoize-asset-display-transform-across-filter-tab-switches-3496d73d36508112822dd6e7b58040fe) by [Unito](https://www.unito.io) --------- Co-authored-by: Christian Byrne <cbyrne@comfy.org>
270 lines
7.6 KiB
TypeScript
270 lines
7.6 KiB
TypeScript
import { computed, ref } from 'vue'
|
|
import type { Ref } from 'vue'
|
|
import { useFuse } from '@vueuse/integrations/useFuse'
|
|
import type { UseFuseOptions } from '@vueuse/integrations/useFuse'
|
|
import { storeToRefs } from 'pinia'
|
|
|
|
import { t } from '@/i18n'
|
|
import type {
|
|
AssetFilterState,
|
|
OwnershipOption
|
|
} from '@/platform/assets/types/filterTypes'
|
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
|
import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions'
|
|
import {
|
|
filterByBaseModels,
|
|
filterByCategory,
|
|
filterByFileFormats,
|
|
filterByOwnership
|
|
} from '@/platform/assets/utils/assetFilterUtils'
|
|
import {
|
|
getAssetBaseModels,
|
|
getAssetFilename
|
|
} from '@/platform/assets/utils/assetMetadataUtils'
|
|
import { MODELS_TAG } from '@/platform/assets/services/assetService'
|
|
import { sortAssets } from '@/platform/assets/utils/assetSortUtils'
|
|
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
|
|
import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
|
|
|
type NavId = 'all' | 'imported' | (string & {})
|
|
|
|
type AssetBadge = {
|
|
label: string
|
|
type: 'type' | 'base' | 'size'
|
|
}
|
|
|
|
// Display properties for transformed assets
|
|
export interface AssetDisplayItem extends AssetItem {
|
|
secondaryText: string
|
|
badges: AssetBadge[]
|
|
stats: {
|
|
downloadCount?: string
|
|
stars?: string
|
|
}
|
|
}
|
|
|
|
const displayItemCache = new WeakMap<AssetItem, AssetDisplayItem>()
|
|
|
|
function buildDisplayItem(asset: AssetItem): AssetDisplayItem {
|
|
const badges: AssetBadge[] = []
|
|
|
|
const typeTag = asset.tags.find((tag) => tag !== 'models')
|
|
if (typeTag) {
|
|
const badgeLabel = typeTag.includes('/')
|
|
? typeTag.substring(typeTag.indexOf('/') + 1)
|
|
: typeTag
|
|
|
|
badges.push({ label: badgeLabel, type: 'type' })
|
|
}
|
|
|
|
for (const model of getAssetBaseModels(asset)) {
|
|
badges.push({ label: model, type: 'base' })
|
|
}
|
|
|
|
// Intentionally no formatted date here — the WeakMap caches by AssetItem
|
|
// reference, so a pre-formatted string would pin the locale active at first
|
|
// transform. AssetCard formats `created_at` at render via `d()` instead.
|
|
return {
|
|
...asset,
|
|
secondaryText: getAssetFilename(asset),
|
|
badges,
|
|
stats: {
|
|
downloadCount: undefined,
|
|
stars: undefined
|
|
}
|
|
}
|
|
}
|
|
|
|
function transformAssetForDisplay(asset: AssetItem): AssetDisplayItem {
|
|
const cached = displayItemCache.get(asset)
|
|
if (cached) return cached
|
|
const built = buildDisplayItem(asset)
|
|
displayItemCache.set(asset, built)
|
|
return built
|
|
}
|
|
|
|
/**
|
|
* Asset Browser composable
|
|
* Manages search, filtering, asset transformation and selection logic
|
|
*/
|
|
export function useAssetBrowser(
|
|
assetsSource: Ref<AssetItem[] | undefined> = ref<AssetItem[] | undefined>([])
|
|
) {
|
|
const assets = computed<AssetItem[]>(() => assetsSource.value ?? [])
|
|
const assetDownloadStore = useAssetDownloadStore()
|
|
const { sessionDownloadCount } = storeToRefs(assetDownloadStore)
|
|
|
|
// State
|
|
const searchQuery = ref('')
|
|
const selectedNavItem = ref<NavId>('all')
|
|
const filters = ref<AssetFilterState>({
|
|
sortBy: 'recent',
|
|
fileFormats: [],
|
|
baseModels: [],
|
|
ownership: 'all'
|
|
})
|
|
|
|
const selectedOwnership = computed<OwnershipOption>(() => {
|
|
if (typeCategories.value.length <= 1) return filters.value.ownership
|
|
if (selectedNavItem.value === 'imported') return 'my-models'
|
|
if (selectedNavItem.value === 'all') return 'all'
|
|
return filters.value.ownership
|
|
})
|
|
|
|
const selectedCategory = computed(() => {
|
|
if (
|
|
selectedNavItem.value === 'all' ||
|
|
selectedNavItem.value === 'imported'
|
|
) {
|
|
return 'all'
|
|
}
|
|
return selectedNavItem.value
|
|
})
|
|
|
|
const typeCategories = computed<NavItemData[]>(() => {
|
|
const categories = assets.value
|
|
.filter((asset) => asset.tags.includes(MODELS_TAG))
|
|
.flatMap((asset) =>
|
|
asset.tags.filter((tag) => tag !== MODELS_TAG && tag.length > 0)
|
|
)
|
|
.map((tag) => tag.split('/')[0])
|
|
|
|
return Array.from(new Set(categories))
|
|
.sort()
|
|
.map((category) => ({
|
|
id: category,
|
|
label: category.charAt(0).toUpperCase() + category.slice(1),
|
|
icon: 'icon-[lucide--folder]'
|
|
}))
|
|
})
|
|
|
|
const navItems = computed<(NavItemData | NavGroupData)[]>(() => {
|
|
const quickFilters: NavItemData[] = [
|
|
{
|
|
id: 'all',
|
|
label: t('assetBrowser.allModels'),
|
|
icon: 'icon-[lucide--list]'
|
|
},
|
|
{
|
|
id: 'imported',
|
|
label: t('assetBrowser.imported'),
|
|
icon: 'icon-[lucide--folder-input]',
|
|
badge:
|
|
sessionDownloadCount.value > 0
|
|
? sessionDownloadCount.value
|
|
: undefined
|
|
}
|
|
]
|
|
|
|
if (typeCategories.value.length === 0) {
|
|
return quickFilters
|
|
}
|
|
|
|
return [
|
|
...quickFilters,
|
|
{
|
|
title: t('assetBrowser.byType'),
|
|
items: typeCategories.value,
|
|
collapsible: false
|
|
}
|
|
]
|
|
})
|
|
|
|
const isImportedSelected = computed(
|
|
() => selectedNavItem.value === 'imported'
|
|
)
|
|
|
|
// Compute content title from selected nav item
|
|
const contentTitle = computed(() => {
|
|
if (selectedNavItem.value === 'all') {
|
|
return t('assetBrowser.allModels')
|
|
}
|
|
if (selectedNavItem.value === 'imported') {
|
|
return t('assetBrowser.imported')
|
|
}
|
|
|
|
const category = typeCategories.value.find(
|
|
(cat) => cat.id === selectedNavItem.value
|
|
)
|
|
return category?.label || t('assetBrowser.assets')
|
|
})
|
|
|
|
// Category-filtered assets for filter options (before search/format/base model filters)
|
|
const categoryFilteredAssets = computed(() => {
|
|
return assets.value.filter(filterByCategory(selectedCategory.value))
|
|
})
|
|
|
|
const { availableFileFormats, availableBaseModels } = useAssetFilterOptions(
|
|
categoryFilteredAssets
|
|
)
|
|
|
|
const activeFileFormats = computed(() =>
|
|
filters.value.fileFormats.filter((f) =>
|
|
availableFileFormats.value.some((opt) => opt.value === f)
|
|
)
|
|
)
|
|
|
|
const activeBaseModels = computed(() =>
|
|
filters.value.baseModels.filter((m) =>
|
|
availableBaseModels.value.some((opt) => opt.value === m)
|
|
)
|
|
)
|
|
|
|
const fuseOptions: UseFuseOptions<AssetItem> = {
|
|
fuseOptions: {
|
|
keys: [
|
|
{ name: 'name', weight: 0.4 },
|
|
{ name: 'tags', weight: 0.3 },
|
|
{ name: 'user_metadata.name', weight: 0.4 },
|
|
{ name: 'user_metadata.additional_tags', weight: 0.3 },
|
|
{ name: 'user_metadata.trained_words', weight: 0.3 },
|
|
{ name: 'user_metadata.user_description', weight: 0.3 },
|
|
{ name: 'metadata.name', weight: 0.4 },
|
|
{ name: 'metadata.trained_words', weight: 0.3 }
|
|
],
|
|
threshold: 0.4, // Higher threshold for typo tolerance (0.0 = exact, 1.0 = match all)
|
|
ignoreLocation: true, // Search anywhere in the string, not just at the beginning
|
|
includeScore: true
|
|
},
|
|
matchAllWhenSearchEmpty: true
|
|
}
|
|
|
|
const { results: fuseResults } = useFuse(
|
|
searchQuery,
|
|
categoryFilteredAssets,
|
|
fuseOptions
|
|
)
|
|
|
|
const searchFiltered = computed(() =>
|
|
fuseResults.value.map((result) => result.item)
|
|
)
|
|
|
|
const filteredAssets = computed(() => {
|
|
const filtered = searchFiltered.value
|
|
.filter(filterByFileFormats(activeFileFormats.value))
|
|
.filter(filterByBaseModels(activeBaseModels.value))
|
|
.filter(filterByOwnership(selectedOwnership.value))
|
|
|
|
const sortedAssets = sortAssets(filtered, filters.value.sortBy)
|
|
|
|
// Transform to display format
|
|
return sortedAssets.map(transformAssetForDisplay)
|
|
})
|
|
|
|
function updateFilters(newFilters: AssetFilterState) {
|
|
filters.value = { ...newFilters }
|
|
}
|
|
|
|
return {
|
|
searchQuery,
|
|
selectedNavItem,
|
|
selectedCategory,
|
|
navItems,
|
|
contentTitle,
|
|
categoryFilteredAssets,
|
|
filteredAssets,
|
|
isImportedSelected,
|
|
updateFilters
|
|
}
|
|
}
|