DynamicSizeVirtualScrollStrategy strands rendered items past the runway after the list shrinks (internal _virtualItems is never truncated)
Package / version
@rx-angular/template 21.2.0 (virtual-scrolling)
- Strategy:
DynamicSizeVirtualScrollStrategy (*rxVirtualFor + [dynamic])
- Also using an external scroll element via
rxVirtualScrollElement, though the bug is independent of that.
Summary
When the data array passed to *rxVirtualFor shrinks (e.g. 2000 → 1213 items), DynamicSizeVirtualScrollStrategy.maintainVirtualItems recomputes the sizes for the new, smaller length but never truncates its internal _virtualItems array. The array keeps its previous (larger) length, so contentLength (get contentLength() { return this._virtualItems.length; }) stays out of sync with the actual data length and with contentSize.
That desync corrupts the anchor / position math: rendered rows are positioned with translateY values far beyond the runway height, so the viewport shows empty space, and the scroll position snaps to the bottom. It only self-corrects after many scroll events (each scroll incrementally walks the anchor back into range).
Expected behavior
After the data array shrinks, contentLength should equal the new data length, and rendered items should be positioned within [0, contentSize]. Scrolling should work immediately.
Actual behavior
contentLength stays at the pre-shrink count.
- Rendered items get a
translateY greater than the runway height → visible empty area.
- Scroll "jumps to the end" and is "stuck" scrolling back up; it only reconciles after repeatedly scrolling.
Real measurements from a shrink (2000 → 1213 rows, ~36px each)
Immediately after the list shrank to 1213 rows:
| Value |
Observed |
runway height (rx-virtual-scroll-viewport style height = contentSize) |
43696px ✅ (matches 1213 rows) |
scroll container scrollHeight |
43760 |
first rendered row transform |
translateY(66304px) ❌ (well past the 43696px runway) |
The runway (contentSize) is correctly sized for the new count, but the rendered rows are positioned as if the old, larger item count still applied — because _virtualItems.length was never reduced.
Root cause
In DynamicSizeVirtualScrollStrategy.maintainVirtualItems, the non-empty branch rebuilds sizes with a loop bounded by the new length, but does not shorten the array when the new length is smaller than the old:
valueArray$.pipe(this.until$()).subscribe((dataArr) => {
const dataLength = dataArr.length;
if (!dataLength) {
this._virtualItems = []; // only place the array is ever reset
// ...
} else {
let shouldRecalculateRange = false;
let contentSize = 0;
for (let i = 0; i < dataArr.length; i++) {
const oldSize = this._virtualItems[i]?.size;
const newSize = this.itemSize(dataArr[i]);
contentSize += newSize;
if (oldSize === undefined || oldSize !== newSize) {
this._virtualItems[i] = { size: newSize };
// ...
}
}
// ⛔ nothing truncates this._virtualItems here, so stale entries
// from a previous, larger data array survive → contentLength is wrong.
if (dataLength < this._renderedRange.end) {
// re-anchor ...
}
this.contentSize = contentSize;
if (shouldRecalculateRange) {
this.recalculateRange$.next();
}
}
});
Because contentLength (= _virtualItems.length) is used in calcRenderedRange (range.end = Math.min(this.contentLength, ...)) and the anchor walks over _virtualItems in calculateAnchoredItem, the stale trailing entries make the strategy position items using indices/offsets that no longer exist in the runway.
Proposed fix
Truncate _virtualItems to the new length after the rebuild loop, and trigger a range recalculation when it changes:
for (let i = 0; i < dataArr.length; i++) {
// ... existing size rebuild ...
}
// Drop stale entries when the list shrank so contentLength matches the data.
if (this._virtualItems.length > dataLength) {
this._virtualItems.length = dataLength;
shouldRecalculateRange = true;
}
if (dataLength < this._renderedRange.end) {
// ... existing re-anchor ...
}
This restored correct behavior in our app (rows position within the runway, scroll works immediately, no snap-to-bottom) with no observed regressions on growth or same-length updates.
Notes
AutosizeVirtualScrollStrategy appears to have the same pattern (a size-rebuild loop with no truncation) and may need the same guard — worth checking.
- Happy to open a PR with the fix + a test if that's helpful.
Minimal reproduction
*rxVirtualFor with [dynamic]="(item) => item.height" over a signal/observable list.
- Render a large list (e.g. 2000 items), scroll near the bottom.
- Replace the list with a much smaller one (e.g. 1213 items).
- Observe: the runway shrinks correctly, but rendered rows are positioned beyond it (empty viewport), and scrolling up is stuck until repeatedly scrolled.
DynamicSizeVirtualScrollStrategystrands rendered items past the runway after the list shrinks (internal_virtualItemsis never truncated)Package / version
@rx-angular/template21.2.0 (virtual-scrolling)DynamicSizeVirtualScrollStrategy(*rxVirtualFor+[dynamic])rxVirtualScrollElement, though the bug is independent of that.Summary
When the data array passed to
*rxVirtualForshrinks (e.g. 2000 → 1213 items),DynamicSizeVirtualScrollStrategy.maintainVirtualItemsrecomputes the sizes for the new, smaller length but never truncates its internal_virtualItemsarray. The array keeps its previous (larger) length, socontentLength(get contentLength() { return this._virtualItems.length; }) stays out of sync with the actual data length and withcontentSize.That desync corrupts the anchor / position math: rendered rows are positioned with
translateYvalues far beyond the runway height, so the viewport shows empty space, and the scroll position snaps to the bottom. It only self-corrects after many scroll events (eachscrollincrementally walks the anchor back into range).Expected behavior
After the data array shrinks,
contentLengthshould equal the new data length, and rendered items should be positioned within[0, contentSize]. Scrolling should work immediately.Actual behavior
contentLengthstays at the pre-shrink count.translateYgreater than the runway height → visible empty area.Real measurements from a shrink (2000 → 1213 rows, ~36px each)
Immediately after the list shrank to 1213 rows:
rx-virtual-scroll-viewportstyle height =contentSize)43696px✅ (matches 1213 rows)scrollHeight43760transformtranslateY(66304px)❌ (well past the 43696px runway)The runway (
contentSize) is correctly sized for the new count, but the rendered rows are positioned as if the old, larger item count still applied — because_virtualItems.lengthwas never reduced.Root cause
In
DynamicSizeVirtualScrollStrategy.maintainVirtualItems, the non-empty branch rebuilds sizes with a loop bounded by the new length, but does not shorten the array when the new length is smaller than the old:Because
contentLength(=_virtualItems.length) is used incalcRenderedRange(range.end = Math.min(this.contentLength, ...)) and the anchor walks over_virtualItemsincalculateAnchoredItem, the stale trailing entries make the strategy position items using indices/offsets that no longer exist in the runway.Proposed fix
Truncate
_virtualItemsto the new length after the rebuild loop, and trigger a range recalculation when it changes:This restored correct behavior in our app (rows position within the runway, scroll works immediately, no snap-to-bottom) with no observed regressions on growth or same-length updates.
Notes
AutosizeVirtualScrollStrategyappears to have the same pattern (a size-rebuild loop with no truncation) and may need the same guard — worth checking.Minimal reproduction
*rxVirtualForwith[dynamic]="(item) => item.height"over a signal/observable list.