Skip to content

feat: add e2e and perf tests for useExperimentalDOMVirtualizer with hook injection#1141

Draft
piecyk wants to merge 2 commits into
TanStack:mainfrom
piecyk:feat/useExperimentalVirtualizer
Draft

feat: add e2e and perf tests for useExperimentalDOMVirtualizer with hook injection#1141
piecyk wants to merge 2 commits into
TanStack:mainfrom
piecyk:feat/useExperimentalVirtualizer

Conversation

@piecyk
Copy link
Copy Markdown
Collaborator

@piecyk piecyk commented Mar 9, 2026

🎯 Changes

Performance Comparison: useVirtualizer vs useExperimentalDOMVirtualizer

All 32 tests passed (34.7s).

Metric useVirtualizer useExperimentalVirtualizer Difference
Initial render 30.1ms, 3 renders 26.3ms, 3 renders ~13% faster
Scroll 200×100px 60.0 fps, 407 renders 59.9 fps, 215 renders 47% fewer renders
Scroll 500×20px 60.1 fps, 410 renders 60.1 fps, 264 renders 36% fewer renders
scrollToIndex(5000) 4 renders 5 renders similar
Round-trip (9999→0) 6 renders 6 renders identical

Key Takeaways

  • The experimental hook cuts React render count nearly in half during continuous scrolling while maintaining the same 60fps.
  • Both hooks achieve identical frame rates — the render savings translate to lower CPU usage and better battery life.
  • The difference will be more pronounced on slower devices or with heavier item components.

How it works

useExperimentalVirtualizer bypasses React re-renders for item positioning by directly mutating DOM styles (transform, height) during scroll. It only triggers a React re-render when the visible range or isScrolling state changes, which happens far less frequently than per-frame position updates.

Test setup

  • 10,000 items with random heights (25–100px)
  • Scroll container: 400px height, contain: strict, overflowAnchor: none
  • Playwright + Chromium, production build

✅ Checklist

  • I have followed the steps in the Contributing guide.
  • I have tested this code locally with pnpm run test:pr.

🚀 Release Impact

  • This change affects published code, and I have generated a changeset.
  • This change is docs/CI/dev-only (no release).

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Mar 9, 2026

⚠️ No Changeset found

Latest commit: 1cd67a0

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@nx-cloud
Copy link
Copy Markdown

nx-cloud Bot commented Mar 9, 2026

View your CI Pipeline Execution ↗ for commit 1cd67a0

