Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

DynamicSizeVirtualScrollStrategy strands rendered items past the runway after the list shrinks (internal _virtualItems is never truncated) #1923

Copy link
Copy link

Description

@mitrikM
Issue body actions

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

  1. *rxVirtualFor with [dynamic]="(item) => item.height" over a signal/observable list.
  2. Render a large list (e.g. 2000 items), scroll near the bottom.
  3. Replace the list with a much smaller one (e.g. 1213 items).
  4. Observe: the runway shrinks correctly, but rendered rows are positioned beyond it (empty viewport), and scrolling up is stuck until repeatedly scrolled.
Reactions are currently unavailable

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions

    Morty Proxy This is a proxified and sanitized view of the page, visit original site.