Compare commits

...

5 Commits

Author SHA1 Message Date
dante01yoon
9e87042a4f test(form-dropdown): replace fragile selectors in FE-535 spec
Adds data-testid to FormDropdownInput trigger, FormDropdownMenu root,
and forwards data-testid="form-dropdown-list" to VirtualGrid's scroll
container. The reopen spec drops the :has(...) / nth(2) CSS chains in
favor of those testids and reuses comfyPage.nodeOps.addNode.
2026-05-13 18:10:32 +09:00
dante01yoon
1d6377c5d5 fix(form-dropdown): lock VirtualGrid row height to prevent image-load stutter
Preview tiles grew vertically after each <img> resolved because
FormDropdownMenuItem appends an actualDimensions span on load, shifting
every row beneath while the user scrolls. Pin grid-auto-rows to the
configured itemHeight so the layout matches the virtualizer's row
estimate regardless of cell content.
2026-05-13 18:10:06 +09:00
dante01yoon
a280d072cf Merge remote-tracking branch 'origin/main' into jaewon/fe-535-virtual-grid-blank-on-scroll 2026-05-08 11:27:43 +09:00
dante01yoon
7e251d29d3 fix(virtual-grid): guard isNearEnd during width=0 mount transient
ManagerDialog tests in CI failed with duplicate PackCard rendering: the
same Test Pack appeared 2-3 times in the dialog. Root cause is a
spurious approach-end emit during mount.

When the container is briefly zero-sized at mount, cols falls back to 1
(via the `floor(width / itemWidth) || 1` guard). A small list then
appears near-end, isNearEnd transitions false→true, and `whenever`
fires approach-end. ManagerDialog's handler is `pageNumber++`, so the
same mock response is fetched again and appended to displayPacks,
producing duplicate items in the grid.

The hand-rolled VirtualGrid had `isValidGrid = height && width &&
items.length` and gated renderedItems on it. Restore the same guard
for both `state` (so isNearEnd stays false) and `renderedItems` (so
no rendering happens before the container is sized).

Adds a regression unit test that simulates the width=0→width=800
mount transient with a small list and asserts approach-end never
fires.
2026-05-07 22:18:58 +09:00
dante01yoon
70f8c68523 refactor: migrate VirtualGrid to @tanstack/vue-virtual (FE-535)
Replace VirtualGrid's hand-rolled windowing with useVirtualizer. All
five consumers (FormDropdownMenu, AssetsSidebarListView/GridView,
AssetGrid, ManagerDialog) keep their existing API — no per-consumer
changes.

The reported FE-535 reproducer (LoadImage form-dropdown blank on
reopen) was caused by stale scrollY in useScroll: PrimeVue Popover
keeps the menu mounted across close/open, so the cached y value
persisted past a value that was no longer valid once items refreshed,
and floor(scrollY / itemHeight) produced an offset past items.length.
useVirtualizer reads scrollOffset fresh from the DOM each computation;
the browser auto-clamps scrollTop when content shrinks, removing the
blank-state class for every consumer.

Preserves prior behavior flagged in the earlier migration review:
- cols re-derived from the first item's measured clientWidth when
  maxColumns is not provided (consumers that rely on responsive
  column count keep working without changes).
- approach-end stays edge-triggered via whenever(state.isNearEnd, …),
  not fired on every recompute.

