Skip to content
Open
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
8 changes: 7 additions & 1 deletion benchmarks/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Virtualization benchmarks

Reproducible browser benchmarks comparing **@tanstack/react-virtual**, **virtua**, **react-virtuoso**, and **react-window** v2.
Reproducible browser benchmarks comparing **@tanstack/react-virtual**, **virtua**, **react-virtuoso**, **react-window** v2, and **@mui/x-virtualizer**.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Keep result sections consistent with the new five-library claim.

The intro says five libraries, but the “Latest results”/“Bottom line” content still reads as four-library output. Please regenerate or clearly label those sections to avoid misleading comparisons.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@benchmarks/README.md` at line 3, The README's intro claims five libraries but
the "Latest results" and "Bottom line" sections still present four-library
output; update the "Latest results" and "Bottom line" sections to either
regenerate and include the new fifth-library benchmark data (ensuring
tables/plots and summary include `@tanstack/react-virtual`, virtua,
react-virtuoso, react-window v2, and `@mui/x-virtualizer`) or explicitly label
those sections as "four-library results (pre-update)" if you cannot regenerate;
ensure headings "Latest results" and "Bottom line" and any result tables/legends
reflect the chosen option so the document is not misleading.


Same data, same scenarios, same harness — driven by Playwright against a real browser running a real Vite-built React app for each library.

Expand Down Expand Up @@ -159,6 +159,12 @@ it's measuring — it just calls one global function per page.
TanStack uses `useVirtualizer` + `measureElement`; virtua uses `VList` with
the `data`/`item` props; virtuoso uses `Virtuoso` with `fixedItemHeight`
when applicable; react-window uses `List` + `useDynamicRowHeight`.
- `@mui/x-virtualizer` is a grid-oriented engine; we adapt it to a list with
`LayoutList` and the default 1-column placeholder (the only way the library
is currently exposed for non-grid use). It still carries column/dimensions
bookkeeping the others don't, so its mount numbers should be read with
that caveat in mind. Dynamic rows use `getRowHeight: () => 'auto'` plus
the per-row `observeRowHeight` ref callback.
- React 18 runs in production mode (no `<StrictMode>`).
- Dataset is deterministic (LCG-seeded) and identical across libraries.
- `--enable-precise-memory-info` + `--js-flags=--expose-gc` are passed to
Expand Down
1 change: 1 addition & 0 deletions benchmarks/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"bench:headed": "node runner/run.mjs --headed"
},
"dependencies": {
"@mui/x-virtualizer": "^9.0.0-alpha.7",
"@tanstack/react-virtual": "workspace:*",
"react": "^18.3.1",
"react-dom": "^18.3.1",
Expand Down
2 changes: 1 addition & 1 deletion benchmarks/runner/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const BENCH_DIR = path.resolve(__dirname, '..')
const PORT = 4173
const BASE = `http://localhost:${PORT}`

