From d621821b51c4f0b91c5022135cc84d62bc57a455 Mon Sep 17 00:00:00 2001 From: Olivier Tassinari Date: Sat, 23 May 2026 01:47:07 +0200 Subject: [PATCH] add MUI X --- benchmarks/README.md | 8 +- benchmarks/package.json | 1 + benchmarks/runner/run.mjs | 2 +- benchmarks/src/main.tsx | 3 + benchmarks/src/pages/MuiPage.tsx | 205 ++++++++++++++++++++++++++++++ benchmarks/src/scenarios/types.ts | 7 +- pnpm-lock.yaml | 145 +++++++++++++++++++++ 7 files changed, 368 insertions(+), 3 deletions(-) create mode 100644 benchmarks/src/pages/MuiPage.tsx diff --git a/benchmarks/README.md b/benchmarks/README.md index dc6dc34c..4c2469aa 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -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**. Same data, same scenarios, same harness — driven by Playwright against a real browser running a real Vite-built React app for each library. @@ -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 ``). - Dataset is deterministic (LCG-seeded) and identical across libraries. - `--enable-precise-memory-info` + `--js-flags=--expose-gc` are passed to diff --git a/benchmarks/package.json b/benchmarks/package.json index efc24d01..dfb6b453 100644 --- a/benchmarks/package.json +++ b/benchmarks/package.json @@ -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", diff --git a/benchmarks/runner/run.mjs b/benchmarks/runner/run.mjs index cec981e5..f7a78453 100644 --- a/benchmarks/runner/run.mjs +++ b/benchmarks/runner/run.mjs @@ -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', diff --git a/benchmarks/src/main.tsx b/benchmarks/src/main.tsx index e4ceca2c..ee9128ac 100644 --- a/benchmarks/src/main.tsx +++ b/benchmarks/src/main.tsx @@ -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' @@ -34,6 +35,8 @@ function App() { return case 'window': return + case 'mui-x': + return default: return (
diff --git a/benchmarks/src/pages/MuiPage.tsx b/benchmarks/src/pages/MuiPage.tsx new file mode 100644 index 00000000..73e5e0b0 --- /dev/null +++ b/benchmarks/src/pages/MuiPage.tsx @@ -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' + +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(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(null) + const scrollerRef = useRef(null) + const refs = useMemo( + () => ({ container: containerRef, scroller: scrollerRef }), + [], + ) + + const layoutRef = useRef(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 }) => ( + + ), + [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 ( +
+
+
+ + + + + +
+ ) +} + +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(null) + useEffect(() => { + if (!dynamic || !nodeRef.current || !observeRowHeight) return undefined + return observeRowHeight(nodeRef.current, id) + }, [observeRowHeight, dynamic, id]) + return ( +
+ {model.text} +
+ ) +} + +export function MuiPageRoot({ scenario }: Props) { + markMountStart() + return +} diff --git a/benchmarks/src/scenarios/types.ts b/benchmarks/src/scenarios/types.ts index 48210d16..95b8e23b 100644 --- a/benchmarks/src/scenarios/types.ts +++ b/benchmarks/src/scenarios/types.ts @@ -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. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 415ad769..8a678ee3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,6 +74,9 @@ importers: benchmarks: dependencies: + '@mui/x-virtualizer': + specifier: ^9.0.0-alpha.7 + version: 9.0.0-alpha.7(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@tanstack/react-virtual': specifier: workspace:* version: link:../packages/react-virtual @@ -2237,6 +2240,10 @@ packages: resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} engines: {node: '>=6.9.0'} + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + '@babel/template@7.27.2': resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} @@ -3063,6 +3070,47 @@ packages: cpu: [x64] os: [win32] + '@mui/types@9.0.0': + resolution: {integrity: sha512-i1cuFCAWN44b3AJWO7mh7tuh1sqbQSeVr/94oG0TX5uXivac8XalgE4/6fQZcmGZigzbQ35IXxj/4jLpRIBYZg==} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@mui/utils@9.0.0': + resolution: {integrity: sha512-bQcqyg/gjULUqTuyUjSAFr6LQGLvtkNtDbJerAtoUn9kGZ0hg5QJiN1PLHMLbeFpe3te1831uq7GFl2ITokGdg==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@mui/utils@9.0.1': + resolution: {integrity: sha512-f3UO3jNN1pYg5zxqXC81Bvv8hx5ACcYc0387382ZI7M5ono1heIwHYLrKsz85myguWdeVKPRZGmDdynWUBjK2g==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@mui/x-internals@9.1.0': + resolution: {integrity: sha512-fVezTa1lU+Hb3y9UMI8D/iWXADhs0I8PaZqoh2LOUXjGEUJmKqwsRD19ZXInZsH2yu+YS0dqYMPDvzjYTTyo+Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@mui/x-virtualizer@9.0.0-alpha.7': + resolution: {integrity: sha512-Hi21IoN7AWiW6vWEjj2mpK2Y3e8dhHwDkf35k6K6L87/6bvd/cYvPHndSCOvzO54qUpRfBumuFyoj1os/9joIA==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + '@napi-rs/nice-android-arm-eabi@1.1.1': resolution: {integrity: sha512-kjirL3N6TnRPv5iuHw36wnucNqXAO46dzK9oPb0wj076R5Xm8PfUVA9nAFB5ZNMmfJQJVKACAPd/Z2KYMppthw==} engines: {node: '>= 10'} @@ -4847,6 +4895,10 @@ packages: resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} engines: {node: '>=0.8'} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + co-body@6.2.0: resolution: {integrity: sha512-Kbpv2Yd1NdL1V/V4cwLVxraHDV6K8ayohr2rmH0J87Er8+zJjcTa6dAn9QMPC9CRgU8+aNajKbSf1TzDB1yKPA==} engines: {node: '>=8.0.0'} @@ -6635,6 +6687,10 @@ packages: '@swc/core': optional: true + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -6984,6 +7040,9 @@ packages: resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} engines: {node: '>=10'} + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -7033,12 +7092,18 @@ packages: peerDependencies: react: ^18.3.1 + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react-is@19.2.6: + resolution: {integrity: sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==} + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -7123,6 +7188,9 @@ packages: requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + reselect@5.2.0: + resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==} + resize-observer-polyfill@1.5.1: resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} @@ -7866,6 +7934,11 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -9332,6 +9405,8 @@ snapshots: '@babel/runtime@7.28.4': {} + '@babel/runtime@7.29.2': {} + '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.27.1 @@ -10099,6 +10174,56 @@ snapshots: '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': optional: true + '@mui/types@9.0.0(@types/react@18.3.26)': + dependencies: + '@babel/runtime': 7.29.2 + optionalDependencies: + '@types/react': 18.3.26 + + '@mui/utils@9.0.0(@types/react@18.3.26)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.2 + '@mui/types': 9.0.0(@types/react@18.3.26) + '@types/prop-types': 15.7.15 + clsx: 2.1.1 + prop-types: 15.8.1 + react: 18.3.1 + react-is: 19.2.6 + optionalDependencies: + '@types/react': 18.3.26 + + '@mui/utils@9.0.1(@types/react@18.3.26)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.2 + '@mui/types': 9.0.0(@types/react@18.3.26) + '@types/prop-types': 15.7.15 + clsx: 2.1.1 + prop-types: 15.8.1 + react: 18.3.1 + react-is: 19.2.6 + optionalDependencies: + '@types/react': 18.3.26 + + '@mui/x-internals@9.1.0(@types/react@18.3.26)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.2 + '@mui/utils': 9.0.0(@types/react@18.3.26)(react@18.3.1) + react: 18.3.1 + reselect: 5.2.0 + use-sync-external-store: 1.6.0(react@18.3.1) + transitivePeerDependencies: + - '@types/react' + + '@mui/x-virtualizer@9.0.0-alpha.7(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.2 + '@mui/utils': 9.0.1(@types/react@18.3.26)(react@18.3.1) + '@mui/x-internals': 9.1.0(@types/react@18.3.26)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + transitivePeerDependencies: + - '@types/react' + '@napi-rs/nice-android-arm-eabi@1.1.1': optional: true @@ -12004,6 +12129,8 @@ snapshots: clone@2.1.2: {} + clsx@2.1.1: {} + co-body@6.2.0: dependencies: '@hapi/bourne': 3.0.0 @@ -13913,6 +14040,8 @@ snapshots: transitivePeerDependencies: - debug + object-assign@4.1.1: {} + object-inspect@1.13.4: {} obuf@1.1.2: {} @@ -14285,6 +14414,12 @@ snapshots: err-code: 2.0.3 retry: 0.12.0 + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -14335,10 +14470,14 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 + react-is@16.13.1: {} + react-is@17.0.2: {} react-is@18.3.1: {} + react-is@19.2.6: {} + react-refresh@0.17.0: {} react-virtuoso@4.18.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1): @@ -14424,6 +14563,8 @@ snapshots: requires-port@1.0.0: {} + reselect@5.2.0: {} + resize-observer-polyfill@1.5.1: {} resolve-from@4.0.0: {} @@ -15202,6 +15343,10 @@ snapshots: dependencies: punycode: 2.3.1 + use-sync-external-store@1.6.0(react@18.3.1): + dependencies: + react: 18.3.1 + util-deprecate@1.0.2: {} utils-merge@1.0.1: {}