Compare commits

...

2 Commits

Author SHA1 Message Date
jaeone94
1ab9752af8 fix: keep Reka overlays above PrimeVue dialogs (#12038)
## Summary

Temporarily patch FE-569 by keeping the affected portaled Reka dropdowns
and menus above their containing PrimeVue dialogs when PrimeVue auto
z-index state has been elevated.

## Changes

- **What**: Added a small compatibility helper,
`usePrimeVueOverlayChildStyle`, that returns an anchor ref plus a
computed inline style for child popover content. The helper finds the
nearest PrimeVue dialog mask (`.p-dialog-mask` / `.p-overlay-mask`) from
the parent surface and, only when found, applies `parent z-index + 1` to
the affected Reka overlay content.
- **What**: Applied that helper at the exact PrimeVue parent surfaces
where the issue was found. This PR does not add a global overlay policy
and does not change every Reka select/dropdown in the app.
- **What**: Added optional `contentStyle`/`selectContentStyle` plumbing
only where needed so the style reaches the actual portaled Reka overlay
root.
- **What**: Added focused unit coverage for the helper contract: no
PrimeVue parent preserves existing stacking, PrimeVue dialog/overlay
masks render child content above the parent, low parent z-index values
respect the Reka floor, and invalid z-index values do not inject an
inline override.
- **Approach**: This is intentionally a minimal, parent-scoped band-aid.
It avoids a global PrimeVue overlay scanner because global sampling can
be polluted by unrelated persistent PrimeVue roots such as Toast and
would turn this fix into a broader layering policy.
- **Approach**: The patch targets the confirmed failure mode: a Reka
child overlay rendering below its owning PrimeVue dialog after PrimeVue
autoZIndex has been elevated. It does not attempt to solve PrimeVue
z-index globally.
- **Lifecycle**: This is temporary migration compatibility. PrimeVue
dialogs and controls are being incrementally migrated to Reka UI, so
`usePrimeVueOverlayChildStyle` and the optional style props added for
FE-569 should be removed once the affected parent surfaces move to Reka.
- **Breaking**: None. New props are optional and no public API contract
is changed.
- **Dependencies**: None.

## Patched Entry Points

This PR pinpoints the six affected user-facing surfaces below. Each
patch is applied from the PrimeVue dialog parent and passed only to the
Reka child overlay content that can render underneath that parent.


https://github.com/user-attachments/assets/d0d1522a-ffc7-4934-9e7a-06b83e20f809

1. **Workflow Template Library filters**
- **How to enter**: click the Templates button in the left sidebar, or
open the Comfy menu and choose **Browse Templates**.
- **Affected elements**: the template filter popovers in
`WorkflowTemplateSelectorDialog`: **Model**, **Use case**, **Runs on**,
and **Sort by**.
- **Patch point**: `WorkflowTemplateSelectorDialog.vue` anchors to the
template dialog content filter area and passes `selectContentStyle` to
the affected `MultiSelect` / `SingleSelect` controls.


https://github.com/user-attachments/assets/3641fa24-da51-4392-a904-9085f8a5a2f4

2. **Manager dialog header controls**
- **How to enter**: open Manager from the top/menu Manager entry when
the new Manager UI is available.
- **Affected elements**: the Manager header controls in `ManagerDialog`:
search mode `SingleSelect`, search autocomplete suggestions, and
**Sort** `SingleSelect`.
- **Patch point**: `ManagerDialog.vue` anchors to the dialog header and
passes `selectContentStyle` to those three Reka overlays.


https://github.com/user-attachments/assets/cf25cc06-f851-48ef-9d9c-9ec2da8afc06

3. **Asset Browser filter bar**
- **How to enter**: open the Asset Browser from an eligible model widget
browse action, the Model Library flow, or another
`useAssetBrowserDialog` caller.
- **Affected elements**: `AssetFilterBar` controls: **File formats**,
**Base models**, **Ownership**, and **Sort by**.
- **Patch point**: `AssetBrowserModal.vue` anchors to the PrimeVue
dialog header and passes the style through `AssetFilterBar` to its
`MultiSelect` / `SingleSelect` controls.


https://github.com/user-attachments/assets/e27bd805-10c0-4b3b-97f3-9e11faa47021

4. **Asset Browser model info panel**
- **How to enter**: open Asset Browser, select an asset, then use the
right-side model info panel.
- **Affected element**: the **Model type** select in `ModelInfoPanel`.
- **Patch point**: `AssetBrowserModal.vue` reuses the same parent-scoped
style and passes it to `ModelInfoPanel` as `selectContentStyle`.


https://github.com/user-attachments/assets/5e9f7ef0-ebd7-4987-ba1b-2137c034086f

5. **Upload Model confirmation step**
- **How to enter**: open Asset Browser, click **Upload**, enter/fetch
model metadata, then proceed to the confirmation step.
- **Affected element**: the **Model type** `SingleSelect` in
`UploadModelConfirmation`.
- **Patch point**: `UploadModelConfirmation.vue` anchors within the
upload dialog content and passes `selectContentStyle` to the model type
selector.



https://github.com/user-attachments/assets/ec145f26-8621-455b-915e-bedee47e1cbd

6. **Settings > Keybinding panel controls**
- **How to enter**: open Settings from the sidebar/menu, then select the
**Keybinding** panel.
- **Affected elements**: the keybinding preset select, the preset
overflow dropdown menu, and the row context menu inside
`KeybindingPanel`.
- **Patch point**: `KeybindingPanel.vue` anchors to the settings dialog
panel and passes `keybindingOverlayContentStyle` only to those Reka
overlay roots.

## Review Focus

- Confirm the patch stays narrowly scoped to the six known PrimeVue
parent + Reka child overlay surfaces above.
- Confirm `contentStyle` reaches the actual portaled Reka overlay
content in each patched path.
- Confirm the fallback behavior preserves existing stacking when no
PrimeVue parent overlay is found; in that case the helper returns an
empty style object and leaves existing Tailwind z-index classes alone.
- Please avoid expanding this into a larger overlay refactor. The goal
is a clean, backport-friendly compatibility patch while the Reka
migration continues.

Validation performed:

- `pnpm exec vitest run src/composables/usePopoverSizing.test.ts`
- `pnpm typecheck`
- `pnpm lint` (passes with existing unrelated warnings only)
- `pnpm format:check`
- commit hook lint-staged checks (`oxfmt`, `stylelint`, `oxlint`,
`eslint --fix`, `pnpm typecheck`)
- pre-push `pnpm knip`

Linear: FE-569

## Bug Screenshots 



https://github.com/user-attachments/assets/e73761af-9867-4c50-ab0d-4e32e59011e1



https://github.com/user-attachments/assets/145daf4d-3268-428b-9987-1e1afd0b866f


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12038-fix-keep-Reka-overlays-above-PrimeVue-dialogs-3596d73d365081e7af49dbc4d3905962)
by [Unito](https://www.unito.io)
2026-05-07 13:37:08 +09:00
Dante
e469611f6d perf: memoize asset display transform across filter tab switches (#11491)
## 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>
2026-05-07 13:03:57 +09:00
17 changed files with 378 additions and 71 deletions

View File

@@ -40,7 +40,10 @@
<template #contentFilter>
<div class="relative flex flex-wrap justify-between gap-2 px-6 pb-4">
<div class="flex flex-wrap gap-2">
<div
:ref="primeVueOverlay.overlayScopeRef"
class="flex flex-wrap gap-2"
>
<!-- Model Filter -->
<MultiSelect
v-model="selectedModelObjects"
@@ -48,6 +51,7 @@
class="w-[250px]"
:label="modelFilterLabel"
:options="modelOptions"
:content-style="selectContentStyle"
:show-search-box="true"
:show-selected-count="true"
:show-clear-button="true"
@@ -62,6 +66,7 @@
v-model="selectedUseCaseObjects"
:label="useCaseFilterLabel"
:options="useCaseOptions"
:content-style="selectContentStyle"
:show-search-box="true"
:show-selected-count="true"
:show-clear-button="true"
@@ -76,6 +81,7 @@
v-model="selectedRunsOnObjects"
:label="runsOnFilterLabel"
:options="runsOnOptions"
:content-style="selectContentStyle"
:show-search-box="true"
:show-selected-count="true"
:show-clear-button="true"
@@ -92,6 +98,7 @@
v-model="sortBy"
:label="$t('templateWorkflows.sorting', 'Sort by')"
:options="sortOptions"
:content-style="selectContentStyle"
class="w-62.5"
>
<template #icon>
@@ -416,6 +423,7 @@ import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
import { useLazyPagination } from '@/composables/useLazyPagination'
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
@@ -632,6 +640,8 @@ const selectedRunsOnObjects = computed({
const loadingTemplate = ref<string | null>(null)
const hoveredTemplate = ref<string | null>(null)
const cardRefs = ref<HTMLElement[]>([])
const primeVueOverlay = usePrimeVueOverlayChildStyle()
const selectContentStyle = primeVueOverlay.contentStyle
// Force re-render key for templates when sorting changes
const templateListKey = ref(0)

View File

@@ -1,5 +1,8 @@
<template>
<div class="keybinding-panel flex flex-col gap-2">
<div
:ref="primeVueOverlay.overlayScopeRef"
class="keybinding-panel flex flex-col gap-2"
>
<Teleport defer to="#keybinding-panel-header">
<SearchInput
v-model="filters['global'].value"
@@ -15,10 +18,12 @@
<div class="flex items-center gap-2">
<KeybindingPresetToolbar
:preset-names="presetNames"
:content-style="keybindingOverlayContentStyle"
@presets-changed="refreshPresetList"
/>
<DropdownMenu
:entries="menuEntries"
:style="keybindingOverlayContentStyle"
icon="icon-[lucide--ellipsis]"
item-class="text-sm gap-2"
button-size="unset"
@@ -238,6 +243,7 @@
</ContextMenuTrigger>
<ContextMenuPortal>
<ContextMenuContent
:style="keybindingOverlayContentStyle"
class="z-1200 min-w-56 rounded-lg border border-border-subtle bg-base-background px-2 py-3 shadow-interface"
>
<ContextMenuItem
@@ -314,6 +320,7 @@ import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
import Button from '@/components/ui/button/Button.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import { useEditKeybindingDialog } from '@/composables/useEditKeybindingDialog'
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
import type { KeybindingImpl } from '@/platform/keybindings/keybinding'
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
@@ -337,6 +344,8 @@ const settingStore = useSettingStore()
const commandStore = useCommandStore()
const dialogStore = useDialogStore()
const { t } = useI18n()
const primeVueOverlay = usePrimeVueOverlayChildStyle()
const keybindingOverlayContentStyle = primeVueOverlay.contentStyle
const presetNames = ref<string[]>([])

View File

@@ -9,7 +9,10 @@
{{ displayLabel }}
</SelectValue>
</SelectTrigger>
<SelectContent class="max-w-64 min-w-0 **:[[role=listbox]]:gap-1">
<SelectContent
:style="contentStyle"
class="max-w-64 min-w-0 **:[[role=listbox]]:gap-1"
>
<div class="max-w-60">
<SelectItem
value="default"
@@ -46,6 +49,7 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { StyleValue } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
@@ -57,8 +61,9 @@ import SelectValue from '@/components/ui/select/SelectValue.vue'
import { useKeybindingPresetService } from '@/platform/keybindings/presetService'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
const { presetNames } = defineProps<{
const { presetNames, contentStyle } = defineProps<{
presetNames: string[]
contentStyle?: StyleValue
}>()
const emit = defineEmits<{

View File

@@ -50,7 +50,7 @@
position="popper"
:side-offset="8"
align="start"
:style="popoverStyle"
:style="[popoverStyle, contentStyle]"
:class="selectContentClass"
@keydown="onContentKeydown"
@focus-outside="preventFocusDismiss"
@@ -152,6 +152,7 @@ import {
ComboboxViewport
} from 'reka-ui'
import { computed, ref } from 'vue'
import type { StyleValue } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
@@ -183,7 +184,8 @@ const {
searchPlaceholder,
listMaxHeight = '28rem',
popoverMinWidth,
popoverMaxWidth
popoverMaxWidth,
contentStyle
} = defineProps<{
/** Input label shown on the trigger button */
label?: string
@@ -207,6 +209,7 @@ const {
popoverMinWidth?: string
/** Maximum width of the popover (default: auto) */
popoverMaxWidth?: string
contentStyle?: StyleValue
}>()
const selectedItems = defineModel<SelectOption[]>({

View File

@@ -70,6 +70,7 @@
v-if="suggestions.length > 0"
position="popper"
:side-offset="4"
:style="contentStyle"
:class="
cn(
'z-3000 max-h-60 w-(--reka-combobox-trigger-width) overflow-y-auto',
@@ -99,7 +100,7 @@
</template>
<script setup lang="ts" generic="T">
import type { HTMLAttributes } from 'vue'
import type { HTMLAttributes, StyleValue } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import {
@@ -132,7 +133,8 @@ const {
suggestions = [],
optionLabel,
optionKey,
class: className
class: className,
contentStyle
} = defineProps<{
placeholder?: string
icon?: string
@@ -144,6 +146,7 @@ const {
optionLabel?: keyof T & string
optionKey?: keyof T & string
class?: HTMLAttributes['class']
contentStyle?: StyleValue
}>()
const emit = defineEmits<{

View File

@@ -37,7 +37,7 @@
position="popper"
:side-offset="8"
align="start"
:style="optionStyle"
:style="[optionStyle, contentStyle]"
:class="cn(selectContentClass, 'min-w-(--reka-select-trigger-width)')"
@keydown="onContentKeydown"
>
@@ -82,6 +82,7 @@ import {
SelectViewport
} from 'reka-ui'
import { ref } from 'vue'
import type { StyleValue } from 'vue'
import { useI18n } from 'vue-i18n'
import {
@@ -108,7 +109,8 @@ const {
disabled = false,
listMaxHeight = '28rem',
popoverMinWidth,
popoverMaxWidth
popoverMaxWidth,
contentStyle
} = defineProps<{
label?: string
options?: SelectOption[]
@@ -126,6 +128,7 @@ const {
popoverMinWidth?: string
/** Maximum width of the popover (default: auto) */
popoverMaxWidth?: string
contentStyle?: StyleValue
}>()
const selectedItem = defineModel<string | undefined>({ required: true })

View File

@@ -0,0 +1,91 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { effectScope } from 'vue'
import type { EffectScope } from 'vue'
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
describe('usePrimeVueOverlayChildStyle', () => {
let scope: EffectScope | undefined
function mountComposable() {
scope = effectScope()
let composable: ReturnType<typeof usePrimeVueOverlayChildStyle> | undefined
scope.run(() => {
composable = usePrimeVueOverlayChildStyle()
})
if (!composable) {
throw new Error('Failed to mount composable')
}
return composable
}
beforeEach(() => {
document.body.innerHTML = ''
})
afterEach(() => {
scope?.stop()
scope = undefined
document.body.innerHTML = ''
})
it('preserves existing stacking when there is no PrimeVue parent overlay', () => {
const { overlayScopeRef, contentStyle } = mountComposable()
overlayScopeRef.value = document.createElement('div')
expect(contentStyle.value).toEqual({})
})
it('renders above the closest PrimeVue dialog mask', () => {
const { overlayScopeRef, contentStyle } = mountComposable()
overlayScopeRef.value = appendPrimeVueOverlay('p-dialog-mask', 5000)
expect(contentStyle.value).toEqual({ zIndex: 5001 })
})
it('renders above the closest PrimeVue overlay mask', () => {
const { overlayScopeRef, contentStyle } = mountComposable()
overlayScopeRef.value = appendPrimeVueOverlay('p-overlay-mask', 4200)
expect(contentStyle.value).toEqual({ zIndex: 4201 })
})
it('does not drop below the Reka select overlay z-index floor', () => {
const { overlayScopeRef, contentStyle } = mountComposable()
overlayScopeRef.value = appendPrimeVueOverlay('p-dialog-mask', 1200)
expect(contentStyle.value).toEqual({ zIndex: 3000 })
})
it('preserves existing stacking when the PrimeVue overlay z-index is not numeric', () => {
const { overlayScopeRef, contentStyle } = mountComposable()
overlayScopeRef.value = appendPrimeVueOverlay('p-dialog-mask')
expect(contentStyle.value).toEqual({})
})
})
function appendPrimeVueOverlay(
className: string,
zIndex?: number
): HTMLElement {
const overlay = document.createElement('div')
overlay.className = className
if (zIndex !== undefined) {
overlay.style.zIndex = String(zIndex)
}
const anchor = document.createElement('div')
overlay.append(anchor)
document.body.append(overlay)
return anchor
}

View File

@@ -1,11 +1,14 @@
import { computed } from 'vue'
import type { CSSProperties, ComputedRef } from 'vue'
import { computed, ref } from 'vue'
import type { CSSProperties, ComputedRef, Ref } from 'vue'
interface PopoverSizeOptions {
minWidth?: string
maxWidth?: string
}
// Matches the highest existing Reka popover z-index (e.g. z-3000 on SearchAutocomplete).
const PRIMEVUE_DIALOG_CHILD_Z_INDEX_FLOOR = 3000
/**
* Composable for managing popover sizing styles
* @param options Popover size configuration
@@ -29,3 +32,30 @@ export function usePopoverSizing(
return style
})
}
/**
* Keeps portaled Reka popovers above their containing PrimeVue dialog.
*
* This is a temporary bridge while PrimeVue dialogs and controls are
* incrementally migrated to Reka UI. Once the affected PrimeVue parents are
* migrated, this helper should be removed with the compatibility patch.
*/
export function usePrimeVueOverlayChildStyle(): {
overlayScopeRef: Ref<HTMLElement | null>
contentStyle: ComputedRef<CSSProperties>
} {
const overlayScopeRef = ref<HTMLElement | null>(null)
const contentStyle = computed<CSSProperties>(() => {
const overlay = overlayScopeRef.value?.closest(
'.p-dialog-mask, .p-overlay-mask'
)
if (!overlay) return {}
const zIndex = Number.parseInt(getComputedStyle(overlay).zIndex, 10)
if (!Number.isFinite(zIndex)) return {}
return { zIndex: Math.max(PRIMEVUE_DIALOG_CHILD_Z_INDEX_FLOOR, zIndex + 1) }
})
return { overlayScopeRef, contentStyle }
}

View File

@@ -23,6 +23,7 @@
<template #header>
<div
:ref="primeVueOverlay.overlayScopeRef"
class="flex w-full items-center justify-between gap-2"
@click.self="focusedAsset = null"
>
@@ -52,6 +53,7 @@
<AssetFilterBar
:assets="categoryFilteredAssets"
:show-ownership-filter
:content-style="selectContentStyle"
@filter-change="updateFilters"
@click.self="focusedAsset = null"
/>
@@ -72,7 +74,12 @@
</template>
<template #rightPanel>
<ModelInfoPanel v-if="focusedAsset" :asset="focusedAsset" :cache-key />
<ModelInfoPanel
v-if="focusedAsset"
:asset="focusedAsset"
:cache-key
:select-content-style="selectContentStyle"
/>
<div
v-else
class="flex h-full items-center justify-center p-6 text-center wrap-break-word text-muted"
@@ -92,6 +99,7 @@ import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import Button from '@/components/ui/button/Button.vue'
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
import AssetFilterBar from '@/platform/assets/components/AssetFilterBar.vue'
import AssetGrid from '@/platform/assets/components/AssetGrid.vue'
import ModelInfoPanel from '@/platform/assets/components/modelInfo/ModelInfoPanel.vue'
@@ -109,6 +117,8 @@ const { t } = useI18n()
const assetStore = useAssetsStore()
const modelToNodeStore = useModelToNodeStore()
const breakpoints = useBreakpoints(breakpointsTailwind)
const primeVueOverlay = usePrimeVueOverlayChildStyle()
const selectContentStyle = primeVueOverlay.contentStyle
const props = defineProps<{
nodeType?: string

View File

@@ -17,7 +17,6 @@ const createAssetData = (
{ label: '2.1 GB', type: 'size' }
],
stats: {
formattedDate: '3/15/25',
downloadCount: '1.8k',
stars: '4.2k'
},
@@ -147,8 +146,7 @@ export const EdgeCases: Story = {
name: 'No Stars',
secondaryText: 'Testing missing stars data gracefully',
stats: {
downloadCount: '1.8k',
formattedDate: '3/15/25'
downloadCount: '1.8k'
}
}),
// No downloads
@@ -157,8 +155,7 @@ export const EdgeCases: Story = {
name: 'No Downloads',
secondaryText: 'Testing missing downloads data gracefully',
stats: {
stars: '4.2k',
formattedDate: '3/15/25'
stars: '4.2k'
}
}),
// No date

View File

@@ -103,12 +103,9 @@
<i class="icon-[lucide--download] size-3" />
{{ asset.stats.downloadCount }}
</span>
<span
v-if="asset.stats.formattedDate"
class="flex items-center gap-1"
>
<span v-if="formattedDate" class="flex items-center gap-1">
<i class="icon-[lucide--clock] size-3" />
{{ asset.stats.formattedDate }}
{{ formattedDate }}
</span>
</div>
<Button
@@ -162,7 +159,7 @@ const emit = defineEmits<{
showInfo: [asset: AssetDisplayItem]
}>()
const { t } = useI18n()
const { t, d } = useI18n()
const settingStore = useSettingStore()
const { closeDialog } = useDialogStore()
const { isDownloadedThisSession, acknowledgeAsset } = useAssetDownloadStore()
@@ -176,6 +173,15 @@ const descId = useId()
const displayName = computed(() => getAssetCardTitle(asset))
// Format at render so locale switches re-flow; the upstream WeakMap caches
// AssetItem -> AssetDisplayItem by reference, which would otherwise pin the
// formatted string to whichever locale was active when first transformed.
const formattedDate = computed(() =>
asset.created_at
? d(new Date(asset.created_at), { dateStyle: 'short' })
: undefined
)
const isNewlyImported = computed(() => isDownloadedThisSession(asset.id))
const showAssetOptions = computed(() => !(asset.is_immutable ?? true))

View File

@@ -12,6 +12,7 @@
v-model="activeFileFormatObjects"
:label="$t('assetBrowser.fileFormats')"
:options="availableFileFormats"
:content-style="contentStyle"
class="min-w-32"
data-component-id="asset-filter-file-formats"
@update:model-value="handleFilterChange"
@@ -22,6 +23,7 @@
v-model="activeBaseModelObjects"
:label="$t('assetBrowser.baseModels')"
:options="availableBaseModels"
:content-style="contentStyle"
class="min-w-32"
data-component-id="asset-filter-base-models"
@update:model-value="handleFilterChange"
@@ -32,6 +34,7 @@
v-model="ownership"
:label="$t('assetBrowser.ownership')"
:options="ownershipOptions"
:content-style="contentStyle"
class="min-w-32"
data-component-id="asset-filter-ownership"
@update:model-value="handleFilterChange"
@@ -43,6 +46,7 @@
v-model="sortBy"
:label="$t('assetBrowser.sortBy')"
:options="sortOptions"
:content-style="contentStyle"
class="min-w-32"
data-component-id="asset-filter-sort"
@update:model-value="handleFilterChange"
@@ -57,6 +61,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { StyleValue } from 'vue'
import { useI18n } from 'vue-i18n'
import MultiSelect from '@/components/ui/multi-select/MultiSelect.vue'
@@ -78,9 +83,14 @@ const sortOptions = computed(() => [
{ name: t('assetBrowser.sortZA'), value: 'name-desc' as const }
])
const { assets = [], showOwnershipFilter = false } = defineProps<{
const {
assets = [],
showOwnershipFilter = false,
contentStyle
} = defineProps<{
assets?: AssetItem[]
showOwnershipFilter?: boolean
contentStyle?: StyleValue
}>()
const selectedFileFormats = ref<SelectOption[]>([])

View File

@@ -1,5 +1,8 @@
<template>
<div class="flex flex-col gap-4 text-sm text-muted-foreground">
<div
:ref="primeVueOverlay.overlayScopeRef"
class="flex flex-col gap-4 text-sm text-muted-foreground"
>
<div class="flex flex-col gap-2">
<p class="m-0">
{{ $t('assetBrowser.modelAssociatedWithLink') }}
@@ -39,6 +42,7 @@
"
:options="modelTypes"
:disabled="isLoading"
:content-style="selectContentStyle"
data-attr="upload-model-step2-type-selector"
/>
</div>
@@ -47,6 +51,7 @@
<script setup lang="ts">
import SingleSelect from '@/components/ui/single-select/SingleSelect.vue'
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
@@ -58,4 +63,6 @@ defineProps<{
const modelValue = defineModel<string | undefined>()
const { modelTypes, isLoading } = useModelTypes()
const primeVueOverlay = usePrimeVueOverlayChildStyle()
const selectContentStyle = primeVueOverlay.contentStyle
</script>

View File

@@ -77,7 +77,7 @@
:placeholder="t('assetBrowser.modelInfo.selectModelType')"
/>
</SelectTrigger>
<SelectContent>
<SelectContent :style="selectContentStyle">
<SelectItem
v-for="option in modelTypes"
:key="option.value"
@@ -210,6 +210,7 @@
<script setup lang="ts">
import { useDebounceFn } from '@vueuse/core'
import { computed, ref, useTemplateRef, watch } from 'vue'
import type { StyleValue } from 'vue'
import { useI18n } from 'vue-i18n'
import EditableText from '@/components/common/EditableText.vue'
@@ -257,9 +258,10 @@ const accordionClass = cn(
'border-t border-border-default bg-modal-panel-background'
)
const { asset, cacheKey } = defineProps<{
const { asset, cacheKey, selectContentStyle } = defineProps<{
asset: AssetDisplayItem
cacheKey?: string
selectContentStyle?: StyleValue
}>()
const assetsStore = useAssetsStore()

View File

@@ -0,0 +1,113 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import * as assetMetadataUtils from '@/platform/assets/utils/assetMetadataUtils'
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
})
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key,
d: (date: Date) => date.toLocaleDateString()
}))
const ASSET_COUNT = 200
const CATEGORIES = ['inputs', 'outputs'] as const
const TAB_SWITCHES = 6
function makeAsset(index: number): AssetItem {
const category = CATEGORIES[index % CATEGORIES.length]
return {
id: `asset-${index}`,
name: `asset-${index}.safetensors`,
asset_hash: `blake3:${index}`,
size: 1024,
mime_type: 'application/octet-stream',
tags: ['models', category],
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
last_access_time: '2024-01-01T00:00:00Z',
is_immutable: false
}
}
describe('useAssetBrowser - filter tab switching perf (FE-229)', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.restoreAllMocks()
})
it('does not re-transform every asset on each filter tab switch', async () => {
const assets = Array.from({ length: ASSET_COUNT }, (_, i) => makeAsset(i))
const filenameSpy = vi.spyOn(assetMetadataUtils, 'getAssetFilename')
const { selectedNavItem, filteredAssets } = useAssetBrowser(ref(assets))
// Initial materialization of the 'all' tab.
void filteredAssets.value
await nextTick()
const baselineCalls = filenameSpy.mock.calls.length
// Simulate the user clicking back and forth between All / Inputs / Outputs.
const tabs: ('all' | 'inputs' | 'outputs')[] = [
'inputs',
'outputs',
'all',
'inputs',
'outputs',
'all'
]
expect(tabs).toHaveLength(TAB_SWITCHES)
for (const tab of tabs) {
selectedNavItem.value = tab
void filteredAssets.value
await nextTick()
}
const switchCalls = filenameSpy.mock.calls.length - baselineCalls
// Naive (no memoization) cost is approximately:
// inputs (100) + outputs (100) + all (200) + inputs (100) + outputs (100) + all (200) = 800.
// With per-asset memoization the same asset object should never be transformed twice,
// so total work across all tab switches must stay within a small multiple of ASSET_COUNT.
const budget = ASSET_COUNT * 2
expect(switchCalls).toBeLessThanOrEqual(budget)
})
it('returns identical display item references for unchanged assets across tab switches', async () => {
const assets = Array.from({ length: ASSET_COUNT }, (_, i) => makeAsset(i))
const { selectedNavItem, filteredAssets } = useAssetBrowser(ref(assets))
const firstAllSnapshot = new Map(
filteredAssets.value.map((item) => [item.id, item])
)
await nextTick()
selectedNavItem.value = 'inputs'
void filteredAssets.value
await nextTick()
selectedNavItem.value = 'all'
const secondAll = filteredAssets.value
await nextTick()
// If transformAssetForDisplay is memoized per asset, the display items for
// the unchanged underlying assets should be the very same object identity
// when we navigate back to 'all'. Without memoization every re-render
// produces brand-new objects, which forces downstream components
// (AssetGrid / AssetCard) to re-render every card.
const reusedReferences = secondAll.filter(
(item) => firstAllSnapshot.get(item.id) === item
).length
expect(reusedReferences).toBe(ASSET_COUNT)
})
})

View File

@@ -4,7 +4,7 @@ import { useFuse } from '@vueuse/integrations/useFuse'
import type { UseFuseOptions } from '@vueuse/integrations/useFuse'
import { storeToRefs } from 'pinia'
import { d, t } from '@/i18n'
import { t } from '@/i18n'
import type {
AssetFilterState,
OwnershipOption
@@ -38,12 +38,51 @@ export interface AssetDisplayItem extends AssetItem {
secondaryText: string
badges: AssetBadge[]
stats: {
formattedDate?: string
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
@@ -82,46 +121,6 @@ export function useAssetBrowser(
return selectedNavItem.value
})
// Transform API asset to display asset
function transformAssetForDisplay(asset: AssetItem): AssetDisplayItem {
const secondaryText = getAssetFilename(asset)
const badges: AssetBadge[] = []
const typeTag = asset.tags.find((tag) => tag !== 'models')
// Type badge from non-root tag
if (typeTag) {
// Remove category prefix from badge label (e.g. "checkpoint/model" → "model")
const badgeLabel = typeTag.includes('/')
? typeTag.substring(typeTag.indexOf('/') + 1)
: typeTag
badges.push({ label: badgeLabel, type: 'type' })
}
// Base model badges from metadata
const baseModels = getAssetBaseModels(asset)
for (const model of baseModels) {
badges.push({ label: model, type: 'base' })
}
// Create display stats from API data
const stats = {
formattedDate: asset.created_at
? d(new Date(asset.created_at), { dateStyle: 'short' })
: undefined,
downloadCount: undefined, // Not available in API
stars: undefined // Not available in API
}
return {
...asset,
secondaryText,
badges,
stats
}
}
const typeCategories = computed<NavItemData[]>(() => {
const categories = assets.value
.filter((asset) => asset.tags.includes(MODELS_TAG))

View File

@@ -14,16 +14,21 @@
</template>
<template #header>
<div class="flex w-full items-center justify-between gap-2">
<div
:ref="primeVueOverlay.overlayScopeRef"
class="flex w-full items-center justify-between gap-2"
>
<div class="flex w-full items-center gap-2">
<SingleSelect
v-model="searchMode"
class="min-w-34"
:options="filterOptions"
:content-style="selectContentStyle"
/>
<SearchAutocomplete
v-model="searchQuery"
:suggestions="suggestions"
:content-style="selectContentStyle"
:placeholder="$t('manager.searchPlaceholder')"
option-label="query"
autofocus
@@ -87,6 +92,7 @@
v-model="sortField"
:label="$t('g.sort')"
:options="availableSortOptions"
:content-style="selectContentStyle"
class="w-48"
>
<template #icon>
@@ -163,6 +169,7 @@ import Button from '@/components/ui/button/Button.vue'
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
import { useExternalLink } from '@/composables/useExternalLink'
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import type { components } from '@/types/comfyRegistryTypes'
@@ -197,6 +204,8 @@ const { initialTab, initialPackId, onClose } = defineProps<{
provide(OnCloseKey, onClose)
const { t } = useI18n()
const primeVueOverlay = usePrimeVueOverlayChildStyle()
const selectContentStyle = primeVueOverlay.contentStyle
const { buildDocsUrl } = useExternalLink()
const comfyManagerStore = useComfyManagerStore()
const { getPackById } = useComfyRegistryStore()