diff --git a/benchmarks/README.md b/benchmarks/README.md index dc6dc34c..0d794c39 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -1,9 +1,23 @@ # 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 **react-aria-components** `Virtualizer`. Same data, same scenarios, same harness — driven by Playwright against a real browser running a real Vite-built React app for each library. +## Library matrix + +| id | What it measures | +| --- | --- | +| `tanstack` | Headless `@tanstack/react-virtual` + plain DOM rows | +| `tanstack-rac` | TanStack virtual + `role="listbox"` / `role="option"` (no RAC collection) | +| `rac-listbox` | React Aria `ListBox` only — collection overhead, all items in DOM | +| `rac` | React Aria `Virtualizer` + `ListBox` — full integrated stack | +| `virtua`, `virtuoso`, `window` | Existing third-party baselines | + +`rac-listbox` skips `mount-fixed-100k` (100k DOM nodes is intentionally out of scope). + +**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. + ## Running ```bash @@ -74,7 +88,7 @@ it's measuring — it just calls one global function per page. | `jankMs` | Sum of frame durations > 50 ms during the action. | | `memoryBytes` | `performance.memory.usedJSHeapSize` after the scenario. Chromium only; ungated by `--enable-precise-memory-info`. | -## Latest results (medians of 5 runs each) +## Latest results — third-party libraries (medians of 5 runs each) **Hardware**: Author's machine — see `results/.json` for run conditions. @@ -140,6 +154,64 @@ it's measuring — it just calls one global function per page. > the others — same root cause as the slow mount: we hold a `VirtualItem` > object per item, while virtua holds two numbers per item. +## React Aria comparison (medians of 2 runs each) + +Run with `--libs tanstack,tanstack-rac,rac,rac-listbox` on 2026-06-21. See +`results/LATEST.md` for the full table set. + +### Mount time — `React.render` → commit (lower is better, ms) + +| Scenario | tanstack | tanstack-rac | rac | rac-listbox | +| ------------------- | -------: | -----------: | ------: | ----------: | +| `mount-fixed-1k` | 3.7 | **1.0** | 9.6 | 7.8 | +| `mount-fixed-10k` | **2.4** | 1.5 | 20.0 | 25.5 | +| `mount-fixed-100k` | **5.8** | 4.2 | 175.2 | — | +| `mount-dynamic-1k` | 1.8 | **1.4** | 7.3 | 8.1 | +| `mount-dynamic-10k` | **4.9** | 5.2 | 26.2 | 26.3 | + +> **What we see:** `tanstack-rac` (TanStack virtual + WAI-ARIA roles only) mounts +> as fast as headless TanStack. The full RAC stack (`rac`) pays 10–30× more at +> 10k because of collection + layout setup. `rac-listbox` (no virtualizer) is +> similar at 10k but skipped at 100k — 100k DOM nodes is out of scope. + +### Dynamic measurement — commit → stable total size (lower is better, ms) + +| Scenario | tanstack | tanstack-rac | rac | rac-listbox | +| ------------------- | -------: | -----------: | ----: | ----------: | +| `mount-dynamic-1k` | 124.8 | **122.6** | 3,004 | **108.1** | +| `mount-dynamic-10k` | 118.7 | 119.4 | 3,009 | **107.6** | + +> **What we see:** RAC's virtualizer waits ~3 s for layout to settle (its +> `isFullyMeasured` gate). Non-virtualized `rac-listbox` is fastest here because +> every row is already in the DOM — no scroll-range estimation needed. + +### Memory after mount (lower is better, MB) + +| Scenario | tanstack | tanstack-rac | rac | rac-listbox | +| ------------------- | -------: | -----------: | ----: | ----------: | +| `mount-fixed-10k` | **3.3** | 6.3 | 13.9 | 534.0 | +| `mount-fixed-100k` | **9.9** | 12.9 | 99.7 | — | +| `mount-dynamic-10k` | **4.8** | 7.7 | 15.4 | 535.3 | + +> **What we see:** `rac-listbox` at 10k uses ~80× more heap than virtualized +> libraries because all 10k React nodes stay mounted. RAC virtualized is ~2× +> TanStack at 10k, ~10× at 100k. + +### scrollToIndex landing accuracy — px offset from target (lower is better) + +| Scenario | tanstack | tanstack-rac | rac | rac-listbox | +| ----------------------------------------- | -------: | -----------: | ---: | ----------: | +| `jump-to-middle-accuracy-dynamic-10k` | 0 | 0 | −1 | 0 | +| `jump-to-last-accuracy-dynamic-10k` | 0 | 0 | −1 | 0 | +| `jump-while-measuring-accuracy-dynamic-10k` | 0 | 0 | −1 | 0 | +| `jump-wide-variance-accuracy-10k` | 0 | 0 | −1 | 0 | + +> **What we see:** `−1` means the target row was not in the DOM after scroll +> settled — RAC has no public `scrollToIndex`, so the harness uses layout-derived +> `scrollTop`. `rac-listbox` reports 0 px because every row stays mounted. +> Scroll FPS and jump-to-end settle time were identical across all four (60 fps, +> ~65–85 ms). + ## Bottom line - **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. libraries sustain 60 fps with zero dropped frames. - **Jump-to-index** — react-window leads, TanStack lands ~15 ms slower, virtuoso 2× slower than the leader. +- **React Aria overhead** — WAI-ARIA roles alone (`tanstack-rac`) add negligible + cost. The RAC collection + virtualizer stack adds ~10–30× mount time at 10k and + ~30× at 100k vs headless TanStack. Non-virtualized `ListBox` is unusable at + scale (~534 MB heap at 10k items). +- **RAC accuracy** — virtualized RAC cannot satisfy `scrollToIndex` accuracy + probes today (`landingErrorPx = −1`); non-virtualized `rac-listbox` is exact. ## Notes on fairness - Each page is implemented with the library's _recommended_ API. For example, TanStack uses `useVirtualizer` + `measureElement`; virtua uses `VList` with the `data`/`item` props; virtuoso uses `Virtuoso` with `fixedItemHeight` - when applicable; react-window uses `List` + `useDynamicRowHeight`. + when applicable; react-window uses `List` + `useDynamicRowHeight`; RAC uses + `Virtualizer` + `ListLayout` + `ListBox`; `tanstack-rac` adds listbox/option + roles to TanStack rows without RAC's collection layer. - 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 @@ -177,6 +257,7 @@ Add an entry to `SCENARIOS` in `src/scenarios/types.ts`. The runner discovers it 1. Create `src/pages/MyLibPage.tsx` that registers a `HarnessHandle` (see existing pages for the contract). 2. Wire it into `src/main.tsx`'s switch. 3. Add the library name to `ALL_LIBS` in `runner/run.mjs`. +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`). ## Known limitations diff --git a/benchmarks/package.json b/benchmarks/package.json index 36fb4100..7c6db8f4 100644 --- a/benchmarks/package.json +++ b/benchmarks/package.json @@ -13,6 +13,7 @@ "dependencies": { "@tanstack/react-virtual": "workspace:*", "react": "^19.2.7", + "react-aria-components": "^1.19.0", "react-dom": "^19.2.7", "react-virtuoso": "^4.15.0", "react-window": "^2.2.4", diff --git a/benchmarks/runner/run.mjs b/benchmarks/runner/run.mjs index cec981e5..4814d217 100644 --- a/benchmarks/runner/run.mjs +++ b/benchmarks/runner/run.mjs @@ -10,13 +10,22 @@ import { setTimeout as sleep } from 'node:timers/promises' import { writeFileSync, mkdirSync } from 'node:fs' import path from 'node:path' import url from 'node:url' +import { LIB_SCENARIO_EXCLUSIONS } from '../src/scenarios/libScenarioExclusions.mjs' const __dirname = path.dirname(url.fileURLToPath(import.meta.url)) 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', + 'tanstack-rac', + 'virtua', + 'virtuoso', + 'window', + 'rac', + 'rac-listbox', +] const ALL_SCENARIOS = [ 'mount-fixed-1k', 'mount-fixed-10k', @@ -260,7 +269,9 @@ async function main() { const results = [] for (const lib of opts.libs) { - for (const scenarioId of opts.scenarios) { + const excluded = LIB_SCENARIO_EXCLUSIONS[lib] ?? [] + const scenarios = opts.scenarios.filter((id) => !excluded.includes(id)) + for (const scenarioId of scenarios) { for (let r = 0; r < opts.runs; r++) { process.stderr.write( `\n ${lib.padEnd(9)} ${scenarioId.padEnd(28)} run ${r + 1}/${opts.runs} ... `, diff --git a/benchmarks/src/lib/harness.ts b/benchmarks/src/lib/harness.ts index 141f00c0..c88f5f0a 100644 --- a/benchmarks/src/lib/harness.ts +++ b/benchmarks/src/lib/harness.ts @@ -8,6 +8,8 @@ import type { ScenarioInput, ScenarioMetrics } from '../scenarios/types' export interface HarnessHandle { /** Container element the library is told to scroll. */ getScrollContainer: () => HTMLElement | null + /** Optional wider DOM root for accuracy probes (e.g. virtualized item wrappers). */ + getSearchRoot?: () => HTMLElement | null /** Programmatically scroll to a target offset (px). */ scrollToOffset?: (offset: number) => void /** Programmatically scroll to a target index. Some libraries expose @@ -44,6 +46,21 @@ function nextFrame(): Promise { return new Promise((resolve) => requestAnimationFrame(resolve)) } +async function waitForTargetItem( + searchRoot: HTMLElement, + targetIndex: number, + timeoutMs = 2000, +): Promise { + const selector = `[data-index="${targetIndex}"]` + const start = performance.now() + while (performance.now() - start < timeoutMs) { + const el = searchRoot.querySelector(selector) as HTMLElement | null + if (el) return el + await nextFrame() + } + return searchRoot.querySelector(selector) as HTMLElement | null +} + function waitFor( predicate: () => T | false | null | undefined, timeoutMs = 8000, @@ -203,13 +220,8 @@ export function installBenchAPI(): void { } actionMs = performance.now() - t0 - // Now: find the DOM element for the target index. Its viewport-relative - // top tells us where it actually landed. With align:'start', we want - // item[targetIndex]'s top to be at viewport top — i.e., offset 0. - const itemSelector = `[data-index="${targetIndex}"]` - const itemEl = container.querySelector( - itemSelector, - ) as HTMLElement | null + const searchRoot = h.getSearchRoot?.() ?? container + const itemEl = await waitForTargetItem(searchRoot, targetIndex) if (itemEl) { const itemRect = itemEl.getBoundingClientRect() const containerRect = container.getBoundingClientRect() @@ -269,11 +281,8 @@ export function installBenchAPI(): void { } actionMs = performance.now() - t0 - // Compute landing error: distance between the relevant edge of the - // target item and the relevant edge of the viewport. - const itemEl = container.querySelector( - `[data-index="${targetIndex}"]`, - ) as HTMLElement | null + const searchRoot = h.getSearchRoot?.() ?? container + const itemEl = await waitForTargetItem(searchRoot, targetIndex) if (itemEl) { const iRect = itemEl.getBoundingClientRect() const cRect = container.getBoundingClientRect() diff --git a/benchmarks/src/lib/itemRow.tsx b/benchmarks/src/lib/itemRow.tsx new file mode 100644 index 00000000..c7ed97d6 --- /dev/null +++ b/benchmarks/src/lib/itemRow.tsx @@ -0,0 +1,26 @@ +import type { Item } from './dataset' + +export function ItemRow({ + item, + itemSize, + dynamic, + index, +}: { + item: Item + itemSize: number + dynamic: boolean + index: number +}) { + return ( +
+ {item.text} +
+ ) +} diff --git a/benchmarks/src/lib/racBench.ts b/benchmarks/src/lib/racBench.ts new file mode 100644 index 00000000..a0fd0aae --- /dev/null +++ b/benchmarks/src/lib/racBench.ts @@ -0,0 +1,114 @@ +import type { ListLayout } from 'react-aria-components' +import type { HarnessHandle } from './harness' +import type { ScenarioInput } from '../scenarios/types' +import type { Item } from './dataset' + +export function findRacScrollContainer( + root: HTMLElement | null, +): HTMLElement | null { + if (!root) return null + for (const node of root.querySelectorAll('div')) { + const el = node as HTMLElement + const oy = getComputedStyle(el).overflowY + if (oy === 'auto' || oy === 'scroll') return el + } + return root +} + +export function scrollRacToIndex( + container: HTMLElement, + layout: ListLayout | null, + items: Array, + index: number, + itemSize: number, + align: 'start' | 'end' = 'start', +): void { + const key = items[index]?.id ?? index + + for (let attempt = 0; attempt < 4; attempt++) { + let scrollTop = index * itemSize + + if (layout?.virtualizer) { + layout.update({}) + layout.getLayoutInfo(key) + const layoutInfo = layout.getLayoutInfo(key) + if (layoutInfo) { + scrollTop = + align === 'end' + ? layoutInfo.rect.y + layoutInfo.rect.height - container.clientHeight + : layoutInfo.rect.y + } + } else if (align === 'end') { + scrollTop += itemSize - container.clientHeight + } + + const maxScroll = Math.max( + 0, + container.scrollHeight - container.clientHeight, + ) + container.scrollTop = Math.max(0, Math.min(scrollTop, maxScroll)) + container.scrollTo({ top: container.scrollTop, behavior: 'instant' }) + container.dispatchEvent(new Event('scroll', { bubbles: true })) + } +} + +export function createRacVirtualHarness({ + hostRef, + scrollerRef, + layoutRef, + items, + scenario, +}: { + hostRef: { current: HTMLDivElement | null } + scrollerRef: { current: HTMLElement | null } + layoutRef: { current: ListLayout | null } + items: Array + scenario: ScenarioInput +}): Omit & { + getScrollContainer: () => HTMLElement | null +} { + return { + getScrollContainer: () => + scrollerRef.current ?? findRacScrollContainer(hostRef.current), + getSearchRoot: () => hostRef.current, + scrollToIndex: (index, opts) => { + const container = + scrollerRef.current ?? findRacScrollContainer(hostRef.current) + if (!container) return + scrollRacToIndex( + container, + layoutRef.current, + items, + index, + scenario.itemSize, + opts?.align ?? 'start', + ) + }, + getTotalSize: () => { + const container = + scrollerRef.current ?? findRacScrollContainer(hostRef.current) + if (!container) return 0 + const content = container.firstElementChild as HTMLElement | null + return content?.offsetHeight ?? container.scrollHeight + }, + isFullyMeasured: () => { + if (!scenario.dynamic) return true + const container = + scrollerRef.current ?? findRacScrollContainer(hostRef.current) + const total = container?.scrollHeight ?? 0 + const estimate = scenario.count * scenario.itemSize + return total > 0 && total !== estimate + }, + } +} + +export function cacheRacScroller( + host: HTMLDivElement | null, + scrollerRef: { current: HTMLElement | null }, +): void { + const scroller = findRacScrollContainer(host) + if (scroller) { + scrollerRef.current = scroller + scroller.dataset.benchRacScroller = 'true' + } +} diff --git a/benchmarks/src/main.tsx b/benchmarks/src/main.tsx index e4ceca2c..90f8d0bb 100644 --- a/benchmarks/src/main.tsx +++ b/benchmarks/src/main.tsx @@ -1,6 +1,9 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { RacListboxPageRoot } from './pages/RacListboxPage' +import { RacPageRoot } from './pages/RacPage' import { TanstackPageRoot } from './pages/TanstackPage' +import { TanstackRacPageRoot } from './pages/TanstackRacPage' import { VirtuaPageRoot } from './pages/VirtuaPage' import { VirtuosoPageRoot } from './pages/VirtuosoPage' import { WindowPageRoot } from './pages/WindowPage' @@ -28,12 +31,18 @@ function App() { switch (lib) { case 'tanstack': return + case 'tanstack-rac': + return case 'virtua': return case 'virtuoso': return case 'window': return + case 'rac': + return + case 'rac-listbox': + return default: return (
diff --git a/benchmarks/src/pages/RacListboxPage.tsx b/benchmarks/src/pages/RacListboxPage.tsx new file mode 100644 index 00000000..3f5a99f5 --- /dev/null +++ b/benchmarks/src/pages/RacListboxPage.tsx @@ -0,0 +1,105 @@ +import { useEffect, useMemo, useRef } from 'react' +import { ListBox, ListBoxItem } from 'react-aria-components' +import { + markFirstPaint, + markMountEnd, + markMountStart, + registerHarness, +} from '../lib/harness' +import { ItemRow } from '../lib/itemRow' +import { makeDataset } from '../lib/dataset' +import type { ScenarioInput } from '../scenarios/types' + +interface Props { + scenario: ScenarioInput +} + +export function RacListboxPage({ scenario }: Props) { + const items = useMemo( + () => + makeDataset( + scenario.count, + scenario.dynamic, + scenario.action === 'jump-wide-variance-accuracy', + ), + [scenario.count, scenario.dynamic, scenario.action], + ) + + const hostRef = useRef(null) + + useEffect(() => { + registerHarness({ + getScrollContainer: () => hostRef.current, + getSearchRoot: () => hostRef.current, + scrollToIndex: (index, opts) => { + const host = hostRef.current + if (!host) return + const align = opts?.align ?? 'start' + const itemEl = host.querySelector(`[data-index="${index}"]`) + if (itemEl instanceof HTMLElement) { + itemEl.scrollIntoView({ + block: align === 'end' ? 'end' : 'start', + inline: 'nearest', + }) + return + } + const top = + align === 'end' + ? index * scenario.itemSize + + scenario.itemSize - + host.clientHeight + : index * scenario.itemSize + const max = Math.max(0, host.scrollHeight - host.clientHeight) + host.scrollTop = Math.max(0, Math.min(top, max)) + }, + getTotalSize: () => hostRef.current?.scrollHeight ?? 0, + // Non-virtualized list: all rows are in the DOM once mounted. + isFullyMeasured: () => hostRef.current !== null, + }) + markMountEnd() + markFirstPaint() + }, [scenario]) + + return ( +
+ + {(item) => ( + + + + )} + +
+ ) +} + +export function RacListboxPageRoot({ scenario }: Props) { + markMountStart() + return +} diff --git a/benchmarks/src/pages/RacPage.tsx b/benchmarks/src/pages/RacPage.tsx new file mode 100644 index 00000000..d51074c4 --- /dev/null +++ b/benchmarks/src/pages/RacPage.tsx @@ -0,0 +1,118 @@ +import { useEffect, useMemo, useRef } from 'react' +import { + ListBox, + ListBoxItem, + ListLayout, + Virtualizer, +} from 'react-aria-components' +import { + markFirstPaint, + markMountEnd, + markMountStart, + registerHarness, +} from '../lib/harness' +import { ItemRow } from '../lib/itemRow' +import { + cacheRacScroller, + createRacVirtualHarness, +} from '../lib/racBench' +import { makeDataset } from '../lib/dataset' +import type { ListLayoutOptions } from 'react-aria-components' +import type { ScenarioInput } from '../scenarios/types' + +interface Props { + scenario: ScenarioInput +} + +export function RacPage({ scenario }: Props) { + const items = useMemo( + () => + makeDataset( + scenario.count, + scenario.dynamic, + scenario.action === 'jump-wide-variance-accuracy', + ), + [scenario.count, scenario.dynamic, scenario.action], + ) + + const hostRef = useRef(null) + const scrollerRef = useRef(null) + const layoutRef = useRef>(null) + + const layoutOptions = useMemo(() => { + if (scenario.dynamic) { + return { + rowSize: scenario.itemSize, + estimatedRowSize: scenario.itemSize, + } + } + return { rowSize: scenario.itemSize } + }, [scenario.dynamic, scenario.itemSize]) + + const layout = useMemo(() => { + const l = new ListLayout(layoutOptions) + layoutRef.current = l + return l + }, [layoutOptions]) + + useEffect(() => { + cacheRacScroller(hostRef.current, scrollerRef) + registerHarness( + createRacVirtualHarness({ + hostRef, + scrollerRef, + layoutRef, + items, + scenario, + }), + ) + markMountEnd() + markFirstPaint() + }, [items, scenario]) + + return ( +
+ + + {(item) => ( + + + + )} + + +
+ ) +} + +export function RacPageRoot({ scenario }: Props) { + markMountStart() + return +} diff --git a/benchmarks/src/pages/TanstackRacPage.tsx b/benchmarks/src/pages/TanstackRacPage.tsx new file mode 100644 index 00000000..febbf96a --- /dev/null +++ b/benchmarks/src/pages/TanstackRacPage.tsx @@ -0,0 +1,124 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react' +import { useVirtualizer } from '@tanstack/react-virtual' +import { + markFirstPaint, + markMountEnd, + markMountStart, + registerHarness, +} from '../lib/harness' +import { ItemRow } from '../lib/itemRow' +import { makeDataset } from '../lib/dataset' +import type { ScenarioInput } from '../scenarios/types' + +interface Props { + scenario: ScenarioInput +} + +/** + * TanStack virtualizer + WAI-ARIA listbox/option roles on plain DOM. + * Isolates headless virtualizer cost without React Aria collection building. + */ +export function TanstackRacPage({ scenario }: Props) { + const items = useMemo( + () => + makeDataset( + scenario.count, + scenario.dynamic, + scenario.action === 'jump-wide-variance-accuracy', + ), + [scenario.count, scenario.dynamic, scenario.action], + ) + + const parentRef = useRef(null) + const measuredIndexesRef = useRef>(new Set()) + + const virtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => parentRef.current, + estimateSize: () => scenario.itemSize, + overscan: 5, + }) + + const measureElement = useCallback( + (element: HTMLDivElement | null) => { + if (element) { + const index = Number(element.dataset.index) + if (Number.isFinite(index)) { + measuredIndexesRef.current.add(index) + } + } + virtualizer.measureElement(element) + }, + [virtualizer], + ) + + useEffect(() => { + registerHarness({ + getScrollContainer: () => parentRef.current, + scrollToIndex: (i, opts) => + virtualizer.scrollToIndex(i, { align: opts?.align ?? 'start' }), + getTotalSize: () => virtualizer.getTotalSize(), + isFullyMeasured: () => { + if (!scenario.dynamic) return true + const virtualItems = virtualizer.getVirtualItems() + return ( + virtualItems.length > 0 && + virtualItems.every((vi) => measuredIndexesRef.current.has(vi.index)) + ) + }, + }) + markMountEnd() + markFirstPaint() + }, [virtualizer, scenario.dynamic, scenario.itemSize]) + + return ( +
+
+ {virtualizer.getVirtualItems().map((vi) => { + const item = items[vi.index] + if (item == null) return null + return ( +
+ +
+ ) + })} +
+
+ ) +} + +export function TanstackRacPageRoot({ scenario }: Props) { + markMountStart() + return +} diff --git a/benchmarks/src/scenarios/libScenarioExclusions.d.mts b/benchmarks/src/scenarios/libScenarioExclusions.d.mts new file mode 100644 index 00000000..518a8078 --- /dev/null +++ b/benchmarks/src/scenarios/libScenarioExclusions.d.mts @@ -0,0 +1,5 @@ +import type { LibraryName } from './types.ts' + +export declare const LIB_SCENARIO_EXCLUSIONS: Partial< + Record> +> diff --git a/benchmarks/src/scenarios/libScenarioExclusions.mjs b/benchmarks/src/scenarios/libScenarioExclusions.mjs new file mode 100644 index 00000000..5753321a --- /dev/null +++ b/benchmarks/src/scenarios/libScenarioExclusions.mjs @@ -0,0 +1,4 @@ +/** Shared per-library scenario skip list (imported by runner + TypeScript sources). */ +export const LIB_SCENARIO_EXCLUSIONS = { + 'rac-listbox': ['mount-fixed-100k'], +} diff --git a/benchmarks/src/scenarios/types.ts b/benchmarks/src/scenarios/types.ts index 48210d16..b90daa50 100644 --- a/benchmarks/src/scenarios/types.ts +++ b/benchmarks/src/scenarios/types.ts @@ -1,7 +1,21 @@ // 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' +import { LIB_SCENARIO_EXCLUSIONS as LIB_SCENARIO_EXCLUSIONS_RAW } from './libScenarioExclusions.mjs' + +export type LibraryName = + | 'tanstack' + | 'tanstack-rac' + | 'virtua' + | 'virtuoso' + | 'window' + | 'rac' + | 'rac-listbox' + +/** Scenarios skipped for specific libraries (e.g. non-virtualized RAC at 100k). */ +export const LIB_SCENARIO_EXCLUSIONS: Partial< + Record> +> = LIB_SCENARIO_EXCLUSIONS_RAW export interface ScenarioInput { /** Stable id used for table keys and result filenames. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ddecb31..6ace1b51 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,9 @@ importers: react: specifier: ^19.2.7 version: 19.2.7 + react-aria-components: + specifier: ^1.19.0 + version: 1.19.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react-dom: specifier: ^19.2.7 version: 19.2.7(react@19.2.7) @@ -2953,6 +2956,15 @@ packages: '@types/node': optional: true + '@internationalized/date@3.12.2': + resolution: {integrity: sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==} + + '@internationalized/number@3.6.7': + resolution: {integrity: sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg==} + + '@internationalized/string@3.2.9': + resolution: {integrity: sha512-kzP/M/mbQxODlmOt4bIQZ2SBVUWUSqMLXooXixnX7noche8WHaQcA+nwFN1K2KCF/cp+LDUhcJsCicwkvhD1pg==} + '@isaacs/balanced-match@4.0.1': resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} engines: {node: 20 || >=22} @@ -3597,6 +3609,11 @@ packages: js-cookie: optional: true + '@react-types/shared@3.36.0': + resolution: {integrity: sha512-DkP/H0C2YjjS7gZWKNqOmU8a16qHPjQNdzMwmTq9SzplM6Iw0kVMTZ0OIoe6FOgGqa+FwMsE2QbPjh/n3g/jXQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -3835,6 +3852,9 @@ packages: svelte: ^4.0.0 || ^5.0.0-next.0 vite: ^5.0.0 + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} + '@tanstack/angular-query-experimental@5.80.7': resolution: {integrity: sha512-eO23TFVliEzZ3A1PFbegddN8UKXqg02BX6azktUP48Zsqq8OTAK74VvReNfKFm5muTnchbRAbKg3Qeg/GGdFVw==} peerDependencies: @@ -4642,6 +4662,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} @@ -4898,6 +4922,9 @@ packages: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -4914,6 +4941,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'} @@ -7098,6 +7129,18 @@ packages: resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} engines: {node: '>= 0.8'} + react-aria-components@1.19.0: + resolution: {integrity: sha512-2smSS5nqJ8cGYMQezuUXveZm7eMyHCqTN6mDpylQBYLYbdF5dxCCuW1DHn1VKLe1DybSfPvX/cZtJlDmvFfn8A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + react-aria@3.50.0: + resolution: {integrity: sha512-S0Os6QZk33fzUAKu1QLT9afoUaCBt1ZNdoiq0n2YMVgKIdNIQS8zxiZ8O9hYE6QyDkHKjD6q39LQZ+qaSAIgjw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom@19.2.7: resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} peerDependencies: @@ -7113,6 +7156,11 @@ packages: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} + react-stately@3.48.0: + resolution: {integrity: sha512-ImicSAG+lTotAe5izcs1fz49Zk48w7pDusqYg04WaPhCoej8BJ24soMu3iLXIrsi273s4P1gZrYGrqReMfgEEA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-virtuoso@4.18.7: resolution: {integrity: sha512-xNF5zDGEEIMB7cKwcen/pLig0YDf6OnfFrVgKFa7sHPf9fRem0CaLshyObbBcP88jzn0enavL39EgplgdyT21g==} peerDependencies: @@ -7936,6 +7984,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==} @@ -9990,6 +10043,18 @@ snapshots: optionalDependencies: '@types/node': 24.9.2 + '@internationalized/date@3.12.2': + dependencies: + '@swc/helpers': 0.5.23 + + '@internationalized/number@3.6.7': + dependencies: + '@swc/helpers': 0.5.23 + + '@internationalized/string@3.2.9': + dependencies: + '@swc/helpers': 0.5.23 + '@isaacs/balanced-match@4.0.1': {} '@isaacs/brace-expansion@5.0.0': @@ -10548,6 +10613,10 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) + '@react-types/shared@3.36.0(react@19.2.7)': + dependencies: + react: 19.2.7 + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/pluginutils@5.3.0(rollup@4.59.0)': @@ -10757,6 +10826,10 @@ snapshots: transitivePeerDependencies: - supports-color + '@swc/helpers@0.5.23': + dependencies: + tslib: 2.8.1 + '@tanstack/angular-query-experimental@5.80.7(@angular/common@19.2.20(@angular/core@19.2.20(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@19.2.20(rxjs@7.8.2)(zone.js@0.15.1))': dependencies: '@angular/common': 19.2.20(@angular/core@19.2.20(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2) @@ -11755,6 +11828,10 @@ snapshots: argparse@2.0.1: {} + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + aria-query@5.3.0: dependencies: dequal: 2.0.3 @@ -12062,6 +12139,8 @@ snapshots: cli-width@4.1.0: {} + client-only@0.0.1: {} + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -12078,6 +12157,8 @@ snapshots: clone@2.1.2: {} + clsx@2.1.1: {} + co-body@6.2.0: dependencies: '@hapi/bourne': 3.0.0 @@ -14403,6 +14484,31 @@ snapshots: iconv-lite: 0.4.24 unpipe: 1.0.0 + react-aria-components@1.19.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@internationalized/date': 3.12.2 + '@react-types/shared': 3.36.0(react@19.2.7) + '@swc/helpers': 0.5.23 + client-only: 0.0.1 + react: 19.2.7 + react-aria: 3.50.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react-dom: 19.2.7(react@19.2.7) + react-stately: 3.48.0(react@19.2.7) + + react-aria@3.50.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@internationalized/date': 3.12.2 + '@internationalized/number': 3.6.7 + '@internationalized/string': 3.2.9 + '@react-types/shared': 3.36.0(react@19.2.7) + '@swc/helpers': 0.5.23 + aria-hidden: 1.2.6 + clsx: 2.1.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-stately: 3.48.0(react@19.2.7) + use-sync-external-store: 1.6.0(react@19.2.7) + react-dom@19.2.7(react@19.2.7): dependencies: react: 19.2.7 @@ -14414,6 +14520,16 @@ snapshots: react-refresh@0.17.0: {} + react-stately@3.48.0(react@19.2.7): + dependencies: + '@internationalized/date': 3.12.2 + '@internationalized/number': 3.6.7 + '@internationalized/string': 3.2.9 + '@react-types/shared': 3.36.0(react@19.2.7) + '@swc/helpers': 0.5.23 + react: 19.2.7 + use-sync-external-store: 1.6.0(react@19.2.7) + react-virtuoso@4.18.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: react: 19.2.7 @@ -15271,6 +15387,10 @@ snapshots: dependencies: punycode: 2.3.1 + use-sync-external-store@1.6.0(react@19.2.7): + dependencies: + react: 19.2.7 + util-deprecate@1.0.2: {} utils-merge@1.0.1: {}