You've built a virtualized data table with @tanstack/react-virtual. It works perfectly in the browser. Then you write a test and... nothing renders. getVirtualItems() returns an empty array. Your rows are gone.
This happens because TanStack Virtual uses real DOM measurements to determine which items are visible in the scroll viewport. It calls getBoundingClientRect() on each item to measure its size and monitors the scroll container's dimensions with ResizeObserver. In a test environment, even with Vitest browser mode, these measurements can return zero or behave unexpectedly depending on how your component is rendered. Without valid measurements the virtualizer calculates that zero items fit in a zero-height container, so it renders nothing.
Let's build a data table component and walk through exactly what needs to be mocked to make it testable.
Here's a typical virtualized data table. It renders a fixed-height scroll container and only mounts the rows that are currently visible.
// src/DataTable.tsx
import { useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
interface Row {
id: number;
name: string;
email: string;
role: string;
}
interface DataTableProps {
rows: Row[];
}
const ROW_HEIGHT = 40;
const TABLE_HEIGHT = 400;
export function DataTable({ rows }: DataTableProps) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: 5,
});
return (
<div>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 2fr 1fr",
fontWeight: "bold",
padding: "8px 16px",
borderBottom: "2px solid #e2e8f0",
}}
>
<span>Name</span>
<span>Email</span>
<span>Role</span>
</div>
<div
ref={parentRef}
data-testid="table-scroll-container"
style={{
height: TABLE_HEIGHT,
overflow: "auto",
}}
>
<div
style={{
height: virtualizer.getTotalSize(),
width: "100%",
position: "relative",
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const row = rows[virtualRow.index];
return (
<div
key={row.id}
data-testid={`row-${row.id}`}
ref={virtualizer.measureElement}
data-index={virtualRow.index}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: ROW_HEIGHT,
transform: `translateY(${virtualRow.start}px)`,
display: "grid",
gridTemplateColumns: "1fr 2fr 1fr",
padding: "8px 16px",
borderBottom: "1px solid #f1f5f9",
alignItems: "center",
}}
>
<span>{row.name}</span>
<span>{row.email}</span>
<span>{row.role}</span>
</div>
);
})}
</div>
</div>
<div data-testid="row-count" style={{ padding: "8px 16px", color: "#64748b" }}>
{rows.length} total rows
</div>
</div>
);
}
The important parts:
getScrollElement returns the container ref that the virtualizer watches for scroll events and measures for heightestimateSize returns the estimated pixel height of each rowmeasureElement is passed as a ref to each row so the virtualizer can call getBoundingClientRect() on it for precise measurementoverscan: 5 renders 5 extra rows above and below the visible area for smoother scrollingWhen the virtualizer initializes, it does three things that depend on real DOM measurements:
Reads the scroll container dimensions - It uses ResizeObserver to watch the container element. If the container has zero height, the virtualizer calculates that zero items fit.
Measures each rendered item - When measureElement is attached as a ref, the virtualizer calls element.getBoundingClientRect() to get the actual rendered height. If this returns zero, item sizes stay at the estimate but positioning breaks.
Calculates the visible range - Based on the container size and scroll position, it determines which items to render. Zero container height means zero visible items means an empty getVirtualItems() array.
We need to mock three things to get the virtualizer rendering in tests:
ResizeObserver - so the virtualizer can observe the scroll containergetBoundingClientRect - so item measurements return real valuesHere's the complete test file:
// src/DataTable.test.tsx
import { page } from "@vitest/browser/context";
import { render } from "vitest-browser-react";
import { expect, test, beforeEach, vi } from "vitest";
import { DataTable } from "./DataTable";
const ROW_HEIGHT = 40;
const TABLE_HEIGHT = 400;
// Generate test data
function generateRows(count: number) {
return Array.from({ length: count }, (_, i) => ({
id: i + 1,
name: `User ${i + 1}`,
email: `user${i + 1}@example.com`,
role: i % 3 === 0 ? "Admin" : i % 3 === 1 ? "Editor" : "Viewer",
}));
}
beforeEach(() => {
// 1. Mock ResizeObserver to immediately report dimensions
const ResizeObserverMock = vi.fn((callback: ResizeObserverCallback) => ({
observe: vi.fn((element: Element) => {
// Immediately invoke the callback with the element's dimensions
callback(
[
{
target: element,
contentRect: {
width: element.clientWidth || 800,
height:
element.getAttribute("data-testid") === "table-scroll-container"
? TABLE_HEIGHT
: ROW_HEIGHT,
top: 0,
left: 0,
bottom: 0,
right: 0,
x: 0,
y: 0,
toJSON: () => {},
},
borderBoxSize: [{ blockSize: TABLE_HEIGHT, inlineSize: 800 }],
contentBoxSize: [{ blockSize: TABLE_HEIGHT, inlineSize: 800 }],
devicePixelContentBoxSize: [
{ blockSize: TABLE_HEIGHT, inlineSize: 800 },
],
} as unknown as ResizeObserverEntry,
],
{} as ResizeObserver
);
}),
unobserve: vi.fn(),
disconnect: vi.fn(),
}));
vi.stubGlobal("ResizeObserver", ResizeObserverMock);
// 2. Mock getBoundingClientRect for row measurement
const originalGetBCR = Element.prototype.getBoundingClientRect;
Element.prototype.getBoundingClientRect = function () {
// If this element has a data-index attribute, it's a virtual row
if (this.hasAttribute("data-index")) {
return {
width: 800,
height: ROW_HEIGHT,
top: Number(this.getAttribute("data-index")) * ROW_HEIGHT,
left: 0,
bottom: Number(this.getAttribute("data-index")) * ROW_HEIGHT + ROW_HEIGHT,
right: 800,
x: 0,
y: 0,
toJSON: () => {},
} as DOMRect;
}
// For the scroll container, return the table height
if (this.getAttribute("data-testid") === "table-scroll-container") {
return {
width: 800,
height: TABLE_HEIGHT,
top: 0,
left: 0,
bottom: TABLE_HEIGHT,
right: 800,
x: 0,
y: 0,
toJSON: () => {},
} as DOMRect;
}
// Fall back to the original for everything else
return originalGetBCR.call(this);
};
// 3. Mock offsetHeight on the scroll container
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
configurable: true,
get() {
if (this.getAttribute("data-testid") === "table-scroll-container") {
return TABLE_HEIGHT;
}
if (this.hasAttribute("data-index")) {
return ROW_HEIGHT;
}
return 0;
},
});
Object.defineProperty(HTMLElement.prototype, "scrollHeight", {
configurable: true,
get() {
if (this.getAttribute("data-testid") === "table-scroll-container") {
return 10000; // Large enough for any test data set
}
return 0;
},
});
});
Let's walk through each mock.
The virtualizer uses ResizeObserver to detect when the scroll container's size changes. Our mock immediately calls the callback with the container's dimensions when observe() is called. This is the critical piece. Without this, the virtualizer never learns the container height and never calculates which items to show.
We check the element's data-testid to return TABLE_HEIGHT for the scroll container and ROW_HEIGHT for individual rows. This gives the virtualizer the exact measurements it needs.
When the virtualizer measures each row element via measureElement, it calls getBoundingClientRect(). Our mock checks for data-index (which we set on each row) and returns a DOMRect with the correct height and position.
For the scroll container itself, we return the full table height. Everything else falls through to the original implementation.
Some code paths in the virtualizer read offsetHeight and scrollHeight directly from the scroll container element. We define getters on HTMLElement.prototype that return the correct values based on the element's identity.
Now that measurements are mocked, our tests can render the component and actually find rows:
test("renders visible rows from the data set", async () => {
const rows = generateRows(100);
render(<DataTable rows={rows} />);
// The virtualizer should render rows that fit in the 400px container
// At 40px per row, that's ~10 visible + 5 overscan above + 5 overscan below
await expect.element(page.getByTestId("row-1")).toBeInTheDocument();
await expect.element(page.getByTestId("row-10")).toBeInTheDocument();
// Rows far beyond the visible range should not be rendered
const row100 = page.getByTestId("row-100");
await expect.element(row100).not.toBeInTheDocument();
});
test("displays correct data in each row", async () => {
const rows = generateRows(50);
render(<DataTable rows={rows} />);
await expect.element(page.getByText("User 1")).toBeInTheDocument();
await expect.element(page.getByText("user1@example.com")).toBeInTheDocument();
await expect.element(page.getByText("Admin")).toBeInTheDocument();
});
test("shows the total row count", async () => {
const rows = generateRows(250);
render(<DataTable rows={rows} />);
await expect
.element(page.getByTestId("row-count"))
.toHaveTextContent("250 total rows");
});
test("renders correct total size for scroll area", async () => {
const rows = generateRows(100);
render(<DataTable rows={rows} />);
// Total size should be count * row height = 100 * 40 = 4000px
await expect.element(page.getByTestId("row-1")).toBeInTheDocument();
const totalSize = rows.length * ROW_HEIGHT;
expect(totalSize).toBe(4000);
});
test("does not render rows when given an empty array", async () => {
render(<DataTable rows={[]} />);
await expect
.element(page.getByTestId("row-count"))
.toHaveTextContent("0 total rows");
const row = page.getByTestId("row-1");
await expect.element(row).not.toBeInTheDocument();
});
test("renders with a small data set that fits entirely in the viewport", async () => {
// 5 rows at 40px = 200px, fits entirely in the 400px container
const rows = generateRows(5);
render(<DataTable rows={rows} />);
// All rows should be rendered since they all fit
await expect.element(page.getByTestId("row-1")).toBeInTheDocument();
await expect.element(page.getByTestId("row-2")).toBeInTheDocument();
await expect.element(page.getByTestId("row-3")).toBeInTheDocument();
await expect.element(page.getByTestId("row-4")).toBeInTheDocument();
await expect.element(page.getByTestId("row-5")).toBeInTheDocument();
});
"renders visible rows" confirms the virtualizer is actually doing its job: rendering only the rows that fit in the viewport plus overscan. With a 400px container and 40px rows, roughly 10 rows are visible. Adding overscan: 5 means up to 15 rows get rendered from the top. Row 100 should be nowhere in the DOM.
"displays correct data" ensures the data mapping between the virtual rows and your actual data array is working. This catches off-by-one bugs in the index mapping.
"shows the total row count" tests the non-virtualized part of the component to make sure it receives and displays the full data set length correctly.
"does not render rows when given an empty array" is an edge case that should produce zero virtual items and no row elements.
"renders with a small data set" tests the case where all items fit in the viewport. This validates that the virtualizer doesn't accidentally hide items when there's no need to virtualize.
Here's the sequence of events that makes the virtualizer render in tests:
1. Component mounts, parentRef attaches to scroll container
2. Virtualizer calls getScrollElement(), gets the container
3. ResizeObserver.observe(container) is called
→ Our mock immediately fires the callback with height: 400
4. Virtualizer calculates: 400px / 40px = 10 visible rows + overscan
5. getVirtualItems() returns items for rows 0-14
6. React renders those rows with measureElement as ref
7. measureElement calls getBoundingClientRect() on each row
→ Our mock returns height: 40 for each
8. Virtualizer confirms measurements match estimates
9. Component is fully rendered and testable
If any step in this chain returns zero, the virtualizer renders nothing. That's why we need all three mocks working together.
If you're testing multiple virtualized components, extract the mocks into a shared setup:
// src/test/virtual-mocks.ts
import { vi } from "vitest";
interface VirtualMockOptions {
containerHeight?: number;
containerWidth?: number;
rowHeight?: number;
containerTestId?: string;
}
export function setupVirtualMocks({
containerHeight = 400,
containerWidth = 800,
rowHeight = 40,
containerTestId = "table-scroll-container",
}: VirtualMockOptions = {}) {
const ResizeObserverMock = vi.fn((callback: ResizeObserverCallback) => ({
observe: vi.fn((element: Element) => {
const isContainer =
element.getAttribute("data-testid") === containerTestId;
const height = isContainer ? containerHeight : rowHeight;
callback(
[
{
target: element,
contentRect: {
width: containerWidth,
height,
top: 0,
left: 0,
bottom: 0,
right: 0,
x: 0,
y: 0,
toJSON: () => {},
},
borderBoxSize: [{ blockSize: height, inlineSize: containerWidth }],
contentBoxSize: [{ blockSize: height, inlineSize: containerWidth }],
devicePixelContentBoxSize: [
{ blockSize: height, inlineSize: containerWidth },
],
} as unknown as ResizeObserverEntry,
],
{} as ResizeObserver
);
}),
unobserve: vi.fn(),
disconnect: vi.fn(),
}));
vi.stubGlobal("ResizeObserver", ResizeObserverMock);
const originalGetBCR = Element.prototype.getBoundingClientRect;
Element.prototype.getBoundingClientRect = function () {
if (this.hasAttribute("data-index")) {
const index = Number(this.getAttribute("data-index"));
return {
width: containerWidth,
height: rowHeight,
top: index * rowHeight,
left: 0,
bottom: index * rowHeight + rowHeight,
right: containerWidth,
x: 0,
y: 0,
toJSON: () => {},
} as DOMRect;
}
if (this.getAttribute("data-testid") === containerTestId) {
return {
width: containerWidth,
height: containerHeight,
top: 0,
left: 0,
bottom: containerHeight,
right: containerWidth,
x: 0,
y: 0,
toJSON: () => {},
} as DOMRect;
}
return originalGetBCR.call(this);
};
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
configurable: true,
get() {
if (this.getAttribute("data-testid") === containerTestId) {
return containerHeight;
}
if (this.hasAttribute("data-index")) {
return rowHeight;
}
return 0;
},
});
Object.defineProperty(HTMLElement.prototype, "scrollHeight", {
configurable: true,
get() {
if (this.getAttribute("data-testid") === containerTestId) {
return 10000;
}
return 0;
},
});
}
Then in any test file:
import { setupVirtualMocks } from "./test/virtual-mocks";
beforeEach(() => {
setupVirtualMocks({ containerHeight: 400, rowHeight: 40 });
});
Or for a horizontal virtualizer with different dimensions:
beforeEach(() => {
setupVirtualMocks({
containerHeight: 200,
containerWidth: 1200,
rowHeight: 150,
containerTestId: "carousel-container",
});
});
TanStack Virtualizer depends on three DOM measurement APIs to decide what to render: ResizeObserver for container dimensions, getBoundingClientRect() for item sizes, and offsetHeight/scrollHeight for scroll calculations. In test environments these all return zero, which means the virtualizer calculates that zero items are visible and renders nothing.
The fix is straightforward: mock all three APIs in your beforeEach to return the dimensions that match your component's styling. The key insight is that the ResizeObserver mock must fire its callback immediately when observe() is called, so the virtualizer gets the container dimensions on the first render cycle.
Once the mocks are in place, your virtualized components become fully testable, and you can verify that the right rows render, the data maps correctly, and edge cases like empty lists are handled.