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
- Bind an N-row data source to
<regular-table> with virtual_mode: 'vertical'.
- 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.
- 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.
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 in0.9.0, confirmed absent in0.8.6.Root cause
When
ridx_offset % 1 !== 0(a fractional row offset — reached in practice wheneverscrollHeight - clientHeightisn't itself an exact multiple of the row height, which is common),overdrawis set to1, extendingclean_ridxone row past what was actually drawn:If the "extra" row at
ridxhappens 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 whenbroke === 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
<regular-table>withvirtual_mode: 'vertical'.scrollHeight - clientHeightisn'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
0.9.00.8.6Happy 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.