From 9e19dce953d6652f563e4052dcb6bbfc3d185dd5 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Sun, 20 Sep 2026 16:34:41 -0400 Subject: [PATCH] Improve scrollToRow accuracy for dynamic height rows --- .../tests/tests/dynamic-scroll.spec.tsx | 172 +++++++++++++++++ integrations/vite/src/main.tsx | 2 + integrations/vite/src/routes/ListDynamic.tsx | 79 ++++++++ lib/components/list/List.test.tsx | 82 ++++++++ lib/components/list/List.tsx | 66 ++++--- lib/components/list/types.ts | 12 ++ .../list/useDynamicRowHeight.test.ts | 16 ++ lib/components/list/useDynamicRowHeight.ts | 7 +- lib/components/list/useScrollToRow.ts | 179 ++++++++++++++++++ lib/core/getOffsetForIndex.test.ts | 48 +++++ lib/core/getOffsetForIndex.ts | 26 +-- public/generated/docs/ListImperativeAPI.json | 6 +- public/generated/site-map.json | 4 +- src/routes/list/DynamicRowHeightsRoute.tsx | 4 + 14 files changed, 648 insertions(+), 55 deletions(-) create mode 100644 integrations/tests/tests/dynamic-scroll.spec.tsx create mode 100644 integrations/vite/src/routes/ListDynamic.tsx create mode 100644 lib/components/list/useScrollToRow.ts diff --git a/integrations/tests/tests/dynamic-scroll.spec.tsx b/integrations/tests/tests/dynamic-scroll.spec.tsx new file mode 100644 index 00000000..6eb56f01 --- /dev/null +++ b/integrations/tests/tests/dynamic-scroll.spec.tsx @@ -0,0 +1,172 @@ +import { expect, test, type Page } from "@playwright/test"; + +const HOST = "http://localhost:3012"; + +async function settle(page: Page) { + await page.evaluate(async () => { + for (let i = 0; i < 75; i++) await new Promise(requestAnimationFrame); + }); +} + +async function bounds(page: Page, index: number) { + return page.evaluate((index) => { + const list = document.querySelector('[role="list"]')!; + const row = list.querySelector(`[data-index="${index}"]`); + if (!row) return null; + const viewport = list.getBoundingClientRect(); + const rect = row.getBoundingClientRect(); + return { + top: rect.top - viewport.top, + bottom: rect.bottom - viewport.top, + center: (rect.top + rect.bottom) / 2 - viewport.top + }; + }, index); +} + +for (const scale of [0.5, 1, 2]) { + test(`settles promptly under ancestor scale ${scale}`, async ({ page }) => { + await page.goto(`${HOST}/list-dynamic?estimate=25&align=start`); + // Exercise fractional border-box sizes as well as transformed geometry. + await page.addStyleTag({ + content: + '[role="listitem"] { height: 40.5px !important; border: 2px solid; padding: 3px; box-sizing: border-box; }' + }); + const frames = await page.evaluate(async (scale) => { + document.body.style.transform = `scale(${scale})`; + const requestFrame = window.requestAnimationFrame.bind(window); + let corrections = 0; + window.requestAnimationFrame = (callback) => { + corrections++; + return requestFrame(callback); + }; + try { + document.querySelector("button")!.click(); + // Use the original function so the test's own frames are not counted. + for (let index = 0; index < 40; index++) { + await new Promise(requestFrame); + } + return corrections; + } finally { + window.requestAnimationFrame = requestFrame; + } + }, scale); + expect(frames).toBeGreaterThan(0); + expect(frames).toBeLessThan(20); + const rect = await bounds(page, 250); + expect(rect).not.toBeNull(); + expect(Math.abs(rect!.top / scale)).toBeLessThanOrEqual(1); + }); +} + +for (const estimate of [10, 120]) { + for (const align of ["auto", "start", "center", "end", "smart"]) { + test(`corrects ${align} alignment with estimate ${estimate}`, async ({ + page + }) => { + await page.goto( + `${HOST}/list-dynamic?estimate=${estimate}&align=${align}` + ); + await page.getByText("Jump", { exact: true }).click(); + await settle(page); + const rect = await bounds(page, 250); + expect(rect).not.toBeNull(); + if (!rect) return; + if (align === "start") expect(Math.abs(rect.top)).toBeLessThanOrEqual(1); + else if (align === "center" || align === "smart") + expect(Math.abs(rect.center - 100)).toBeLessThanOrEqual(1); + else if (align === "end") + expect(Math.abs(rect.bottom - 200)).toBeLessThanOrEqual(1); + else { + expect(rect.top).toBeGreaterThanOrEqual(-1); + expect(rect.bottom).toBeLessThanOrEqual(201); + } + }); + } +} + +test("reaches the last row", async ({ page }) => { + await page.goto(`${HOST}/list-dynamic?estimate=10&index=499&align=end`); + await page.getByText("Jump", { exact: true }).click(); + await settle(page); + const rect = await bounds(page, 499); + expect(rect).not.toBeNull(); + expect(Math.abs(rect!.bottom - 200)).toBeLessThanOrEqual(1); +}); + +test("scrolls to the last row without measuring additional children", async ({ + page +}) => { + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto( + `${HOST}/list-dynamic?estimate=25&index=499&align=end&overlay` + ); + await page.getByText("Jump", { exact: true }).click(); + await settle(page); + expect(errors).toEqual([]); + await expect(page.getByTestId("overlay")).not.toHaveAttribute( + "data-react-window-index" + ); + const rect = await bounds(page, 499); + expect(rect).not.toBeNull(); + expect(Math.abs(rect!.bottom - 200)).toBeLessThanOrEqual(1); +}); + +test("auto alignment settles for rows taller than the viewport", async ({ + page +}) => { + await page.goto(`${HOST}/list-dynamic?estimate=10&tall`); + await page.getByText("Jump", { exact: true }).click(); + await settle(page); + const rect = await bounds(page, 250); + expect(rect).not.toBeNull(); + expect(rect!.top).toBeLessThanOrEqual(1); + expect(rect!.bottom).toBeGreaterThanOrEqual(199); + await settle(page); + expect(await bounds(page, 250)).toEqual(rect); +}); + +for (const event of ["wheel", "touchstart", "pointerdown", "keydown"]) { + test(`cancels correction on ${event}`, async ({ page }) => { + await page.goto(`${HOST}/list-dynamic?estimate=10`); + await page.evaluate((event) => { + document.querySelector("button")!.click(); + const list = document.querySelector('[role="list"]')!; + list.dispatchEvent( + event === "keydown" + ? new KeyboardEvent(event, { key: "Home" }) + : new Event(event) + ); + list.scrollTo({ top: 0, behavior: "instant" }); + }, event); + await settle(page); + expect( + await page.locator('[role="list"]').evaluate((el) => el.scrollTop) + ).toBe(0); + }); +} + +test("a newer request replaces the pending target", async ({ page }) => { + await page.goto(`${HOST}/list-dynamic?estimate=10`); + await page.evaluate(() => { + const buttons = document.querySelectorAll("button"); + buttons[0].click(); + buttons[1].click(); + }); + await settle(page); + expect(Math.abs((await bounds(page, 20))!.top)).toBeLessThanOrEqual(1); +}); + +test("removing the target cancels correction", async ({ page }) => { + const errors: Error[] = []; + page.on("pageerror", (error) => errors.push(error)); + await page.goto(`${HOST}/list-dynamic?estimate=10`); + await page.evaluate(() => { + const buttons = document.querySelectorAll("button"); + buttons[0].click(); + buttons[2].click(); + }); + await settle(page); + expect(errors).toEqual([]); + expect(await bounds(page, 250)).toBeNull(); +}); diff --git a/integrations/vite/src/main.tsx b/integrations/vite/src/main.tsx index 6ce5ab56..d22a22c5 100644 --- a/integrations/vite/src/main.tsx +++ b/integrations/vite/src/main.tsx @@ -4,6 +4,7 @@ import { BrowserRouter, Route, Routes } from "react-router"; import { DecoderRoute } from "./routes/Decoder"; import { HomeRoute } from "./routes/Home"; import { GridRoute } from "./routes/Grid"; +import { ListDynamicRoute } from "./routes/ListDynamic"; import { ListRoute } from "./routes/List"; import "./tailwind.css"; @@ -14,6 +15,7 @@ createRoot(document.getElementById("root")!).render( } /> } /> + } /> } /> } /> diff --git a/integrations/vite/src/routes/ListDynamic.tsx b/integrations/vite/src/routes/ListDynamic.tsx new file mode 100644 index 00000000..507f45ec --- /dev/null +++ b/integrations/vite/src/routes/ListDynamic.tsx @@ -0,0 +1,79 @@ +import { useState } from "react"; +import { + List, + useDynamicRowHeight, + useListRef, + type RowComponentProps +} from "react-window"; + +export function ListDynamicRoute() { + const params = new URLSearchParams(location.search); + const listRef = useListRef(null); + const [rowCount, setRowCount] = useState(500); + const rowHeight = useDynamicRowHeight({ + defaultRowHeight: Number(params.get("estimate") ?? 25) + }); + return ( + <> + + + + + Overlay + + ) : undefined + } + listRef={listRef} + style={{ height: 200, width: 300 }} + rowCount={rowCount} + rowHeight={rowHeight} + rowComponent={Row} + rowProps={{ tall: params.has("tall") }} + /> + + ); +} + +function Row({ + index, + style, + ariaAttributes, + tall +}: RowComponentProps<{ tall: boolean }>) { + return ( +
+ Row {index} +
+ ); +} diff --git a/lib/components/list/List.test.tsx b/lib/components/list/List.test.tsx index f4424924..98b823a3 100644 --- a/lib/components/list/List.test.tsx +++ b/lib/components/list/List.test.tsx @@ -583,6 +583,88 @@ describe("List", () => { }); describe("type: DynamicRowHeight", () => { + test.each([0, 2, 100])( + "does not observe additional children with %s rows", + (rowCount) => { + const observeRowElements = vi.fn< + (elements: Element[] | NodeListOf) => () => void + >(() => () => {}); + const rowHeight = { + getRowHeight: () => 25, + getAverageRowHeight: () => 25, + setRowHeight: () => {}, + observeRowElements + }; + render( + +
Overlay
+
+ ); + expect(screen.getByTestId("overlay")).not.toHaveAttribute( + DATA_ATTRIBUTE_LIST_INDEX + ); + expect(observeRowElements).toHaveBeenCalled(); + for (const [rows] of observeRowElements.mock.calls) { + expect(rows.length).toBeLessThanOrEqual(rowCount); + for (const row of rows) + expect(row).toHaveAttribute("role", "listitem"); + } + } + ); + + test.each(["unmount", "timeout"])( + "stops pending correction on %s", + (reason) => { + let frame: FrameRequestCallback | undefined; + const requestFrame = vi + .spyOn(window, "requestAnimationFrame") + .mockImplementation((callback) => { + frame = callback; + return 42; + }); + const cancelFrame = vi + .spyOn(window, "cancelAnimationFrame") + .mockImplementation(() => {}); + const now = vi.spyOn(performance, "now").mockReturnValue(0); + const listRef = createRef(); + function PendingScrollExample() { + const rowHeight = useDynamicRowHeight({ defaultRowHeight: 25 }); + return ( + + ); + } + const { unmount } = render(); + try { + act(() => listRef.current?.scrollToRow({ index: 50 })); + expect(requestFrame).toHaveBeenCalledTimes(1); + if (reason === "unmount") { + unmount(); + expect(cancelFrame).toHaveBeenCalledWith(42); + } else { + now.mockReturnValue(1001); + act(() => frame?.(1001)); + expect(requestFrame).toHaveBeenCalledTimes(1); + } + } finally { + unmount(); + requestFrame.mockRestore(); + cancelFrame.mockRestore(); + now.mockRestore(); + } + } + ); + let onRowsRendered: ReturnType; function Example() { diff --git a/lib/components/list/List.tsx b/lib/components/list/List.tsx index 00903077..a30e90c0 100644 --- a/lib/components/list/List.tsx +++ b/lib/components/list/List.tsx @@ -6,6 +6,7 @@ import { useEffect, useImperativeHandle, useMemo, + useRef, useState, type ReactElement, type ReactNode @@ -13,10 +14,11 @@ import { import { useVirtualizer } from "../../core/useVirtualizer"; import { useIsomorphicLayoutEffect } from "../../hooks/useIsomorphicLayoutEffect"; import { useMemoizedObject } from "../../hooks/useMemoizedObject"; -import type { Align, TagNames } from "../../types"; +import type { TagNames } from "../../types"; import { arePropsEqual } from "../../utils/arePropsEqual"; import { isDynamicRowHeight as isDynamicRowHeightUtil } from "./isDynamicRowHeight"; import type { ListProps } from "./types"; +import { useScrollToRow } from "./useScrollToRow"; export const DATA_ATTRIBUTE_LIST_INDEX = "data-react-window-index"; @@ -86,37 +88,25 @@ export function List< overscanCount }); + const rowElements = useRef([]); + const scrollToRow = useScrollToRow({ + element, + getCellBounds, + isDynamicRowHeight, + rowCount, + rowElements, + scrollToIndex + }); + useImperativeHandle( listRef, () => ({ get element() { return element; }, - - scrollToRow({ - align = "auto", - behavior = "auto", - index - }: { - align?: Align; - behavior?: ScrollBehavior; - index: number; - }) { - const top = scrollToIndex({ - align, - containerScrollOffset: element?.scrollTop ?? 0, - index - }); - - if (typeof element?.scrollTo === "function") { - element.scrollTo({ - behavior, - top - }); - } - } + scrollToRow }), - [element, scrollToIndex] + [element, scrollToRow] ); useIsomorphicLayoutEffect(() => { @@ -124,17 +114,25 @@ export function List< return; } - const rows = Array.from(element.children).filter((item, index) => { - if (item.hasAttribute("aria-hidden")) { - // Ignore sizing element - return false; - } + // Additional children (e.g. overlays) are not rows and must not be measured. + const rows = Array.from(element.children) + .slice( + 0, + startIndexOverscan < 0 ? 0 : stopIndexOverscan - startIndexOverscan + 1 + ) + .filter((item, index) => { + if (item.hasAttribute("aria-hidden")) { + // Ignore sizing element + return false; + } + + const attribute = `${startIndexOverscan + index}`; + item.setAttribute(DATA_ATTRIBUTE_LIST_INDEX, attribute); - const attribute = `${startIndexOverscan + index}`; - item.setAttribute(DATA_ATTRIBUTE_LIST_INDEX, attribute); + return true; + }); - return true; - }); + rowElements.current = rows; if (isDynamicRowHeight) { return rowHeightProp.observeRowElements(rows); diff --git a/lib/components/list/types.ts b/lib/components/list/types.ts index 92fcc18d..0f2576ec 100644 --- a/lib/components/list/types.ts +++ b/lib/components/list/types.ts @@ -57,6 +57,12 @@ export type ListProps< /** * Scrolls the list so that the specified row is visible. * + * Dynamic row heights are measured after rendering; + * Content that resizes later may require another scrollToRow call. + * + * ⚠️ `behavior` "smooth" is ignored for dynamic height lists; + * `scrollToRow` is instant as it requires rows to be lazily measured + * * @param align Determines the vertical alignment of the element within the list * @param behavior Determines whether scrolling is instant or animates smoothly * @param index Index of the row to scroll to (0-based) @@ -200,6 +206,12 @@ export interface ListImperativeAPI { /** * Scrolls the list so that the specified row is visible. * + * Dynamic row heights are measured after rendering; + * Content that resizes later may require another scrollToRow call. + * + * ⚠️ `behavior` "smooth" is ignored for dynamic height lists; + * `scrollToRow` is instant as it requires rows to be lazily measured + * * @param align Determines the vertical alignment of the element within the list * @param behavior Determines whether scrolling is instant or animates smoothly * @param index Index of the row to scroll to (0-based) diff --git a/lib/components/list/useDynamicRowHeight.test.ts b/lib/components/list/useDynamicRowHeight.test.ts index d6fea725..123d6b6c 100644 --- a/lib/components/list/useDynamicRowHeight.test.ts +++ b/lib/components/list/useDynamicRowHeight.test.ts @@ -7,6 +7,22 @@ import { NOOP_FUNCTION } from "../../../src/constants"; describe("useDynamicRowHeight", () => { describe("getAverageRowHeight", () => { + test("does not include estimates read by getRowHeight", () => { + const { result } = renderHook(() => + useDynamicRowHeight({ defaultRowHeight: 100 }) + ); + for (let index = 0; index < 250; index++) { + expect(result.current.getRowHeight(index)).toBe(100); + } + act(() => { + result.current.setRowHeight(0, 20); + result.current.setRowHeight(1, 40); + }); + expect(result.current.getAverageRowHeight()).toBe(30); + expect(result.current.getRowHeight(200)).toBe(100); + expect(result.current.getAverageRowHeight()).toBe(30); + }); + test("returns an initial estimate based on the defaultRowHeight", () => { const { result } = renderHook(() => useDynamicRowHeight({ diff --git a/lib/components/list/useDynamicRowHeight.ts b/lib/components/list/useDynamicRowHeight.ts index f5d9c654..601fc6a3 100644 --- a/lib/components/list/useDynamicRowHeight.ts +++ b/lib/components/list/useDynamicRowHeight.ts @@ -49,11 +49,8 @@ export function useDynamicRowHeight({ return measuredHeight; } - // Temporarily store default height in the cache map to avoid scroll jumps if rowProps change - // Else rowProps changes can impact the average height, and cause rows to shift up or down within the list - // see github.com/bvaughn/react-window/issues/863 - map.set(index, defaultRowHeight); - + // Keep estimates stable when rowProps change (see issue #863), but do not + // store them as measurements or include them in the measured average. return defaultRowHeight; }, [defaultRowHeight, map] diff --git a/lib/components/list/useScrollToRow.ts b/lib/components/list/useScrollToRow.ts new file mode 100644 index 00000000..7ec11b7d --- /dev/null +++ b/lib/components/list/useScrollToRow.ts @@ -0,0 +1,179 @@ +import { useCallback, useRef, type RefObject } from "react"; +import type { Bounds } from "../../core/types"; +import { useIsomorphicLayoutEffect } from "../../hooks/useIsomorphicLayoutEffect"; +import { useStableCallback } from "../../hooks/useStableCallback"; +import type { Align } from "../../types"; +import { DATA_ATTRIBUTE_LIST_INDEX } from "./List"; +import type { ListImperativeAPI } from "./types"; + +export function useScrollToRow({ + element, + getCellBounds, + isDynamicRowHeight, + rowCount, + rowElements, + scrollToIndex +}: { + element: HTMLDivElement | null; + getCellBounds: (index: number) => Bounds; + isDynamicRowHeight: boolean; + rowCount: number; + rowElements: RefObject; + scrollToIndex: (options: { + align?: Align; + containerScrollOffset: number; + index: number; + }) => number | undefined; +}): ListImperativeAPI["scrollToRow"] { + const pendingScroll = useRef<{ + align: Align; + index: number; + deadline: number; + stableFrames: number; + } | null>(null); + + const scrollFrame = useRef(undefined); + + const cancelScroll = useStableCallback(() => { + pendingScroll.current = null; + if (scrollFrame.current !== undefined) { + cancelAnimationFrame(scrollFrame.current); + scrollFrame.current = undefined; + } + }); + + const correctScroll = useStableCallback(() => { + scrollFrame.current = undefined; + const request = pendingScroll.current; + if (!request) return; + + if ( + !element || + !isDynamicRowHeight || + request.index >= rowCount || + performance.now() >= request.deadline + ) { + cancelScroll(); + return; + } + + const offset = scrollToIndex({ + align: request.align, + containerScrollOffset: element.scrollTop, + index: request.index + }); + if (offset !== undefined) { + // Use the committed DOM extent: the browser clamps offsets near the end. + const top = Math.max( + 0, + Math.min(offset, element.scrollHeight - element.clientHeight) + ); + if (Math.abs(top - element.scrollTop) > 1) { + request.stableFrames = 0; + element.scrollTo({ behavior: "instant", top }); + } else { + // An unchanged estimated offset is not enough: + // React may not have committed the destination rows or their ResizeObserver updates yet. + const rows = rowElements.current; + const targetIsRendered = rows.some( + (row) => + row.getAttribute(DATA_ATTRIBUTE_LIST_INDEX) === `${request.index}` + ); + const measurementsCommitted = + targetIsRendered && + rows.every((row) => { + const index = Number(row.getAttribute(DATA_ATTRIBUTE_LIST_INDEX)); + // ResizeObserver reports untransformed border-box sizes. + // offsetHeight uses the same coordinate space; allow for its integer rounding. + return ( + Math.abs( + getCellBounds(index).size - (row as HTMLElement).offsetHeight + ) <= 1 + ); + }); + if (!measurementsCommitted) { + request.stableFrames = 0; + } else if (++request.stableFrames >= 2) { + cancelScroll(); + return; + } + } + } + scrollFrame.current = requestAnimationFrame(correctScroll); + }); + + useIsomorphicLayoutEffect(() => { + if (!element) return; + + const onKeyDown = (event: KeyboardEvent) => { + switch (event.key) { + case "ArrowUp": + case "ArrowDown": + case "PageUp": + case "PageDown": + case "Home": + case "End": + case " ": { + cancelScroll(); + } + } + }; + + element.addEventListener("wheel", cancelScroll, { passive: true }); + element.addEventListener("touchstart", cancelScroll, { passive: true }); + element.addEventListener("pointerdown", cancelScroll); + element.addEventListener("keydown", onKeyDown); + + return () => { + cancelScroll(); + + element.removeEventListener("wheel", cancelScroll); + element.removeEventListener("touchstart", cancelScroll); + element.removeEventListener("pointerdown", cancelScroll); + element.removeEventListener("keydown", onKeyDown); + }; + }, [cancelScroll, element]); + + return useCallback( + ({ + align = "auto", + behavior = "auto", + index + }: Parameters[0]) => { + // Resolve smart alignment once, before measurements move the target. + if (isDynamicRowHeight && align === "smart") { + const autoOffset = scrollToIndex({ + align: "auto", + containerScrollOffset: element?.scrollTop ?? 0, + index + }); + align = autoOffset === (element?.scrollTop ?? 0) ? "auto" : "center"; + } + + const top = scrollToIndex({ + align, + containerScrollOffset: element?.scrollTop ?? 0, + index + }); + + cancelScroll(); + + if (typeof element?.scrollTo === "function") { + if (isDynamicRowHeight) { + pendingScroll.current = { + align, + index, + deadline: performance.now() + 1000, + stableFrames: 0 + }; + scrollFrame.current = requestAnimationFrame(correctScroll); + } + element.scrollTo({ + behavior: isDynamicRowHeight ? "instant" : behavior, + top + }); + } + }, + [cancelScroll, correctScroll, element, isDynamicRowHeight, scrollToIndex] + ); +} diff --git a/lib/core/getOffsetForIndex.test.ts b/lib/core/getOffsetForIndex.test.ts index 71915eab..8fc3469d 100644 --- a/lib/core/getOffsetForIndex.test.ts +++ b/lib/core/getOffsetForIndex.test.ts @@ -29,6 +29,54 @@ describe("getOffsetForIndex", () => { }; describe("align", () => { + test.each(["auto", "smart"] as const)( + "%s scrolls to an offscreen row when the initial total is underestimated", + (align) => { + const itemSize = (index: number) => (index === 0 ? 1 : 100); + const cachedBounds = createCachedBounds({ + itemCount: 100, + itemProps: EMPTY_OBJECT, + itemSize + }); + expect( + getOffsetForIndex({ + align, + cachedBounds, + itemSize, + itemCount: 100, + index: 50, + containerSize: 100, + containerScrollOffset: 0 + }) + ).toBe(4901); + } + ); + + test.each(["auto", "smart"] as const)( + "%s preserves the offset inside an oversized row", + (align) => { + const itemSize = 200; + const cachedBounds = createCachedBounds({ + itemCount: 10, + itemProps: EMPTY_OBJECT, + itemSize + }); + for (const containerScrollOffset of [400, 450, 500]) { + expect( + getOffsetForIndex({ + ...DEFAULT_ARGS, + align, + cachedBounds, + itemSize, + index: 2, + containerSize: 100, + containerScrollOffset + }) + ).toBe(containerScrollOffset); + } + } + ); + function createTestHelper(align: Align) { return function testHelperAuto( index: number, diff --git a/lib/core/getOffsetForIndex.ts b/lib/core/getOffsetForIndex.ts index c1a0614e..02351444 100644 --- a/lib/core/getOffsetForIndex.ts +++ b/lib/core/getOffsetForIndex.ts @@ -25,13 +25,14 @@ export function getOffsetForIndex({ }); } + // Populate the target bounds before estimating the total from cached sizes. + const bounds = cachedBounds.get(index); const estimatedTotalSize = getEstimatedSize({ cachedBounds, itemCount, itemSize }); - const bounds = cachedBounds.get(index); const maxOffset = Math.max( 0, Math.min(estimatedTotalSize - containerSize, bounds.scrollOffset) @@ -41,15 +42,17 @@ export function getOffsetForIndex({ bounds.scrollOffset - containerSize + bounds.size ); + // Visibility depends on the row itself, not the estimated scroll extent. + // For oversized rows, leave the offset alone when the row fills the viewport. + const isVisible = + bounds.size > containerSize + ? containerScrollOffset >= bounds.scrollOffset && + containerScrollOffset <= minOffset + : containerScrollOffset >= minOffset && + containerScrollOffset <= bounds.scrollOffset; + if (align === "smart") { - if ( - containerScrollOffset >= minOffset && - containerScrollOffset <= maxOffset - ) { - align = "auto"; - } else { - align = "center"; - } + align = isVisible ? "auto" : "center"; } switch (align) { @@ -75,10 +78,7 @@ export function getOffsetForIndex({ } case "auto": default: { - if ( - containerScrollOffset >= minOffset && - containerScrollOffset <= maxOffset - ) { + if (isVisible) { return containerScrollOffset; } else if (containerScrollOffset < minOffset) { return minOffset; diff --git a/public/generated/docs/ListImperativeAPI.json b/public/generated/docs/ListImperativeAPI.json index 94433159..06d5e5f7 100644 --- a/public/generated/docs/ListImperativeAPI.json +++ b/public/generated/docs/ListImperativeAPI.json @@ -13,7 +13,11 @@ { "description": [ { - "content": "

Scrolls the list so that the specified row is visible.

\n" + "content": "

Scrolls the list so that the specified row is visible.\nDynamic row heights are measured after rendering;\nContent that resizes later may require another scrollToRow call.

\n" + }, + { + "content": "

behavior "smooth" is ignored for dynamic height lists;\nscrollToRow is instant as it requires rows to be lazily measured

\n", + "intent": "warning" } ], "html": "
scrollToRow: ({
\n
align,
\n
behavior,
\n
index
\n
}: {
\n
align?: \"auto\" | \"center\" | \"end\" | \"smart\" | \"start\";
\n
behavior?: \"auto\" | \"instant\" | \"smooth\";
\n
index: number;
\n
}) => void;
", diff --git a/public/generated/site-map.json b/public/generated/site-map.json index df3e6fd8..fdb22cae 100644 --- a/public/generated/site-map.json +++ b/public/generated/site-map.json @@ -29,7 +29,7 @@ { "path": "/list/dynamic-row-height", "section": "Lists", - "text": " Sometimes the height of a row isn't known until it's been rendered. Here is an example list of lorem ipsum text of varying sizes. Each row can also be toggled collapsed/expanded by clicking on the \"+\"/\"-\" button. For this kind of list, react-window provides a helper hook called useDynamicRowHeight. import { useMemo, useState } from \"react\";\nimport { List, useDynamicRowHeight } from \"react-window\";\nimport { RowComponent } from \"./ListRowDynamicRowHeights\";\n \nfunction Example({ lorem }: { lorem: string[] }) {\n const listState = useListState(lorem);\n \n const rowHeight = useDynamicRowHeight({\n defaultRowHeight: 50\n });\n \n return (\n \n );\n} In this case, rows can just render their content as they normally would and react-window will measure it for you. import { type RowComponentProps } from \"react-window\";\n \nfunction RowComponent({\n index,\n listState,\n style\n}: RowComponentProps<{\n listState: ListState;\n}>) {\n const isCollapsed = listState.isRowCollapsed(index);\n const text = listState.getText(index);\n \n return (\n listState.toggleRow(index)}\n style={style}\n >\n {index}: {text}\n \n );\n} Dynamic row heights are not as efficient as predetermined sizes. It's recommended to provide your own height values if they can be determined ahead of time. Continue to imperative methods… ", + "text": " Sometimes the height of a row isn't known until it's been rendered. Here is an example list of lorem ipsum text of varying sizes. Each row can also be toggled collapsed/expanded by clicking on the \"+\"/\"-\" button. For this kind of list, react-window provides a helper hook called useDynamicRowHeight. import { useMemo, useState } from \"react\";\nimport { List, useDynamicRowHeight } from \"react-window\";\nimport { RowComponent } from \"./ListRowDynamicRowHeights\";\n \nfunction Example({ lorem }: { lorem: string[] }) {\n const listState = useListState(lorem);\n \n const rowHeight = useDynamicRowHeight({\n defaultRowHeight: 50\n });\n \n return (\n \n );\n} In this case, rows can just render their content as they normally would and react-window will measure it for you. import { type RowComponentProps } from \"react-window\";\n \nfunction RowComponent({\n index,\n listState,\n style\n}: RowComponentProps<{\n listState: ListState;\n}>) {\n const isCollapsed = listState.isRowCollapsed(index);\n const text = listState.getText(index);\n \n return (\n listState.toggleRow(index)}\n style={style}\n >\n {index}: {text}\n \n );\n} Dynamic row heights are not as efficient as predetermined sizes. It's recommended to provide your own height values if they can be determined ahead of time. Dynamic-height lists scroll to the target row instantly, even when smooth scrolling is requested. Continue to imperative methods… ", "title": "Dynamic row heights" }, { @@ -53,7 +53,7 @@ { "path": "/list/imperative-handle", "section": "Imperative API", - "text": " Imperative List API.\n The useListRef and useListCallbackRef hooks are exported for convenience use in TypeScript projects.\n scrollToRow Scrolls the list so that the specified row is visible.\n scrollToRow: ({\n align,\n behavior,\n index\n }: {\n align?: \"auto\" | \"center\" | \"end\" | \"smart\" | \"start\";\n behavior?: \"auto\" | \"instant\" | \"smooth\";\n index: number;\n }) => void; Hooks The useListRef hook returns a mutable ref object. function Example(props: Props) {\n const listRef = useListRef(null);\n \n return ;\n} And the useListCallbackRef hook returns a ref callback function. This is better when sharing the ref with another hook or component. import { useListCallbackRef } from \"react-window\";\n \nfunction Example(props: Props) {\n const [list, setList] = useListCallbackRef(null);\n \n useCustomHook(list);\n \n return ;\n} ", + "text": " Imperative List API.\n The useListRef and useListCallbackRef hooks are exported for convenience use in TypeScript projects.\n scrollToRow Scrolls the list so that the specified row is visible.\nDynamic row heights are measured after rendering;\nContent that resizes later may require another scrollToRow call.\n behavior \"smooth\" is ignored for dynamic height lists;\nscrollToRow is instant as it requires rows to be lazily measured\n scrollToRow: ({\n align,\n behavior,\n index\n }: {\n align?: \"auto\" | \"center\" | \"end\" | \"smart\" | \"start\";\n behavior?: \"auto\" | \"instant\" | \"smooth\";\n index: number;\n }) => void; Hooks The useListRef hook returns a mutable ref object. function Example(props: Props) {\n const listRef = useListRef(null);\n \n return ;\n} And the useListCallbackRef hook returns a ref callback function. This is better when sharing the ref with another hook or component. import { useListCallbackRef } from \"react-window\";\n \nfunction Example(props: Props) {\n const [list, setList] = useListCallbackRef(null);\n \n useCustomHook(list);\n \n return ;\n} ", "title": "ListImperativeAPI" }, { diff --git a/src/routes/list/DynamicRowHeightsRoute.tsx b/src/routes/list/DynamicRowHeightsRoute.tsx index c2ce67e6..2a6f0626 100644 --- a/src/routes/list/DynamicRowHeightsRoute.tsx +++ b/src/routes/list/DynamicRowHeightsRoute.tsx @@ -45,6 +45,10 @@ export default function DynamicRowHeightsRoute() { recommended to provide your own height values if they can be determined ahead of time. + + Dynamic-height lists scroll to the target row instantly + , even when smooth scrolling is requested. + );