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
whenscrollTopis 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
-
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 wrotescrollTop
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 supplyshouldAdjustScrollPositionOnItemSizeChange
which 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: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 toestimateSizeon restore. -
Mount-time, measurement, and memory rewrite for huge lists. The hot path (#1168)
throughgetMeasurements()no longer allocates aVirtualItemobject per
index for single-lane lists; instead it fills aFloat64Arrayof
start/size pairs and materializesVirtualItemobjects 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-resizeMapclone inresizeItem, theObject.entries+delete
deopt insetOptions, theMath.min(...pendingMeasuredCacheIndexes)
spread, thedefaultRangeExtractorpushgrowth pattern, the eager
measurementsCachereference invalidation, and the leakedelementsCache
entries 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 an
Array<VirtualItem>-shaped value supporting[i],.length, iteration,
etc. Internal consumers that previously read fields offVirtualItem
objects 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 when
scrollTopalready 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.