Skip to content

Regression in 0.9.0 (#284): stale row content persists past the last page on fractional scroll offset #286

Description

@amirburbea

Summary

Since 0.9.0 (specifically #284, "Profile-guided performance optimization for scrolling"), RegularBodyViewModel.draw() (src/ts/tbody.ts) can leave a stale, previously-rendered <tr> visible past the true end of the dataset when the scroll position lands on a fractional row offset at the last page. Confirmed present in 0.9.0, confirmed absent in 0.8.6.

Root cause

When ridx_offset % 1 !== 0 (a fractional row offset — reached in practice whenever scrollHeight - clientHeight isn't itself an exact multiple of the row height, which is common), overdraw is set to 1, extending clean_ridx one row past what was actually drawn:

let clean_ridx = broke ? ridx : ridx + overdraw;
if (clean_ridx > ridx) {
    const stale = this.rows[ridx];
    const prev = this.rows[ridx - 1];
    const mergeable = ...;
    if (
        mergeable ||
        !stale ||
        !prev ||
        stale.children.length !== prev.children.length ||
        stale.querySelector("[colspan],[rowspan]") !== null ||
        prev.querySelector("[colspan],[rowspan]") !== null
    ) {
        clean_ridx = ridx;
    }
    // otherwise: the row at `ridx` is left untouched
}
this._clean_rows(clean_ridx);

If the "extra" row at ridx happens to be structurally compatible with its neighbor (same child count, no colspan/rowspan), it's never cleaned — it's left showing whatever content it last rendered, from an earlier scroll position, sort, or filter. Since this branch is only reachable when broke === false (i.e. we're at the true end of the dataset, not just the edge of the viewport), there's no subsequent "next scroll" to correct it the way there would be mid-list — the stale row persists indefinitely, partially visible at the bottom of the scrollable area.

Reproduction

  1. Bind an N-row data source to <regular-table> with virtual_mode: 'vertical'.
  2. Scroll around the middle of the list (so some other row's content gets rendered into what will become the last DOM row slot), then scroll all the way to the bottom.
  3. If scrollHeight - clientHeight isn't an exact multiple of the configured row height (the common case), the row rendered just past the true last row shows stale content instead of being empty/absent — it visually reads as a duplicate of an earlier row in the list, clipped by the container's bottom edge.

This did not occur on 0.8.6, where the equivalent cleanup was unconditional (this._clean_rows(ridx)), with no overdraw/compatibility-check step at all.

Versions

  • Confirmed present: 0.9.0
  • Confirmed absent: 0.8.6

Happy to provide more detail from our integration if useful — this was found via code reading plus a reproducible instance in a production virtual-scrolling grid, rather than an isolated minimal repro, so let me know if a standalone repro would help triage.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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