Public API (items, gridStyle, bufferRows, defaultItemHeight,
defaultItemWidth, maxColumns, resizeDebounce, approach-end emit,
#item slot with :item and :index) is unchanged. The unused
scrollThrottle prop is removed (no consumer uses it; tanstack handles
its own scroll listener).

Tests:
- VirtualGrid.test.ts: 10 tests covering visible range, slot index
  continuity, cols derivation (width-based and maxColumns), empty
  list, approach-end edge-trigger (transition, far-from-end, fires
  once per transition only), and the FE-535 invariant (items shrink
  below previous render window does not blank).
- loadImageDropdownReopen.spec.ts: Playwright spec exercising the
  popover reproducer (open, scroll, close, reopen, assert items
  visible).
2026-05-07 15:24:17 +09:00
6 changed files with 342 additions and 93 deletions

View File

@@ -0,0 +1,60 @@
import { mergeTests } from '@playwright/test'
import { assetApiFixture } from '@e2e/fixtures/assetApiFixture'
import {
comfyExpect as expect,
comfyPageFixture
} from '@e2e/fixtures/ComfyPage'
import { withInputFiles } from '@e2e/fixtures/helpers/AssetHelper'
const test = mergeTests(comfyPageFixture, assetApiFixture)
test.describe(
'LoadImage form dropdown reopen (FE-535)',
{ tag: '@vue-nodes' },
() => {
test('items remain visible after scroll → close → reopen', async ({
comfyPage,
assetApi
}) => {
assetApi.configure(withInputFiles(60))
await assetApi.mock()
await comfyPage.appMode.enableLinearMode()
const loadImage = await comfyPage.nodeOps.addNode('LoadImage')
const loadImageId = String(loadImage.id)
await comfyPage.nextFrame()
await comfyPage.appMode.enterAppModeWithInputs([[loadImageId, 'image']])
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
const imageRow = comfyPage.appMode.widgets.getWidgetItem(
`${loadImageId}:image`
)
const dropdownButton = imageRow.getByTestId('form-dropdown-trigger')
await dropdownButton.click()
const popover = comfyPage.appMode.imagePickerPopover
await expect(popover).toBeVisible()
const scrollContainer = popover.getByTestId('form-dropdown-list')
await expect
.poll(() => scrollContainer.locator('img').count())
.toBeGreaterThan(0)
await scrollContainer.evaluate((el) =>
el.scrollTo({ top: el.scrollHeight, behavior: 'instant' })
)
await comfyPage.page.keyboard.press('Escape')
await expect(popover).toBeHidden()
await dropdownButton.click()
await expect(popover).toBeVisible()
await expect
.poll(() => popover.locator('img').count(), { timeout: 5000 })
.toBeGreaterThan(0)
})
}
)

View File

@@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Ref } from 'vue'
import { nextTick, ref } from 'vue'
import { computed, nextTick, ref } from 'vue'
import VirtualGrid from './VirtualGrid.vue'
@@ -9,21 +9,53 @@ type TestItem = { key: string; name: string }
let mockedWidth: Ref<number>
let mockedHeight: Ref<number>
let mockedScrollY: Ref<number>
let mockedFirstRow: Ref<number>
let mockedLastRow: 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 })
useElementSize: () => ({ width: mockedWidth, height: mockedHeight })
}
})
vi.mock('@tanstack/vue-virtual', () => ({
useVirtualizer: (options: {
count: number
estimateSize: (index: number) => number
}) =>
computed(() => {
const count = options.count
const size = count > 0 ? options.estimateSize(0) : 0
const first = Math.max(0, Math.min(count - 1, mockedFirstRow.value))
const last = Math.max(first, Math.min(count - 1, mockedLastRow.value))
const items =
count === 0
? []
: Array.from({ length: last - first + 1 }, (_, i) => {
const index = first + i
return {
index,
key: index,
start: index * size,
end: (index + 1) * size,
size
}
})
return {
getVirtualItems: () => items,
getTotalSize: () => count * size,
measure: () => {}
}
})
}))
beforeEach(() => {
mockedWidth = ref(400)
mockedHeight = ref(200)
mockedScrollY = ref(0)
mockedFirstRow = ref(0)
mockedLastRow = ref(0)
})
function createItems(count: number): TestItem[] {
@@ -42,9 +74,8 @@ describe('VirtualGrid', () => {
it('renders items within the visible range', async () => {
const items = createItems(100)
mockedWidth.value = 400
mockedHeight.value = 200
mockedScrollY.value = 0
mockedFirstRow.value = 0
mockedLastRow.value = 2
render(VirtualGrid, {
props: {
@@ -73,9 +104,8 @@ describe('VirtualGrid', () => {
it('provides correct index in slot props', async () => {
const items = createItems(20)
const receivedIndices: number[] = []
mockedWidth.value = 400
mockedHeight.value = 200
mockedScrollY.value = 0
mockedFirstRow.value = 0
mockedLastRow.value = 4
render(VirtualGrid, {
props: {
@@ -106,9 +136,8 @@ describe('VirtualGrid', () => {
it('respects maxColumns prop', async () => {
const items = createItems(10)
mockedWidth.value = 400
mockedHeight.value = 200
mockedScrollY.value = 0
mockedFirstRow.value = 0
mockedLastRow.value = 1
const { container } = render(VirtualGrid, {
props: {
@@ -150,11 +179,13 @@ describe('VirtualGrid', () => {
expect(renderedItems.length).toBe(0)
})
it('emits approach-end for single-column list when scrolled near bottom', async () => {
it('emits approach-end when last visible row reaches within bufferRows of end', async () => {
const items = createItems(50)
mockedWidth.value = 400
mockedHeight.value = 600
mockedScrollY.value = 0
// start far from end so whenever() can observe the false→true transition
mockedFirstRow.value = 0
mockedLastRow.value = 5
const onApproachEnd = vi.fn()
@@ -180,42 +211,30 @@ describe('VirtualGrid', () => {
})
await nextTick()
expect(onApproachEnd).not.toHaveBeenCalled()
// Scroll near the end: 50 items * 48px = 2400px total
// viewRows = ceil(600/48) = 13, buffer = 1
// Need toCol >= items.length - cols*bufferRows = 50 - 1 = 49
// toCol = (offsetRows + bufferRows + viewRows) * cols
// offsetRows = floor(scrollY / 48)
// Need (offsetRows + 1 + 13) * 1 >= 49 → offsetRows >= 35
// scrollY = 35 * 48 = 1680
mockedScrollY.value = 1680
// 50 rows total, bufferRows=1 → fires when lastRow >= 48
mockedFirstRow.value = 35
mockedLastRow.value = 48
await nextTick()
expect(onApproachEnd).toHaveBeenCalled()
})
it('does not emit approach-end without maxColumns in single-column layout', async () => {
// Demonstrates the bug: without maxColumns=1, cols is calculated
// from width/itemWidth (400/200 = 2), causing incorrect row math
it('does not emit approach-end while window is far from end', async () => {
const items = createItems(50)
mockedWidth.value = 400
mockedHeight.value = 600
mockedScrollY.value = 0
mockedFirstRow.value = 0
mockedLastRow.value = 5
const onApproachEnd = vi.fn()
render(VirtualGrid, {
props: {
items,
gridStyle: {
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr)'
},
gridStyle: defaultGridStyle,
defaultItemHeight: 48,
defaultItemWidth: 200,
// No maxColumns — cols will be floor(400/200) = 2
maxColumns: 1,
bufferRows: 1,
onApproachEnd
},
@@ -229,20 +248,168 @@ describe('VirtualGrid', () => {
await nextTick()
// Same scroll position as the passing test
mockedScrollY.value = 1680
expect(onApproachEnd).not.toHaveBeenCalled()
})
it('derives cols from container width when maxColumns is not set', async () => {
// width=400, defaultItemWidth=200 → cols = floor(400/200) = 2
mockedWidth.value = 400
mockedFirstRow.value = 0
mockedLastRow.value = 2
const items = createItems(20)
render(VirtualGrid, {
props: {
items,
gridStyle: defaultGridStyle,
defaultItemHeight: 100,
defaultItemWidth: 200,
bufferRows: 0
},
slots: {
item: `<template #item="{ item }">
<div class="test-item">{{ item.name }}</div>
</template>`
},
container: document.body.appendChild(document.createElement('div'))
})
await nextTick()
// With cols=2, toCol = (35+1+13)*2 = 98, which exceeds items.length (50)
// remainingCol = 50-98 = -48, hasMoreToRender = false → isNearEnd = false
// The approach-end never fires at the correct scroll position
// 3 rows rendered (firstRow=0..lastRow=2) * 2 cols = 6 items
const rendered = screen.getAllByText(/^Item \d+$/)
expect(rendered.length).toBe(6)
})
it('emits approach-end only once per false→true transition', async () => {
const items = createItems(50)
mockedFirstRow.value = 0
mockedLastRow.value = 5
const onApproachEnd = vi.fn()
render(VirtualGrid, {
props: {
items,
gridStyle: defaultGridStyle,
defaultItemHeight: 48,
defaultItemWidth: 200,
maxColumns: 1,
bufferRows: 1,
onApproachEnd
},
slots: {
item: `<template #item="{ item }">
<div class="test-item">{{ item.name }}</div>
</template>`
},
container: document.body.appendChild(document.createElement('div'))
})
await nextTick()
expect(onApproachEnd).toHaveBeenCalledTimes(0)
// false → true transition: should fire exactly once
mockedFirstRow.value = 35
mockedLastRow.value = 48
await nextTick()
expect(onApproachEnd).toHaveBeenCalledTimes(1)
// still near-end (recompute but no transition): must not re-fire
mockedFirstRow.value = 36
mockedLastRow.value = 49
await nextTick()
expect(onApproachEnd).toHaveBeenCalledTimes(1)
})
it('does not emit approach-end during the width=0 mount transient', async () => {
// Regression: with width=0 (pre-mount), cols falls back to 1 and a
// small list looks near-end. Without the isValidGrid guard, isNearEnd
// flips false→true→false during mount and consumers that increment a
// page number (ManagerDialog) double-load and dedupe-fail.
mockedWidth.value = 0
mockedHeight.value = 0
mockedFirstRow.value = 0
mockedLastRow.value = 2
const onApproachEnd = vi.fn()
render(VirtualGrid, {
props: {
items: createItems(3),
gridStyle: defaultGridStyle,
defaultItemHeight: 200,
defaultItemWidth: 200,
bufferRows: 4,
onApproachEnd
},
slots: {
item: `<template #item="{ item }">
<div class="test-item">{{ item.name }}</div>
</template>`
},
container: document.body.appendChild(document.createElement('div'))
})
await nextTick()
expect(onApproachEnd).not.toHaveBeenCalled()
// Container measures real width — list is small, all items fit, so
// isNearEnd stays false. A spurious mount-time fire would surface here.
mockedWidth.value = 800
mockedHeight.value = 600
mockedFirstRow.value = 0
mockedLastRow.value = 0
await nextTick()
expect(onApproachEnd).not.toHaveBeenCalled()
})
it('renders correctly after items shrink below previous render window (FE-535)', async () => {
// Simulate "scrolled deep, then items shrink" scenario
mockedFirstRow.value = 28
mockedLastRow.value = 32
const { rerender } = render(VirtualGrid, {
props: {
items: createItems(100),
gridStyle: defaultGridStyle,
defaultItemHeight: 50,
defaultItemWidth: 100,
maxColumns: 1
},
slots: {
item: `<template #item="{ item }">
<div class="test-item">{{ item.name }}</div>
</template>`
},
container: document.body.appendChild(document.createElement('div'))
})
await nextTick()
expect(screen.queryAllByText(/^Item \d+$/).length).toBeGreaterThan(0)
// Items shrink. Browser auto-clamps scrollTop, virtualizer reports
// valid rows for the new (smaller) count.
mockedFirstRow.value = 0
mockedLastRow.value = 4
await rerender({
items: createItems(5),
gridStyle: defaultGridStyle,
defaultItemHeight: 50,
defaultItemWidth: 100,
maxColumns: 1
})
await nextTick()
expect(screen.queryAllByText(/^Item \d+$/).length).toBe(5)
})
it('forces cols to maxColumns when maxColumns is finite', async () => {
mockedWidth.value = 100
mockedHeight.value = 200
mockedScrollY.value = 0
mockedFirstRow.value = 0
mockedLastRow.value = 2
const items = createItems(20)
render(VirtualGrid, {

View File

@@ -18,22 +18,16 @@
</template>
<script setup lang="ts" generic="T">
import { useElementSize, useScroll, whenever } from '@vueuse/core'
import { clamp, debounce } from 'es-toolkit/compat'
import { useVirtualizer } from '@tanstack/vue-virtual'
import { useElementSize, whenever } from '@vueuse/core'
import { debounce } from 'es-toolkit/compat'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import type { CSSProperties } from 'vue'
type GridState = {
start: number
end: number
isNearEnd: boolean
}
const {
items,
gridStyle,
bufferRows = 1,
scrollThrottle = 64,
resizeDebounce = 64,
defaultItemHeight = 200,
defaultItemWidth = 200,
@@ -42,7 +36,6 @@ const {
items: (T & { key: string })[]
gridStyle: CSSProperties
bufferRows?: number
scrollThrottle?: number
resizeDebounce?: number
defaultItemHeight?: number
defaultItemWidth?: number
@@ -51,7 +44,8 @@ const {
const emit = defineEmits<{
/**
* Emitted when `bufferRows` (or fewer) rows remaining between scrollY and grid bottom.
* Edge-triggered when the rendered window reaches within `bufferRows`
* rows of the grid's last item.
*/
'approach-end': []
}>()
@@ -60,10 +54,14 @@ const itemHeight = ref(defaultItemHeight)
const itemWidth = ref(defaultItemWidth)
const container = ref<HTMLElement | null>(null)
const { width, height } = useElementSize(container)
const { y: scrollY } = useScroll(container, {
throttle: scrollThrottle,
eventListenerOptions: { passive: true }
})
// Suppress range computation while the container is unmounted/zero-sized.
// Without this, cols collapses to 1 during the brief width=0 mount window,
// which makes a small list look near-end and emits a spurious approach-end
// that double-loads paginated consumers (ManagerDialog).
const isValidGrid = computed(
() => width.value > 0 && height.value > 0 && items.length > 0
)
const cols = computed(() => {
if (maxColumns !== Infinity) return maxColumns
@@ -78,39 +76,57 @@ const mergedGridStyle = computed<CSSProperties>(() => {
}
})
const viewRows = computed(() => Math.ceil(height.value / itemHeight.value))
const offsetRows = computed(() => Math.floor(scrollY.value / itemHeight.value))
const isValidGrid = computed(() => height.value && width.value && items?.length)
const rowCount = computed(() => Math.ceil(items.length / cols.value))
const virtualizer = useVirtualizer({
get count() {
return rowCount.value
},
estimateSize: () => itemHeight.value,
getScrollElement: () => container.value,
overscan: bufferRows
})
const virtualRows = computed(() => virtualizer.value.getVirtualItems())
const totalSize = computed(() => virtualizer.value.getTotalSize())
type GridState = {
start: number
end: number
isNearEnd: boolean
}
const state = computed<GridState>(() => {
const fromRow = offsetRows.value - bufferRows
const toRow = offsetRows.value + bufferRows + viewRows.value
const rows = virtualRows.value
if (!isValidGrid.value || rows.length === 0) {
return { start: 0, end: 0, isNearEnd: false }
}
const firstRow = rows[0]
const lastRow = rows[rows.length - 1]
const start = firstRow.index * cols.value
const end = Math.min(items.length, (lastRow.index + 1) * cols.value)
const fromCol = fromRow * cols.value
const toCol = toRow * cols.value
const toCol = (lastRow.index + 1) * cols.value
const remainingCol = items.length - toCol
const hasMoreToRender = remainingCol >= 0
const isNearEnd = hasMoreToRender && remainingCol <= cols.value * bufferRows
return {
start: clamp(fromCol, 0, items?.length),
end: clamp(toCol, fromCol, items?.length),
isNearEnd: hasMoreToRender && remainingCol <= cols.value * bufferRows
}
return { start, end, isNearEnd }
})
const renderedItems = computed(() =>
isValidGrid.value ? items.slice(state.value.start, state.value.end) : []
)
function rowsToHeight(itemsCount: number): string {
const rows = Math.ceil(itemsCount / cols.value)
return `${rows * itemHeight.value}px`
}
const topSpacerStyle = computed<CSSProperties>(() => ({
height: rowsToHeight(state.value.start)
}))
const bottomSpacerStyle = computed<CSSProperties>(() => ({
height: rowsToHeight(items.length - state.value.end)
height: `${virtualRows.value[0]?.start ?? 0}px`
}))
const bottomSpacerStyle = computed<CSSProperties>(() => {
const rows = virtualRows.value
if (rows.length === 0) return { height: '0px' }
const lastEnd = rows[rows.length - 1].end
return { height: `${Math.max(0, totalSize.value - lastEnd)}px` }
})
whenever(
() => state.value.isNearEnd,
@@ -120,22 +136,24 @@ whenever(
)
function updateItemSize(): void {
if (container.value) {
const firstItem = container.value.querySelector('[data-virtual-grid-item]')
if (!firstItem?.clientHeight || !firstItem?.clientWidth) return
if (itemHeight.value !== firstItem.clientHeight) {
itemHeight.value = firstItem.clientHeight
}
if (itemWidth.value !== firstItem.clientWidth) {
itemWidth.value = firstItem.clientWidth
}
if (!container.value) return
const firstItem = container.value.querySelector('[data-virtual-grid-item]')
if (!firstItem?.clientHeight || !firstItem?.clientWidth) return
if (itemHeight.value !== firstItem.clientHeight) {
itemHeight.value = firstItem.clientHeight
}
if (itemWidth.value !== firstItem.clientWidth) {
itemWidth.value = firstItem.clientWidth
}
}
const onResize = debounce(updateItemSize, resizeDebounce)
watch([width, height], onResize, { flush: 'post' })
watch(width, onResize, { flush: 'post' })
whenever(() => items, updateItemSize, { flush: 'post' })
watch(itemHeight, () => {
virtualizer.value.measure()
})
onBeforeUnmount(() => {
onResize.cancel()
})

View File

@@ -61,6 +61,7 @@ const theButtonStyle = computed(() =>
}
)
"
data-testid="form-dropdown-trigger"
@click="emit('select-click', $event)"
>
<span class="min-w-0 flex-1 truncate px-1 py-2 text-left">

View File

@@ -59,7 +59,7 @@ describe('FormDropdownMenu', () => {
global: globalConfig
})
expect(screen.getByTestId('virtual-grid')).toBeTruthy()
expect(screen.getByTestId('form-dropdown-list')).toBeTruthy()
})
it('transforms items to include key property for VirtualGrid', () => {
@@ -72,7 +72,7 @@ describe('FormDropdownMenu', () => {
global: globalConfig
})
const virtualGrid = screen.getByTestId('virtual-grid')
const virtualGrid = screen.getByTestId('form-dropdown-list')
const virtualItems = JSON.parse(virtualGrid.getAttribute('data-items')!)
expect(virtualItems).toHaveLength(2)
@@ -89,7 +89,7 @@ describe('FormDropdownMenu', () => {
global: globalConfig
})
const virtualGrid = screen.getByTestId('virtual-grid')
const virtualGrid = screen.getByTestId('form-dropdown-list')
expect(virtualGrid.getAttribute('data-max-columns')).toBe('1')
})

View File

@@ -83,7 +83,8 @@ const gridStyle = computed<CSSProperties>(() => ({
display: 'grid',
gap: layoutConfig.value.gap,
padding: '1rem',
width: '100%'
width: '100%',
gridAutoRows: `${layoutConfig.value.itemHeight}px`
}))
type VirtualDropdownItem = FormDropdownItem & { key: string }
@@ -99,6 +100,7 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
<div
class="flex h-[640px] w-103 flex-col rounded-lg bg-component-node-background pt-4 outline -outline-offset-1 outline-node-component-border"
data-capture-wheel="true"
data-testid="form-dropdown-menu"
>
<FormDropdownMenuFilter
v-if="filterOptions.length > 0"
@@ -137,6 +139,7 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
:default-item-width="layoutConfig.itemWidth"
:buffer-rows="2"
class="mt-2 min-h-0 flex-1"
data-testid="form-dropdown-list"
>
<template #item="{ item, index }">
<FormDropdownMenuItem