perf: virtualize FormDropdownMenu to reduce DOM nodes and image requests (#8476)

## Summary

Virtualize the FormDropdownMenu to only render visible items, fixing
slow dropdown performance on cloud.

## Changes

- Integrate `VirtualGrid` into `FormDropdownMenu` for virtualized
rendering
- Add computed properties for grid configuration per layout mode
(grid/list/list-small)
- Extend `VirtualGrid` slot to provide original item index for O(1)
lookups
- Change container from `max-h-[640px]` to fixed `h-[640px]` for proper
virtualization

## Review Focus

- VirtualGrid integration within the popover context
- Layout mode switching with `:key="layoutMode"` to force re-render
- Grid style computed properties match original Tailwind classes

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8476-perf-virtualize-FormDropdownMenu-to-reduce-DOM-nodes-and-image-requests-2f86d73d365081b3a79dd5e0b84df944)
by [Unito](https://www.unito.io)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Dropdowns now render with a virtualized grid/list (stable indexes,
responsive sizing) and show an empty-state icon when no items exist.

* **Bug Fixes**
* Reduced layout shift and rendering glitches with improved
spacer/scroll calculations and more reliable media measurement.

* **Style**
* Simplified media rendering (standard img/video), unified item visuals
and hover/background behavior.

* **Tests**
* Added unit and end-to-end tests for virtualization, indexing, layouts,
dynamic updates, and empty states.

* **Breaking Changes**
* Dropdown item/selection shapes and related component props/events were
updated (adapter changes may be required).
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Christian Byrne
2026-02-21 22:54:19 -08:00
committed by GitHub
parent f707098f05
commit 1dcaf5d0dc
8 changed files with 400 additions and 52 deletions

View File

@@ -0,0 +1,189 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Ref } from 'vue'
import { nextTick, ref } from 'vue'
import VirtualGrid from './VirtualGrid.vue'
type TestItem = { key: string; name: string }
let mockedWidth: Ref<number>
let mockedHeight: Ref<number>
let mockedScrollY: Ref<number>
vi.mock('@vueuse/core', async () => {
const actual = await vi.importActual<Record<string, unknown>>('@vueuse/core')
return {
...actual,
useElementSize: () => ({ width: mockedWidth, height: mockedHeight }),
useScroll: () => ({ y: mockedScrollY })
}
})
beforeEach(() => {
mockedWidth = ref(400)
mockedHeight = ref(200)
mockedScrollY = ref(0)
})
function createItems(count: number): TestItem[] {
return Array.from({ length: count }, (_, i) => ({
key: `item-${i}`,
name: `Item ${i}`
}))
}
describe('VirtualGrid', () => {
const defaultGridStyle = {
display: 'grid',
gridTemplateColumns: 'repeat(4, 1fr)',
gap: '1rem'
}
it('renders items within the visible range', async () => {
const items = createItems(100)
mockedWidth.value = 400
mockedHeight.value = 200
mockedScrollY.value = 0
const wrapper = mount(VirtualGrid<TestItem>, {
props: {
items,
gridStyle: defaultGridStyle,
defaultItemHeight: 100,
defaultItemWidth: 100,
maxColumns: 4,
bufferRows: 1
},
slots: {
item: `<template #item="{ item }">
<div class="test-item">{{ item.name }}</div>
</template>`
},
attachTo: document.body
})
await nextTick()
const renderedItems = wrapper.findAll('.test-item')
expect(renderedItems.length).toBeGreaterThan(0)
expect(renderedItems.length).toBeLessThan(items.length)
wrapper.unmount()
})
it('provides correct index in slot props', async () => {
const items = createItems(20)
const receivedIndices: number[] = []
mockedWidth.value = 400
mockedHeight.value = 200
mockedScrollY.value = 0
const wrapper = mount(VirtualGrid<TestItem>, {
props: {
items,
gridStyle: defaultGridStyle,
defaultItemHeight: 50,
defaultItemWidth: 100,
maxColumns: 1,
bufferRows: 0
},
slots: {
item: ({ index }: { index: number }) => {
receivedIndices.push(index)
return null
}
},
attachTo: document.body
})
await nextTick()
expect(receivedIndices.length).toBeGreaterThan(0)
expect(receivedIndices[0]).toBe(0)
for (let i = 1; i < receivedIndices.length; i++) {
expect(receivedIndices[i]).toBe(receivedIndices[i - 1] + 1)
}
wrapper.unmount()
})
it('respects maxColumns prop', async () => {
const items = createItems(10)
mockedWidth.value = 400
mockedHeight.value = 200
mockedScrollY.value = 0
const wrapper = mount(VirtualGrid<TestItem>, {
props: {
items,
gridStyle: defaultGridStyle,
maxColumns: 2
},
attachTo: document.body
})
await nextTick()
const gridElement = wrapper.find('[style*="display: grid"]')
expect(gridElement.exists()).toBe(true)
const gridEl = gridElement.element as HTMLElement
expect(gridEl.style.gridTemplateColumns).toBe('repeat(2, minmax(0, 1fr))')
wrapper.unmount()
})
it('renders empty when no items provided', async () => {
const wrapper = mount(VirtualGrid<TestItem>, {
props: {
items: [],
gridStyle: defaultGridStyle
},
slots: {
item: `<template #item="{ item }">
<div class="test-item">{{ item.name }}</div>
</template>`
}
})
await nextTick()
const renderedItems = wrapper.findAll('.test-item')
expect(renderedItems.length).toBe(0)
wrapper.unmount()
})
it('forces cols to maxColumns when maxColumns is finite', async () => {
mockedWidth.value = 100
mockedHeight.value = 200
mockedScrollY.value = 0
const items = createItems(20)
const wrapper = mount(VirtualGrid<TestItem>, {
props: {
items,
gridStyle: defaultGridStyle,
defaultItemHeight: 50,
defaultItemWidth: 200,
maxColumns: 4,
bufferRows: 0
},
slots: {
item: `<template #item="{ item }">
<div class="test-item">{{ item.name }}</div>
</template>`
},
attachTo: document.body
})
await nextTick()
const renderedItems = wrapper.findAll('.test-item')
expect(renderedItems.length).toBeGreaterThan(0)
expect(renderedItems.length % 4).toBe(0)
wrapper.unmount()
})
})

