Regression in 0.9.0 (#284): stale row content persists past the last page on fractional scroll offset
- Dominant language
- JavaScript
- Stars
- 403
- Forks
- 43
- Avg merge
- 3m
- Merged PRs (30d)
- 1
Description
## 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 `` 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:
```ts
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 `` 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.
Contributor guide
Research direction
Start in src/ts/tbody.ts at RegularBodyViewModel.draw(), focusing on the clean_ridx and overdraw branch described in the issue. Reproduce the fractional-offset case by scrolling a virtual vertical table from the middle to the bottom, then verify that the extra row slot no longer retains stale content past the dataset.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, typescript
- Domain
- frontend, performance
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 72/100