Search documentation

Search all pages, headings and text, in the current package or topic or in all documentation.

Changelog

What changed for users of active-virtual, per version.

@workspace/active-virtual 2.1.0Status unknowncontent differs from the recorded release 2.1.0baseline ce081dc3

Release state

The version line at the top of this page is read from the package and the release register of Foundation. A recorded release counts only for exactly the content it recorded: when the package changed after it, this site does not call that version released. The section Unreleased lists what changed since, including the changes made since Foundation maintains the package.

Notable changes to @workspace/active-virtual. Format based on Keep a Changelog; versioning per Semantic Versioning.

This file is the authoritative release history for @workspace/active-virtual. The pre-1.0.0 entries (v0.1.0 → v0.2.8) document the internal iteration that led to the v1.0.0 externally-publishable baseline and are preserved verbatim in intent (internal tracking IDs and test-count tallies have been stripped — the behavior change is what stays).


[Unreleased]#

Maintained in Foundation (since 2026-10-21)#

  • Documentation: references to documents of the source repository are text, marked (source repository: …), instead of links that resolve nowhere here. No code changed.

[2.1.0] — 2026-10-18#

Changed — canonical transfer form (2026-10-18, platform ADR-112)#

  • The manifest is the transferable manifest (RPS-D7): no workspace package in devDependencies (what the package's own development takes from Datyx, such as the lint preset, is host tooling in the reusable package register); the declaration packages of what the source imports sit in the field of their module (@types/react and @types/react-dom as optional peers beside the react peers). Nothing changes for a Datyx consumer.
  • tsconfig.json is self-contained: it no longer extends @workspace/typescript-config, with the same compiler options, because a bundler in a consumer reads it for every source file it compiles.

Added (portable runtime P6, D-12)#

  • toLogical, toPhysical and ScrollPlane are exported from the root entry: the one conversion between a physical scroll offset and the logical offset, for a consumer that reads or writes scrollLeft beside an axis. Unchanged behaviour.

Fixed#

  • A request whose write landed short before the layout caught up is written again. When an instant write of a request to an item or an edge lands short because the container had not laid out a grown extent yet, the browser adapter writes the same target once more as soon as the container's scroll range has grown, checked at that request's own ticks. Only the same, still active request with the same target is served, only on growth and never because time passed; the write and visible-time budgets run on unchanged, and a target that stays out of reach gives up as before (DECISIONS.md D-13). No API change.

  • An instant scroll write the browser clamps no longer leaves the axis at the requested offset. After every instant write the browser adapter gives the axis the offset the browser actually reached, also when the write moved nothing and no scroll event follows (a container already at its physical end, or with no scroll range). The projection now shows what the viewport shows, and such a request never settles on the offset it asked for: it keeps its target, warns scroll-unreachable, and gives up at its budget.

  • A request's pending write no longer lands after the request has ended. A request that is superseded, interrupted, loses its target or gives up before its write reaches the browser now writes nothing, also when the newer request writes nothing itself (for example one for a missing key). A compensation the withdrawn write had replaced still lands, and the other axis's writes are untouched; the axis returns to the offset the container keeps.

  • Measurements held back during a smooth request no longer regain withdrawn authority. A held measurement is withdrawn by a newer accepted measurement of the item, an invalidation of it (stale or void), a reset, or the item leaving the collection. It no longer returns after a void invalidation, never applies to a key that was removed and inserted again, and never overwrites a newer measurement.

  • A running smooth scroll animation no longer outlives its request. When a newer request supersedes a smooth request whose animation the browser is still running, the browser adapter stops that animation, also when the newer request writes nothing (a missing item, or auto to an item already visible); the viewport stays at the offset the newer request was resolved against instead of drifting to the old target. The same holds when a collection change removes the request's target and when input interrupts it, including in Firefox, which kept animating through a write to the current position. Only the ended request's direction stops: the other axis's smooth request carries on and lands. An animation whose request settles or gives up, or that the user takes over by moving the container, is left alone.

Documentation#

  • The ROADMAP holds no open package work. What stood there as open choices is recorded as current contract or envelope (DECISIONS.md D-14): an active request across a container switch (now also pinned by a test), the 2 000-unit visible-time budget for long smooth scrolls, and the O(n) key index for keyed edits. Evidence and decisions owned elsewhere moved to active-grid: the Safari RTL measurement (R-61) and the legal question about versions before 2.0.0 (TD-42).

  • Projection.visible is described as it behaves: with one lane every position between its bounds intersects the viewport, except that a viewport edge in a gap, a padding or beyond the content takes in the nearest item on that side. API.md and the TSDoc said every position between them was visible, which a layout gap contradicted. No runtime change; the rule is now pinned in range.test.ts.

  • The 1.x → 2.0.0 migration table moved from API.md into the 2.0.0 entry below: API.md carries the current contract only.

  • ARCHITECTURE.md states the actual cost of a scroll: every offset change rebuilds the rendered items and compares the projection by value, O(w) on top of the lane searches, and sorts the merged items when there is more than one lane. A key sequence rebuilds the key index and the size register directly (O(n)); edits are derived only to follow a removed anchor. API.md no longer says the engine derives the edits of a key sequence. No runtime change.

  • API.md states the exact rules for held measurements, a reused key, a withdrawn pending write and a write that lands short, instead of the absolute "never dropped" and "never counts as reached" claims.

  • THIRD-PARTY-NOTICES.md is removed. It carried the TanStack Virtual (MIT) notice for the engine of the versions before 2.0.0; that engine and all of its source were removed in 2.0.0, and a provenance review of the shipping engine supported its independence (docs/DECISIONS.md D-8). This is not a legal opinion.

  • The build-time documents of the independent rewrite (product contract, programme architecture, acceptance baseline, research) are removed; their durable content is in API.md, ARCHITECTURE.md, FUNCTIONEEL-OVERZICHT.md and DECISIONS.md. API.md now states the guarantees and measured limits per browser itself.

[2.0.0] — 2026-09-25#

Breaking — a new engine and API#

  • The engine is replaced. The package ships one, independently written engine: a framework-neutral, DOM-independent core per axis, a browser scroll host, and a React adapter. The previous engine, its React hooks and its whole API are removed; nothing of the 1.x API remains.
  • Root entry: createAxisStore, createScrollHost, findScrollHost and the value types (AxisInit, AxisInput, Projection, ProjectedItem, AnchorPolicy, RangePolicy, Diagnostic, …).
  • ./react: useVirtualAxis, one hook per axis, returning the projection of the render, a controller with a stable identity, itemRef and isRendered; inspectAxis for lifecycle tests.

Added#

  • Keyed identity with explicit edits and key sequences; measurements follow their item.
  • Anchoring through every geometry change (start edge, end edge, keyed, focus, none, or a function per change) and edge follow for end-oriented lists; a removed anchor is replaced by its nearest surviving neighbour.
  • Explicit invalidation (stale, void) and reset with collection epochs; a measurement for an old epoch or an unknown item is dropped.
  • Range observation (observeRange, onRangeChange) and isRendered.
  • Focus custody in the React adapter (opt-in).
  • An explicit pre-measurement status (awaiting-viewport) that is identical on the server and the first client render; seeded server windows.
  • A browser adapter that merges the scroll writes of one turn per container, recognises its own writes, and reports a write the browser cannot reach.

Changed#

  • Scroll requests and compensations are clamped to the declared extent; content that follows the list inside the container is declared as paddingEnd, content before it as listOrigin.
  • A measurement equal to an item's current size is kept and survives a later estimate change.
  • Diagnostics are structured (code, message, data) and go to one sink.

Migration#

Replace useVirtualizer / useWindowVirtualizer with useVirtualAxis, estimateSize with estimate, getVirtualItems() / getTotalSize() with projection.items / projection.extent, ref={measureElement} plus data-index with ref={itemRef(identity)}, measure() with controller.reset(), and scrollToIndex with controller.scrollToPosition. The full table:

1.x2.0.0
useVirtualizer({count, getScrollElement, estimateSize})useVirtualAxis({count, container, estimate})
horizontal: trueorientation: 'horizontal'
getItemKeykeys (keyed identity) instead of count
getVirtualItems(), getTotalSize()projection.items, projection.extent
VirtualItem.index, .startProjectedItem.position, .offset
ref={measureElement} with data-indexref={itemRef(identity)}; no attribute
measure()controller.reset(), or controller.invalidate(target, strength)
scrollToIndex(i, {align})controller.scrollToPosition(i, {align})
onWarn, onDebugonDiagnostic
useWindowVirtualizeruseVirtualAxis({container: window, …})
shouldAdjustScrollPositionOnItemSizeChangeanchor (a choice, or a function of the geometry change)
rangeExtractorrangePolicy
initialRect, initialOffset, initialMeasurementsCacheseed: {viewportExtent, offset, sizes}
new Virtualizer({...}) with observerscreateAxisStore(init) and createScrollHost(container).attach(axis, options)
Clamped to the element's physical scroll rangeClamped to the declared extent; declare trailing content as paddingEnd (D-6)

Unreleased changes to the replaced engine (superseded by 2.0.0)#

These entries describe the 1.x engine after 1.0.1; they were never released on their own, and 2.0.0 removed that engine.

Fixed — standards convergence#

  • getVirtualItemForOffset returns an item that covers the offset when lanes have different heights: starts do not rise with the index across lanes, so each lane is searched on its own (the lowest covering index wins; in a gap, the nearest item below). A single lane is unchanged.
  • getTotalSize() of an empty list keeps paddingStart and paddingEnd when a scrollMargin is set (it returned 0).

Documentation — standards convergence#

  • The range contract says precisely that in one lane the first and last item of calculateRange() may lie just outside the viewport when the offset falls in a gap or past the last item. Scale and browser claims narrowed to what is measured (operation counts up to 100,000 items; one recorded T6 run).

Documentation#

  • License and attribution. active-virtual is proprietary and workspace-private (LICENSE, UNLICENSED). The MIT notice of TanStack Virtual, from which it is derived, is now reproduced in THIRD-PARTY-NOTICES.md; README and docs no longer call the package MIT.
  • The documentation describes the current engine and the current public API, and no longer carries the v1.0.0 external-publication promise: the package is not published, and its API is what the two entry points export.

Fixed#

  • Measurements are recomputed incrementally. A read recomputes only from the first item whose measurement became invalid; a read with nothing invalid does no work. Previously every internal read rebuilt every item, so a scroll event cost O(count) (about 5 ms per event at 100 000 items) and measuring a mounted window cost O(count) per measured item. Unchanged reads no longer depend on the item count; neither do scroll events, in one lane or in several (see below for strongly uneven lanes).

  • Zero-size items no longer drop out of the rendered range. Items of size 0 that share a start position with their neighbours stay in the range, so they are measured again once they have a size.

  • A pre-mount render no longer stays on screen when mounting empties the range. Render-skipping compares against the index set the subscriber last read, not the set last notified.

  • The total size frozen during a scroll follows a change of count.

  • A smooth scrollToIndex no longer interrupts itself. Every scroll event of the animation between the previous position and the target belongs to the programmatic scroll; only a movement away from the target, or past it, is a user interruption. Reconciliation waits while a smooth scroll progresses instead of re-issuing it every other frame, so deferred measurements are applied on arrival rather than after the second frame.

  • Cleanup is a hard boundary. The scroll-end timer and the animation frames of the container and item observers are cancelled on cleanup and on a scroll-element swap, and callbacks of an older subscription are ignored, so no late work writes into an unmounted or re-subscribed instance. The container observer's frame now runs on the owning window (iframe-aware).

  • Cleanup no longer constructs a ResizeObserver. Releasing an element or disconnecting when no item observer exists does nothing.

  • Options changed through setOptions take effect on the mounted element. useAnimationFrameWithResizeObserver is read at every resize; isScrollingResetDelay and useScrollendEvent re-subscribe the offset observer at the next _willUpdate() without replacing the scroll element.

  • Horizontal RTL scrolling writes as it reads. scrollToOffset, scrollBy, scrollToIndex and resize corrections now use the same logical offset as the offset observer; the built-in element and window observers map it to the negative RTL scrollLeft/scrollX of CSSOM View.

  • Changing horizontal no longer keeps sizes measured along the old axis. The change drops the old-axis sizes and scroll state at once; the next _willUpdate() re-subscribes, writes initialOffset along the new axis and measures the mounted items again.

  • A page hidden during scrollToIndex no longer aborts it on return. Time the owning document is hidden no longer counts toward the reconciliation timeout, so the scroll continues instead of ending with a spurious timeout warning and an early flush of deferred measurements.

  • The latest scroll command wins. scrollToOffset and scrollBy now end a pending scrollToIndex reconciliation, as a newer scrollToIndex already did, so an older reconciliation no longer drags the list back to its index. A user scroll that the running scrollToIndex cannot explain now ends it in instant mode too, not only in smooth mode.

  • A window from another realm is virtualized. The owner window of a window scroll element is resolved without instanceof Window, so an iframe's contentWindow gets its scroll listener, timers and frames.

  • A measurement replaces an initialMeasurementsCache seed for good. The seeds are applied once, before the first computation. Previously an invalidation from index 0 (a measurement of item 0, or any structural option change) applied them again and overwrote newer measured sizes. measure() and enabled: false, which discard every measurement, still start from the seeds again.

  • The newest scroll command is the only writer, also under re-entry. A newer command, a cleanup or a scroll-element swap issued synchronously inside a scrollToIndex's own write (a custom scrollToFn that reports the offset at once) or inside estimateSize/getItemKey while it computes its target now ends that scrollToIndex at once. Previously its reconciliation scheduled one more verification and wrote its old target over the newer command.

  • A command issued from any callback of an older command wins. Every command now owns the scroll target before it ends the previous one, and checks its ownership after each consumer callback. A newer command issued from onChange (while deferred measurements of the previous command apply), from the smooth-scroll onWarn of scrollToOffset/scrollBy, from estimateSize in a later scrollToIndex verification, or from onDebug('Schedule retry') is no longer overwritten by the older command. Previously the older command still wrote its target, a later verification threw on its cancelled state, or a retry scheduled an obsolete frame. The smooth-scroll warning of scrollToOffset/scrollBy now follows the end of the previous command.

  • Applying deferred measurements stops when ownership passes on. A batch of measurements a smooth scrollToIndex deferred is the work of the command that flushes it, and its own callbacks reach consumer code: measureElement, shouldAdjustScrollPositionOnItemSizeChange, the correction debug notification, onChange, and estimateSize/getItemKey while a later entry is looked up. A command issued from any of them now takes the scroll target over at once and the rest of the batch is dropped — no further correction, no size recorded, no notification — at a command's start, at a user interruption and at the settlement of a reconciliation alike. Previously the batch ran on: a two-entry flush wrote [230, 3000, 260] where the newer command's 3000 had to be last, a settled reconciliation wrote [5030, 3000, 5060], and a single entry whose own callback replaced the owner still wrote its stale correction after it ([3000, 230]). Measurement that belongs to no command (measureElement() from a ref, resizeItem(), measure()) is unchanged.

  • align: 'end' aligns the requested item in a multi-lane layout. The scroll-to-the-very-end rule (maximum offset, paddingEnd in view) now applies to the item whose end is the end of the content, not to the last index; in several lanes an earlier item can reach further, and the last index then aligns by its own end. The result is also clamped at 0 when the content is shorter than the viewport.

  • The frozen total size follows every layout change. During a scroll episode getTotalSize() was re-based only when count changed. It is now re-based on any structural change: gap, paddingStart, paddingEnd, scrollMargin, lanes, horizontal, enabled, a new estimateSize or getItemKey function (an active-grid column resize mid-scroll), or measure(). Measured size changes stay frozen as before.

  • Multi-lane totals and placement no longer scan the list. An unchanged getTotalSize() read one item per lane at best but, in strongly uneven lanes, every item back to the start (100 000 inspections per read at 100 000 items); placing an item and resuming after an invalidation scanned backward the same way, so building such a layout was quadratic. Per-lane bookkeeping now makes an unchanged read one inspection per lane, an append a handful, and a recomputation the changed suffix plus the affected lanes.

Changed#

  • Breaking: visibility is per lane, and the engine applies the overscan. With lanes > 1 every lane is searched in its own geometry, and the rendered set is each lane's visible run plus its own directional overscan, merged in ascending index order. It need not be contiguous by index: a strongly uneven (masonry) layout renders only the visible items of each lane and their overscan, however far apart their indexes are, at O(lanes × log count) plus the visible items per scroll event. Previously one index interval covered every index between the lowest and highest visible item and was expanded until every lane passed the viewport, which in the extreme rendered the whole list at O(count) per scroll event. overscan now counts items per lane (roughly rows) instead of indexes. One lane renders exactly what it rendered before.
  • Breaking: Range gains indexes, the default render set (visible items plus per-lane overscan, ascending). startIndex and endIndex are the lowest and highest visible index; in several lanes they are bounds only, as is calculateRange(). The default rangeExtractor returns range.indexes; a custom extractor extends that set instead of computing overscan.
  • A new identity of estimateSize or getItemKey is now the signal to recompute every item; a function that keeps its identity is treated as pure. Keep both stable and pass a new function when estimates or keys change.
  • Measured sizes of keys the list no longer produces are dropped once the size cache holds more sizes than there are items.
  • Size and count contract. count must be a non-negative integer, or the constructor and setOptions throw RangeError. An estimateSize result must be finite and >= 0, or the read that needs it throws RangeError. An invalid measured size (measureElement, resizeItem) is reported through onWarn and ignored. Zero and fractional sizes stay valid.
  • Layout option contract. lanes must be an integer >= 1; gap, paddingStart, paddingEnd, scrollPaddingStart, scrollPaddingEnd and scrollMargin must be finite and >= 0. An invalid value throws RangeError from the constructor or setOptions, and a rejected setOptions keeps the previous options.
  • initialMeasurementsCache sizes obey the size contract. A seeded size that is negative, NaN or infinite throws RangeError when the cache is seeded. Only key and size are read from the cache.
  • Changing isScrollingResetDelay or useScrollendEvent during a scroll ends the running scroll episode (isScrolling becomes false).
  • Explicit public surface. Both entry points name every export; nothing is re-exported by wildcard. A contract test pins the export lists (types included), the export map and the public members of Virtualizer.
  • ./react exports Virtualizer as a type only. Construct a Virtualizer from the root entry.
  • Read-only shared shapes (type level, no runtime freeze). The fields of VirtualItem and Range are readonly; getVirtualItems() returns readonly VirtualItem[] and getVirtualIndexes() readonly number[], because both arrays are memoized and the items are the virtualizer's cached measurements. rangeExtractor may return a readonly number[], initialMeasurementsCache accepts a readonly VirtualItem[], and the members of VirtualizerLike are readonly. Code that mutated a returned item or array no longer compiles.
  • calculateRange, getVirtualIndexes and getVirtualItems are typed as plain functions; the internal memo helper's updateDeps is no longer part of their type.
  • elementScroll, windowScroll, measureElement and the four observers are generic over the item element type instead of accepting any there, so they are assignable to any VirtualizerOptions without a cast.

Added#

  • VirtualizerLike is exported from the root entry: the instance as the pluggable functions (scrollToFn, the observers, measureElement, onChange) receive it, for typing a custom one.
  • ./react exports UseVirtualizerOptions and UseWindowVirtualizerOptions, the option types of the two hooks, and AdaptiveOverscanOptions.

Removed#

  • Breaking: root exports that were implementation helpers, public only through wildcard re-exports: approxEqual, debounce, defaultKeyExtractor, findNearestBinarySearch, getElementRect, indexesEqual, memo, MemoizedFunction, MemoOptions, notUndefined, resolveOverscan, PartialKeys and ScrollToOptions (still the shape behind ScrollToIndexOptions and ScrollToOffsetOptions). debounce itself is deleted: nothing called it since the scroll-end timer became cancellable.
  • Breaking: ./react no longer exports ReactVirtualizerOptions, an alias of VirtualizerOptions (use VirtualizerOptions or the hook option types), or PartialKeys.
  • Breaking: defaultRangeExtractor. The default render set, overscan included, is Range.indexes; a custom rangeExtractor extends it.
  • Breaking: the Virtualizer instance members range (use calculateRange()) and elementsCache (internal bookkeeping of the measured elements).

[1.0.1] — 2026-05-20#

Internal type-cleanup. Reduces @typescript-eslint/no-explicit-any warnings from 14 → 0 across the package via two centralized type-aliases and a handful of motivated eslint-disable directives. No API surface changes; no runtime behaviour changes.

Changed (internal)#

  • New internal ScrollObserverInstance<T> and ItemMeasurementInstance<T> type-aliases in core/virtualizer/types.ts. They consolidate the VirtualizerLike<T, any> / VirtualizerLike<any, T> pattern used by observer-functions where one of the two generic parameters is irrelevant to the caller. The any in the unused slot is required because VirtualizerLike is invariant in both parameters (TItemElement appears in parameter and return positions of options.measureElement/options.onChange), so narrowing to Element would reject valid downstream-typed instances. Motivated eslint-disable lives on the alias-definitions with JSDoc rationale.
  • core/observers/element-observers.ts uses ScrollObserverInstance<T> for observeElementRect, observeElementOffset, and elementScroll.
  • core/observers/window-observers.ts uses ScrollObserverInstance<Window> / ScrollObserverInstance<T> for observeWindowRect, observeWindowOffset, and windowScroll.
  • core/observers/measure-element.ts uses ItemMeasurementInstance<TItemElement> for measureElement.
  • core/utils/memo.ts introduces a file-internal AnyDepsTuple = ReadonlyArray<any> alias for variadic-tuple-inference on TDeps (TS #28259). Single eslint-disable on the alias-definition covers all three call-sites (MemoOptions, MemoizedFunction, memo function-generic). Internal storage variable deps switched from ReadonlyArray<any> → ReadonlyArray<unknown> (the storage role doesn't need the variadic-tuple semantics). Removed explicit : any annotation from the .some() callback parameter (TypeScript infers from TDeps).
  • core/utils/helpers.ts debounce retains its variadic-tuple-inference pattern (TArgs extends Array<any>) and transparent this-forwarding (this: any), now with motivated eslint-disable directives explaining each.

Not changed#

  • Public API surface: no changes to Virtualizer, observer functions, measureElement, memo, or any exported type/function shape.
  • Runtime behaviour: no changes.
  • Downstream @workspace/active-grid consumer still typechecks clean.

Tests#

  • All 221 tests remain green (no behaviour changes, no test additions).

[1.0.0] — 2026-05-19#

First stable major release. Promotes the 0.2.8 surface to a stable, externally-publishable baseline with an API-stability promise from this version onward.

No API changes versus 0.2.8 — this release is the version-promotion itself.

Notes for consumers#

  • Import paths, surface, and runtime behaviour are unchanged from 0.2.8.
  • The README license block has been corrected to match the LICENSE file (MIT — TanStack-Virtual-derivation). The previous "All rights reserved" wording was misleading documentation, not a license change.
    • Correction, 2026-09-19: this no longer describes the package. Its LICENSE is the proprietary Datyx license and package.json says UNLICENSED; active-virtual is not MIT-licensed. The MIT notice of TanStack Virtual, from which the versions before 2.0.0 were derived, was carried in THIRD-PARTY-NOTICES.md until that engine was removed. The entry above is kept as it was released.
  • The src/index.ts packageDocumentation now states "React 19 compatible" only, matching package.json peerDeps (^19.0.0) and the README claim.
  • Full package documentation now lives under docs/; this CHANGELOG.md and DECISIONS.md moved from the package root into docs/ as part of the same restructure.

[0.2.8] — 2026-04-06#

Fixed#

  • measure() clears deferred measurements and cancels smooth scroll. measure() now clears _deferredMeasurements, cancels the active _cancelScrollToIndex, and resets _smoothScrollState. Previously, a measure() call during an active smooth scroll (triggered by a cross-package sort/filter flow in active-grid) could leave stale deferred measurements that would be applied after the reset.

[0.2.7] — 2026-04-06#

Fixed#

  • EMA velocity no upper bound. Added MAX_VELOCITY cap (10 000 px/s) after the EMA update in _updateScrollVelocity(). macOS momentum scrolling can reach 50k–100k px/s, which would produce thousands of overscan rows. The cap prevents extreme overscan while preserving smooth velocity tracking for normal scroll speeds.

[0.2.6] — 2026-04-06#

Fixed#

  • EMA velocity one-frame blank after scroll pause. Velocity tracking no longer hard-resets _lastScrollOffset, _lastScrollTime, and _scrollVelocity to 0 when scrolling stops. Instead, tracking state is preserved and velocity decays gradually via Math.pow(0.5, dt / 100) when the next scroll event arrives after a pause > 100 ms. Prevents a one-frame blank flash (overscan = 0) when rapidly resuming scroll after a brief pause.
  • User scroll interrupt now cancels reconciliation. When a user-initiated scroll is detected during a smooth scrollToIndex animation, the reconciliation RAF loop is now cancelled (_cancelScrollToIndex) in addition to flushing deferred measurements. Previously, the RAF loop continued running after interrupt.

[0.2.5] — 2026-04-06#

Fixed#

  • scrollToIndex cancel/new race condition. scrollToIndex() now calls _flushDeferredMeasurements() before cancelling the previous reconciliation and setting new smooth scroll state. Previously, deferred measurements from the previous scroll were lost because _smoothScrollState was overwritten and _deferredMeasurements cleared before cancel() was called. The cancel() function skips onSettled, so deferred measurements were never flushed.

[0.2.4] — 2026-04-05#

Tests#

  • Explicit cancel() unmount behavior. Test verifies that cancel() prevents scrollToFn from being called after the virtualizer is cleaned up. Dynamic mode with changing offsets — after cancel(), no further scroll attempts are made and onSettled is not called.

[0.2.3] — 2026-04-05#

Fixed#

  • RAF cancellation on unmount. performScrollToIndex now returns a cancel() function that cancels pending requestAnimationFrame callbacks. The Virtualizer calls cancel() in its cleanup() method on unmount, preventing scrollToFn from being called on disconnected elements. Previous scroll-to-index reconciliations are also cancelled when a new scrollToIndex call is made.

[0.2.2] — 2026-04-04#

Breaking Changes#

  • useFlushSync option removed from ReactVirtualizerOptions — was accepted but never implemented.

Fixed#

  • elementsCache is now cleaned up on measure() — prevents stale element references after item removal.

Documentation#

  • SSR usage with initialMeasurementsCache documented in README.
  • Next.js App Router example added.

[0.2.1] — 2026-04-04#

Fixed#

  • useVirtualizer snapshot bug. useSyncExternalStore received the same Virtualizer class-instance reference as snapshot, causing Object.is() to always return true and preventing re-renders in Concurrent Mode — fixed by returning a _stateVersion counter that increments on every notify().
  • Velocity tracking jojo-effect. Sliding-window velocity calculation returned 0 during rapid direction changes (e.g. 200 px down then 200 px up within 100 ms) — replaced with Exponential Moving Average (alpha = 0.3) using absolute instant velocity.
  • Smooth scroll interrupt. User-initiated scroll during a smooth scrollToIndex animation left deferred measurements waiting for the 2000 ms timeout — now detects user interrupts via _programmaticScrollPending flag and flushes deferred measurements immediately.

Changed#

  • Velocity tracking: replaced sliding-window array with EMA (two private fields instead of array allocation, better spike filtering).
  • Default velocityFactor lowered from 0.05 to 0.01 — produces a smoother gradient between min and max overscan instead of binary behavior.
  • Default scroll-reconciliation timeout lowered from 5000 ms to 2000 ms — 2 seconds is sufficient for DOM settling, 5 seconds felt like frozen UI.
  • Velocity unit is now px/s (was px/ms internally).

[0.2.0] — 2026-04-04#

Fixed#

  • Scroll reconciliation: added 5000 ms safety timeout and stability-frame check — prevents infinite loops in edge cases (broken layout, zero-height containers).
  • indexFromElement: added NaN handling for non-numeric data-index attributes.

Added#

  • resizeItem(index, size) method — programmatic resize without DOM measurement, with scroll adjustment for items above viewport and bounds checking with dev-mode warnings.
  • Scroll-frame optimization — skips React re-renders when scroll offset changes but visible items remain the same, comparing full virtual index arrays.
  • Directional overscan — asymmetric overscan based on scroll direction (70 % in scroll direction, 30 % opposite), minimum 1 in each direction.
  • Adaptive overscan — velocity-based dynamic overscan via AdaptiveOverscanOptions ({min, max, velocityFactor}). Fast scrolling increases overscan, idle reduces to minimum. Integrates with directional overscan (adaptive = how much, directional = where).
  • Smooth-scroll measurement throttling — during smooth scrollToIndex, measurements are limited to items within a buffer around the target index. Deferred measurements are flushed when reconciliation settles.
  • initialOffset option documentation and edge-case handling.
  • indexFromElement() method documentation.
  • calculateRange() public-method documentation.

Changed#

  • overscan option now accepts number | AdaptiveOverscanOptions (backwards compatible).
  • defaultRangeExtractor now applies directional overscan based on scrollDirection property on Range.
  • Scroll-reconciliation state includes startedAt timestamp and stableFrames counter.
  • Virtualizer tracks scroll velocity via EMA (used by adaptive overscan).

[0.1.0] — Initial release#

  • Virtualizer core with vertical/horizontal support.
  • Multi-lane (grid) layout with lanes option.
  • Dynamic item measurement via ResizeObserver.
  • Scroll to index/offset with alignment options.
  • useVirtualizer and useWindowVirtualizer React hooks.
  • SSR support with initialMeasurementsCache.

This page as Markdown