diff --git a/lib/components/list/List.tsx b/lib/components/list/List.tsx index a30e90c0..c1a67ac8 100644 --- a/lib/components/list/List.tsx +++ b/lib/components/list/List.tsx @@ -138,9 +138,13 @@ export function List< return rowHeightProp.observeRowElements(rows); } }, [ + RowComponent, element, isDynamicRowHeight, + rowCount, rowHeightProp, + rowKey, + rowProps, startIndexOverscan, stopIndexOverscan ]); diff --git a/lib/core/createCachedBounds.test.ts b/lib/core/createCachedBounds.test.ts index 367ac0fd..6782c9e3 100644 --- a/lib/core/createCachedBounds.test.ts +++ b/lib/core/createCachedBounds.test.ts @@ -67,3 +67,18 @@ describe("createCachedBounds", () => { }).toThrow("Invalid index 1"); }); }); + +test("fixed-size jumps do not allocate bounds for preceding items", () => { + const cachedBounds = createCachedBounds({ + itemCount: 1000000, + itemProps: {}, + itemSize: 20 + }); + expect(cachedBounds.get(999999)).toEqual({ + scrollOffset: 19999980, + size: 20 + }); + expect(cachedBounds.size).toBe(1); + expect(cachedBounds.get(0)).toEqual({ scrollOffset: 0, size: 20 }); + expect(cachedBounds.size).toBe(2); +}); diff --git a/lib/core/createCachedBounds.ts b/lib/core/createCachedBounds.ts index 3e527473..f0282e54 100644 --- a/lib/core/createCachedBounds.ts +++ b/lib/core/createCachedBounds.ts @@ -13,23 +13,24 @@ export function createCachedBounds({ const cache = new Map(); return { + itemSize: typeof itemSize === "number" ? itemSize : undefined, get(index: number) { assert(index < itemCount, `Invalid index ${index}`); + if (typeof itemSize === "number") { + assert(index >= 0, `Invalid index ${index}`); + let bounds = cache.get(index); + if (bounds === undefined) { + bounds = { size: itemSize, scrollOffset: index * itemSize }; + cache.set(index, bounds); + } + return bounds; + } + while (cache.size - 1 < index) { const currentIndex = cache.size; - let size: number; - switch (typeof itemSize) { - case "function": { - size = itemSize(currentIndex, itemProps); - break; - } - case "number": { - size = itemSize; - break; - } - } + const size = itemSize(currentIndex, itemProps); if (currentIndex === 0) { cache.set(currentIndex, { diff --git a/lib/core/getOffsetForIndex.test.ts b/lib/core/getOffsetForIndex.test.ts index 8fc3469d..65940847 100644 --- a/lib/core/getOffsetForIndex.test.ts +++ b/lib/core/getOffsetForIndex.test.ts @@ -174,3 +174,75 @@ describe("getOffsetForIndex", () => { }); }); }); + +test("variable-size start alignment measures target before estimating total", () => { + const itemSize = (index: number) => (index < 10 ? 10 : 100); + const cachedBounds = createCachedBounds({ + itemCount: 100, + itemSize, + itemProps: {} + }); + cachedBounds.get(9); + expect( + getOffsetForIndex({ + align: "start", + cachedBounds, + itemCount: 100, + itemSize, + index: 90, + containerScrollOffset: 0, + containerSize: 100 + }) + ).toBe(8100); +}); + +test("center alignment uses the item midpoint near the start", () => { + const cachedBounds = createCachedBounds({ + itemCount: 100, + itemSize: 100, + itemProps: {} + }); + expect( + getOffsetForIndex({ + align: "center", + cachedBounds, + itemCount: 100, + itemSize: 100, + index: 2, + containerScrollOffset: 0, + containerSize: 450 + }) + ).toBe(25); +}); + +test.each([ + { + itemSize: 1000, + itemCount: 10, + index: 0, + containerSize: 100, + expected: 450 + }, + { itemSize: 10, itemCount: 2, index: 1, containerSize: 100, expected: 0 }, + { itemSize: 100, itemCount: 10, index: 9, containerSize: 450, expected: 550 } +])( + "clamps centered items to the scrollable extent: $expected", + ({ itemSize, itemCount, index, containerSize, expected }) => { + const cachedBounds = createCachedBounds({ + itemSize, + itemCount, + itemProps: {} + }); + expect( + getOffsetForIndex({ + align: "center", + cachedBounds, + itemSize, + itemCount, + index, + containerSize, + containerScrollOffset: 0 + }) + ).toBe(expected); + } +); diff --git a/lib/core/getOffsetForIndex.ts b/lib/core/getOffsetForIndex.ts index 02351444..69d2dfb1 100644 --- a/lib/core/getOffsetForIndex.ts +++ b/lib/core/getOffsetForIndex.ts @@ -63,18 +63,13 @@ export function getOffsetForIndex({ return minOffset; } case "center": { - if (bounds.scrollOffset <= containerSize / 2) { - // Too near the beginning to center-align - return 0; - } else if ( - bounds.scrollOffset + bounds.size / 2 >= - estimatedTotalSize - containerSize / 2 - ) { - // Too near the end to center-align - return estimatedTotalSize - containerSize; - } else { - return bounds.scrollOffset + bounds.size / 2 - containerSize / 2; - } + return Math.max( + 0, + Math.min( + estimatedTotalSize - containerSize, + bounds.scrollOffset + bounds.size / 2 - containerSize / 2 + ) + ); } case "auto": default: { diff --git a/lib/core/getStartStopIndices.test.ts b/lib/core/getStartStopIndices.test.ts index 17a60e8f..97452964 100644 --- a/lib/core/getStartStopIndices.test.ts +++ b/lib/core/getStartStopIndices.test.ts @@ -213,3 +213,123 @@ describe("getStartStopIndices", () => { }); }); }); + +test("quantify warm-cache work near the end of a large fixed-size list", () => { + const cachedBounds = createCachedBounds({ + itemCount: 100000, + itemProps: {}, + itemSize: 20 + }); + cachedBounds.get(99999); + let reads = 0; + const counted = { + ...cachedBounds, + get: (index: number) => { + reads++; + return cachedBounds.get(index); + } + }; + const result = getStartStopIndices({ + cachedBounds: counted, + containerScrollOffset: 1900000, + containerSize: 500, + itemCount: 100000, + overscanCount: 3 + }); + expect(result.startIndexVisible).toBe(95000); + expect(reads).toBe(0); +}); + +test("searches a populated variable-size cache without rescanning earlier rows", () => { + const cachedBounds = createCachedBounds({ + itemCount: 100000, + itemProps: {}, + itemSize: () => 20 + }); + cachedBounds.get(99999); + let reads = 0; + const counted = { + ...cachedBounds, + get: (index: number) => { + reads++; + return cachedBounds.get(index); + } + }; + const result = getStartStopIndices({ + cachedBounds: counted, + containerScrollOffset: 1900000, + containerSize: 500, + itemCount: 100000, + overscanCount: 3 + }); + expect(result).toEqual({ + startIndexVisible: 95000, + stopIndexVisible: 95024, + startIndexOverscan: 94997, + stopIndexOverscan: 95027 + }); + expect(reads).toBeLessThan(50); +}); + +test("initial variable-size lookup only measures the viewport", () => { + let measurements = 0; + const cachedBounds = createCachedBounds({ + itemCount: 100000, + itemProps: {}, + itemSize: () => { + measurements++; + return 20; + } + }); + getStartStopIndices({ + cachedBounds, + containerScrollOffset: 0, + containerSize: 500, + itemCount: 100000, + overscanCount: 3 + }); + expect(measurements).toBe(25); +}); + +test("matches a linear lookup across boundaries, zero sizes and scroll directions", () => { + for (const sizes of [ + [], + [0], + [20], + [0, 10, 0, 30, 5, 0, 15], + Array.from({ length: 100 }, (_, i) => (i * 17) % 31) + ]) { + const cachedBounds = createCachedBounds({ + itemCount: sizes.length, + itemProps: {}, + itemSize: (index) => sizes[index] + }); + const ends = sizes.map((_, index) => + sizes.slice(0, index + 1).reduce((sum, size) => sum + size, 0) + ); + for (const offset of [-10, 0, 1, 10, 40, 10000, 900, 100, 0]) { + for (const containerSize of [0, 1, 20, 100]) { + let start = 0; + while (start < sizes.length - 1 && ends[start] <= offset) start++; + let stop = start; + while (stop < sizes.length - 1 && ends[stop] < offset + containerSize) + stop++; + stop = Math.min(sizes.length - 1, stop); + expect( + getStartStopIndices({ + cachedBounds, + containerScrollOffset: offset, + containerSize, + itemCount: sizes.length, + overscanCount: 2 + }) + ).toEqual({ + startIndexVisible: start, + stopIndexVisible: stop, + startIndexOverscan: Math.max(0, start - 2), + stopIndexOverscan: Math.min(sizes.length - 1, stop + 2) + }); + } + } + } +}); diff --git a/lib/core/getStartStopIndices.ts b/lib/core/getStartStopIndices.ts index c38d84a3..d8416bde 100644 --- a/lib/core/getStartStopIndices.ts +++ b/lib/core/getStartStopIndices.ts @@ -20,52 +20,72 @@ export function getStartStopIndices({ } { const maxIndex = itemCount - 1; - let startIndexVisible = 0; - let stopIndexVisible = -1; - let startIndexOverscan = 0; - let stopIndexOverscan = -1; - let currentIndex = 0; - - while (currentIndex < maxIndex) { - const bounds = cachedBounds.get(currentIndex); - - if (bounds.scrollOffset + bounds.size > containerScrollOffset) { - break; - } - - currentIndex++; + if (itemCount === 0) { + return { + startIndexVisible: 0, + stopIndexVisible: -1, + startIndexOverscan: 0, + stopIndexOverscan: -1 + }; } - startIndexVisible = currentIndex; - startIndexOverscan = Math.max(0, startIndexVisible - overscanCount); - - while (currentIndex < maxIndex) { - const bounds = cachedBounds.get(currentIndex); + let startIndexVisible: number; + let stopIndexVisible: number; + const { itemSize } = cachedBounds; - if ( - bounds.scrollOffset + bounds.size >= - containerScrollOffset + containerSize - ) { - break; + if (itemSize !== undefined && itemSize > 0) { + startIndexVisible = Math.max( + 0, + Math.min(maxIndex, Math.floor(containerScrollOffset / itemSize)) + ); + stopIndexVisible = Math.max( + startIndexVisible, + Math.min( + maxIndex, + Math.ceil((containerScrollOffset + containerSize) / itemSize) - 1 + ) + ); + } else { + // Search the measured prefix first. Grow the search exponentially only + // when scrolling beyond it, so an initial render doesn't measure the list. + let low = 0; + let high = Math.min(maxIndex, Math.max(0, cachedBounds.size - 1)); + let step = 1; + while (high < maxIndex) { + const bounds = cachedBounds.get(high); + if (bounds.scrollOffset + bounds.size > containerScrollOffset) break; + low = high + 1; + high = Math.min(maxIndex, high + step); + step *= 2; } - currentIndex++; - } - - stopIndexVisible = Math.min(maxIndex, currentIndex); - stopIndexOverscan = Math.min(itemCount - 1, stopIndexVisible + overscanCount); + while (low < high) { + const middle = Math.floor((low + high) / 2); + const bounds = cachedBounds.get(middle); + if (bounds.scrollOffset + bounds.size > containerScrollOffset) { + high = middle; + } else { + low = middle + 1; + } + } - if (startIndexVisible < 0) { - startIndexVisible = 0; - stopIndexVisible = -1; - startIndexOverscan = 0; - stopIndexOverscan = -1; + startIndexVisible = low; + stopIndexVisible = low; + while (stopIndexVisible < maxIndex) { + const bounds = cachedBounds.get(stopIndexVisible); + if ( + bounds.scrollOffset + bounds.size >= + containerScrollOffset + containerSize + ) + break; + stopIndexVisible++; + } } return { startIndexVisible, stopIndexVisible, - startIndexOverscan, - stopIndexOverscan + startIndexOverscan: Math.max(0, startIndexVisible - overscanCount), + stopIndexOverscan: Math.min(maxIndex, stopIndexVisible + overscanCount) }; } diff --git a/lib/core/types.ts b/lib/core/types.ts index d9f409f6..85237890 100644 --- a/lib/core/types.ts +++ b/lib/core/types.ts @@ -4,6 +4,10 @@ export type Bounds = { }; export type CachedBounds = { + /** + * Fixed size, when bounds can be calculated without measuring earlier items. + */ + itemSize?: number; get(index: number): Bounds; set(index: number, bounds: Bounds): void; size: number; diff --git a/lib/core/useItemSize.test.ts b/lib/core/useItemSize.test.ts new file mode 100644 index 00000000..e89c90f8 --- /dev/null +++ b/lib/core/useItemSize.test.ts @@ -0,0 +1,6 @@ +import { expect, test } from "vitest"; +import { useItemSize } from "./useItemSize"; + +test("fractional percentage sizes are preserved", () => { + expect(useItemSize({ containerSize: 1000, itemSize: "0.5%" })).toBe(5); +}); diff --git a/lib/core/useItemSize.ts b/lib/core/useItemSize.ts index b86520e3..af838d8a 100644 --- a/lib/core/useItemSize.ts +++ b/lib/core/useItemSize.ts @@ -20,7 +20,7 @@ export function useItemSize({ "Container size must be defined if a percentage item size is specified" ); - itemSize = (containerSize * parseInt(itemSizeProp)) / 100; + itemSize = (containerSize * parseFloat(itemSizeProp)) / 100; break; } default: { diff --git a/lib/core/useVirtualizer.ts b/lib/core/useVirtualizer.ts index 11aabf1f..38b77438 100644 --- a/lib/core/useVirtualizer.ts +++ b/lib/core/useVirtualizer.ts @@ -209,6 +209,7 @@ export function useVirtualizer({ containerElement, containerSize, direction, + isRtl, itemCount, overscanCount ]); @@ -226,7 +227,12 @@ export function useVirtualizer({ let scrollOffset = getOffsetForIndex({ align, cachedBounds, - containerScrollOffset, + containerScrollOffset: adjustScrollOffsetForRtl({ + containerElement, + direction, + isRtl, + scrollOffset: containerScrollOffset + }), containerSize, index, itemCount, diff --git a/lib/hooks/useMemoizedObject.test.ts b/lib/hooks/useMemoizedObject.test.ts index cfac99ca..e71d5bb9 100644 --- a/lib/hooks/useMemoizedObject.test.ts +++ b/lib/hooks/useMemoizedObject.test.ts @@ -50,3 +50,35 @@ describe("useMemoizedObject", () => { }); }); }); + +test("changing prop names with identical values updates the props", () => { + const { result, rerender } = renderHook( + (props: object) => useMemoizedObject(props), + { initialProps: { a: 1 } as object } + ); + rerender({ b: 1 }); + expect(result.current).toEqual({ b: 1 }); +}); + +test("adding an optional prop updates the props", () => { + const { result, rerender } = renderHook( + (props: object) => useMemoizedObject(props), + { initialProps: { a: 1 } as object } + ); + rerender({ a: 1, b: 2 }); + expect(result.current).toEqual({ a: 1, b: 2 }); +}); + +test("removes optional props and compares undefined-valued keys", () => { + const { result, rerender } = renderHook( + (props: object) => useMemoizedObject(props), + { initialProps: { a: 1, b: undefined } as object } + ); + rerender({ a: 1, c: undefined }); + expect(result.current).toEqual({ a: 1, c: undefined }); + rerender({ a: 1 }); + expect(result.current).toEqual({ a: 1 }); + const previous = result.current; + rerender({ a: 1 }); + expect(result.current).toBe(previous); +}); diff --git a/lib/hooks/useMemoizedObject.ts b/lib/hooks/useMemoizedObject.ts index c905c73e..f598dc3d 100644 --- a/lib/hooks/useMemoizedObject.ts +++ b/lib/hooks/useMemoizedObject.ts @@ -1,10 +1,16 @@ -import { useMemo } from "react"; +import { useState } from "react"; +import { shallowCompare } from "../utils/shallowCompare"; export function useMemoizedObject( unstableObject: Type ): Type { - return useMemo(() => { + const [memoizedObject, setMemoizedObject] = useState(unstableObject); + + if (!shallowCompare(memoizedObject, unstableObject)) { + setMemoizedObject(unstableObject); + return unstableObject; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, Object.values(unstableObject)); + } + + return memoizedObject; } diff --git a/lib/utils/adjustScrollOffsetForRtl.ts b/lib/utils/adjustScrollOffsetForRtl.ts index 49bef8d2..bd6c7a04 100644 --- a/lib/utils/adjustScrollOffsetForRtl.ts +++ b/lib/utils/adjustScrollOffsetForRtl.ts @@ -23,8 +23,8 @@ export function adjustScrollOffsetForRtl({ } case "positive-descending": { if (containerElement) { - const { clientWidth, scrollLeft, scrollWidth } = containerElement; - return scrollWidth - clientWidth - scrollLeft; + const { clientWidth, scrollWidth } = containerElement; + return scrollWidth - clientWidth - scrollOffset; } break; } diff --git a/lib/utils/shallowCompare.ts b/lib/utils/shallowCompare.ts index 3e324ca2..1d1ee8f4 100644 --- a/lib/utils/shallowCompare.ts +++ b/lib/utils/shallowCompare.ts @@ -20,7 +20,7 @@ export function shallowCompare( } for (const key in a) { - if (!Object.is(b[key], a[key])) { + if (!Object.hasOwn(b, key) || !Object.is(b[key], a[key])) { return false; } } diff --git a/lib/virtualization.test.tsx b/lib/virtualization.test.tsx new file mode 100644 index 00000000..64c30820 --- /dev/null +++ b/lib/virtualization.test.tsx @@ -0,0 +1,226 @@ +import { act, render, renderHook } from "@testing-library/react"; +import { createRef } from "react"; +import { beforeEach, expect, test, vi } from "vitest"; +import { Grid } from "./components/grid/Grid"; +import type { + CellComponentProps, + GridImperativeAPI +} from "./components/grid/types"; +import { List } from "./components/list/List"; +import type { + DynamicRowHeight, + RowComponentProps +} from "./components/list/types"; +import { useDynamicRowHeight } from "./components/list/useDynamicRowHeight"; +import { useVirtualizer } from "./core/useVirtualizer"; +import { getRTLOffsetType } from "./utils/getRTLOffsetType"; +import { setElementSize } from "./utils/test/mockResizeObserver"; + +vi.mock("./utils/getRTLOffsetType", () => ({ getRTLOffsetType: vi.fn() })); + +beforeEach(() => { + vi.mocked(getRTLOffsetType).mockReturnValue("negative"); +}); + +test("RTL auto alignment preserves an already visible column", () => { + const gridRef = createRef(); + function Cell({ style }: CellComponentProps) { + return
; + } + render( + + ); + act(() => { + gridRef.current!.element!.scrollTo({ left: -200 }); + }); + act(() => { + gridRef.current!.scrollToColumn({ index: 5 }); + }); + expect(gridRef.current!.element!.scrollLeft).toBe(-200); +}); + +test("switching RTL updates the scroll listener", () => { + const containerElement = document.createElement("div"); + const itemProps = {}; + const { result, rerender } = renderHook( + (isRtl: boolean) => + useVirtualizer({ + containerElement, + containerStyle: { width: 200, height: 100 }, + direction: "horizontal", + isRtl, + itemCount: 100, + itemProps, + itemSize: 50, + overscanCount: 0, + onResize: undefined + }), + { initialProps: false } + ); + rerender(true); + act(() => { + containerElement.scrollTo({ left: -200 }); + }); + expect(result.current.startIndexVisible).toBe(4); +}); + +test("inherited RTL uses RTL coordinates when scrolling", () => { + const gridRef = createRef(); + function Cell({ columnIndex, style }: CellComponentProps) { + return
; + } + const { container } = render( +
+ +
+ ); + act(() => { + gridRef.current!.element!.scrollTo({ left: -200 }); + }); + expect( + container.querySelector("[data-column]")!.getAttribute("data-column") + ).toBe("4"); +}); + +test("custom row key reordering updates measurement indices", () => { + const rowHeight = { + getRowHeight: () => 50, + getAverageRowHeight: () => 50, + setRowHeight: vi.fn(), + observeRowElements: () => () => {} + }; + const rowKey = (index: number, { data }: { data: string[] }) => data[index]; + function Row({ index, data, style }: RowComponentProps<{ data: string[] }>) { + return
{data[index]}
; + } + const { container, rerender } = render( + + ); + rerender( + + ); + expect(container.firstElementChild!.firstElementChild!.textContent).toBe("b"); + expect( + container.firstElementChild!.firstElementChild!.getAttribute( + "data-react-window-index" + ) + ).toBe("0"); +}); + +test.each(["negative", "positive-ascending", "positive-descending"] as const)( + "normalizes both directions of imperative RTL scrolling (%s)", + (offsetType) => { + vi.mocked(getRTLOffsetType).mockReturnValue(offsetType); + const gridRef = createRef(); + function Cell({ style }: CellComponentProps) { + return
; + } + render( + + ); + const element = gridRef.current!.element!; + Object.defineProperties(element, { + scrollWidth: { value: 5000 }, + clientWidth: { value: 200 } + }); + const toNative = (offset: number) => + offsetType === "negative" + ? -offset + : offsetType === "positive-descending" + ? 4800 - offset + : offset; + act(() => { + element.scrollTo({ left: toNative(200) }); + }); + act(() => { + gridRef.current!.scrollToColumn({ index: 5 }); + }); + expect(element.scrollLeft).toBe(toNative(200)); + act(() => { + gridRef.current!.scrollToColumn({ index: 10, align: "start" }); + }); + expect(element.scrollLeft).toBe(toNative(500)); + act(() => { + gridRef.current!.scrollToCell({ + columnIndex: 5, + columnAlign: "smart", + rowIndex: 0 + }); + }); + expect(element.scrollLeft).toBe(toNative(175)); + } +); + +test("updates the correct height cache entry after keyed rows reorder", () => { + let rowHeight: DynamicRowHeight; + const rowKey = (index: number, { data }: { data: string[] }) => data[index]; + function Row({ index, data, style }: RowComponentProps<{ data: string[] }>) { + return
{data[index]}
; + } + function Example({ data }: { data: string[] }) { + rowHeight = useDynamicRowHeight({ defaultRowHeight: 50 }); + return ( + + ); + } + const { container, rerender } = render(); + rerender(); + const firstRow = container.firstElementChild! + .firstElementChild! as HTMLElement; + act(() => { + setElementSize({ element: firstRow, height: 80, width: 100 }); + }); + expect(rowHeight!.getRowHeight(0)).toBe(80); + expect(rowHeight!.getRowHeight(1)).toBe(50); +});