diff --git a/pages/table/virtual-scroll.page.tsx b/pages/table/virtual-scroll.page.tsx new file mode 100644 index 0000000000..1afb1067bc --- /dev/null +++ b/pages/table/virtual-scroll.page.tsx @@ -0,0 +1,72 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useMemo, useState } from 'react'; + +import Box from '~components/box'; +import Checkbox from '~components/checkbox'; +import Header from '~components/header'; +import SpaceBetween from '~components/space-between'; +import Table from '~components/table'; + +import ScreenshotArea from '../utils/screenshot-area'; + +interface Item { + id: string; + name: string; + region: string; + state: string; +} + +function generateItems(count: number): Item[] { + const states = ['RUNNING', 'STOPPED', 'PENDING', 'TERMINATED']; + const regions = ['us-east-1', 'us-west-2', 'eu-central-1', 'ap-south-1']; + return Array.from({ length: count }, (_, i) => ({ + id: `id-${i + 1}`, + name: `Instance ${i + 1}`, + region: regions[i % regions.length], + state: states[i % states.length], + })); +} + +const columnDefinitions = [ + { id: 'id', header: 'ID', cell: (item: Item) => item.id, sortingField: 'id' }, + { id: 'name', header: 'Name', cell: (item: Item) => item.name }, + { id: 'region', header: 'Region', cell: (item: Item) => item.region }, + { id: 'state', header: 'State', cell: (item: Item) => item.state }, +]; + +// Dev page for the experimental opt-in windowed (virtual) scrolling mode. +// The table is placed inside a fixed-height scroll container so windowing takes effect. +export default function TableVirtualScrollPage() { + const [enabled, setEnabled] = useState(true); + const items = useMemo(() => generateItems(10000), []); + + return ( + + +
+ Table virtual scrolling (v0, experimental) +
+ + setEnabled(detail.checked)}> + Enable virtualScroll + + + +
+ + + + + Total items: {items.length} + + + ); +} diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index b54c1b56ab..c8d42ac1a0 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -29162,6 +29162,33 @@ It is also used in the following situations: "type": "string", "visualRefreshTag": "\`embedded\`, \`stacked\`, and \`full-page\` variants", }, + { + "description": "Enables experimental windowed (virtual) scrolling for the table body. When set, only the +rows within (and near) the visible viewport are rendered to the DOM, which keeps very large +tables performant. Pass \`true\` to enable with defaults, or an object to configure it: +* \`rowHeight\` (number) - Estimated height of a single row in pixels. Used to size the virtual + window. Defaults to \`40\`. **Note:** v0 assumes a uniform row height. +* \`overscan\` (number) - Number of additional rows rendered above and below the viewport to + reduce blank areas while scrolling. Defaults to \`5\`. + +The table must be rendered inside a scroll container with a bounded height for virtualization +to take effect. This API is currently experimental.", + "inlineType": { + "name": "boolean | TableProps.VirtualScrollConfig", + "type": "union", + "values": [ + "false", + "true", + "TableProps.VirtualScrollConfig", + ], + }, + "name": "virtualScroll", + "optional": true, + "systemTags": [ + "core", + ], + "type": "boolean | TableProps.VirtualScrollConfig", + }, { "deprecatedTag": "Replaced by \`columnDisplay\`.", "description": "Specifies an array containing the \`id\`s of visible columns. If not set, all columns are displayed. diff --git a/src/table/__tests__/use-virtual-scroll.test.tsx b/src/table/__tests__/use-virtual-scroll.test.tsx new file mode 100644 index 0000000000..5455f19372 --- /dev/null +++ b/src/table/__tests__/use-virtual-scroll.test.tsx @@ -0,0 +1,90 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { fireEvent } from '@testing-library/react'; + +import { act, renderHook } from '../../__tests__/render-hook'; +import { DEFAULT_VIRTUAL_ROW_HEIGHT, useVirtualScroll } from '../use-virtual-scroll'; + +function createContainer({ scrollTop = 0, clientHeight = 0 }: { scrollTop?: number; clientHeight?: number }) { + const el = document.createElement('div'); + document.body.appendChild(el); + Object.defineProperty(el, 'clientHeight', { configurable: true, value: clientHeight }); + el.scrollTop = scrollTop; + return el; +} + +describe('useVirtualScroll', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + test('is a no-op when disabled: renders the full range with no padding', () => { + const container = createContainer({ clientHeight: 400 }); + const ref = { current: container } as React.RefObject; + const { result } = renderHook(() => + useVirtualScroll({ enabled: false, itemCount: 1000, containerRef: ref, rowHeight: 40 }) + ); + + expect(result.current.enabled).toBe(false); + expect(result.current.startIndex).toBe(0); + expect(result.current.endIndex).toBe(1000); + expect(result.current.topPadding).toBe(0); + expect(result.current.bottomPadding).toBe(0); + expect(result.current.totalSize).toBe(1000 * 40); + }); + + test('computes a windowed range based on scroll offset and viewport height', () => { + const container = createContainer({ scrollTop: 4000, clientHeight: 400 }); + const ref = { current: container } as React.RefObject; + const { result } = renderHook(() => + useVirtualScroll({ enabled: true, itemCount: 1000, containerRef: ref, rowHeight: 40, overscan: 5 }) + ); + + // 4000 / 40 = row 100; minus overscan 5 => startIndex 95 + expect(result.current.startIndex).toBe(95); + // visibleCount ceil(400/40)=10; endIndex = 95 + 10 + 2*5 = 115 + expect(result.current.endIndex).toBe(115); + expect(result.current.topPadding).toBe(95 * 40); + expect(result.current.bottomPadding).toBe((1000 - 115) * 40); + }); + + test('recomputes the window after a scroll event', () => { + const container = createContainer({ scrollTop: 0, clientHeight: 400 }); + const ref = { current: container } as React.RefObject; + const { result } = renderHook(() => + useVirtualScroll({ enabled: true, itemCount: 1000, containerRef: ref, rowHeight: 40, overscan: 0 }) + ); + + expect(result.current.startIndex).toBe(0); + + act(() => { + container.scrollTop = 2000; + fireEvent.scroll(container); + }); + + expect(result.current.startIndex).toBe(50); + }); + + test('handles an empty dataset', () => { + const container = createContainer({ clientHeight: 400 }); + const ref = { current: container } as React.RefObject; + const { result } = renderHook(() => + useVirtualScroll({ enabled: true, itemCount: 0, containerRef: ref, rowHeight: 40 }) + ); + + expect(result.current.startIndex).toBe(0); + expect(result.current.endIndex).toBe(0); + expect(result.current.totalSize).toBe(0); + }); + + test('falls back to the default row height when a non-positive value is provided', () => { + const container = createContainer({ clientHeight: 400 }); + const ref = { current: container } as React.RefObject; + const { result } = renderHook(() => + useVirtualScroll({ enabled: true, itemCount: 100, containerRef: ref, rowHeight: 0 }) + ); + + expect(result.current.totalSize).toBe(100 * DEFAULT_VIRTUAL_ROW_HEIGHT); + }); +}); diff --git a/src/table/__tests__/virtual-scroll.test.tsx b/src/table/__tests__/virtual-scroll.test.tsx new file mode 100644 index 0000000000..13c769846a --- /dev/null +++ b/src/table/__tests__/virtual-scroll.test.tsx @@ -0,0 +1,55 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import * as React from 'react'; +import { render } from '@testing-library/react'; + +import Table, { TableProps } from '../../../lib/components/table'; +import createWrapper from '../../../lib/components/test-utils/dom'; + +import styles from '../../../lib/components/table/styles.css.js'; + +interface Item { + id: number; + name: string; +} + +const columnDefinitions: TableProps.ColumnDefinition[] = [ + { id: 'id', header: 'ID', cell: item => item.id }, + { id: 'name', header: 'Name', cell: item => item.name }, +]; + +function generateItems(count: number): Item[] { + return Array.from({ length: count }, (_, i) => ({ id: i + 1, name: `Item ${i + 1}` })); +} + +function renderTable(props: Partial>) { + const { container } = render( +
+ ); + return createWrapper(container).findTable()!; +} + +describe('Table virtualScroll (v0)', () => { + test('renders every row when virtualScroll is not set', () => { + const wrapper = renderTable({}); + expect(wrapper.findRows()).toHaveLength(500); + expect(wrapper.findByClassName(styles['virtual-scroll-spacer'])).toBeNull(); + }); + + test('renders only a windowed subset of rows when virtualScroll is enabled', () => { + const wrapper = renderTable({ virtualScroll: true }); + // Only a small window (viewport + overscan) is rendered, far fewer than 500. + expect(wrapper.findRows().length).toBeLessThan(500); + expect(wrapper.findRows().length).toBeGreaterThan(0); + }); + + test('renders a spacer row to preserve total scroll height', () => { + const wrapper = renderTable({ virtualScroll: true }); + expect(wrapper.findByClassName(styles['virtual-scroll-spacer'])).not.toBeNull(); + }); + + test('accepts an object configuration', () => { + const wrapper = renderTable({ virtualScroll: { rowHeight: 30, overscan: 2 } }); + expect(wrapper.findRows().length).toBeLessThan(500); + }); +}); diff --git a/src/table/interfaces.tsx b/src/table/interfaces.tsx index e00ef8fc17..94ffd04a92 100644 --- a/src/table/interfaces.tsx +++ b/src/table/interfaces.tsx @@ -70,6 +70,21 @@ export interface TableProps extends BaseComponentProps { */ skeleton?: TableProps.SkeletonConfig; + /** + * Enables experimental windowed (virtual) scrolling for the table body. When set, only the + * rows within (and near) the visible viewport are rendered to the DOM, which keeps very large + * tables performant. Pass `true` to enable with defaults, or an object to configure it: + * * `rowHeight` (number) - Estimated height of a single row in pixels. Used to size the virtual + * window. Defaults to `40`. **Note:** v0 assumes a uniform row height. + * * `overscan` (number) - Number of additional rows rendered above and below the viewport to + * reduce blank areas while scrolling. Defaults to `5`. + * + * The table must be rendered inside a scroll container with a bounded height for virtualization + * to take effect. This API is currently experimental. + * @awsuiSystem core + */ + virtualScroll?: boolean | TableProps.VirtualScrollConfig; + /** * Specifies a property that uniquely identifies an individual item. * When it's set, it's used to provide [keys for React](https://reactjs.org/docs/lists-and-keys.html#keys) @@ -709,6 +724,19 @@ export namespace TableProps { export interface SkeletonConfig { totalRows: number; } + + export interface VirtualScrollConfig { + /** + * Estimated height of a single row in pixels. Used to size the virtual window. + * Defaults to `40`. v0 assumes a uniform row height. + */ + rowHeight?: number; + /** + * Number of additional rows rendered above and below the viewport to reduce + * blank areas while scrolling. Defaults to `5`. + */ + overscan?: number; + } } export type TableRow = TableDataRow | TableLoaderRow; diff --git a/src/table/internal.tsx b/src/table/internal.tsx index d58a59f1f9..194f496d4c 100644 --- a/src/table/internal.tsx +++ b/src/table/internal.tsx @@ -68,6 +68,7 @@ import { ColumnWidthDefinition, ColumnWidthsProvider, DEFAULT_COLUMN_WIDTH } fro import { usePreventStickyClickScroll } from './use-prevent-sticky-click-scroll'; import { useRowEvents } from './use-row-events'; import useTableFocusNavigation from './use-table-focus-navigation'; +import { useVirtualScroll } from './use-virtual-scroll'; import { checkSortingState, getColumnKey, getItemKey, getVisibleColumnDefinitions, toContainerVariant } from './utils'; import buttonStyles from '../button/styles.css.js'; @@ -152,6 +153,7 @@ const InternalTable = React.forwardRef( renderLoaderEmpty, renderLoaderCounter, cellVerticalAlign, + virtualScroll, __funnelSubStepProps, ...rest }: InternalTableProps, @@ -296,6 +298,21 @@ const InternalTable = React.forwardRef( const wrapperRefObject = useRef(null); const handleScroll = useScrollSync([wrapperRefObject, scrollbarRef, secondaryWrapperRef]); + // Experimental opt-in windowed (virtual) scrolling. Additive: when disabled the hook is a + // no-op and every row is rendered as before. + const virtualScrollConfig = virtualScroll === true ? {} : virtualScroll || undefined; + const virtualScrollState = useVirtualScroll({ + enabled: !!virtualScroll, + itemCount: allRows.length, + containerRef: wrapperRefObject, + rowHeight: virtualScrollConfig?.rowHeight, + overscan: virtualScrollConfig?.overscan, + }); + const renderStartIndex = virtualScrollState.enabled ? virtualScrollState.startIndex : 0; + const renderedRows = virtualScrollState.enabled + ? allRows.slice(virtualScrollState.startIndex, virtualScrollState.endIndex) + : allRows; + const { moveFocusDown, moveFocusUp, moveFocus } = useSelectionFocusMove(selectionType, allItems.length); const { onRowClickHandler, onRowContextMenuHandler } = useRowEvents({ onRowClick, onRowContextMenu }); @@ -638,189 +655,210 @@ const InternalTable = React.forwardRef( /> ) : ( - allRows.map((row, rowIndex) => { - const isFirstRow = rowIndex === 0; - const hasSkeletonBelow = - loading && skeleton && allItems.length > 0 && skeleton.totalRows - allItems.length > 0; - const isLastDataRow = rowIndex === allRows.length - 1; - const isLastRow = isLastDataRow && !hasSkeletonBelow; - const rowExpandableProps = - row.type === 'data' ? expandableRows.getExpandableItemProps(row.item) : undefined; - const rowRoleProps = getTableRowRoleProps({ - tableRole, - firstIndex, - rowIndex, - headerRowCount, - level: row.type === 'loader' ? row.level : undefined, - ...rowExpandableProps, - }); - const getTableItemKey = (item: T) => getItemKey(trackBy, item, rowIndex); - const sharedCellProps = { - isFirstRow, - isLastRow, - isSelected: hasSelection && isRowSelected(row), - isPrevSelected: hasSelection && !isFirstRow && isRowSelected(allRows[rowIndex - 1]), - isNextSelected: hasSelection && !isLastDataRow && isRowSelected(allRows[rowIndex + 1]), - isEvenRow: rowIndex % 2 === 0, - stripedRows, - hasSelection, - hasFooter, - stickyState, - tableRole, - }; - if (row.type === 'data') { - const rowId = `${getTableItemKey(row.item)}`; - return ( - { - // When an element inside table row receives focus we want to adjust the scroll. - // However, that behavior is unwanted when the focus is received as result of a click - // as it causes the click to never reach the target element. - if (!currentTarget.contains(getMouseDownTarget())) { - stickyHeaderRef.current?.scrollToRow(currentTarget); + <> + {virtualScrollState.enabled && virtualScrollState.topPadding > 0 && ( + + + )} + {renderedRows.map((row, localRowIndex) => { + const rowIndex = renderStartIndex + localRowIndex; + const isFirstRow = rowIndex === 0; + const hasSkeletonBelow = + loading && skeleton && allItems.length > 0 && skeleton.totalRows - allItems.length > 0; + const isLastDataRow = rowIndex === allRows.length - 1; + const isLastRow = isLastDataRow && !hasSkeletonBelow; + const rowExpandableProps = + row.type === 'data' ? expandableRows.getExpandableItemProps(row.item) : undefined; + const rowRoleProps = getTableRowRoleProps({ + tableRole, + firstIndex, + rowIndex, + headerRowCount, + level: row.type === 'loader' ? row.level : undefined, + ...rowExpandableProps, + }); + const getTableItemKey = (item: T) => getItemKey(trackBy, item, rowIndex); + const sharedCellProps = { + isFirstRow, + isLastRow, + isSelected: hasSelection && isRowSelected(row), + isPrevSelected: hasSelection && !isFirstRow && isRowSelected(allRows[rowIndex - 1]), + isNextSelected: hasSelection && !isLastDataRow && isRowSelected(allRows[rowIndex + 1]), + isEvenRow: rowIndex % 2 === 0, + stripedRows, + hasSelection, + hasFooter, + stickyState, + tableRole, + }; + if (row.type === 'data') { + const rowId = `${getTableItemKey(row.item)}`; + return ( + { + // When an element inside table row receives focus we want to adjust the scroll. + // However, that behavior is unwanted when the focus is received as result of a click + // as it causes the click to never reach the target element. + if (!currentTarget.contains(getMouseDownTarget())) { + stickyHeaderRef.current?.scrollToRow(currentTarget); + } + }} + {...focusMarkers.item} + onClick={onRowClickHandler && onRowClickHandler.bind(null, rowIndex, row.item)} + onContextMenu={ + onRowContextMenuHandler && onRowContextMenuHandler.bind(null, rowIndex, row.item) } - }} - {...focusMarkers.item} - onClick={onRowClickHandler && onRowClickHandler.bind(null, rowIndex, row.item)} - onContextMenu={ - onRowContextMenuHandler && onRowContextMenuHandler.bind(null, rowIndex, row.item) - } - {...rowRoleProps} - > - {selection.getItemSelectionProps && ( - - )} - - {visibleColumnDefinitions.map((column, colIndex) => { - const colId = `${getColumnKey(column, colIndex)}`; - const cellId = { row: rowId, col: colId }; - const isEditing = cellEditing.checkEditing(cellId); - const successfulEdit = cellEditing.checkLastSuccessfulEdit(cellId); - const isEditable = !!column.editConfig && !cellEditing.isLoading; - const cellExpandableProps = - isExpandable && colIndex === 0 ? rowExpandableProps : undefined; - const counter = column.counter?.({ - item: row.item, - itemsCount: rowExpandableProps?.itemsCount, - selectedItemsCount: rowExpandableProps?.selectedItemsCount, - }); - - const analyticsMetadata: GeneratedAnalyticsMetadataFragment = { - component: { - innerContext: { - position: `${rowIndex + 1},${colIndex + 1}`, - columnId: column.id ? `${column.id}` : '', - columnLabel: { - selector: `table thead tr th:nth-child(${colIndex + (selectionType ? 2 : 1)})`, - root: 'component', - }, - item: rowId, - } as GeneratedAnalyticsMetadataTableComponent['innerContext'], - }, - }; - - return ( - + {selection.getItemSelectionProps && ( + cellEditing.startEdit(cellId)} - onEditEnd={editCancelled => cellEditing.completeEdit(cellId, editCancelled)} - submitEdit={cellEditing.submitEdit} - columnId={column.id ?? colIndex} - colIndex={colIndex + colIndexOffset} - verticalAlign={column.verticalAlign ?? cellVerticalAlign} + verticalAlign={cellVerticalAlign} + tableVariant={computedVariant} + /> + )} + + {visibleColumnDefinitions.map((column, colIndex) => { + const colId = `${getColumnKey(column, colIndex)}`; + const cellId = { row: rowId, col: colId }; + const isEditing = cellEditing.checkEditing(cellId); + const successfulEdit = cellEditing.checkLastSuccessfulEdit(cellId); + const isEditable = !!column.editConfig && !cellEditing.isLoading; + const cellExpandableProps = + isExpandable && colIndex === 0 ? rowExpandableProps : undefined; + const counter = column.counter?.({ + item: row.item, + itemsCount: rowExpandableProps?.itemsCount, + selectedItemsCount: rowExpandableProps?.selectedItemsCount, + }); + + const analyticsMetadata: GeneratedAnalyticsMetadataFragment = { + component: { + innerContext: { + position: `${rowIndex + 1},${colIndex + 1}`, + columnId: column.id ? `${column.id}` : '', + columnLabel: { + selector: `table thead tr th:nth-child(${colIndex + (selectionType ? 2 : 1)})`, + root: 'component', + }, + item: rowId, + } as GeneratedAnalyticsMetadataTableComponent['innerContext'], + }, + }; + + return ( + cellEditing.startEdit(cellId)} + onEditEnd={editCancelled => cellEditing.completeEdit(cellId, editCancelled)} + submitEdit={cellEditing.submitEdit} + columnId={column.id ?? colIndex} + colIndex={colIndex + colIndexOffset} + verticalAlign={column.verticalAlign ?? cellVerticalAlign} + tableVariant={computedVariant} + counter={counter} + {...cellExpandableProps} + {...getAnalyticsMetadataAttribute(analyticsMetadata)} + /> + ); + })} + + ); + } + const loaderSelectionProps = + selection.getLoaderSelectionProps && selection.getLoaderSelectionProps(row.item); + const rowSelection = selectionType === 'group' ? loaderSelectionProps : undefined; + const loaderContent = getLoaderContent({ + item: row.item, + loadingStatus: row.status, + renderLoaderPending, + renderLoaderLoading, + renderLoaderError, + renderLoaderEmpty, + }); + const loaderCounter = renderLoaderCounter?.({ + item: row.item, + loadingStatus: row.status, + selected: !!rowSelection?.checked, + }); + return ( + loaderContent && ( + + {selectionType ? ( + - ); - })} - + ) : null} + {visibleColumnDefinitions.map((column, colIndex) => ( + + {loaderContent} + + ))} + + ) ); - } - const loaderSelectionProps = - selection.getLoaderSelectionProps && selection.getLoaderSelectionProps(row.item); - const rowSelection = selectionType === 'group' ? loaderSelectionProps : undefined; - const loaderContent = getLoaderContent({ - item: row.item, - loadingStatus: row.status, - renderLoaderPending, - renderLoaderLoading, - renderLoaderError, - renderLoaderEmpty, - }); - const loaderCounter = renderLoaderCounter?.({ - item: row.item, - loadingStatus: row.status, - selected: !!rowSelection?.checked, - }); - return ( - loaderContent && ( - - {selectionType ? ( - - ) : null} - {visibleColumnDefinitions.map((column, colIndex) => ( - - {loaderContent} - - ))} - - ) - ); - }) + })} + {virtualScrollState.enabled && virtualScrollState.bottomPadding > 0 && ( + + + )} + )} {loading && skeleton && allItems.length > 0 && skeleton.totalRows - allItems.length > 0 && ( ; + /** Estimated per-row height in px. v0 assumes a uniform row height. */ + rowHeight?: number; + /** Extra rows rendered outside the viewport on each side. */ + overscan?: number; +} + +export interface VirtualScrollResult { + /** Mirrors the `enabled` input; convenient for guarding render logic. */ + enabled: boolean; + /** Index of the first rendered row (inclusive). */ + startIndex: number; + /** Index one past the last rendered row (exclusive). */ + endIndex: number; + /** Height (px) of the spacer rendered before the first rendered row. */ + topPadding: number; + /** Height (px) of the spacer rendered after the last rendered row. */ + bottomPadding: number; + /** Total virtual height (px) of all rows. */ + totalSize: number; +} + +interface Viewport { + scrollTop: number; + clientHeight: number; +} + +/** + * Computes the slice of rows that need to be rendered for a windowed (virtual) + * table body, based on the current scroll offset and viewport height of the + * scroll container. + * + * This is a first-cut (v0) implementation that assumes a uniform `rowHeight`. + * Variable-height rows, expandable-row measurement, and window-level scrolling + * are intentionally out of scope and tracked as follow-ups. + */ +export function useVirtualScroll({ + enabled, + itemCount, + containerRef, + rowHeight = DEFAULT_VIRTUAL_ROW_HEIGHT, + overscan = DEFAULT_VIRTUAL_OVERSCAN, +}: UseVirtualScrollProps): VirtualScrollResult { + const [viewport, setViewport] = useState({ scrollTop: 0, clientHeight: 0 }); + + useEffect(() => { + const container = containerRef.current; + if (!enabled || !container) { + return; + } + + const measure = () => { + setViewport({ scrollTop: container.scrollTop, clientHeight: container.clientHeight }); + }; + + // Measure once on mount and whenever inputs that affect geometry change. + measure(); + container.addEventListener('scroll', measure, { passive: true }); + return () => { + container.removeEventListener('scroll', measure); + }; + }, [enabled, containerRef, itemCount, rowHeight]); + + return useMemo(() => { + const safeRowHeight = rowHeight > 0 ? rowHeight : DEFAULT_VIRTUAL_ROW_HEIGHT; + const totalSize = itemCount * safeRowHeight; + + if (!enabled || itemCount === 0) { + return { + enabled, + startIndex: 0, + endIndex: itemCount, + topPadding: 0, + bottomPadding: 0, + totalSize, + }; + } + + const visibleCount = Math.max(1, Math.ceil(viewport.clientHeight / safeRowHeight)); + const rawStart = Math.floor(viewport.scrollTop / safeRowHeight) - overscan; + const startIndex = Math.max(0, Math.min(rawStart, Math.max(0, itemCount - 1))); + const endIndex = Math.min(itemCount, startIndex + visibleCount + overscan * 2); + + return { + enabled, + startIndex, + endIndex, + topPadding: startIndex * safeRowHeight, + bottomPadding: Math.max(0, (itemCount - endIndex) * safeRowHeight), + totalSize, + }; + }, [enabled, itemCount, rowHeight, overscan, viewport.scrollTop, viewport.clientHeight]); +}