diff --git a/src/js/core/rendering/renderers/VirtualDomVertical.js b/src/js/core/rendering/renderers/VirtualDomVertical.js
index 361044c1d..22aacb942 100644
--- a/src/js/core/rendering/renderers/VirtualDomVertical.js
+++ b/src/js/core/rendering/renderers/VirtualDomVertical.js
@@ -97,8 +97,21 @@ export default class VirtualDomVertical extends Renderer{
callback();
}
- if(this.rows().length){
- this._virtualRenderFill((topRow === false ? this.rows.length - 1 : topRow), true, topOffset || 0);
+ var newRows = this.rows();
+
+ if(newRows.length){
+ //The anchor scan above used the PRE-callback (e.g. pre-filter) window
+ //indices. If that window now points past the new row count, topRow/
+ //topOffset are stale and would inflate vDomTopPad into a blank strip
+ //across the top. In that case do a fresh fill, which resets
+ //vDomTopPad to 0.
+ var windowInvalid = this.vDomTop >= newRows.length || this.vDomBottom >= newRows.length;
+
+ if(windowInvalid){
+ this._virtualRenderFill();
+ }else{
+ this._virtualRenderFill((topRow === false ? newRows.length - 1 : topRow), true, topOffset || 0);
+ }
}else{
this.clear();
this.table.rowManager.tableEmpty();
@@ -366,7 +379,13 @@ export default class VirtualDomVertical extends Renderer{
this.vDomScrollHeight = topPadHeight + rowsHeight + this.vDomBottomPad - containerHeight;
}else {
this.vDomTopPad = !forceMove ? this.scrollTop - topPadHeight : (this.vDomRowHeight * this.vDomTop) + offset;
- this.vDomBottomPad = this.vDomBottom == rowsCount-1 ? 0 : Math.max(this.vDomScrollHeight - this.vDomTopPad - rowsHeight - topPadHeight, 0);
+ //Derive the bottom pad from the CURRENT row count (mirroring the
+ //!position branch) rather than the previously-cached
+ //vDomScrollHeight, which goes stale after a filter/sort/resize
+ //changes rowsCount and leaves an inflated blank strip below the
+ //last row. Refresh vDomScrollHeight so later reads stay coherent.
+ this.vDomBottomPad = this.vDomBottom == rowsCount-1 ? 0 : this.vDomRowHeight * (rowsCount - this.vDomBottom - 1);
+ this.vDomScrollHeight = topPadHeight + rowsHeight + this.vDomBottomPad - containerHeight;
}
element.style.paddingTop = this.vDomTopPad+"px";
diff --git a/test/e2e/rerender-filter.html b/test/e2e/rerender-filter.html
new file mode 100644
index 000000000..e8d920c20
--- /dev/null
+++ b/test/e2e/rerender-filter.html
@@ -0,0 +1,52 @@
+
+
+
+
+ Tabulator rerenderRows filter blank-strip test
+
+
+
+
+
+
+
+
+
diff --git a/test/e2e/rerender-filter.spec.ts b/test/e2e/rerender-filter.spec.ts
new file mode 100644
index 000000000..8b12d2d41
--- /dev/null
+++ b/test/e2e/rerender-filter.spec.ts
@@ -0,0 +1,65 @@
+import { test, expect, Page } from "@playwright/test";
+import { join } from "path";
+
+// Regression coverage for rerenderRows after a filter (blank strip).
+//
+// rerenderRows scanned the PRE-filter vDomTop..vDomBottom window for an anchor
+// row, then filled against the POST-filter rows. When the pre-filter window
+// pointed past the new (smaller) row count, the stale topOffset inflated
+// vDomTopPad into a blank strip across the top. Separately, the position branch
+// of _virtualRenderFill derived vDomBottomPad from a stale vDomScrollHeight,
+// leaving an inflated blank strip below the last row after the row count shrank.
+//
+// Metric: gap (px) from the top / bottom edge of the holder to the nearest
+// rendered row. A large gap after filtering is the bug.
+
+async function gaps(page: Page) {
+ return page.evaluate(() => {
+ const holder = document.querySelector(".tabulator-tableholder") as HTMLElement;
+ const table = document.querySelector(".tabulator-table") as HTMLElement;
+ const r = holder.getBoundingClientRect();
+ const x = r.left + r.width / 2;
+ const max = Math.min(r.height, 600);
+ const scan = (fromTop: boolean) => {
+ for (let d = 2; d < max; d += 6) {
+ const y = fromTop ? r.top + d : r.bottom - d;
+ const el = document.elementFromPoint(x, y) as HTMLElement | null;
+ if (el && el.closest && el.closest(".tabulator-row")) return Math.max(0, d - 2);
+ }
+ return max;
+ };
+ return {
+ topGap: scan(true),
+ bottomGap: scan(false),
+ paddingTop: parseFloat(table.style.paddingTop) || 0,
+ };
+ });
+}
+
+test.describe("rerenderRows after filter does not leave a blank strip", () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(`file://${join(__dirname, "rerender-filter.html")}`);
+ await page.waitForSelector(".tabulator-tableholder");
+ });
+
+ test("filtering a long list down to a short one keeps content flush", async ({ page }) => {
+ // Scroll to the middle so the pre-filter window is deep in the list.
+ await page.locator(".tabulator-tableholder").evaluate((h) => {
+ h.scrollTop = Math.round((h.scrollHeight - h.clientHeight) / 2);
+ h.dispatchEvent(new Event("scroll"));
+ });
+ await page.waitForTimeout(80);
+
+ // Filter 2000 -> ~50 rows.
+ await page.evaluate(() => {
+ // @ts-expect-error test global
+ window.testTable.setFilter("cat", "=", "rare");
+ });
+ await page.waitForTimeout(120);
+
+ const g = await gaps(page);
+ expect(g.topGap).toBeLessThanOrEqual(6);
+ expect(g.paddingTop).toBeLessThanOrEqual(6);
+ expect(g.bottomGap).toBeLessThanOrEqual(6);
+ });
+});
diff --git a/test/unit/core/VirtualDomVertical.rerender.spec.js b/test/unit/core/VirtualDomVertical.rerender.spec.js
new file mode 100644
index 000000000..0fb5d77e7
--- /dev/null
+++ b/test/unit/core/VirtualDomVertical.rerender.spec.js
@@ -0,0 +1,50 @@
+import TabulatorFull from "../../../src/js/core/TabulatorFull";
+
+// Regression: rerenderRows' fallback index was `this.rows.length - 1`, but
+// `this.rows` is the METHOD (arity 0), so the expression was always -1. When the
+// pre-render window scan found no anchor row (stale / out-of-range window), the
+// renderer filled from position -1. Asserted on the argument passed to
+// _virtualRenderFill because jsdom reports the holder as non-visible, so the
+// fill itself is a no-op there.
+describe("VirtualDomVertical rerenderRows fallback index", () => {
+ let el;
+
+ beforeEach(() => {
+ el = document.createElement("div");
+ document.body.appendChild(el);
+ });
+
+ afterEach(() => {
+ el.remove();
+ });
+
+ const data = Array.from({ length: 1000 }, (_, i) => ({ id: i, a: "row " + i }));
+
+ const build = () =>
+ new Promise((resolve) => {
+ const table = new TabulatorFull(el, {
+ height: "300px",
+ data,
+ columns: [{ title: "A", field: "a" }],
+ });
+ table.on("tableBuilt", () => resolve(table));
+ });
+
+ test("fallback passes the last display-row index, not -1", async () => {
+ const table = await build();
+ const renderer = table.rowManager.renderer;
+
+ // Inverted rendered window so the anchor scan never runs its body and
+ // topRow stays false, exercising the fallback branch. The indices stay
+ // in range on purpose: an out-of-range window is the separate
+ // windowInvalid case, which takes a fresh fill instead.
+ renderer.vDomTop = 5;
+ renderer.vDomBottom = 3;
+
+ const spy = jest.spyOn(renderer, "_virtualRenderFill");
+ renderer.rerenderRows(() => {});
+
+ expect(spy).toHaveBeenCalled();
+ expect(spy.mock.calls[0][0]).toBe(data.length - 1); // 999, not -1
+ });
+});