Merge branch 'main' into fix/solid-preserve-measurements · TanStack/virtual@3d8b455 · GitHub
Skip to content

Commit 3d8b455

Browse files
authored
Merge branch 'main' into fix/solid-preserve-measurements
2 parents 2346cc5 + 18d5b16 commit 3d8b455

131 files changed

Lines changed: 5261 additions & 453 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎benchmarks/CHANGELOG.md‎

Lines changed: 21 additions & 0 deletions

‎benchmarks/README.md‎

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,23 @@
11
# Virtualization benchmarks
22

3-
Reproducible browser benchmarks comparing **@tanstack/react-virtual**, **virtua**, **react-virtuoso**, and **react-window** v2.
3+
Reproducible browser benchmarks comparing **@tanstack/react-virtual**, **virtua**, **react-virtuoso**, **react-window** v2, and **react-aria-components** `Virtualizer`.
44

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

7+
## Library matrix
8+
9+
| id | What it measures |
10+
| ------------------------------ | ------------------------------------------------------------------------- |
11+
| `tanstack` | Headless `@tanstack/react-virtual` + plain DOM rows |
12+
| `tanstack-rac` | TanStack virtual + `role="listbox"` / `role="option"` (no RAC collection) |
13+
| `rac-listbox` | React Aria `ListBox` only — collection overhead, all items in DOM |
14+
| `rac` | React Aria `Virtualizer` + `ListBox` — full integrated stack |
15+
| `virtua`, `virtuoso`, `window` | Existing third-party baselines |
16+
17+
`rac-listbox` skips `mount-fixed-100k` (100k DOM nodes is intentionally out of scope).
18+
19+
**RAC accuracy scenarios:** React Aria's virtualizer does not expose `scrollToIndex`. The harness uses layout-derived `scrollTop` + native scroll events. If the target row is not mounted after scroll settles, `landingErrorPx` is `-1` (item missing from DOM). `rac-listbox` (non-virtualized) reports accurate landings because every row stays in the DOM.
20+
721
## Running
822

