Skip to content
Merged
Show file tree
Hide file tree
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 May 17, 2026
61d0dd9
perf(virtual-core): rewrite setOptions to avoid Object.entries+delete
tannerlinsley May 17, 2026
9ccdae4
perf(virtual-core): track pending-rebuild min with a counter, not an …
tannerlinsley May 17, 2026
b9d4123
chore(virtual-core): add Layer 4 bench scenarios (resizeItem notify c…
tannerlinsley May 17, 2026
9fa57ff
perf(virtual-core): pre-size defaultRangeExtractor's result array
tannerlinsley May 17, 2026
e7244e4
fix(virtual-core): cast setOptions merged-defaults through unknown
tannerlinsley May 17, 2026
4af143c
perf(react-virtual): use a number counter for useReducer instead of a…
tannerlinsley May 17, 2026
843690b
fix(virtual-core): drop elementsCache entry when RO sees disconnected…
tannerlinsley May 17, 2026
e3b8d2a
perf(virtual-core): make memo's debug instrumentation tree-shakable
tannerlinsley May 17, 2026
e8ba499
refactor(virtual-core): collapse element/window observer pairs to one…
tannerlinsley May 17, 2026
8524bb3
refactor(virtual-core): replace utils barrel with named exports
tannerlinsley May 17, 2026
de984d6
chore(benchmarks): add reproducible cross-library benchmark suite
tannerlinsley May 17, 2026
395a004
docs: add competitor claims verification matrix
tannerlinsley May 17, 2026
bb5b96f
exp(virtual-core): lazy VirtualItem materialization for lanes===1 fas…
tannerlinsley May 17, 2026
a3039d9
exp(virtual-core): defer scroll-position adjustments during iOS momen…
tannerlinsley May 17, 2026
4327745
exp(virtual-core): keep smooth scroll while still > viewport from new…
tannerlinsley May 17, 2026
b5f513c
exp(virtual-core): skip scroll-position adjustment while user scrolls…
tannerlinsley May 17, 2026
da91bf6
exp(virtual-core): add takeSnapshot() for scroll restoration round-trips
tannerlinsley May 17, 2026
2304108
exp(virtual-core): bypass lazy-view Proxy in calculateRange + getVirt…
tannerlinsley May 17, 2026
7d076c4
docs: summarize 3-hour experimentation loop results
tannerlinsley May 17, 2026
31b2fb3
exp(virtual-core): getTotalSize reads last end directly from flat typ…
tannerlinsley May 17, 2026
bf532fe
docs: update experiments summary with final cross-library numbers
tannerlinsley May 17, 2026
0bfd973
fix(benchmarks): remove 1px border on .scroll-host so accuracy bench …
tannerlinsley May 17, 2026
d6c4b38
test(benchmarks): add three accuracy edge cases for scrollToIndex
tannerlinsley May 17, 2026
225a615
docs: plan iOS Phase 1 + Phase 2 (touch distinction, subpixel reconci…
tannerlinsley May 17, 2026
941a0c6
docs: add bundle-impact section to iOS support plan
tannerlinsley May 17, 2026
78b2942
feat(virtual-core): iOS Phase 1 — touch event distinction for scroll …
tannerlinsley May 17, 2026
f32c2a1
feat(virtual-core): iOS Phase 2a — subpixel reconciliation for scroll…
tannerlinsley May 17, 2026
7dab6f8
feat(virtual-core): iOS Phase 2b — skip flush during Safari elastic-o…
tannerlinsley May 17, 2026
94dc5c7
chore: clean up lint, sherif, knip for release readiness
tannerlinsley May 19, 2026
e3265d8
docs(api): document takeSnapshot, initialMeasurementsCache, new defaults
tannerlinsley May 19, 2026
8fa9d48
chore: add changesets for the release
tannerlinsley May 19, 2026
e732f6a
docs: blog post draft for the release
tannerlinsley May 19, 2026
001ea2a
docs: release readiness verdict + summary
tannerlinsley May 19, 2026
4004e0c
docs: voice pass on blog post against tanner-writing-style skill
tannerlinsley May 19, 2026
b270e0a
docs: aggressive trim on blog post
tannerlinsley May 20, 2026
f011e91
docs: strip comparative framing from blog post
tannerlinsley May 20, 2026
bb64a63
docs: convert numbers section from bullets to a Before/After table
tannerlinsley May 20, 2026
5a171e7
ci: apply automated fixes
autofix-ci[bot] May 20, 2026
c09bcab
chore: remove working-doc artifacts from the audit/experiment phase
tannerlinsley May 20, 2026
67db591
fix: address CodeRabbit findings on PR #1168
tannerlinsley May 20, 2026
ab9c00f
docs(changeset): record measure() pendingMin and iOS flush accumulato…
tannerlinsley May 20, 2026
186b3bd
fix(virtual-core): don't call getItemKey with a stale index in RO dis…
tannerlinsley May 20, 2026
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
31 changes: 31 additions & 0 deletions .changeset/feat-core-ios-scroll-handling.md
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.
18 changes: 18 additions & 0 deletions .changeset/feat-core-scroll-to-index-smooth.md
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.
18 changes: 18 additions & 0 deletions .changeset/feat-core-scroll-up-jank-default.md
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.
24 changes: 24 additions & 0 deletions .changeset/feat-core-take-snapshot.md
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.
9 changes: 9 additions & 0 deletions .changeset/fix-core-elementscache-stale-index.md
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).
13 changes: 13 additions & 0 deletions .changeset/fix-core-measure-and-ios-flush.md
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.
37 changes: 37 additions & 0 deletions .changeset/perf-core-mount-and-measure-storm.md
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.
9 changes: 9 additions & 0 deletions .changeset/perf-react-virtual-rerender-alloc.md
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.
1 change: 1 addition & 0 deletions .claude/scheduled_tasks.lock
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}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Kill?

6 changes: 6 additions & 0 deletions benchmarks/.gitignore
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
189 changes: 189 additions & 0 deletions benchmarks/README.md
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.
Loading
Loading