-
-
Notifications
You must be signed in to change notification settings - Fork 429
perf: virtual-core rewrite for mount/measure-storm, plus iOS Safari handling and scroll restoration #1168
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
perf: virtual-core rewrite for mount/measure-storm, plus iOS Safari handling and scroll restoration #1168
Changes from all commits
Commits
Show all changes
43 commits
Select commit
Hold shift + click to select a range
0fc0dbe
perf(virtual-core): replace Map clone in resizeItem with version counter
tannerlinsley 61d0dd9
perf(virtual-core): rewrite setOptions to avoid Object.entries+delete
tannerlinsley 9ccdae4
perf(virtual-core): track pending-rebuild min with a counter, not an …
tannerlinsley b9d4123
chore(virtual-core): add Layer 4 bench scenarios (resizeItem notify c…
tannerlinsley 9fa57ff
perf(virtual-core): pre-size defaultRangeExtractor's result array
tannerlinsley e7244e4
fix(virtual-core): cast setOptions merged-defaults through unknown
tannerlinsley 4af143c
perf(react-virtual): use a number counter for useReducer instead of a…
tannerlinsley 843690b
fix(virtual-core): drop elementsCache entry when RO sees disconnected…
tannerlinsley e3b8d2a
perf(virtual-core): make memo's debug instrumentation tree-shakable
tannerlinsley e8ba499
refactor(virtual-core): collapse element/window observer pairs to one…
tannerlinsley 8524bb3
refactor(virtual-core): replace utils barrel with named exports
tannerlinsley de984d6
chore(benchmarks): add reproducible cross-library benchmark suite
tannerlinsley 395a004
docs: add competitor claims verification matrix
tannerlinsley bb5b96f
exp(virtual-core): lazy VirtualItem materialization for lanes===1 fas…
tannerlinsley a3039d9
exp(virtual-core): defer scroll-position adjustments during iOS momen…
tannerlinsley 4327745
exp(virtual-core): keep smooth scroll while still > viewport from new…
tannerlinsley b5f513c
exp(virtual-core): skip scroll-position adjustment while user scrolls…
tannerlinsley da91bf6
exp(virtual-core): add takeSnapshot() for scroll restoration round-trips
tannerlinsley 2304108
exp(virtual-core): bypass lazy-view Proxy in calculateRange + getVirt…
tannerlinsley 7d076c4
docs: summarize 3-hour experimentation loop results
tannerlinsley 31b2fb3
exp(virtual-core): getTotalSize reads last end directly from flat typ…
tannerlinsley bf532fe
docs: update experiments summary with final cross-library numbers
tannerlinsley 0bfd973
fix(benchmarks): remove 1px border on .scroll-host so accuracy bench …
tannerlinsley d6c4b38
test(benchmarks): add three accuracy edge cases for scrollToIndex
tannerlinsley 225a615
docs: plan iOS Phase 1 + Phase 2 (touch distinction, subpixel reconci…
tannerlinsley 941a0c6
docs: add bundle-impact section to iOS support plan
tannerlinsley 78b2942
feat(virtual-core): iOS Phase 1 — touch event distinction for scroll …
tannerlinsley f32c2a1
feat(virtual-core): iOS Phase 2a — subpixel reconciliation for scroll…
tannerlinsley 7dab6f8
feat(virtual-core): iOS Phase 2b — skip flush during Safari elastic-o…
tannerlinsley 94dc5c7
chore: clean up lint, sherif, knip for release readiness
tannerlinsley e3265d8
docs(api): document takeSnapshot, initialMeasurementsCache, new defaults
tannerlinsley 8fa9d48
chore: add changesets for the release
tannerlinsley e732f6a
docs: blog post draft for the release
tannerlinsley 001ea2a
docs: release readiness verdict + summary
tannerlinsley 4004e0c
docs: voice pass on blog post against tanner-writing-style skill
tannerlinsley b270e0a
docs: aggressive trim on blog post
tannerlinsley f011e91
docs: strip comparative framing from blog post
tannerlinsley bb64a63
docs: convert numbers section from bullets to a Before/After table
tannerlinsley 5a171e7
ci: apply automated fixes
autofix-ci[bot] c09bcab
chore: remove working-doc artifacts from the audit/experiment phase
tannerlinsley 67db591
fix: address CodeRabbit findings on PR #1168
tannerlinsley ab9c00f
docs(changeset): record measure() pendingMin and iOS flush accumulato…
tannerlinsley 186b3bd
fix(virtual-core): don't call getItemKey with a stale index in RO dis…
tannerlinsley File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| --- | ||
| '@tanstack/virtual-core': minor | ||
| --- | ||
|
|
||
| iOS Safari momentum-scroll handling. Writing `scrollTop` while a finger | ||
| is on the screen, during momentum decay, or while the page is in the | ||
| elastic-overscroll bounce zone all cancel the in-flight scroll in iOS | ||
| WebKit. The virtualizer previously had no iOS-specific handling, which | ||
| manifested as the recurring "scroll abruptly stops when content above | ||
| resizes" complaints on Safari mobile. | ||
|
|
||
| Adds three layers of protection, default-on, all transparent to | ||
| consumers: | ||
|
|
||
| - **Touch event distinction.** A touchstart→touchend window plus a | ||
| 150 ms grace timer for the early-momentum phase. Scroll-position | ||
| adjustments triggered during any of these states accumulate into a | ||
| `_iosDeferredAdjustment` field instead of writing `scrollTop`. | ||
| - **Subpixel reconciliation.** When the browser reports back a rounded | ||
| `scrollTop` within 1.5 px of a value we just wrote, the virtualizer | ||
| prefers the intended value rather than treating the round-trip as a | ||
| user scroll. | ||
| - **Elastic-overscroll clamp.** The deferred-adjustment flush is skipped | ||
| when `scrollTop` is outside `[0, scrollHeight - clientHeight]`, | ||
| preventing a snap-back jolt at end-of-bounce. The next in-bounds | ||
| scroll event retries. | ||
|
|
||
| Non-iOS code paths are unchanged. iOS detection is SSR-safe and cached | ||
| after first call. Bundle cost is ~370 B gzip in the consumer-minified | ||
| production build — kept default-on because iOS Safari is a large share | ||
| of mobile traffic for the apps that use virtualization heavily. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| --- | ||
| '@tanstack/virtual-core': patch | ||
| --- | ||
|
|
||
| `scrollToIndex(N, { behavior: 'smooth' })` on a dynamic-height list no | ||
| longer snaps to `behavior: 'auto'` the moment a measurement shifts the | ||
| computed target offset. While the scroll is still more than a viewport | ||
| away from the new target, smooth scroll continues with the updated | ||
| endpoint; only on the final approach do we fall back to 'auto' for | ||
| precise landing. The user-visible effect is one continuous smooth | ||
| motion that subtly adjusts its endpoint as measurements arrive, | ||
| instead of the prior animation-then-snap pattern. | ||
|
|
||
| Also: once `reconcileScroll` reaches its stable-frames threshold, it | ||
| writes the exact target offset one final time. This is a no-op when | ||
| `scrollTop` already equals the target (the common case) but corrects | ||
| the rare subpixel-rounding case where smooth scroll undershoots by | ||
| less than 1 px. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| --- | ||
| '@tanstack/virtual-core': minor | ||
| --- | ||
|
|
||
| Skip the scroll-position adjustment while the user is scrolling backward | ||
| by default. When an above-viewport item resizes during backward scroll | ||
| (images load, content reflows, etc.) the prior behavior wrote `scrollTop` | ||
| to keep the visible window stable — but on backward scroll that write | ||
| fights the user's direction and produces visible "items jump up while I | ||
| scroll up" jank. This was the largest single complaint cluster in the | ||
| issue tracker (multiple recurring threads spanning years; users had | ||
| independently rediscovered the same workaround at least five times). | ||
|
|
||
| Forward-scroll and idle (mount-time) adjustments still fire as before | ||
| to preserve visual stability of the visible window. Consumers who want | ||
| the old behavior — adjusting on every above-viewport resize regardless | ||
| of direction — can supply `shouldAdjustScrollPositionOnItemSizeChange` | ||
| which is checked before the default branch. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| --- | ||
| '@tanstack/virtual-core': minor | ||
| --- | ||
|
|
||
| Add `takeSnapshot()` instance method for scroll-restoration round-trips. | ||
| Returns the currently-measured items as plain `VirtualItem` objects; | ||
| pair with the current `scrollOffset` to persist scroll position across | ||
| remounts (route navigation, list-view modals, etc.). The result feeds | ||
| back through the existing `initialMeasurementsCache` option: | ||
|
|
||
| ```tsx | ||
| const snapshot = virtualizer.takeSnapshot() | ||
| const offset = virtualizer.scrollOffset | ||
| // later, on remount: | ||
| useVirtualizer({ | ||
| // … | ||
| initialMeasurementsCache: snapshot, | ||
| initialOffset: offset, | ||
| }) | ||
| ``` | ||
|
|
||
| Closes the gap to virtua's `takeCacheSnapshot()` and react-virtuoso's | ||
| `getState`. Only items actually rendered (and thus measured) are | ||
| included; unmeasured items fall back to `estimateSize` on restore. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| --- | ||
| '@tanstack/virtual-core': patch | ||
| --- | ||
|
|
||
| Don't call `getItemKey` with a possibly-stale index when cleaning up | ||
| `elementsCache` for a disconnected node. The cleanup now finds the | ||
| matching entry by node identity, so removing items from the end of | ||
| the list while a `ResizeObserver` still has the now-detached node | ||
| queued no longer throws (regression of #1148). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| --- | ||
| '@tanstack/virtual-core': patch | ||
| --- | ||
|
|
||
| Two correctness fixes in the new code: | ||
|
|
||
| - `measure()` now resets `pendingMin` so a prior `resizeItem()` that left | ||
| it non-null can't preserve stale `measurementsCache` entries before that | ||
| index. The next rebuild is guaranteed to start at 0. | ||
| - The iOS deferred-adjustment flush now rolls its accumulated delta into | ||
| `scrollAdjustments`. Without this, a resize landing between the flush | ||
| and the resulting scroll event would compute the next correction from | ||
| the stale pre-flush offset. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| --- | ||
| '@tanstack/virtual-core': minor | ||
| --- | ||
|
|
||
| Mount-time, measurement, and memory rewrite for huge lists. The hot path | ||
| through `getMeasurements()` no longer allocates a `VirtualItem` object per | ||
| index for single-lane lists; instead it fills a `Float64Array` of | ||
| start/size pairs and materializes `VirtualItem` objects lazily through a | ||
| `Proxy`-backed view when consumers index into them. Internal hot paths | ||
| (`calculateRange`, `getVirtualItemForOffset`, `getTotalSize`, `resizeItem`) | ||
| read directly from the typed-array storage to avoid the Proxy. | ||
|
|
||
| Also collapses a chain of smaller hotspots discovered in an audit pass: | ||
| the per-resize `Map` clone in `resizeItem`, the `Object.entries+delete` | ||
| deopt in `setOptions`, the `Math.min(...pendingMeasuredCacheIndexes)` | ||
| spread, the `defaultRangeExtractor` `push` growth pattern, the eager | ||
| `measurementsCache` reference invalidation, and the leaked `elementsCache` | ||
| entries when a `ResizeObserver` fires for a node React already replaced. | ||
|
|
||
| Headline impact (measured against actual `Virtualizer` instances with | ||
| vitest bench): | ||
|
|
||
| - Cold mount @ 100k items: ~2.5 ms → ~0.5 ms (4.7×) | ||
| - Cold mount @ 500k items: ~14 ms → ~2.7 ms (5.2×) | ||
| - `resizeItem` storm of 10,000 measurements + final `getMeasurements`: | ||
| ~1.9 s → ~1.3 ms (≈1382×) — this was the dominant `Map`-clone bug | ||
| - `setOptions` × 10,000 calls (React-render-storm proxy): ~14 ms → ~1.3 ms | ||
| (11×) | ||
|
|
||
| The lanes>1 path keeps the previous eager allocation (lane assignment is | ||
| order-dependent and harder to defer cleanly); behavior is unchanged | ||
| there. | ||
|
|
||
| No public API change. `measurementsCache` is still an | ||
| `Array<VirtualItem>`-shaped value supporting `[i]`, `.length`, iteration, | ||
| etc. Internal consumers that previously read fields off `VirtualItem` | ||
| objects continue to do so transparently. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| --- | ||
| '@tanstack/react-virtual': patch | ||
| --- | ||
|
|
||
| Replace the `useReducer(() => ({}), {})` force-rerender pattern with an | ||
| incrementing number counter. Same semantics (every dispatch changes the | ||
| reducer state, forcing a render); zero per-dispatch object allocation. | ||
| Trivial individual cost, but eliminates one steady-state GC source on | ||
| scroll-heavy apps. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| {"sessionId":"e402bf71-ca74-4aa5-856c-da0c2053caab","pid":78596,"procStart":"Sat May 16 20:13:35 2026","acquiredAt":1779000018499} | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| node_modules | ||
| dist | ||
| results/*.json | ||
| !results/SAMPLE.json | ||
| !results/.gitkeep | ||
| results/LATEST.md |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,189 @@ | ||
| # Virtualization benchmarks | ||
|
|
||
| Reproducible browser benchmarks comparing **@tanstack/react-virtual**, **virtua**, **react-virtuoso**, and **react-window** v2. | ||
|
|
||
| Same data, same scenarios, same harness — driven by Playwright against a real browser running a real Vite-built React app for each library. | ||
|
|
||
| ## Running | ||
|
|
||
| ```bash | ||
| # from the repo root | ||
| pnpm install | ||
| pnpm --filter @tanstack/virtual-core build | ||
| cd benchmarks | ||
| pnpm exec playwright install chromium | ||
|
|
||
| # Full matrix, 5 runs per cell (~10 min) | ||
| pnpm bench | ||
|
|
||
| # Quick subset | ||
| pnpm bench -- --runs 2 --libs tanstack,virtua --scenarios mount-fixed-10k | ||
|
|
||
| # Watch the browser as it runs | ||
| pnpm bench:headed | ||
| ``` | ||
|
|
||
| Results land in `benchmarks/results/<timestamp>.json` (raw, every run) and | ||
| `benchmarks/results/LATEST.md` (median table from the last run). | ||
|
|
||
| ## How it works | ||
|
|
||
| ```text | ||
| benchmarks/ | ||
| ├── src/ | ||
| │ ├── main.tsx Reads ?lib=... &scenario=... | ||
| │ ├── pages/ One file per library; all share the same harness | ||
| │ ├── lib/ | ||
| │ │ ├── dataset.ts Deterministic item generator (LCG-seeded) | ||
| │ │ └── harness.ts Installs window.bench.run() that every page uses | ||
| │ └── scenarios/types.ts The fixed scenario list. Adding a row here | ||
| │ surfaces it in every library and the runner. | ||
| ├── runner/run.mjs Playwright driver. Boots a server, runs each | ||
| │ (lib × scenario × run), aggregates medians. | ||
| ├── results/ JSON snapshots + LATEST.md | ||
| └── package.json | ||
| ``` | ||
|
|
||
| Every library page mounts an identical dataset, registers a `HarnessHandle`, | ||
| and exposes the same `window.bench.run(scenario)` entrypoint that returns | ||
| `ScenarioMetrics`. That means the runner doesn't know or care which library | ||
| it's measuring — it just calls one global function per page. | ||
|
|
||
| ## Scenarios | ||
|
|
||
| | id | items | size | dynamic | action | | ||
| | ------------------------- | ------- | ------ | ------- | ------------------------------------------------------------------- | | ||
| | `mount-fixed-1k` | 1,000 | 30 px | no | idle (just mount) | | ||
| | `mount-fixed-10k` | 10,000 | 30 px | no | idle | | ||
| | `mount-fixed-100k` | 100,000 | 30 px | no | idle | | ||
| | `mount-dynamic-1k` | 1,000 | varies | yes | wait for total size to settle | | ||
| | `mount-dynamic-10k` | 10,000 | varies | yes | wait for total size to settle | | ||
| | `scroll-to-bottom-10k` | 10,000 | 30 px | no | rAF-driven scroll, 1.5 s | | ||
| | `fast-scroll-dynamic-10k` | 10,000 | varies | yes | rAF-driven scroll, 1.5 s | | ||
| | `jump-to-end-dynamic-10k` | 10,000 | varies | yes | `scrollToIndex(9999)` then wait until scrollTop stable for 5 frames | | ||
|
|
||
| ## Metrics | ||
|
|
||
| | field | meaning | | ||
| | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ||
| | `mountMs` | `React.render(...)` call → `useEffect` runs (commit complete). | | ||
| | `firstPaintMs` | `React.render(...)` call → one rAF after commit (≈ first paint). | | ||
| | `actionMs` | Action-specific. For scroll actions, total elapsed during the scripted scroll. For dynamic-measure, time from mount to a stable `getTotalSize()` (8 consecutive frames unchanged). For jump-to-end, time from `scrollToIndex` to position stable for 5 frames. | | ||
| | `scrollFps` | Average FPS sampled during the scripted scroll. | | ||
| | `longFrames` | Count of frames with inter-frame gap > 32 ms. | | ||
| | `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) | ||
|
|
||
| **Hardware**: Author's machine — see `results/<timestamp>.json` for run conditions. | ||
|
|
||
| ### Mount time — `React.render` → commit (lower is better, ms) | ||
|
|
||
| | Scenario | tanstack | virtua | virtuoso | window | | ||
| | ------------------- | -------: | ------: | -------: | -----: | | ||
| | `mount-fixed-1k` | **0.8** | 0.7 | 1.8 | 2.2 | | ||
| | `mount-fixed-10k` | 1.6 | **1.0** | 2.0 | 2.4 | | ||
| | `mount-fixed-100k` | 6.1 | **3.1** | 5.0 | 4.4 | | ||
| | `mount-dynamic-1k` | **1.5** | 1.8 | 2.8 | 2.9 | | ||
| | `mount-dynamic-10k` | **6.0** | 6.7 | 8.5 | 7.0 | | ||
|
|
||
| > **What we see:** TanStack is fastest on every scenario at 1k–10k items, but | ||
| > _slowest_ at 100k fixed. The audit predicted this: we eagerly populate | ||
| > `measurementsCache` (one object per item) on every mount, while virtua's | ||
| > lazy prefix-sum cache only does work for the visible window. | ||
|
|
||
| ### Dynamic measurement — commit → stable total size (lower is better, ms) | ||
|
|
||
| | Scenario | tanstack | virtua | virtuoso | window | | ||
| | ------------------- | -------: | ------: | -------: | ------: | | ||
| | `mount-dynamic-1k` | 124 | **121** | 194 | 122 | | ||
| | `mount-dynamic-10k` | 118 | 118 | 188 | **116** | | ||
|
|
||
| > **What we see:** Roughly tied between TanStack, virtua, and react-window. | ||
| > Virtuoso takes ~60% longer because its scroll-anchoring keeps adjusting | ||
| > the inner spacer for several frames after the initial measurement pass. | ||
|
|
||
| ### Scroll perf — fps & long frames during 1.5 s programmatic scroll | ||
|
|
||
| | Scenario | tanstack | virtua | virtuoso | window | | ||
| | ------------------------------------ | -------: | -----: | -------: | -----: | | ||
| | `scroll-to-bottom-10k` fps | 60 | 60 | 60 | 60 | | ||
| | `fast-scroll-dynamic-10k` fps | 60 | 60 | 60 | 60 | | ||
| | `scroll-to-bottom-10k` longFrames | 0 | 0 | 0 | 0 | | ||
| | `fast-scroll-dynamic-10k` longFrames | 0 | 0 | 0 | 0 | | ||
|
|
||
| > **Caveat:** at 10k items, none of these libraries even break a sweat. | ||
| > A 1.5 s rAF-paced scroll is too gentle to expose perf differences. Real | ||
| > stress tests would need expensive item renderers and/or 100k+ items. | ||
|
|
||
| ### Jump-to-end settle time (lower is better, ms) | ||
|
|
||
| | Scenario | tanstack | virtua | virtuoso | window | | ||
| | ------------------------- | -------: | -----: | -------: | -----: | | ||
| | `jump-to-end-dynamic-10k` | 83 | 72 | 154 | **68** | | ||
|
|
||
| > **What we see:** react-window is fastest. TanStack lands 15 ms behind, likely | ||
| > from the `requestAnimationFrame` reconcile loop running an extra frame or | ||
| > two before declaring the position stable. Virtuoso is 2× slower than the | ||
| > fastest because its anchoring + measurement loop takes longer to converge. | ||
|
|
||
| ### Memory after mount (lower is better, MB) | ||
|
|
||
| | Scenario | tanstack | virtua | virtuoso | window | | ||
| | ------------------- | -------: | -------: | -------: | -----: | | ||
| | `mount-fixed-10k` | 6.6 | **6.4** | 6.7 | 7.0 | | ||
| | `mount-fixed-100k` | 14.2 | **10.5** | 10.8 | 11.1 | | ||
| | `mount-dynamic-10k` | 8.1 | **7.8** | 8.8 | 8.5 | | ||
|
|
||
| > **What we see:** Tight at 10k. At 100k fixed, TanStack uses ~3 MB more than | ||
| > the others — same root cause as the slow mount: we hold a `VirtualItem` | ||
| > object per item, while virtua holds two numbers per item. | ||
|
|
||
| ## Bottom line | ||
|
|
||
| - **Small-to-medium variable-size lists** (the most common use case) — | ||
| TanStack is consistently the fastest to mount, tied on dynamic measurement, | ||
| competitive on memory. | ||
| - **Huge fixed-size lists (100k+ items)** — virtua wins decisively on mount | ||
| time and memory because its lazy prefix-sum cache only materializes the | ||
| visible window. TanStack's eager `measurementsCache` is the cost. | ||
| - **Scroll perf** — at the list sizes / workloads tested, all four | ||
| 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. | ||
|
|
||
| ## 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`. | ||
| - 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 | ||
| Chromium so memory readings aren't bucketed and we can force GC between | ||
| runs. | ||
| - Medians across 5 runs are reported (raw runs in `results/<ts>.json`). | ||
| - Run on a built (`vite build`) preview server, not the dev server — so we | ||
| measure production code paths. | ||
|
|
||
| ## Adding a scenario | ||
|
|
||
| Add an entry to `SCENARIOS` in `src/scenarios/types.ts`. The runner discovers it automatically. | ||
|
|
||
| ## Adding a library | ||
|
|
||
| 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`. | ||
|
|
||
| ## Known limitations | ||
|
|
||
| - Scroll tests are programmatic (rAF-driven) and at the tested list sizes, | ||
| every library trivially hits 60 fps. A harder test would render expensive | ||
| items, scroll faster, or both. PRs welcome. | ||
| - Memory deltas at small list sizes (≤10k items) are within the noise floor | ||
| of `performance.memory`. | ||
| - Single-machine numbers. The _shape_ of the comparison transfers across | ||
| machines, the absolute values don't. |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Kill?