923
```bash
@@ -74,7 +88,7 @@ it's measuring — it just calls one global function per page.
7488
| `jankMs` | Sum of frame durations > 50 ms during the action. |
7589
| `memoryBytes` | `performance.memory.usedJSHeapSize` after the scenario. Chromium only; ungated by `--enable-precise-memory-info`. |
7690

77-
## Latest results (medians of 5 runs each)
91+
## Latest results — third-party libraries (medians of 5 runs each)
7892

7993
**Hardware**: Author's machine — see `results/<timestamp>.json` for run conditions.
8094

@@ -140,6 +154,64 @@ it's measuring — it just calls one global function per page.
140154
> the others — same root cause as the slow mount: we hold a `VirtualItem`
141155
> object per item, while virtua holds two numbers per item.
142156
157+
## React Aria comparison (medians of 2 runs each)
158+
159+
Run with `--libs tanstack,tanstack-rac,rac,rac-listbox` on 2026-06-21. See
160+
`results/LATEST.md` for the full table set.
161+
162+
### Mount time — `React.render` → commit (lower is better, ms)
163+
164+
| Scenario | tanstack | tanstack-rac | rac | rac-listbox |
165+
| ------------------- | -------: | -----------: | ----: | ----------: |
166+
| `mount-fixed-1k` | 3.7 | **1.0** | 9.6 | 7.8 |
167+
| `mount-fixed-10k` | **2.4** | 1.5 | 20.0 | 25.5 |
168+
| `mount-fixed-100k` | **5.8** | 4.2 | 175.2 | — |
169+
| `mount-dynamic-1k` | 1.8 | **1.4** | 7.3 | 8.1 |
170+
| `mount-dynamic-10k` | **4.9** | 5.2 | 26.2 | 26.3 |
171+
172+
> **What we see:** `tanstack-rac` (TanStack virtual + WAI-ARIA roles only) mounts
173+
> as fast as headless TanStack. The full RAC stack (`rac`) pays 10–30× more at
174+
> 10k because of collection + layout setup. `rac-listbox` (no virtualizer) is
175+
> similar at 10k but skipped at 100k — 100k DOM nodes is out of scope.
176+
177+
### Dynamic measurement — commit → stable total size (lower is better, ms)
178+
179+
| Scenario | tanstack | tanstack-rac | rac | rac-listbox |
180+
| ------------------- | -------: | -----------: | ----: | ----------: |
181+
| `mount-dynamic-1k` | 124.8 | **122.6** | 3,004 | **108.1** |
182+
| `mount-dynamic-10k` | 118.7 | 119.4 | 3,009 | **107.6** |
183+
184+
> **What we see:** RAC's virtualizer waits ~3 s for layout to settle (its
185+
> `isFullyMeasured` gate). Non-virtualized `rac-listbox` is fastest here because
186+
> every row is already in the DOM — no scroll-range estimation needed.
187+
188+
### Memory after mount (lower is better, MB)
189+
190+
| Scenario | tanstack | tanstack-rac | rac | rac-listbox |
191+
| ------------------- | -------: | -----------: | ---: | ----------: |
192+
| `mount-fixed-10k` | **3.3** | 6.3 | 13.9 | 534.0 |
193+
| `mount-fixed-100k` | **9.9** | 12.9 | 99.7 | — |
194+
| `mount-dynamic-10k` | **4.8** | 7.7 | 15.4 | 535.3 |
195+
196+
> **What we see:** `rac-listbox` at 10k uses ~80× more heap than virtualized
197+
> libraries because all 10k React nodes stay mounted. RAC virtualized is ~2×
198+
> TanStack at 10k, ~10× at 100k.
199+
200+
### scrollToIndex landing accuracy — px offset from target (lower is better)
201+
202+
| Scenario | tanstack | tanstack-rac | rac | rac-listbox |
203+
| ------------------------------------------- | -------: | -----------: | --: | ----------: |
204+
| `jump-to-middle-accuracy-dynamic-10k` | 0 | 0 | −1 | 0 |
205+
| `jump-to-last-accuracy-dynamic-10k` | 0 | 0 | −1 | 0 |
206+
| `jump-while-measuring-accuracy-dynamic-10k` | 0 | 0 | −1 | 0 |
207+
| `jump-wide-variance-accuracy-10k` | 0 | 0 | −1 | 0 |
208+
209+
> **What we see:** `−1` means the target row was not in the DOM after scroll
210+
> settled — RAC has no public `scrollToIndex`, so the harness uses layout-derived
211+
> `scrollTop`. `rac-listbox` reports 0 px because every row stays mounted.
212+
> Scroll FPS and jump-to-end settle time were identical across all four (60 fps,
213+
> ~65–85 ms).
214+
143215
## Bottom line
144216

145217
- **Small-to-medium variable-size lists** (the most common use case) —
@@ -152,13 +224,21 @@ it's measuring — it just calls one global function per page.
152224
libraries sustain 60 fps with zero dropped frames.
153225
- **Jump-to-index** — react-window leads, TanStack lands ~15 ms slower,
154226
virtuoso 2× slower than the leader.
227+
- **React Aria overhead** — WAI-ARIA roles alone (`tanstack-rac`) add negligible
228+
cost. The RAC collection + virtualizer stack adds ~10–30× mount time at 10k and
229+
~30× at 100k vs headless TanStack. Non-virtualized `ListBox` is unusable at
230+
scale (~534 MB heap at 10k items).
231+
- **RAC accuracy** — virtualized RAC cannot satisfy `scrollToIndex` accuracy
232+
probes today (`landingErrorPx = −1`); non-virtualized `rac-listbox` is exact.
155233

156234
## Notes on fairness
157235

158236
- Each page is implemented with the library's _recommended_ API. For example,
159237
TanStack uses `useVirtualizer` + `measureElement`; virtua uses `VList` with
160238
the `data`/`item` props; virtuoso uses `Virtuoso` with `fixedItemHeight`
161-
when applicable; react-window uses `List` + `useDynamicRowHeight`.
239+
when applicable; react-window uses `List` + `useDynamicRowHeight`; RAC uses
240+
`Virtualizer` + `ListLayout` + `ListBox`; `tanstack-rac` adds listbox/option
241+
roles to TanStack rows without RAC's collection layer.
162242
- React 18 runs in production mode (no `<StrictMode>`).
163243
- Dataset is deterministic (LCG-seeded) and identical across libraries.
164244
- `--enable-precise-memory-info` + `--js-flags=--expose-gc` are passed to
@@ -177,6 +257,7 @@ Add an entry to `SCENARIOS` in `src/scenarios/types.ts`. The runner discovers it
177257
1. Create `src/pages/MyLibPage.tsx` that registers a `HarnessHandle` (see existing pages for the contract).
178258
2. Wire it into `src/main.tsx`'s switch.
179259
3. Add the library name to `ALL_LIBS` in `runner/run.mjs`.
260+
4. If a library cannot run certain scenarios, add exclusions to `LIB_SCENARIO_EXCLUSIONS` in `src/scenarios/libScenarioExclusions.mjs` (also re-exported from `scenarios/types.ts`).
180261

181262
## Known limitations
182263

‎benchmarks/package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@tanstack/virtual-benchmarks",
33
"private": true,
4-
"version": "0.0.5",
4+
"version": "0.0.8",
55
"type": "module",
66
"scripts": {
77
"dev": "vite",
@@ -13,6 +13,7 @@
1313
"dependencies": {
1414
"@tanstack/react-virtual": "workspace:*",
1515
"react": "^19.2.7",
16+
"react-aria-components": "^1.19.0",
1617
"react-dom": "^19.2.7",
1718
"react-virtuoso": "^4.15.0",
1819
"react-window": "^2.2.4",

‎benchmarks/runner/run.mjs‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,22 @@ import { setTimeout as sleep } from 'node:timers/promises'
1010
import { writeFileSync, mkdirSync } from 'node:fs'
1111
import path from 'node:path'
1212
import url from 'node:url'
13+
import { LIB_SCENARIO_EXCLUSIONS } from '../src/scenarios/libScenarioExclusions.mjs'
1314

1415
const __dirname = path.dirname(url.fileURLToPath(import.meta.url))
1516
const BENCH_DIR = path.resolve(__dirname, '..')
1617
const PORT = 4173
1718
const BASE = `http://localhost:${PORT}`
1819