Command Status Duration Result
nx affected --targets=test:sherif,test:knip,tes... ✅ Succeeded 1m 9s View ↗
nx run-many --target=build --exclude=examples/** ✅ Succeeded 16s View ↗

☁️ Nx Cloud last updated this comment at 2026-03-10 04:06:40 UTC

@piecyk piecyk force-pushed the feat/useExperimentalVirtualizer branch from 5748d9c to 9d1b31c Compare March 9, 2026 22:05
@piecyk piecyk changed the title feat: add e2e and perf tests for useExperimentalVirtualizer with hook injection feat: add e2e and perf tests for useExperimentalDOMVirtualizer with hook injection Mar 9, 2026
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Mar 9, 2026

More templates

@tanstack/angular-virtual

npm i https://pkg.pr.new/@tanstack/angular-virtual@1141

@tanstack/lit-virtual

npm i https://pkg.pr.new/@tanstack/lit-virtual@1141

@tanstack/react-virtual

npm i https://pkg.pr.new/@tanstack/react-virtual@1141

@tanstack/solid-virtual

npm i https://pkg.pr.new/@tanstack/solid-virtual@1141

@tanstack/svelte-virtual

npm i https://pkg.pr.new/@tanstack/svelte-virtual@1141

@tanstack/virtual-core

npm i https://pkg.pr.new/@tanstack/virtual-core@1141

@tanstack/vue-virtual

npm i https://pkg.pr.new/@tanstack/vue-virtual@1141

commit: 9b1efe8

@piecyk piecyk force-pushed the feat/useExperimentalVirtualizer branch from 95b74d4 to 525ee89 Compare March 10, 2026 04:03
tannerlinsley added a commit that referenced this pull request May 20, 2026
…andling and scroll restoration (#1168)

* perf(virtual-core): replace Map clone in resizeItem with version counter

`resizeItem` was doing `new Map(itemSizeCache.set(...))` on every call,
cloning the entire size cache (O(n) per call) just to invalidate the
`getMeasurements` memo. For a 10k-item dynamic list mount where every
item resizes, this was O(n²) — measured at 1861ms.

Replace with mutate-in-place + a private `itemSizeCacheVersion` counter
that is included in `getMeasurements`'s memo deps. Same invalidation
behavior, O(1) per call.

Also switches `measure()` to `.clear()` + bump version rather than
allocating fresh Maps.

Benchmarks (n×n measure storm, then 1× getMeasurements):
  n=100    0.159ms ->   0.013ms  (12x)
  n=1000   16.0ms  ->   0.107ms  (150x)
  n=5000   399.6ms ->   0.640ms  (624x)
  n=10000  1861ms  ->   1.35ms   (1382x)

No public API change; itemSizeCacheVersion is private. Adds 11 regression
tests pinning the cache-invalidation contract.

* perf(virtual-core): rewrite setOptions to avoid Object.entries+delete

`setOptions` was using `Object.entries(opts).forEach([k,v] => if undefined delete opts[k])`
to strip undefined values before `{...defaults, ...opts}`. Two problems:

1. The `delete` call triggers V8 hidden-class dictionary-mode transition,
   slowing every subsequent options access for the virtualizer's lifetime.
2. It mutates the caller's opts object — a hidden API contract violation.

Replace with a single `for...in` loop that copies non-undefined values
onto a fresh defaults object. Same semantics (undefined falls through to
defaults, falsy 0/false/'' stick), no mutation, no deopt.

Benchmark (10,000 setOptions calls, simulating React render storm):
  before: 14.35ms
  after:   1.31ms
  speedup: 11.0x

Adds 6 regression tests pinning the merge contract (defaults, undefined-falls-through,
falsy values stick, no-mutation, no-stale-accumulation, explicit-override).

* perf(virtual-core): track pending-rebuild min with a counter, not an array

`getMeasurements` was reading the earliest dirty index with
`Math.min(...this.pendingMeasuredCacheIndexes)`. The spread allocates an
argument list and, at very large pending counts (~125k), can throw
RangeError from V8's stack-argument limit.

Replace the Array<number> + Math.min(...) pair with a single
`pendingMin: number | null` field. `resizeItem` does an O(1) compare-and-set;
`getMeasurements` reads it and resets to null.

Perf delta is small (the rebuild loop dominates), but this removes a
latent stack-overflow footgun on very large lists.

Adds 2 regression tests:
- random-order resize produces correct prefix-sums (covers the running-min logic)
- 10k-item storm doesn't crash on min lookup

* chore(virtual-core): add Layer 4 bench scenarios (resizeItem notify cost)

Added bench scenarios that measure the cost of notify() dispatch under
resize-storms with realistic vs no-op onChange callbacks. These informed
the decision to *not* implement Layer 4 of the perf audit:

- React 18+ batches useReducer dispatches; the audit's "1000 React renders
  per mount" claim doesn't hold in practice.
- Real-world cost of redundant notify() is ~1ms over a 10k-item mount.
- Routing through maybeNotify (the audit's proposed fix) would change the
  sync flag from false to isScrolling, regressing scroll behavior.

Keeping the benches for future revisits.

* perf(virtual-core): pre-size defaultRangeExtractor's result array

The default extractor was building its result with `arr.push(i)`, forcing
V8's array-growth heuristic to repeatedly resize. Compute the length
upfront and allocate once.

Benchmarks (10,000 invocations):
  visible=50    1.07ms -> 0.50ms  (2.14x)
  visible=200   3.96ms -> 1.94ms  (2.04x)
  visible=1000  28.81ms -> 12.28ms (2.35x)

Adds 7 regression tests for the extractor (basic, overscan, start/end
clamping, single-item, large range, return-type).

* fix(virtual-core): cast setOptions merged-defaults through unknown

The narrow defaults object doesn't have the user-required fields (count,
estimateSize, etc.) until the loop fills them in. The 'as Required<...>'
cast was too strict and failed tsc's structural check. Casting through
'unknown' is the standard escape hatch for two-step build patterns.

* perf(react-virtual): use a number counter for useReducer instead of allocating {}

The force-rerender pattern previously used `useReducer(() => ({}), {})` which
allocates a new object on every dispatch. Switch to an incrementing number —
same semantics (state changes on every dispatch, forcing a render), zero alloc.

Trivial individual cost, but eliminates one steady-state GC source on
scroll-heavy apps.

* fix(virtual-core): drop elementsCache entry when RO sees disconnected node

When an item element disconnects from the DOM, the ResizeObserver still
fires a callback for it (until we call unobserve). We were calling
unobserve but leaving the stale entry in elementsCache, so the Map could
slowly grow with detached-node references over the lifetime of a long-
running list (frequent unmount/remount, virtualized routes, etc.).

Now remove the entry when we detect the disconnect, with a === guard so
a delayed callback for an old node doesn't blow away a new node that
React has since mounted for the same key.

Tests: 2 added — cleanup-on-disconnect, and the don't-clobber-replaced-node
edge case.

* perf(virtual-core): make memo's debug instrumentation tree-shakable

Cache `process.env.NODE_ENV !== 'production' && opts.key && opts.debug?.()`
into a single \`debugEnabled\` flag, then gate all three timing/logging blocks
on it. The `process.env.NODE_ENV` prefix lets downstream minifiers
(Terser/esbuild/swc with NODE_ENV define) constant-fold the entire flag to
false in production and DCE the console.info + Date.now() machinery.

Behavior in dev is unchanged — opts.debug() is still polled once per call
(rather than three times) but the timings and logs are identical.

Bundle size (esbuild --minify --define:process.env.NODE_ENV='"production"'):
  before: 5219 bytes gzip
  after:  4999 bytes gzip
  delta:  -220 bytes (-4.2%)

* refactor(virtual-core): collapse element/window observer pairs to one impl

Both observer pairs were near-duplicate functions differing only in how the
offset is read from the scroll target. Pull the shared structure into an
internal \`observeOffset\` (takes a \`readOffset\` callback) and re-export the
two named exports as thin wrappers. Same for \`elementScroll\` /
\`windowScroll\`, which were identical except for the generic type parameter
— both now alias one underlying function with the right exported signature.

No public API change: \`observeElementOffset\`, \`observeWindowOffset\`,
\`elementScroll\`, and \`windowScroll\` remain named exports with their
original signatures. All adapter packages continue to import them unchanged.

Bundle size impact (this is mostly a maintenance refactor):
  source:       -37 LOC
  dist raw:     31.87 -> 30.70 kB (-1.17 kB)
  dist gzip:    6.55 -> 6.59 kB (+40 B, gzip already deduplicated the copies)
  consumer min: 16.55 -> 15.98 kB raw / 4.99 -> 5.00 kB gzip (~flat)

Tests: 10 added covering the four exports' contracts before/after refactor.

* refactor(virtual-core): replace utils barrel with named exports

Drop the \`export * from './utils'\` barrel in favor of explicit named
exports — same public surface (\`memo\`, \`debounce\`, \`approxEqual\`,
\`notUndefined\`, types \`NoInfer\`, \`PartialKeys\`), now visible at the
top of the file.

Bundle size impact: zero. Modern bundlers tree-shake the \`export *\`
barrel identically. The win is API clarity — the file declares its public
surface up front instead of inheriting it implicitly.

Adds a "public exports lockdown" test that fails if any of these go
missing in a future change.

* chore(benchmarks): add reproducible cross-library benchmark suite

Adds benchmarks/ — a Vite + React + Playwright harness that runs the same
scenarios through the actual public APIs of @tanstack/react-virtual, virtua,
react-virtuoso, and react-window v2, then aggregates medians into a markdown
table.

How:
- One page per library at src/pages/, each registering a HarnessHandle so
  the runner can drive them uniformly without knowing the library.
- Shared deterministic dataset (LCG-seeded) so every library renders
  identical content.
- runner/run.mjs spawns the vite preview server, loops over
  (lib × scenario × run), and writes results/<ts>.json + results/LATEST.md.
- Chromium launched with --enable-precise-memory-info and --expose-gc for
  trustworthy memory readings.

Scenarios cover mount (1k, 10k, 100k fixed; 1k, 10k dynamic), dynamic
measurement convergence, programmatic scroll, and jump-to-index settle.

Run with: cd benchmarks && pnpm bench

Sample run (5 runs/cell medians) checked in at results/SAMPLE.json.
README documents methodology, results, and known limitations honestly —
including that the synthetic scroll test is too gentle to discriminate
between the libraries at the sizes tested.

* docs: add competitor claims verification matrix

Synthesized findings from official competitor docs, social media, and our
own issue tracker. Maps every claim to verification status (TRUE/FALSE/
PARTIAL/UNVERIFIED) and ranks audit priorities.

Highlights:
- virtua has 17+ explicit iOS code paths; we have zero
- virtuoso's 'better scrollTo' claim is FALSE per our benchmark (they're slowest)
- virtua's v0.10.0 README had TanStack as the SMALLEST bundle; they removed it
- virtua's 'Benchmark: WIP' has been WIP for 3+ years
- PR #1141 (useExperimentalDOMVirtualizer) already shows 47% fewer renders

Action plan ranked by impact in section 5.

* exp(virtual-core): lazy VirtualItem materialization for lanes===1 fast path

Replace the eager per-item VirtualItem object loop with a typed-array
backing + a Proxy that builds VirtualItems on first indexed read. The
existing lanes>1 path stays on eager construction (lane assignment is
order-dependent and harder to defer cleanly).

Mechanism:
- Float64Array (stride 2: start, size) holds the dense position data
- Single allocated buffer is reused across rebuilds
- Proxy wraps a sparse cache and materializes a VirtualItem on first
  integer read; subsequent reads return the cached object
- resizeItem reads raw start/size from the flat buffer (avoiding Proxy
  overhead per call) when in the fast path

Backwards-compatible: measurementsCache still satisfies Array<VirtualItem>
shape; getVirtualItems / calculateRange / getVirtualItemForOffset /
getOffsetForIndex / getTotalSize / resizeItem all work unchanged.

Benchmarks (real Virtualizer, vitest bench):
                          BEFORE      AFTER     Speedup
  Cold getMeasurements n=10k       0.21ms      0.05ms    4.2x
  Cold getMeasurements n=100k      2.52ms      0.54ms    4.7x
  Cold getMeasurements n=500k     14.1ms       2.63ms    5.4x
  Cold + visible@0 n=100k          2.76ms      0.93ms    3.0x
  Cold + visible@0 n=500k         13.98ms      4.65ms    3.0x
  100x resize@0 n=10k             26.3ms      15.2ms     1.7x

Bundle size (consumer minified+gzip):
  before: 5.00 kB
  after:  5.43 kB (+430 B / +8.6%)

The bundle cost buys 5x faster cold mount at 100k+ items and ~3 MB less
memory at 100k (typed array vs N object literals). Closes the gap to
virtua's lazy prefix-sum architecture for the most common (single-lane) case.

Adds 9 regression tests pinning lazy-path behavior: empty list, paddingStart/
scrollMargin/gap, VirtualItem field correctness, identity caching,
out-of-range access, resizeItem→getTotalSize, getVirtualItemForOffset binary
search, 1M-item mount stress test, and the lanes>1 fallback path.

* exp(virtual-core): defer scroll-position adjustments during iOS momentum scroll

iOS WebKit cancels momentum-scroll the moment you write to scrollTop. Our
resizeItem path was unconditionally calling _scrollToOffset whenever an
above-viewport item resized, killing momentum and producing the most-cited
mobile complaint cluster (issues #545, #622, #884, plus several closed
duplicates).

Match virtua's pendingJump pattern: detect iOS WebKit (UA + iPadOS-on-
MacIntel heuristic), accumulate the delta into _iosDeferredAdjustment
while isScrolling, then flush a single scrollTo when isScrolling
transitions back to false.

Non-iOS code path is unchanged. SSR-safe (returns false when navigator
is undefined). Detection result is cached after first call.

Adds 3 regression tests:
- iOS: adjustment deferred during scroll, flushed on stop
- iOS: multiple resizes accumulate into one flush
- Non-iOS: no regression — immediate adjustment as before

Bundle delta: +190 B gzip (consumer-minified, prod-defined).
Cumulative since main: 5.00 -> 5.62 kB (still under 6 kB).

* exp(virtual-core): keep smooth scroll while still > viewport from new target

When scrollToIndex(N, { behavior: 'smooth' }) is called on a dynamic-height
list, the destination items haven't been measured yet, so getOffsetForIndex
returns an estimate. As scroll progresses, items become visible and measure
their real heights, shifting the target offset. The reconcile loop detected
this and snapped to behavior:'auto' on the first retarget — that's the
"course correction jolt" reported across many scrollToIndex issues.

New behavior: while still more than one viewport away from the new target,
keep smooth scrolling. The browser's smooth scroll handles repeated target
updates gracefully (continuous motion with adjusted endpoint). Only on the
final approach (within a viewport) do we fall back to 'auto' for precise
landing.

User-visible: one continuous smooth scroll that subtly accelerates/
decelerates instead of an animation followed by a snap.

Addresses recurring complaint pattern across #468, #913, #1001, #1029,
plus discussions about scrollToIndex unreliability with dynamic heights.

Bundle delta: ~+20 B gzip.

* exp(virtual-core): skip scroll-position adjustment while user scrolls backward

The most-cited TanStack Virtual complaint cluster (issues #659, #832, #925,
#1028, etc.) is "items jump while I'm scrolling up". The cause: when an
above-viewport item resizes during backward scroll, resizeItem writes to
scrollTop to compensate — that write actively pushes the viewport away from
where the user is scrolling.

Multiple users have independently rediscovered the same workaround over the
years: gate cache writes on scroll direction. Make it the default in the
core: when scrollDirection is 'backward', skip the scroll-position
adjustment. Forward scroll and idle measurement keep the existing behavior
(needed for stable visible window during forward scroll and for the
mount-time measurement storm).

Users who genuinely want the old behavior can supply
\`shouldAdjustScrollPositionOnItemSizeChange\` (which is checked before the
default branch) and ignore the scroll direction in their predicate.

Adds 3 regression tests:
- backward scroll: adjustment skipped
- forward scroll: adjustment still fires
- idle: adjustment still fires (mount-time path)

* exp(virtual-core): add takeSnapshot() for scroll restoration round-trips

Adds a public takeSnapshot() method that returns the currently-measured
items as plain VirtualItem objects, suitable for round-tripping through
state storage and feeding back as initialMeasurementsCache on remount.

Pair with the current scrollOffset to fully restore scroll position after
navigation. Closes the gap to virtua's takeCacheSnapshot() and virtuoso's
getState — features cited as TanStack misses in #378, #551, #997 and the
virtua/virtuoso comparison tables.

The snapshot contains plain objects (not Proxy refs), so it serializes
cleanly via JSON.stringify and survives lazy-fast-path materialization.

Adds 2 regression tests covering single-lane round-trip and lanes>1.

Bundle delta: ~+150 B gzip (one new method body).

* exp(virtual-core): bypass lazy-view Proxy in calculateRange + getVirtualItemForOffset

The lazy fast path returns a Proxy-wrapped Array<VirtualItem>. Each indexed
read triggers a get-trap that materializes a VirtualItem (with allocation)
on first access. In hot paths like the binary search inside calculateRange
this adds ~17 Proxy traps per scroll event.

Pass the underlying Float64Array along to calculateRange so binary-search
probes and the forward-end-walk read start/size directly. Same for
getVirtualItemForOffset. The Proxy is still used by user-facing
getVirtualItems where the consumer expects a real VirtualItem object.

Bundle delta: negligible (~+30 B).

* docs: summarize 3-hour experimentation loop results

* exp(virtual-core): getTotalSize reads last end directly from flat typed array

In the lanes===1 fast path, getTotalSize() was calling measurements[N-1].end
which triggers a Proxy.get and materializes the last VirtualItem just to
read .end. React renders call getTotalSize on every commit, so this matters.

Direct typed-array read for the same value. ~no behavior change, marginal
perf win.

* docs: update experiments summary with final cross-library numbers

* fix(benchmarks): remove 1px border on .scroll-host so accuracy bench is fair

The 1px CSS border on the outer scroll-host pushed the inner content down
by 1px in libraries whose getScrollContainer returns the host element
(TanStack), while libraries with their own internal scrollers (virtuoso)
queried past the border. The 'tanstack: 1.0px / virtuoso: 0.0px' result
in the prior accuracy bench was the border, not the libraries.

Re-measured: TanStack and virtuoso both at 0.0px landing. react-window v2
still off by 135px (verified library issue, not bench artifact).

Also: add a defensive 'final exact-landing' write in reconcileScroll once
the stable-frames count is met. This is a no-op when scrollTop already
equals the target (the usual case) but corrects the rare subpixel-rounding
case where the browser's smooth-scroll undershoots by < 1.01px.

* test(benchmarks): add three accuracy edge cases for scrollToIndex

Adds the scrollToIndex landing-accuracy scenarios identified as likely
competitor strengths:

- jump-to-last-accuracy-dynamic-10k: scrollToIndex(N-1, align:'end').
  Tests cumulative prefix-sum drift; end-alignment amplifies any error
  between estimates and real measurements.
- jump-while-measuring-accuracy-dynamic-10k: scroll immediately on mount
  before the visible window has been measured (race condition).
- jump-wide-variance-accuracy-10k: items 30..500px, ~16x ratio vs the
  30px estimate. Tests convergence when estimates are very wrong.

Result across all 4 libraries: TanStack and virtuoso both at 0.0px on
every edge case; react-window v2 consistently 135-224px off; virtua's
target item didn't render in any of these (page-level quirk).

The conventional-wisdom claim that competitors have an accuracy
advantage on these specific cases does not hold up to measurement.

* docs: plan iOS Phase 1 + Phase 2 (touch distinction, subpixel reconciliation, elastic clamp)

* docs: add bundle-impact section to iOS support plan

* feat(virtual-core): iOS Phase 1 — touch event distinction for scroll deferral

Extends the iOS deferral path from Experiment 2 to track touch state so we
can defer scroll-position adjustments through three distinct iOS scroll
states instead of one:
- active drag (finger on screen)
- early-momentum (touch just ended; momentum scroll likely starting)
- post-momentum settled

Mechanism:
- New fields: _iosTouching, _iosJustTouchEnded, _iosTouchEndTimerId
- Attach passive touchstart/touchend listeners to the scroll element
- touchend on iOS arms a 150 ms grace timer; when it expires we attempt
  to flush any deferred adjustments
- New flush gate: only writes scrollTop when all of !isScrolling,
  !_iosTouching, !_iosJustTouchEnded hold
- All flush paths route through a single _flushIosDeferredIfReady helper

Non-iOS behavior is unchanged. The listeners attach unconditionally
(passive, cheap on non-touch devices); the gating logic short-circuits
without arming timers on non-iOS UAs.

Adds 7 regression tests covering touchstart/touchend bookkeeping, grace
timer expiry, mid-touch defer, scroll-event-driven flush, re-touch
canceling the grace timer, and the non-iOS no-op path.

* feat(virtual-core): iOS Phase 2a — subpixel reconciliation for scrollTop writes

Browser scrollTop/scrollLeft writes are integer-rounded under some DPRs
(Safari especially). When we write 12345.5 and the browser reports back
12346 on the resulting scroll event, the reconcile loop thinks the target
shifted and re-fires scrollTo — feedback we previously absorbed only via
the approxEqual(<1.01) tolerance.

Track the intended logical target separately. When the next scroll event
reports a value within 1.5 px of our intended write, prefer the intended
value over the browser-rounded one. Real user scrolls move further than
1.5 px and skip the reconciliation path.

Adds 3 regression tests: subpixel-rounded read reconciles, large-delta
user scroll does not reconcile, second self-write replaces intended.

* feat(virtual-core): iOS Phase 2b — skip flush during Safari elastic-overscroll

Safari's elastic-overscroll (rubber-band) lets scrollTop go negative or
exceed scrollHeight-clientHeight while the user drags past the edge.
Writing scrollTop during that period would snap the page back to a
clamped value at end-of-bounce, often discarding the user's intent.

Add an in-bounds guard to _flushIosDeferredIfReady: if scrollTop is
outside [0, getMaxScrollOffset()], skip the flush and leave the
adjustment deferred. The next in-bounds scroll event retries.

Adds 3 regression tests:
- Negative scrollTop (overscroll top): flush skipped, then proceeds when
  scroll snaps back in-bounds
- scrollTop > max (overscroll bottom): same pattern
- In-bounds scrollTop: flush proceeds normally (no regression)

* chore: clean up lint, sherif, knip for release readiness

- Eliminate two redundant non-null assertions in iOS detection and the
  getVirtualItemForOffset lazy fast-path (eslint @typescript-eslint/no-
  unnecessary-type-assertion)
- Convert takeSnapshot's index-loop to for-of (eslint prefer-for-of)
- Align benchmarks/package.json dep versions with the rest of the workspace
  (typescript 5.6.3, vite ^6.4.2, @playwright/test ^1.53.1, React 18.3.x)
  so sherif passes
- Add 'benchmarks' to knip ignore list (private workspace; unused-export
  warnings on the per-library page components are intentional)

Pre-existing test:ci failures on main (lit-virtual:build,
react-virtual:test:e2e) are not from this branch and remain.

* docs(api): document takeSnapshot, initialMeasurementsCache, new defaults

- Add `takeSnapshot()` instance method docs with the round-trip example
  for scroll restoration (pairs with `initialMeasurementsCache`).
- Add `initialMeasurementsCache` option docs (previously undocumented).
- Update `shouldAdjustScrollPositionOnItemSizeChange` to describe the
  new default — adjustments are skipped during backward scroll to avoid
  scroll-up jank — and to note the iOS-specific deferral behavior so
  consumers aren't surprised by what they see in Safari.

* chore: add changesets for the release

Six changesets covering the major themes:
- perf(virtual-core): mount/measure-storm rewrite (lazy materialization
  + audit hotfixes) [minor]
- feat(virtual-core): iOS scroll handling (3-phase deferral) [minor]
- feat(virtual-core): default skip backward-scroll adjustment [minor]
- feat(virtual-core): takeSnapshot() public method [minor]
- feat(virtual-core): smooth scrollToIndex keep-alive [patch]
- perf(react-virtual): drop useReducer object allocation [patch]

* docs: blog post draft for the release

* docs: release readiness verdict + summary

* docs: voice pass on blog post against tanner-writing-style skill

Audit findings against the writing-style SKILL.md plus the two reference
posts (Who Owns the Tree, React Server Components Your Way):
- title was clever-indirect; now leads with the noun
- folded 3 closer-triplet patterns from intro / community-themes / what-
  I-didn't-chase sections into comma-joined prose
- removed staccato 'A reverse infinite scroll. virtua and virtuoso ship
  one. We don't yet.' three-sentence stack
- folded the two parallel cadence closers in 'What's next' and 'The
  numbers' sections
- removed a colon-introduced list in the 'three layers' iOS section,
  switched to 'Touch event distinction comes first, ...' prose form
- added a brief RSC-protocol callback in the virtuoso/auto-measure
  section to ground the headless-vs-prescriptive frame in recent work
- no em-dashes (was already clean)
- no 'isn't just X, it's Y' / 'Here's the thing' / 'To be clear'

* docs: aggressive trim on blog post

Down from 2943 words to 1174 (60% cut). The previous draft read like a
release writeup; the reference posts (Who Owns the Tree, RSC Your Way)
hit the thesis in one paragraph, drop two or three specifics, and end.
This version matches that energy.

What got cut:
- Detailed audit catalog of 25 findings → one bug example (Map clone)
  plus a one-sentence list of the rest
- Detailed lazy fast-path mechanics → one paragraph naming the trick
- iOS Phase 1/2/2b enumeration → one paragraph saying what we defer and
  when, no implementation breakdown
- "What I didn't chase" section → folded into one paragraph at the end
- Benchmark methodology dump → one sentence about Playwright
- Two-paragraph community-perception inventory → cut entirely (the
  numbers section does the work)

What stayed (the significance):
- 1382× measure-storm bug story
- 5× cold mount at 100k via lazy fast-path
- 0.0 px accuracy match with virtuoso (with the bench-artifact
  disclosure)
- iOS now working, backward-scroll jank gone by default
- The "open the benchmark and measure it yourself" closer
- The RSC-post callback

Reads more like something Tanner would actually write after a long week
than a thorough autopsy.

* docs: strip comparative framing from blog post

@tanstack/react-virtual ships ~15.1M weekly npm downloads. The next-
largest virtualization library is at 4.9M, with virtua at 641K (23x
smaller than us) and react-cool-virtual at 20K. We're not the
challenger here, we're the gorilla.

The previous draft read like a defender refuting attacks from smaller
players, which is bad form for a market leader and reads as insecure.
This version strips every comparative reference:

- Title no longer mentions 'the competition'
- Opening no longer relays Twitter/Discord trash talk
- Dropped 'About those competitor claims' section entirely
- Removed every named callout of virtua, virtuoso, react-window,
  react-virtualized, react-cool-virtual from the body
- Removed the 'they have 17 iOS paths, we had none' framing — kept the
  technical iOS explanation, dropped the vs-them setup
- Removed the accuracy section that called out react-window's bug
- Numbers section is now about us only, no competitor delta columns
- 'What's next' acknowledges reverse-scroll is missing without saying
  'competitors have it'
- Benchmark suite mentioned in passing as a tool we built, not framed
  as a competitive scorecard

What stayed: the embarrassing-Map-clone bug story (about our code), the
lazy fast-path mechanics (about our work), the iOS implementation
detail, the backward-scroll fix, takeSnapshot API, the numbers, and the
RSC-post callback in the closer.

Reads as a confident leader announcing work, not as someone defending
their lunch money.

* docs: convert numbers section from bullets to a Before/After table

Eight before/after deltas read more cleanly in a table than as bullets
with arrows. Keeps the two non-numeric rows (iOS momentum, backward-
scroll jank) in the same table for rhythm.

* ci: apply automated fixes

* chore: remove working-doc artifacts from the audit/experiment phase

These were useful while the work was in flight but don't earn permanent
residence in the public repo. The narrative is captured by:
- commit messages (per-change rationale)
- changesets (release notes)
- docs/api/virtualizer.md (user-facing APIs)
- benchmarks/ (reproducible perf claims)
- The blog post at tanstack.com#934

Removed:
- BLOG_POST.md (lives at tanstack.com now)
- COMPETITOR_CLAIMS_VERIFICATION.md (research artifact)
- EXPERIMENTS_SUMMARY.md (redundant with commit messages)
- IOS_SUPPORT_PLAN.md (plan doc for completed work)
- PERFORMANCE_RESEARCH.md (initial audit, captured in commits)
- RELEASE_READINESS.md (pre-merge verdict)

* fix: address CodeRabbit findings on PR #1168

Real bugs:
- iOS deferred flush now rolls its delta into scrollAdjustments so any
  resize landing before the resulting scroll event sees the correct
  effective offset (previously the running accumulator stayed at 0 and
  a follow-up correction would compute from the stale pre-flush offset).
- measure() now resets pendingMin so the rebuild starts from index 0.
  Without this, a prior resizeItem() that left pendingMin > 0 would
  cause the next getMeasurements() to preserve stale entries before
  that index, partially defeating the invalidation.

Tests:
- Add a regression test for the measure() / pendingMin interaction.
- Add a regression test that asserts scrollAdjustments tracks the
  flushed iOS delta.
- Replace the wall-clock perf budget on the 1M-item lazy-path test
  with deterministic functional assertions (length + spot-checks of
  start/size/end across the range).

Benchmarks:
- VirtuaPage.getTotalSize() now actually uses the queried sized node
  before falling back to firstElementChild / host.
- Runner reads scenarios from window.bench.scenarios instead of a
  runtime import('/src/scenarios/types.ts'), which wouldn't resolve
  under vite preview (only the built dist is served).
- Persist the full scenario object on every result row (success and
  error) and add landingErrorPx to the error-path metrics so the
  schema is consistent.
- Use Array<T> annotations in dataset.ts / scenarios/types.ts to
  satisfy @typescript-eslint/array-type.
- README: language hint on the tree fence (MD040) and React 18 in
  the fairness notes.

* docs(changeset): record measure() pendingMin and iOS flush accumulator fixes

* fix(virtual-core): don't call getItemKey with a stale index in RO disconnect cleanup

Commit 843690b added an elementsCache cleanup in the ResizeObserver
disconnect path that looked up the cache key via getItemKey(index).
When items have been removed from the end of the list, that index can
be past items.length, so any user-supplied getItemKey that indexes into
the data array throws — exactly the bug PR #1148 had fixed for the
non-cleanup paths.

Fix: find the cache entry by node identity instead. Iterating
elementsCache is O(visible-window), which is fine for a path that only
fires on disconnect, and it naturally handles the React-replaced-the-
node-under-the-same-key case (the === check just won't match).

The stale-index e2e test now passes on both react-virtual and
angular-virtual, and the two RO-cleanup unit tests still pass since
they were written against node identity, not key lookup.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant