| Name | Modified | Size | Downloads / Week |
|---|---|---|---|
| Parent folder | |||
| @tanstack_virtual-core@3.15.0 source code.tar.gz | 2026-05-20 | 1.3 MB | |
| @tanstack_virtual-core@3.15.0 source code.zip | 2026-05-20 | 1.5 MB | |
| README.md | 2026-05-20 | 7.4 kB | |
| Totals: 3 Items | 2.8 MB | 0 | |
Minor Changes
-
iOS Safari momentum-scroll handling. Writing
scrollTopwhile a finger (#1168) 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
_iosDeferredAdjustmentfield instead of writingscrollTop. - Subpixel reconciliation. When the browser reports back a rounded
scrollTopwithin 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
scrollTopis 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.
- 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
-
Skip the scroll-position adjustment while the user is scrolling backward (#1168) by default. When an above-viewport item resizes during backward scroll (images load, content reflows, etc.) the prior behavior wrote
scrollTopto 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
shouldAdjustScrollPositionOnItemSizeChangewhich is checked before the default branch. -
Add
takeSnapshot()instance method for scroll-restoration round-trips. (#1168) Returns the currently-measured items as plainVirtualItemobjects; pair with the currentscrollOffsetto persist scroll position across remounts (route navigation, list-view modals, etc.). The result feeds back through the existinginitialMeasurementsCacheoption: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'sgetState. Only items actually rendered (and thus measured) are included; unmeasured items fall back toestimateSizeon restore. -
Mount-time, measurement, and memory rewrite for huge lists. The hot path (#1168) through
getMeasurements()no longer allocates aVirtualItemobject per index for single-lane lists; instead it fills aFloat64Arrayof start/size pairs and materializesVirtualItemobjects lazily through aProxy-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
Mapclone inresizeItem, theObject.entries+deletedeopt insetOptions, theMath.min(...pendingMeasuredCacheIndexes)spread, thedefaultRangeExtractorpushgrowth pattern, the eagermeasurementsCachereference invalidation, and the leakedelementsCacheentries when aResizeObserverfires for a node React already replaced.Headline impact (measured against actual
Virtualizerinstances 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×)
resizeItemstorm of 10,000 measurements + finalgetMeasurements: ~1.9 s → ~1.3 ms (≈1382×) — this was the dominantMap-clone bugsetOptions× 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.
measurementsCacheis still anArray<VirtualItem>-shaped value supporting[i],.length, iteration, etc. Internal consumers that previously read fields offVirtualItemobjects continue to do so transparently.
Patch Changes
-
scrollToIndex(N, { behavior: 'smooth' })on a dynamic-height list no (#1168) longer snaps tobehavior: '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
reconcileScrollreaches its stable-frames threshold, it writes the exact target offset one final time. This is a no-op whenscrollTopalready equals the target (the common case) but corrects the rare subpixel-rounding case where smooth scroll undershoots by less than 1 px. -
Don't call
getItemKeywith a possibly-stale index when cleaning up (#1168)elementsCachefor a disconnected node. The cleanup now finds the matching entry by node identity, so removing items from the end of the list while aResizeObserverstill has the now-detached node queued no longer throws (regression of #1148). -
Two correctness fixes in the new code: (#1168)
measure()now resetspendingMinso a priorresizeItem()that left it non-null can't preserve stalemeasurementsCacheentries 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.