From 18fc29a2d82326f95f6da6c4f8f70c06bed588be Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:51:02 +0100 Subject: [PATCH 1/3] fix(render): use correct fallback row index in rerenderRows The rerenderRows anchor-scan fallback used `this.rows.length - 1`, but `this.rows` is the method (arity 0), so the expression was always -1. When the scan found no anchor row (stale or out-of-range rendered window), the renderer filled from position -1 and left vDomTop negative. Use the last display-row index instead. --- .../rendering/renderers/VirtualDomVertical.js | 4 +- .../core/VirtualDomVertical.rerender.spec.js | 48 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 test/unit/core/VirtualDomVertical.rerender.spec.js diff --git a/src/js/core/rendering/renderers/VirtualDomVertical.js b/src/js/core/rendering/renderers/VirtualDomVertical.js index 361044c1d..2126c4355 100644 --- a/src/js/core/rendering/renderers/VirtualDomVertical.js +++ b/src/js/core/rendering/renderers/VirtualDomVertical.js @@ -98,7 +98,9 @@ export default class VirtualDomVertical extends Renderer{ } if(this.rows().length){ - this._virtualRenderFill((topRow === false ? this.rows.length - 1 : topRow), true, topOffset || 0); + //`this.rows` is the method (arity 0) — `this.rows.length - 1` was + //always -1. Fall back to the last display-row index. + this._virtualRenderFill((topRow === false ? this.rows().length - 1 : topRow), true, topOffset || 0); }else{ this.clear(); this.table.rowManager.tableEmpty(); diff --git a/test/unit/core/VirtualDomVertical.rerender.spec.js b/test/unit/core/VirtualDomVertical.rerender.spec.js new file mode 100644 index 000000000..7b5aa1b88 --- /dev/null +++ b/test/unit/core/VirtualDomVertical.rerender.spec.js @@ -0,0 +1,48 @@ +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; + + // Stale / out-of-range rendered window so the anchor scan finds no row and + // topRow stays false, exercising the fallback branch. + renderer.vDomTop = 99999; + renderer.vDomBottom = 99999; + + const spy = jest.spyOn(renderer, "_virtualRenderFill"); + renderer.rerenderRows(() => {}); + + expect(spy).toHaveBeenCalled(); + expect(spy.mock.calls[0][0]).toBe(data.length - 1); // 999, not -1 + }); +}); From c219b0361e40de5784a3db4fd5fab6905e1ee548 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:27:32 +0100 Subject: [PATCH 2/3] fix(render): keep content flush after a filter rerender rerenderRows scanned the pre-filter vDomTop..vDomBottom window for an anchor row, then filled against the post-filter rows. When that window pointed past the new (smaller) row count, the stale topOffset inflated vDomTopPad into a blank strip across the top. Fall back to a fresh fill (which resets vDomTopPad) when the pre-filter window is invalid or no anchor was found. Also derive vDomBottomPad in the position branch of _virtualRenderFill from the current row count (mirroring the full-fill branch) instead of the cached vDomScrollHeight, which goes stale when the row count shrinks and leaves an inflated blank strip below the last row; refresh vDomScrollHeight too. Adds a Playwright regression test filtering 2000 rows down to ~50 and asserting no top/bottom blank strip. --- .../rendering/renderers/VirtualDomVertical.js | 27 ++++++-- test/e2e/rerender-filter.html | 52 +++++++++++++++ test/e2e/rerender-filter.spec.ts | 65 +++++++++++++++++++ 3 files changed, 139 insertions(+), 5 deletions(-) create mode 100644 test/e2e/rerender-filter.html create mode 100644 test/e2e/rerender-filter.spec.ts diff --git a/src/js/core/rendering/renderers/VirtualDomVertical.js b/src/js/core/rendering/renderers/VirtualDomVertical.js index 2126c4355..303623284 100644 --- a/src/js/core/rendering/renderers/VirtualDomVertical.js +++ b/src/js/core/rendering/renderers/VirtualDomVertical.js @@ -97,10 +97,21 @@ export default class VirtualDomVertical extends Renderer{ callback(); } - if(this.rows().length){ - //`this.rows` is the method (arity 0) — `this.rows.length - 1` was - //always -1. Fall back to the last display-row index. - 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 (or when no anchor row was found) do a + //fresh fill, which resets vDomTopPad to 0. + var windowInvalid = this.vDomTop >= newRows.length || this.vDomBottom >= newRows.length; + + if(topRow === false || windowInvalid){ + this._virtualRenderFill(); + }else{ + this._virtualRenderFill(topRow, true, topOffset || 0); + } }else{ this.clear(); this.table.rowManager.tableEmpty(); @@ -368,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); + }); +}); From 46aa93b0e043492b7db358c49bc21e86162180d5 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:09:26 +0100 Subject: [PATCH 3/3] fix(render): keep the rerender fallback index when the window is still valid --- src/js/core/rendering/renderers/VirtualDomVertical.js | 8 ++++---- test/unit/core/VirtualDomVertical.rerender.spec.js | 10 ++++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/js/core/rendering/renderers/VirtualDomVertical.js b/src/js/core/rendering/renderers/VirtualDomVertical.js index 303623284..22aacb942 100644 --- a/src/js/core/rendering/renderers/VirtualDomVertical.js +++ b/src/js/core/rendering/renderers/VirtualDomVertical.js @@ -103,14 +103,14 @@ export default class VirtualDomVertical extends Renderer{ //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 (or when no anchor row was found) do a - //fresh fill, which resets vDomTopPad to 0. + //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(topRow === false || windowInvalid){ + if(windowInvalid){ this._virtualRenderFill(); }else{ - this._virtualRenderFill(topRow, true, topOffset || 0); + this._virtualRenderFill((topRow === false ? newRows.length - 1 : topRow), true, topOffset || 0); } }else{ this.clear(); diff --git a/test/unit/core/VirtualDomVertical.rerender.spec.js b/test/unit/core/VirtualDomVertical.rerender.spec.js index 7b5aa1b88..0fb5d77e7 100644 --- a/test/unit/core/VirtualDomVertical.rerender.spec.js +++ b/test/unit/core/VirtualDomVertical.rerender.spec.js @@ -34,10 +34,12 @@ describe("VirtualDomVertical rerenderRows fallback index", () => { const table = await build(); const renderer = table.rowManager.renderer; - // Stale / out-of-range rendered window so the anchor scan finds no row and - // topRow stays false, exercising the fallback branch. - renderer.vDomTop = 99999; - renderer.vDomBottom = 99999; + // 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(() => {});