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 @@ + + +
+ +