Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions src/components/common/VirtualGrid.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,99 @@ describe('VirtualGrid', () => {
expect(onApproachEnd).not.toHaveBeenCalled()
})

it('renders last page when scrollY exceeds natural max (FE-535)', async () => {
const items = createItems(5)
mockedWidth.value = 400
mockedHeight.value = 200
mockedScrollY.value = 5000

render(VirtualGrid, {
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>`
},
container: document.body.appendChild(document.createElement('div'))
})

await nextTick()

const renderedItems = screen.queryAllByText(/^Item \d+$/)
expect(renderedItems.length).toBeGreaterThan(0)
})

it('keeps last page visible when items shrink below current scroll (FE-535)', async () => {
const items = createItems(8)
mockedWidth.value = 400
mockedHeight.value = 240
mockedScrollY.value = 3000

render(VirtualGrid, {
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>`
},
container: document.body.appendChild(document.createElement('div'))
})

await nextTick()

const renderedItems = screen.queryAllByText(/^Item \d+$/)
expect(renderedItems.length).toBeGreaterThan(0)
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it('renders last page of large list when scrollY exceeds natural max (FE-535)', async () => {
// Covers the maxOffsetRows > 0 branch: a list with valid scroll range
// (totalRows > viewRows) where scrollY drifts far past the natural max.
// Without clamping, fromCol overshoots items.length and the grid renders
// empty; with the clamp, offsetRows snaps to maxOffsetRows so the last
// page (including the final item) stays visible.
const items = createItems(100)
mockedWidth.value = 400
mockedHeight.value = 480
mockedScrollY.value = 100000

render(VirtualGrid, {
props: {
items,
gridStyle: defaultGridStyle,
defaultItemHeight: 120,
defaultItemWidth: 100,
maxColumns: 4,
bufferRows: 1
},
slots: {
item: `<template #item="{ item }">
<div class="test-item">{{ item.name }}</div>
</template>`
},
container: document.body.appendChild(document.createElement('div'))
})

await nextTick()

const rendered = screen.queryAllByText(/^Item \d+$/)
expect(rendered.length).toBeGreaterThan(0)
expect(rendered.some((el) => el.textContent === 'Item 99')).toBe(true)
})

it('forces cols to maxColumns when maxColumns is finite', async () => {
mockedWidth.value = 100
mockedHeight.value = 200
Expand Down
13 changes: 12 additions & 1 deletion src/components/common/VirtualGrid.vue
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WDYT about migratintg the form dropdown widget to tanstack first, rather than mass-migrating all consumers of VirtualGrid like this?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — narrowed to FormDropdownMenu in 89fedb2. VirtualGrid is back to its pre-migration state (with the clamp offsetRows fix already on main); only FormDropdownMenu uses useVirtualizer locally. Updated the PR description with the rationale.

Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,18 @@ const mergedGridStyle = computed<CSSProperties>(() => {
})

const viewRows = computed(() => Math.ceil(height.value / itemHeight.value))
const offsetRows = computed(() => Math.floor(scrollY.value / itemHeight.value))
const totalRows = computed(() => Math.ceil(items.length / cols.value))
const maxOffsetRows = computed(() =>
Math.max(0, totalRows.value - viewRows.value)
)
// Clamp offsetRows so the last page stays visible when scrollY drifts past
// the natural max (e.g. items list shrinks while scroll position is retained,
// or rubberband over-scroll temporarily exceeds the limit). Without this,
// state.start === state.end === items.length and the grid renders blank
// until another scroll event re-syncs the position. (FE-535)
const offsetRows = computed(() =>
Math.min(maxOffsetRows.value, Math.floor(scrollY.value / itemHeight.value))
)
const isValidGrid = computed(() => height.value && width.value && items?.length)

const state = computed<GridState>(() => {
Expand Down
Loading