import { fireEvent, render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { VirtualList } from './virtual-list'; type Row = { id: number; name: string }; const rows: Row[] = Array.from({ length: 10_000 }, (_, index) => ({ id: index, name: `Row ${index}`, })); /** 320px viewport ÷ 40px rows = 8 visible, plus 3 rows of overscan each side. */ const renderList = (props: Partial>> = {}) => render( {(row) =>
{row.name}
}
); const scrollTo = (viewport: HTMLElement, top: number) => { Object.defineProperty(viewport, 'scrollTop', { value: top, writable: true }); fireEvent.scroll(viewport); }; const viewportOf = (container: HTMLElement) => container.querySelector('[data-slot="virtual-list"]') as HTMLElement; describe('VirtualList', () => { it('mounts only the visible window, not all ten thousand rows', () => { const { container } = renderList(); const mounted = container.querySelectorAll('[data-slot="virtual-list-viewport"] > *'); /* 8 visible + 3 overscan below (nothing above at scrollTop 0). */ expect(mounted).toHaveLength(11); expect(screen.getByText('Row 0')).toBeInTheDocument(); expect(screen.queryByText('Row 500')).not.toBeInTheDocument(); }); it('reserves the full scroll height so the scrollbar matches the data', () => { const { container } = renderList(); const spacer = viewportOf(container).firstElementChild as HTMLElement; expect(spacer).toHaveStyle({ height: '400000px' }); }); it('swaps the mounted slice as the viewport scrolls', () => { const { container } = renderList(); const viewport = viewportOf(container); scrollTo(viewport, 4000); // row 100 expect(screen.queryByText('Row 0')).not.toBeInTheDocument(); expect(screen.getByText('Row 100')).toBeInTheDocument(); }); it('offsets the slice so rows land at their real position', () => { const { container } = renderList(); const viewport = viewportOf(container); scrollTo(viewport, 4000); /* First mounted row is 100 - 3 overscan = 97, at 97 × 40px. */ const slice = container.querySelector('[data-slot="virtual-list-viewport"]'); expect(slice).toHaveStyle({ transform: 'translateY(3880px)' }); }); it('does not run past the end of the data', () => { const { container } = renderList(); const viewport = viewportOf(container); scrollTo(viewport, 10_000 * 40); // scrolled beyond the last row expect(screen.getByText('Row 9999')).toBeInTheDocument(); expect(container.querySelectorAll('[data-slot="virtual-list-viewport"] > *').length) .toBeLessThanOrEqual(14); }); it('honours a custom overscan', () => { const { container } = renderList({ overscan: 0 }); expect(container.querySelectorAll('[data-slot="virtual-list-viewport"] > *')).toHaveLength(8); }); it('forwards scroll events for infinite loading', () => { const onScroll = vi.fn(); const { container } = renderList({ onScroll }); scrollTo(viewportOf(container), 800); expect(onScroll).toHaveBeenCalledTimes(1); }); it('accepts a function itemKey', () => { const itemKey = vi.fn((row: Row) => `row-${row.id}`); renderList({ itemKey }); expect(itemKey).toHaveBeenCalled(); expect(screen.getByText('Row 0')).toBeInTheDocument(); }); it('renders a list shorter than the viewport without padding it out', () => { const { container } = render( {(row) =>
{row.name}
}
); expect(container.querySelectorAll('[data-slot="virtual-list-viewport"] > *')).toHaveLength(2); }); it('renders nothing for empty data', () => { const { container } = render( {(row: Row) =>
{row.name}
}
); expect(container.querySelectorAll('[data-slot="virtual-list-viewport"] > *')).toHaveLength(0); }); });