From 1dcaf5d0dc966448744995ea01c7d9ddc7736811 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Sat, 21 Feb 2026 22:54:19 -0800 Subject: [PATCH] perf: virtualize FormDropdownMenu to reduce DOM nodes and image requests (#8476) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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) ## 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). --------- Co-authored-by: GitHub Action --- src/components/common/VirtualGrid.test.ts | 189 ++++++++++++++++++ src/components/common/VirtualGrid.vue | 22 +- src/locales/en/main.json | 3 +- .../components/form/dropdown/FormDropdown.vue | 2 +- .../form/dropdown/FormDropdownInput.vue | 2 +- .../form/dropdown/FormDropdownMenu.test.ts | 113 +++++++++++ .../form/dropdown/FormDropdownMenu.vue | 111 +++++++--- .../form/dropdown/FormDropdownMenuItem.vue | 10 +- 8 files changed, 400 insertions(+), 52 deletions(-) create mode 100644 src/components/common/VirtualGrid.test.ts create mode 100644 src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenu.test.ts diff --git a/src/components/common/VirtualGrid.test.ts b/src/components/common/VirtualGrid.test.ts new file mode 100644 index 000000000..b76640a7f --- /dev/null +++ b/src/components/common/VirtualGrid.test.ts @@ -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 +let mockedHeight: Ref +let mockedScrollY: Ref + +vi.mock('@vueuse/core', async () => { + const actual = await vi.importActual>('@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, { + props: { + items, + gridStyle: defaultGridStyle, + defaultItemHeight: 100, + defaultItemWidth: 100, + maxColumns: 4, + bufferRows: 1 + }, + slots: { + item: `` + }, + 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, { + 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, { + 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, { + props: { + items: [], + gridStyle: defaultGridStyle + }, + slots: { + item: `` + } + }) + + 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, { + props: { + items, + gridStyle: defaultGridStyle, + defaultItemHeight: 50, + defaultItemWidth: 200, + maxColumns: 4, + bufferRows: 0 + }, + slots: { + item: `` + }, + attachTo: document.body + }) + + await nextTick() + + const renderedItems = wrapper.findAll('.test-item') + expect(renderedItems.length).toBeGreaterThan(0) + expect(renderedItems.length % 4).toBe(0) + + wrapper.unmount() + }) +}) diff --git a/src/components/common/VirtualGrid.vue b/src/components/common/VirtualGrid.vue index fb6b4374c..4361f8b26 100644 --- a/src/components/common/VirtualGrid.vue +++ b/src/components/common/VirtualGrid.vue @@ -1,17 +1,16 @@