From b54b8624b4a852a136472e8189e41dcff9eca7d3 Mon Sep 17 00:00:00 2001 From: bymyself Date: Fri, 13 Feb 2026 20:58:04 -0800 Subject: [PATCH] perf: virtualize FormDropdownMenu and fix VirtualGrid jitter - Integrate VirtualGrid into FormDropdownMenu for virtualized rendering - Fix jitter: overflow-anchor:none, scrollbar-gutter:stable, cols=maxColumns when finite - Remove transition-[width] from grid items, replace transition-all with explicit properties - Replace LazyImage with native img (redundant with virtualization) - Change max-h-[640px] to fixed h-[640px] for proper virtualization - Add unit tests for VirtualGrid and FormDropdownMenu - Add E2E test for image dropdown virtualization Amp-Thread-ID: https://ampcode.com/threads/T-019c5a71-66c8-76e9-95ed-671a1b4538da --- .../imageDropdownVirtualization.spec.ts | 49 +++++ src/components/common/VirtualGrid.test.ts | 180 ++++++++++++++++++ src/components/common/VirtualGrid.vue | 17 +- .../form/dropdown/FormDropdownMenu.test.ts | 113 +++++++++++ .../form/dropdown/FormDropdownMenu.vue | 98 ++++++---- .../form/dropdown/FormDropdownMenuItem.vue | 10 +- 6 files changed, 421 insertions(+), 46 deletions(-) create mode 100644 browser_tests/tests/vueNodes/widgets/imageDropdownVirtualization.spec.ts 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/browser_tests/tests/vueNodes/widgets/imageDropdownVirtualization.spec.ts b/browser_tests/tests/vueNodes/widgets/imageDropdownVirtualization.spec.ts new file mode 100644 index 0000000000..6db9594b88 --- /dev/null +++ b/browser_tests/tests/vueNodes/widgets/imageDropdownVirtualization.spec.ts @@ -0,0 +1,49 @@ +import { + comfyExpect as expect, + comfyPageFixture as test +} from '../../../fixtures/ComfyPage' + +test.describe('Image Dropdown Virtualization', { tag: '@widget' }, () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.VueNodes.Enabled', true) + await comfyPage.vueNodes.waitForNodes() + }) + + test('should virtualize items when dropdown has many entries', async ({ + comfyPage + }) => { + await comfyPage.loadWorkflow('widgets/load_image_widget') + await comfyPage.vueNodes.waitForNodes() + + const totalItems = await comfyPage.page.evaluate(() => { + const node = window['graph']._nodes_by_id['10'] + const widget = node.widgets.find( + (w: { name: string }) => w.name === 'image' + ) + const count = 60 + const values = Array.from( + { length: count }, + (_, i) => `test_image_${i}.png` + ) + widget.options.values = values + widget.value = values[0] + return count + }) + + const loadImageNode = comfyPage.vueNodes.getNodeByTitle('Load Image') + const dropdownButton = loadImageNode.locator( + 'button:has(span:has-text("test_image_0.png"))' + ) + await dropdownButton.waitFor({ state: 'visible' }) + await dropdownButton.click() + + const virtualGridItems = comfyPage.page.locator( + '[data-virtual-grid-item]' + ) + await expect(virtualGridItems.first()).toBeVisible() + + const renderedCount = await virtualGridItems.count() + expect(renderedCount).toBeLessThan(totalItems) + expect(renderedCount).toBeGreaterThan(0) + }) +}) diff --git a/src/components/common/VirtualGrid.test.ts b/src/components/common/VirtualGrid.test.ts new file mode 100644 index 0000000000..8b11e092e9 --- /dev/null +++ b/src/components/common/VirtualGrid.test.ts @@ -0,0 +1,180 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import { nextTick, ref } from 'vue' + +import VirtualGrid from './VirtualGrid.vue' + +type TestItem = { key: string; name: string } + +const mockedWidth = ref(400) +const mockedHeight = ref(200) +const mockedScrollY = ref(0) + +vi.mock('@vueuse/core', async () => { + const actual = await vi.importActual>('@vueuse/core') + return { + ...actual, + useElementSize: () => ({ width: mockedWidth, height: mockedHeight }), + useScroll: () => ({ y: mockedScrollY }) + } +}) + +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) + }) + + 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 fb6b4374c0..42d51e4bd0 100644 --- a/src/components/common/VirtualGrid.vue +++ b/src/components/common/VirtualGrid.vue @@ -1,17 +1,16 @@