19-
const ALL_LIBS = ['tanstack', 'virtua', 'virtuoso', 'window']
20+
const ALL_LIBS = [
21+
'tanstack',
22+
'tanstack-rac',
23+
'virtua',
24+
'virtuoso',
25+
'window',
26+
'rac',
27+
'rac-listbox',
28+
]
2029
const ALL_SCENARIOS = [
2130
'mount-fixed-1k',
2231
'mount-fixed-10k',
@@ -260,7 +269,9 @@ async function main() {
260269

261270
const results = []
262271
for (const lib of opts.libs) {
263-
for (const scenarioId of opts.scenarios) {
272+
const excluded = LIB_SCENARIO_EXCLUSIONS[lib] ?? []
273+
const scenarios = opts.scenarios.filter((id) => !excluded.includes(id))
274+
for (const scenarioId of scenarios) {
264275
for (let r = 0; r < opts.runs; r++) {
265276
process.stderr.write(
266277
`\n ${lib.padEnd(9)} ${scenarioId.padEnd(28)} run ${r + 1}/${opts.runs} ... `,

‎benchmarks/src/lib/harness.ts‎

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import type { ScenarioInput, ScenarioMetrics } from '../scenarios/types'
88
export interface HarnessHandle {
99
/** Container element the library is told to scroll. */
1010
getScrollContainer: () => HTMLElement | null
11+
/** Optional wider DOM root for accuracy probes (e.g. virtualized item wrappers). */
12+
getSearchRoot?: () => HTMLElement | null
1113
/** Programmatically scroll to a target offset (px). */
1214
scrollToOffset?: (offset: number) => void
1315
/** Programmatically scroll to a target index. Some libraries expose
@@ -44,6 +46,21 @@ function nextFrame(): Promise<number> {
4446
return new Promise((resolve) => requestAnimationFrame(resolve))
4547
}
4648

49+
async function waitForTargetItem(
50+
searchRoot: HTMLElement,
51+
targetIndex: number,
52+
timeoutMs = 2000,
53+
): Promise<HTMLElement | null> {
54+
const selector = `[data-index="${targetIndex}"]`
55+
const start = performance.now()
56+
while (performance.now() - start < timeoutMs) {
57+
const el = searchRoot.querySelector(selector) as HTMLElement | null
58+
if (el) return el
59+
await nextFrame()
60+
}
61+
return searchRoot.querySelector(selector) as HTMLElement | null
62+
}
63+
4764
function waitFor<T>(
4865
predicate: () => T | false | null | undefined,
4966
timeoutMs = 8000,
@@ -203,13 +220,8 @@ export function installBenchAPI(): void {
203220
}
204221
actionMs = performance.now() - t0
205222

206-
// Now: find the DOM element for the target index. Its viewport-relative
207-
// top tells us where it actually landed. With align:'start', we want
208-
// item[targetIndex]'s top to be at viewport top — i.e., offset 0.
209-
const itemSelector = `[data-index="${targetIndex}"]`
210-
const itemEl = container.querySelector(
211-
itemSelector,
212-
) as HTMLElement | null
223+
const searchRoot = h.getSearchRoot?.() ?? container
224+
const itemEl = await waitForTargetItem(searchRoot, targetIndex)
213225
if (itemEl) {
214226
const itemRect = itemEl.getBoundingClientRect()
215227
const containerRect = container.getBoundingClientRect()
@@ -269,11 +281,8 @@ export function installBenchAPI(): void {
269281
}
270282
actionMs = performance.now() - t0
271283

272-
// Compute landing error: distance between the relevant edge of the
273-
// target item and the relevant edge of the viewport.
274-
const itemEl = container.querySelector(
275-
`[data-index="${targetIndex}"]`,
276-
) as HTMLElement | null
284+
const searchRoot = h.getSearchRoot?.() ?? container
285+
const itemEl = await waitForTargetItem(searchRoot, targetIndex)
277286
if (itemEl) {
278287
const iRect = itemEl.getBoundingClientRect()
279288
const cRect = container.getBoundingClientRect()

‎benchmarks/src/lib/itemRow.tsx‎

Lines changed: 26 additions & 0 deletions

0 commit comments

Comments
 (0)