Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
172 changes: 172 additions & 0 deletions integrations/tests/tests/dynamic-scroll.spec.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement>("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<HTMLButtonElement>("button")!.click();
const list = document.querySelector<HTMLElement>('[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<HTMLButtonElement>("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<HTMLButtonElement>("button");
buttons[0].click();
buttons[2].click();
});
await settle(page);
expect(errors).toEqual([]);
expect(await bounds(page, 250)).toBeNull();
});
2 changes: 2 additions & 0 deletions integrations/vite/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -14,6 +15,7 @@ createRoot(document.getElementById("root")!).render(
<Routes>
<Route path="/" element={<HomeRoute />} />
<Route path="/list" element={<ListRoute />} />
<Route path="/list-dynamic" element={<ListDynamicRoute />} />
<Route path="/grid" element={<GridRoute />} />
<Route path="/decoder/:encoded" element={<DecoderRoute />} />
</Routes>
Expand Down
79 changes: 79 additions & 0 deletions integrations/vite/src/routes/ListDynamic.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<button
onClick={() =>
listRef.current?.scrollToRow({
index: Number(params.get("index") ?? 250),
align: (params.get("align") ?? "auto") as
| "auto"
| "start"
| "center"
| "end"
| "smart",
behavior: "smooth"
})
}
>
Jump
</button>
<button
onClick={() =>
listRef.current?.scrollToRow({ index: 20, align: "start" })
}
>
Replace
</button>
<button onClick={() => setRowCount(10)}>Shrink</button>
<List
children={
params.has("overlay") ? (
<div
data-testid="overlay"
style={{ position: "absolute", height: 10 }}
>
Overlay
</div>
) : 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 (
<div
{...ariaAttributes}
data-index={index}
style={{ ...style, height: tall ? 300 : 20 + (index % 7) * 10 }}
>
Row {index}
</div>
);
}
82 changes: 82 additions & 0 deletions lib/components/list/List.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Element>) => () => void
>(() => () => {});
const rowHeight = {
getRowHeight: () => 25,
getAverageRowHeight: () => 25,
setRowHeight: () => {},
observeRowElements
};
render(
<List
rowCount={rowCount}
rowHeight={rowHeight}
rowComponent={RowComponent}
rowProps={EMPTY_OBJECT}
>
<div data-testid="overlay">Overlay</div>
</List>
);
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<ListImperativeAPI>();
function PendingScrollExample() {
const rowHeight = useDynamicRowHeight({ defaultRowHeight: 25 });
return (
<List
listRef={listRef}
rowCount={100}
rowHeight={rowHeight}
rowComponent={RowComponent}
rowProps={EMPTY_OBJECT}
/>
);
}
const { unmount } = render(<PendingScrollExample />);
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<typeof vi.fn>;

function Example() {
Expand Down
Loading
Loading