View File

@@ -1,17 +1,16 @@
<template> <template>
<div <div
ref="container" ref="container"
class="h-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-(--dialog-surface)" class="h-full overflow-y-auto [overflow-anchor:none] [scrollbar-gutter:stable] scrollbar-thin scrollbar-track-transparent scrollbar-thumb-(--dialog-surface)"
> >
<div :style="topSpacerStyle" /> <div :style="topSpacerStyle" />
<div :style="mergedGridStyle"> <div :style="mergedGridStyle">
<div <div
v-for="item in renderedItems" v-for="(item, i) in renderedItems"
:key="item.key" :key="item.key"
class="transition-[width] duration-150 ease-out"
data-virtual-grid-item data-virtual-grid-item
> >
<slot name="item" :item="item" /> <slot name="item" :item :index="state.start + i" />
</div> </div>
</div> </div>
<div :style="bottomSpacerStyle" /> <div :style="bottomSpacerStyle" />
@@ -66,9 +65,10 @@ const { y: scrollY } = useScroll(container, {
eventListenerOptions: { passive: true } eventListenerOptions: { passive: true }
}) })
const cols = computed(() => const cols = computed(() => {
Math.min(Math.floor(width.value / itemWidth.value) || 1, maxColumns) if (maxColumns !== Infinity) return maxColumns
) return Math.floor(width.value / itemWidth.value) || 1
})
const mergedGridStyle = computed<CSSProperties>(() => { const mergedGridStyle = computed<CSSProperties>(() => {
if (maxColumns === Infinity) return gridStyle if (maxColumns === Infinity) return gridStyle
@@ -101,8 +101,9 @@ const renderedItems = computed(() =>
isValidGrid.value ? items.slice(state.value.start, state.value.end) : [] isValidGrid.value ? items.slice(state.value.start, state.value.end) : []
) )
function rowsToHeight(rows: number): string { function rowsToHeight(itemsCount: number): string {
return `${(rows / cols.value) * itemHeight.value}px` const rows = Math.ceil(itemsCount / cols.value)
return `${rows * itemHeight.value}px`
} }
const topSpacerStyle = computed<CSSProperties>(() => ({ const topSpacerStyle = computed<CSSProperties>(() => ({
height: rowsToHeight(state.value.start) height: rowsToHeight(state.value.start)
@@ -118,11 +119,10 @@ whenever(
} }
) )
const updateItemSize = () => { function updateItemSize(): void {
if (container.value) { if (container.value) {
const firstItem = container.value.querySelector('[data-virtual-grid-item]') const firstItem = container.value.querySelector('[data-virtual-grid-item]')
// Don't update item size if the first item is not rendered yet
if (!firstItem?.clientHeight || !firstItem?.clientWidth) return if (!firstItem?.clientHeight || !firstItem?.clientWidth) return
if (itemHeight.value !== firstItem.clientHeight) { if (itemHeight.value !== firstItem.clientHeight) {

View File

@@ -2459,7 +2459,8 @@
"placeholderVideo": "Select video...", "placeholderVideo": "Select video...",
"placeholderMesh": "Select mesh...", "placeholderMesh": "Select mesh...",
"placeholderModel": "Select model...", "placeholderModel": "Select model...",
"placeholderUnknown": "Select media..." "placeholderUnknown": "Select media...",
"maxSelectionReached": "Maximum selection limit reached"
}, },
"valueControl": { "valueControl": {
"header": { "header": {

View File

@@ -161,7 +161,7 @@ function handleSelection(item: FormDropdownItem, index: number) {
sel.clear() sel.clear()
sel.add(item.id) sel.add(item.id)
} else { } else {
toastStore.addAlert(`Maximum selection limit reached`) toastStore.addAlert(t('widgets.uploadSelect.maxSelectionReached'))
return return
} }
} }

View File

@@ -49,7 +49,7 @@ const theButtonStyle = computed(() =>
<div <div
:class=" :class="
cn(WidgetInputBaseClass, 'flex text-base leading-none', { cn(WidgetInputBaseClass, 'flex text-base leading-none', {
'opacity-50 cursor-not-allowed !outline-zinc-300/10': disabled 'opacity-50 cursor-not-allowed outline-zinc-300/10': disabled
}) })
" "
> >

View File

@@ -0,0 +1,113 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import FormDropdownMenu from './FormDropdownMenu.vue'
import type { FormDropdownItem, LayoutMode } from './types'
function createItem(id: string, name: string): FormDropdownItem {
return {
id,
preview_url: '',
name,
label: name
}
}
describe('FormDropdownMenu', () => {
const defaultProps = {
items: [createItem('1', 'Item 1'), createItem('2', 'Item 2')],
isSelected: () => false,
filterOptions: [],
sortOptions: []
}
it('renders empty state when no items', async () => {
const wrapper = mount(FormDropdownMenu, {
props: {
...defaultProps,
items: []
},
global: {
stubs: {
FormDropdownMenuFilter: true,
FormDropdownMenuActions: true,
VirtualGrid: true
},
mocks: {
$t: (key: string) => key
}
}
})
await nextTick()
const emptyIcon = wrapper.find('.icon-\\[lucide--circle-off\\]')
expect(emptyIcon.exists()).toBe(true)
})
it('renders VirtualGrid when items exist', async () => {
const wrapper = mount(FormDropdownMenu, {
props: defaultProps,
global: {
stubs: {
FormDropdownMenuFilter: true,
FormDropdownMenuActions: true,
VirtualGrid: true
}
}
})
await nextTick()
const virtualGrid = wrapper.findComponent({ name: 'VirtualGrid' })
expect(virtualGrid.exists()).toBe(true)
})
it('transforms items to include key property for VirtualGrid', async () => {
const items = [createItem('1', 'Item 1'), createItem('2', 'Item 2')]
const wrapper = mount(FormDropdownMenu, {
props: {
...defaultProps,
items
},
global: {
stubs: {
FormDropdownMenuFilter: true,
FormDropdownMenuActions: true,
VirtualGrid: true
}
}
})
await nextTick()
const virtualGrid = wrapper.findComponent({ name: 'VirtualGrid' })
const virtualItems = virtualGrid.props('items')
expect(virtualItems).toHaveLength(2)
expect(virtualItems[0]).toHaveProperty('key', '1')
expect(virtualItems[1]).toHaveProperty('key', '2')
})
it('uses single column layout for list modes', async () => {
const wrapper = mount(FormDropdownMenu, {
props: {
...defaultProps,
layoutMode: 'list' as LayoutMode
},
global: {
stubs: {
FormDropdownMenuFilter: true,
FormDropdownMenuActions: true,
VirtualGrid: true
}
}
})
await nextTick()
const virtualGrid = wrapper.findComponent({ name: 'VirtualGrid' })
expect(virtualGrid.props('maxColumns')).toBe(1)
})
})

View File

@@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { MaybeRefOrGetter } from 'vue' import type { CSSProperties, MaybeRefOrGetter } from 'vue'
import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil' import VirtualGrid from '@/components/common/VirtualGrid.vue'
import type { import type {
FilterOption, FilterOption,
@@ -30,7 +31,18 @@ interface Props {
baseModelOptions?: FilterOption[] baseModelOptions?: FilterOption[]
} }
defineProps<Props>() const {
items,
isSelected,
filterOptions,
sortOptions,
searcher,
updateKey,
showOwnershipFilter,
ownershipOptions,
showBaseModelFilter,
baseModelOptions
} = defineProps<Props>()
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'item-click', item: FormDropdownItem, index: number): void (e: 'item-click', item: FormDropdownItem, index: number): void
}>() }>()
@@ -41,11 +53,48 @@ const sortSelected = defineModel<string>('sortSelected')
const searchQuery = defineModel<string>('searchQuery') const searchQuery = defineModel<string>('searchQuery')
const ownershipSelected = defineModel<OwnershipOption>('ownershipSelected') const ownershipSelected = defineModel<OwnershipOption>('ownershipSelected')
const baseModelSelected = defineModel<Set<string>>('baseModelSelected') const baseModelSelected = defineModel<Set<string>>('baseModelSelected')
type LayoutConfig = {
maxColumns: number
itemHeight: number
itemWidth: number
gap: string
}
const LAYOUT_CONFIGS: Record<LayoutMode, LayoutConfig> = {
grid: { maxColumns: 4, itemHeight: 120, itemWidth: 89, gap: '1rem 0.5rem' },
list: { maxColumns: 1, itemHeight: 64, itemWidth: 380, gap: '0.5rem' },
'list-small': {
maxColumns: 1,
itemHeight: 40,
itemWidth: 380,
gap: '0.25rem'
}
}
const layoutConfig = computed<LayoutConfig>(
() => LAYOUT_CONFIGS[layoutMode.value ?? 'grid']
)
const gridStyle = computed<CSSProperties>(() => ({
display: 'grid',
gap: layoutConfig.value.gap,
padding: '1rem',
width: '100%'
}))
type VirtualDropdownItem = FormDropdownItem & { key: string }
const virtualItems = computed<VirtualDropdownItem[]>(() =>
items.map((item) => ({
...item,
key: String(item.id)
}))
)
</script> </script>
<template> <template>
<div <div
class="flex max-h-[640px] w-103 flex-col rounded-lg bg-component-node-background pt-4 outline outline-offset-[-1px] outline-node-component-border" class="flex h-[640px] w-103 flex-col rounded-lg bg-component-node-background pt-4 outline outline-offset-[-1px] outline-node-component-border"
> >
<FormDropdownMenuFilter <FormDropdownMenuFilter
v-if="filterOptions.length > 0" v-if="filterOptions.length > 0"
@@ -66,34 +115,30 @@ const baseModelSelected = defineModel<Set<string>>('baseModelSelected')
:show-base-model-filter :show-base-model-filter
:base-model-options :base-model-options
/> />
<div class="relative flex h-full mt-2 overflow-y-scroll"> <div
<div v-if="items.length === 0"
:class=" class="flex h-50 items-center justify-center"
cn( >
'h-full max-h-full grid gap-x-2 gap-y-4 overflow-y-auto px-4 pt-4 pb-4 w-full', <i
{ :title="$t('g.noItems')"
'grid-cols-4': layoutMode === 'grid', :aria-label="$t('g.noItems')"
'grid-cols-1 gap-y-2': layoutMode === 'list', class="icon-[lucide--circle-off] size-30 text-muted-foreground/20"
'grid-cols-1 gap-y-1': layoutMode === 'list-small' />
} </div>
) <VirtualGrid
" v-else
> :key="layoutMode"
<div class="pointer-events-none absolute inset-x-3 top-0 z-10 h-5" /> :items="virtualItems"
<div :grid-style
v-if="items.length === 0" :max-columns="layoutConfig.maxColumns"
class="h-50 col-span-full flex items-center justify-center" :default-item-height="layoutConfig.itemHeight"
> :default-item-width="layoutConfig.itemWidth"
<i :buffer-rows="2"
:title="$t('g.noItems')" class="mt-2 min-h-0 flex-1"
:aria-label="$t('g.noItems')" >
class="icon-[lucide--circle-off] size-30 text-zinc-500/20" <template #item="{ item, index }">
/>
</div>
<FormDropdownMenuItem <FormDropdownMenuItem
v-for="(item, index) in items" :index
:key="item.id"
:index="index"
:selected="isSelected(item, index)" :selected="isSelected(item, index)"
:preview-url="item.preview_url ?? ''" :preview-url="item.preview_url ?? ''"
:name="item.name" :name="item.name"
@@ -101,7 +146,7 @@ const baseModelSelected = defineModel<Set<string>>('baseModelSelected')
:layout="layoutMode" :layout="layoutMode"
@click="emit('item-click', item, index)" @click="emit('item-click', item, index)"
/> />
</div> </template>
</div> </VirtualGrid>
</div> </div>
</template> </template>

View File

@@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, inject, ref } from 'vue' import { computed, inject, ref } from 'vue'
import LazyImage from '@/components/common/LazyImage.vue'
import { cn } from '@/utils/tailwindUtil' import { cn } from '@/utils/tailwindUtil'
import { AssetKindKey } from './types' import { AssetKindKey } from './types'
@@ -57,7 +56,7 @@ function handleVideoLoad(event: Event) {
:class=" :class="
cn( cn(
'flex gap-1 select-none group/item cursor-pointer bg-component-node-widget-background', 'flex gap-1 select-none group/item cursor-pointer bg-component-node-widget-background',
'transition-all duration-150', 'transition-[transform,box-shadow,background-color] duration-150',
{ {
'flex-col text-center': layout === 'grid', 'flex-col text-center': layout === 'grid',
'flex-row text-left max-h-16 rounded-lg hover:scale-102 active:scale-98': 'flex-row text-left max-h-16 rounded-lg hover:scale-102 active:scale-98':
@@ -79,7 +78,7 @@ function handleVideoLoad(event: Event) {
cn( cn(
'relative', 'relative',
'w-full aspect-square overflow-hidden outline-1 outline-offset-[-1px] outline-interface-stroke', 'w-full aspect-square overflow-hidden outline-1 outline-offset-[-1px] outline-interface-stroke',
'transition-all duration-150', 'transition-[transform,box-shadow] duration-150',
{ {
'min-w-16 max-w-16 rounded-l-lg': layout === 'list', 'min-w-16 max-w-16 rounded-l-lg': layout === 'list',
'rounded-sm group-hover/item:scale-108 group-active/item:scale-95': 'rounded-sm group-hover/item:scale-108 group-active/item:scale-95':
@@ -108,11 +107,12 @@ function handleVideoLoad(event: Event) {
muted muted
@loadeddata="handleVideoLoad" @loadeddata="handleVideoLoad"
/> />
<LazyImage <img
v-else-if="previewUrl" v-else-if="previewUrl"
:src="previewUrl" :src="previewUrl"
:alt="name" :alt="name"
image-class="size-full object-cover" draggable="false"
class="size-full object-cover"
@load="handleImageLoad" @load="handleImageLoad"
/> />
<div <div