const ALL_LIBS = ['tanstack', 'virtua', 'virtuoso', 'window']
const ALL_LIBS = ['tanstack', 'virtua', 'virtuoso', 'window', 'mui-x']
const ALL_SCENARIOS = [
'mount-fixed-1k',
'mount-fixed-10k',
Expand Down
3 changes: 3 additions & 0 deletions benchmarks/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { MuiPageRoot } from './pages/MuiPage'
import { TanstackPageRoot } from './pages/TanstackPage'
import { VirtuaPageRoot } from './pages/VirtuaPage'
import { VirtuosoPageRoot } from './pages/VirtuosoPage'
Expand Down Expand Up @@ -34,6 +35,8 @@ function App() {
return <VirtuosoPageRoot scenario={scenario} />
case 'window':
return <WindowPageRoot scenario={scenario} />
case 'mui-x':
return <MuiPageRoot scenario={scenario} />
default:
return (
<div style={{ padding: 24 }}>
Expand Down
205 changes: 205 additions & 0 deletions benchmarks/src/pages/MuiPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import * as React from 'react'
import { useEffect, useMemo, useRef } from 'react'
import {
LayoutList,
useVirtualizer,
type Virtualizer,
} from '@mui/x-virtualizer'
import {
markFirstPaint,
markMountEnd,
markMountStart,
registerHarness,
} from '../lib/harness'
import { makeDataset, type Item } from '../lib/dataset'
import type { ScenarioInput } from '../scenarios/types'
Comment on lines +3 to +15
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fix import declarations to pass current ESLint checks.

This import block violates sort-imports and import/consistent-type-specifier-style; keep value imports and type imports separate to unblock lint.

Proposed fix
 import * as React from 'react'
 import { useEffect, useMemo, useRef } from 'react'
 import {
   LayoutList,
   useVirtualizer,
-  type Virtualizer,
 } from '`@mui/x-virtualizer`'
+import type { Virtualizer } from '`@mui/x-virtualizer`'
 import {
   markFirstPaint,
   markMountEnd,
   markMountStart,
   registerHarness,
 } from '../lib/harness'
-import { makeDataset, type Item } from '../lib/dataset'
+import { makeDataset } from '../lib/dataset'
+import type { Item } from '../lib/dataset'
 import type { ScenarioInput } from '../scenarios/types'
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import {
LayoutList,
useVirtualizer,
type Virtualizer,
} from '@mui/x-virtualizer'
import {
markFirstPaint,
markMountEnd,
markMountStart,
registerHarness,
} from '../lib/harness'
import { makeDataset, type Item } from '../lib/dataset'
import type { ScenarioInput } from '../scenarios/types'
import {
LayoutList,
useVirtualizer,
} from '`@mui/x-virtualizer`'
import type { Virtualizer } from '`@mui/x-virtualizer`'
import {
markFirstPaint,
markMountEnd,
markMountStart,
registerHarness,
} from '../lib/harness'
import { makeDataset } from '../lib/dataset'
import type { Item } from '../lib/dataset'
import type { ScenarioInput } from '../scenarios/types'
🧰 Tools
🪛 ESLint

[error] 6-6: Member 'Virtualizer' of the import declaration should be sorted alphabetically.

(sort-imports)


[error] 6-6: Prefer using a top-level type-only import instead of inline type specifiers.

(import/consistent-type-specifier-style)


[error] 14-14: Member 'Item' of the import declaration should be sorted alphabetically.

(sort-imports)


[error] 14-14: Prefer using a top-level type-only import instead of inline type specifiers.

(import/consistent-type-specifier-style)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@benchmarks/src/pages/MuiPage.tsx` around lines 3 - 15, Split value and type
imports and reorder groups to satisfy ESLint: import runtime symbols
(LayoutList, useVirtualizer) together from '`@mui/x-virtualizer`' and import
values from local modules (markFirstPaint, markMountEnd, markMountStart,
registerHarness, makeDataset) in their own group, then use "import type" for
purely type imports (Virtualizer, Item, ScenarioInput) as separate statements;
ensure external package imports come before local imports and that each "import
type" only contains type names (e.g., import type { Virtualizer } from
'`@mui/x-virtualizer`'; import type { Item } from '../lib/dataset'; import type {
ScenarioInput } from '../scenarios/types').


interface Props {
scenario: ScenarioInput
}

// @mui/x-virtualizer is a grid-oriented engine. We adapt it to a single-column
// list by setting columns to its default 1-column placeholder and rendering
// each row as a plain block element. The engine still pays for column /
// dimensions bookkeeping that the other libs don't — see README.

const VirtualizerContext = React.createContext<Virtualizer | null>(null)
const ObserveRowHeightContext = React.createContext<
((node: HTMLElement, id: number) => (() => void) | undefined) | null
>(null)

export function MuiPage({ scenario }: Props) {
const items = useMemo(
() =>
makeDataset(
scenario.count,
scenario.dynamic,
scenario.action === 'jump-wide-variance-accuracy',
),
[scenario.count, scenario.dynamic, scenario.action],
)

const rows = useMemo(
() => items.map((it, i) => ({ id: i, model: it })),
[items],
)
const range = useMemo(
() => ({ firstRowIndex: 0, lastRowIndex: rows.length }),
[rows.length],
)

const containerRef = useRef<HTMLDivElement>(null)
const scrollerRef = useRef<HTMLDivElement>(null)
const refs = useMemo(
() => ({ container: containerRef, scroller: scrollerRef }),
[],
)

const layoutRef = useRef<LayoutList | null>(null)
if (layoutRef.current === null) {
layoutRef.current = new LayoutList(refs)
}
const layout = layoutRef.current

// The virtualizer treats these as dependencies of internal effects (notably
// dimensions.useRowsMeta), so unstable references trigger an infinite update
// loop in dynamic mode and React unmounts the tree.
const dimensions = useMemo(
() => ({ rowHeight: scenario.itemSize }),
[scenario.itemSize],
)
const virtualization = useMemo(() => ({}), [])
const getRowHeight = React.useCallback(
() => 'auto' as const,
[],
)
const renderRow = React.useCallback(
(params: { id: unknown; rowIndex: number; model: unknown }) => (
<MuiRow
key={params.id as number}
id={params.id as number}
index={params.rowIndex}
model={params.model as Item}
dynamic={scenario.dynamic}
itemSize={scenario.itemSize}
/>
),
[scenario.dynamic, scenario.itemSize],
)

const virtualizer = useVirtualizer({
layout,
dimensions,
virtualization,
rows,
range,
rowCount: rows.length,
getRowHeight: scenario.dynamic ? getRowHeight : undefined,
renderRow,
})

// virtualizer.api is rebuilt every render, so wrap the observer in a stable
// callback backed by a ref. Rows depend on this callback in a useEffect — an
// unstable observer would re-observe every render and storm storeRowHeightMeasurement.
const apiRef = useRef(virtualizer.api)
apiRef.current = virtualizer.api
const observeRowHeight = React.useCallback(
(node: HTMLElement, id: number) =>
apiRef.current.rowsMeta.observeRowHeight(node, id),
[],
)

useEffect(() => {
registerHarness({
getScrollContainer: () => scrollerRef.current,
scrollToIndex: (i, opts) => {
const scroller = scrollerRef.current
if (!scroller) return
const state = virtualizer.store.state
const positions = state.rowsMeta.positions
const targetTop = positions[i] ?? i * scenario.itemSize
if (opts?.align === 'end') {
const entry = state.rowHeights.get(i)
const rowH = entry?.content ?? scenario.itemSize
scroller.scrollTop = Math.max(
0,
targetTop + rowH - scroller.clientHeight,
)
} else {
scroller.scrollTop = targetTop
}
},
getTotalSize: () =>
virtualizer.store.state.dimensions.contentSize.height ?? 0,
isFullyMeasured: () => {
if (!scenario.dynamic) return true
// ResizeObserver populates rowHeights as items enter the viewport.
return virtualizer.store.state.rowHeights.size >= 10
},
})
markMountEnd()
markFirstPaint()
}, [virtualizer, scenario.dynamic, scenario.itemSize])

const containerProps = virtualizer.store.use(
LayoutList.selectors.containerProps,
)
const contentProps = virtualizer.store.use(LayoutList.selectors.contentProps)
const positionerProps = virtualizer.store.use(
LayoutList.selectors.positionerProps,
)

return (
<div
{...containerProps}
className="scroll-host"
data-bench-scroll-host="mui-x"
>
<div {...contentProps} />
<div {...positionerProps} />
<VirtualizerContext.Provider value={virtualizer}>
<ObserveRowHeightContext.Provider value={observeRowHeight}>
<MuiListContent />
</ObserveRowHeightContext.Provider>
</VirtualizerContext.Provider>
</div>
)
}

const MuiListContent = React.memo(function MuiListContent() {
const virtualizer = React.useContext(VirtualizerContext)!
const { getRows } = virtualizer.api.getters
return <>{getRows()}</>
})

interface RowProps {
id: number
index: number
model: Item
dynamic: boolean
itemSize: number
}

function MuiRow({ id, index, model, dynamic, itemSize }: RowProps) {
const observeRowHeight = React.useContext(ObserveRowHeightContext)
const nodeRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (!dynamic || !nodeRef.current || !observeRowHeight) return undefined
return observeRowHeight(nodeRef.current, id)
}, [observeRowHeight, dynamic, id])
return (
<div
ref={nodeRef}
data-index={index}
className={'item ' + (index % 2 === 0 ? 'even' : '')}
style={{ minHeight: dynamic ? undefined : itemSize }}
>
{model.text}
</div>
)
}

export function MuiPageRoot({ scenario }: Props) {
markMountStart()
return <MuiPage scenario={scenario} />
}
7 changes: 6 additions & 1 deletion benchmarks/src/scenarios/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
// Shared scenario definitions used by every library page + the Playwright runner.
// JSON-serializable so the runner can pass them as JS args via page.evaluate().

export type LibraryName = 'tanstack' | 'virtua' | 'virtuoso' | 'window'
export type LibraryName =
| 'tanstack'
| 'virtua'
| 'virtuoso'
| 'window'
| 'mui-x'

export interface ScenarioInput {
/** Stable id used for table keys and result filenames. */
Expand Down
Loading