From 930f30eb70a147f7d99881b04afffcdba2406794 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:49:52 +0100 Subject: [PATCH 1/6] fix(render): disable browser scroll anchoring on the table holder Chrome's scroll anchoring adjusts scrollTop when the virtual renderer inserts rows above the viewport, double-compensating against the renderer's own paddingTop/scrollTop management and causing drift on scroll-up. Set overflow-anchor:none on .tabulator-tableholder, the standard approach for JS-managed virtual scrollers. --- src/scss/tabulator.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/src/scss/tabulator.scss b/src/scss/tabulator.scss index 0eb30ecc1..e06e68851 100644 --- a/src/scss/tabulator.scss +++ b/src/scss/tabulator.scss @@ -440,6 +440,7 @@ $rangeHeaderTextHighlightBackground: #000000 !default; //header text color when width:100%; white-space: nowrap; overflow:auto; + overflow-anchor: none; -webkit-overflow-scrolling: touch; &:focus{ From dcb0e0e3a7b3ad827023d69a8bbf0afe5d730ea7 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:29:56 +0100 Subject: [PATCH 2/6] test(render): assert table holder disables scroll anchoring e2e guard that .tabulator-tableholder computes overflow-anchor:none, so the scroll-anchoring fix can't silently regress out of the built CSS. --- test/e2e/overflow-anchor.spec.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 test/e2e/overflow-anchor.spec.ts diff --git a/test/e2e/overflow-anchor.spec.ts b/test/e2e/overflow-anchor.spec.ts new file mode 100644 index 000000000..7c4623fd7 --- /dev/null +++ b/test/e2e/overflow-anchor.spec.ts @@ -0,0 +1,21 @@ +import { test, expect } from "@playwright/test"; +import { join } from "path"; + +// Regression coverage for disabling browser scroll anchoring on the table +// holder. The virtual renderer manages scrollTop/padding itself; Chrome's +// scroll anchoring double-compensates when rows are inserted above the +// viewport, causing drift on scroll-up. The fix sets overflow-anchor:none on +// .tabulator-tableholder. This guards that the rule survives in the built CSS +// and is applied by the browser. +test.describe("table holder disables browser scroll anchoring", () => { + test("computed overflow-anchor is none", async ({ page }) => { + await page.goto(`file://${join(__dirname, "scroll-jump.html")}`); + await page.waitForSelector(".tabulator-tableholder"); + + const value = await page + .locator(".tabulator-tableholder") + .evaluate((el) => getComputedStyle(el).overflowAnchor); + + expect(value).toBe("none"); + }); +}); From e3722d606763d915ac784f03050d4ec09352b9cb Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:51:41 +0100 Subject: [PATCH 3/6] wip(render): rewrite VirtualDomVertical windowing on a Fenwick coordinate model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit INCOMPLETE — do not merge. Replaces the incremental padding/tracker model with: - dual Fenwick trees for O(log n) cumHeight / O(log^2 n) findRowAt - padding recomputed from the index each render (never incrementally clamped) - diff-based windowing (detach leavers / attach enterers only) - row-count overscan, batched attach phases, durable data-keyed height cache - previous implementation retained as VirtualDomVerticalLegacy behind the renderVerticalLegacy option Status: jsdom suite green (45/469); 17/21 e2e green; rendered window down to ~12 rows vs 40 (the churn target). Outstanding: variable-height scroll-up content shift (scroll-jump canaries report 328-781px) and frozen-rows layout. Measurement guard now matches the legacy one (!heightInitialized || !getHeight()) — without it rows measured while detached stayed permanently unmeasured and the coordinate space never left the estimate. --- src/js/core/RowManager.js | 6 +- src/js/core/defaults/options.js | 1 + .../rendering/renderers/VirtualDomVertical.js | 1263 +++++++++++------ .../renderers/VirtualDomVerticalLegacy.js | 642 +++++++++ 4 files changed, 1482 insertions(+), 430 deletions(-) create mode 100644 src/js/core/rendering/renderers/VirtualDomVerticalLegacy.js diff --git a/src/js/core/RowManager.js b/src/js/core/RowManager.js index 9c699dfa8..f897ec969 100644 --- a/src/js/core/RowManager.js +++ b/src/js/core/RowManager.js @@ -5,6 +5,7 @@ import Helpers from './tools/Helpers.js'; import RendererBasicVertical from './rendering/renderers/BasicVertical.js'; import RendererVirtualDomVertical from './rendering/renderers/VirtualDomVertical.js'; +import RendererVirtualDomVerticalLegacy from './rendering/renderers/VirtualDomVerticalLegacy.js'; export default class RowManager extends CoreFeature{ @@ -882,7 +883,10 @@ export default class RowManager extends CoreFeature{ var renderClass; var renderers = { - "virtual": RendererVirtualDomVertical, + //renderVerticalLegacy selects the previous virtual renderer + //implementation as a temporary escape hatch; it is scheduled for removal + //once the current one has bedded in. + "virtual": this.table.options.renderVerticalLegacy ? RendererVirtualDomVerticalLegacy : RendererVirtualDomVertical, "basic": RendererBasicVertical, }; diff --git a/src/js/core/defaults/options.js b/src/js/core/defaults/options.js index 2e3214072..78fb45455 100644 --- a/src/js/core/defaults/options.js +++ b/src/js/core/defaults/options.js @@ -39,6 +39,7 @@ export default { renderVertical:"virtual", renderHorizontal:"basic", renderVerticalBuffer:0, // set virtual DOM buffer size + renderVerticalLegacy:false, // use the previous virtual renderer implementation (temporary escape hatch) scrollToRowPosition:"top", scrollToRowIfVisible:true, diff --git a/src/js/core/rendering/renderers/VirtualDomVertical.js b/src/js/core/rendering/renderers/VirtualDomVertical.js index 361044c1d..458643b23 100644 --- a/src/js/core/rendering/renderers/VirtualDomVertical.js +++ b/src/js/core/rendering/renderers/VirtualDomVertical.js @@ -1,46 +1,191 @@ import Renderer from '../Renderer.js'; -import Helpers from '../../tools/Helpers.js'; -export default class VirtualDomVertical extends Renderer{ - constructor(table){ - super(table); +//Variable-height vertical virtual renderer. +// +//Coordinate model — two Fenwick (binary indexed) trees: +// cumHeight(i) = fenwickMeasured.prefixSum(i) +// + fenwickUnmeasuredCount.prefixSum(i) * estimateHeight +//giving O(log n) cumHeight/totalHeight and O(log^2 n) findRowAt. estimateHeight is +//calibrated once from the first painted window then frozen, and additionally +//locked for the duration of each render call, so the coordinate space never +//shifts underneath the user. +// +//Padding is RECOMPUTED from the index on every render (paddingTop = +//cumHeight(top)) rather than adjusted incrementally. That is deliberate: the +//incremental add/subtract model it replaces accumulated error and needed a clamp +//which, when it fired mid-list, collapsed the spacer reserving space for the +//unrendered rows above and left the table unable to scroll up. +// +//Display rows are a UNION of Row objects (type "row"/"calc") and GroupRows' +//Group objects. Group HAS `initialized` and a no-op `deinitializeHeight()`, has +//NO `heightInitialized` (so it always re-measures on attach), has NO `data` (so +//it is never durably cached), and its `getHeight()` returns a real measured +//height. All member access below is duck-typed accordingly. + +const DEFAULT_ESTIMATE_HEIGHT = 20; + +//Overscan is counted in ROWS, not pixels: render cost is linear in rendered row +//count, so capping the count is the meaningful knob. Deliberately small. +const OVERSCAN_MIN = 4; +const OVERSCAN_MAX = 16; + +//Both loops below are hard-bounded rather than run to convergence. +const MAX_COVERAGE_ITER = 4; +const MAX_RECONCILE = 4; + +//Minimum holder-width delta (px) that counts as a real resize; ignores subpixel +//retina jitter. +const RESIZE_WIDTH_THRESHOLD_PX = 1; + +//Standard 1-indexed Fenwick tree over a Float64Array, exposing 0-indexed +//operations. tree[0] is unused. +class Fenwick{ + constructor(n){ + this.n = n; + this.tree = new Float64Array(n + 1); + } - this.verticalFillMode = "fill"; + resize(n){ + this.n = n; + this.tree = new Float64Array(n + 1); + } - this.scrollTop = 0; - this.scrollLeft = 0; + resetZero(){ + this.tree.fill(0); + } - this.vDomRowHeight = 20; //approximation of row heights for padding + //Initialize as if values[i] = value for all i, using the identity + //tree[i] = lowbit(i) * value for constant arrays — O(n), no n log n cost. + bulkInitConstant(value){ + this.tree[0] = 0; + for(let i = 1; i <= this.n; i++){ + this.tree[i] = (i & -i) * value; + } + } - this.vDomTop = 0; //hold position for first rendered row in the virtual DOM - this.vDomBottom = 0; //hold position for last rendered row in the virtual DOM + update(i0, delta){ + if(delta === 0){ + return; + } + + let i = i0 + 1; - this.vDomScrollPosTop = 0; //last scroll position of the vDom top; - this.vDomScrollPosBottom = 0; //last scroll position of the vDom bottom; + while(i <= this.n){ + this.tree[i] += delta; + i += i & -i; + } + } - this.vDomTopPad = 0; //hold value of padding for top of virtual DOM - this.vDomBottomPad = 0; //hold value of padding for bottom of virtual DOM + //Sum of values[0..count). count clamped to [0, n]. + prefixSum(count){ + let i = count; - this.vDomMaxRenderChain = 90; //the maximum number of dom elements that can be rendered in 1 go + if(i > this.n){ + i = this.n; + }else if(i <= 0){ + return 0; + } - this.vDomWindowBuffer = 0; //window row buffer before removing elements, to smooth scrolling + let s = 0; - this.vDomWindowMinTotalRows = 20; //minimum number of rows to be generated in virtual dom (prevent buffering issues on tables with tall rows) - this.vDomWindowMinMarginRows = 5; //minimum number of rows to be generated in virtual dom margin + while(i > 0){ + s += this.tree[i]; + i -= i & -i; + } - this.vDomTopNewRows = []; //rows to normalize after appending to optimize render speed - this.vDomBottomNewRows = []; //rows to normalize after appending to optimize render speed + return s; + } +} + +export default class VirtualDomVertical extends Renderer{ + constructor(table){ + super(table); + + this.verticalFillMode = "fill"; + + this.scrollTop = 0; + this.scrollLeft = 0; + + //Rendered window, two views. vDomTop/vDomBottom are the stable + //post-render snapshot and are read externally (SelectRange); they are + //written exactly once per render so no reader sees a torn value. + //renderedRange is the live value the attach path reads mid-coverage-loop. + this.vDomTop = 0; + this.vDomBottom = -1; //inclusive; -1 = empty + this.renderedRange = {top:0, bottom:-1}; + + //Coordinate model. Positional, rebuilt on structural change. + this.fenwickMeasured = new Fenwick(0); //sum of measured heights + this.fenwickUnmeasuredCount = new Fenwick(0); //1 per unmeasured row + this.measuredHeight = new Float64Array(0); + this.isMeasured = new Uint8Array(0); + this.rowsCountCached = 0; + + //Durable heights keyed by data OBJECT REFERENCE, so a row keeps its real + //height across sort/filter/tree toggles. Group rows have no `data` and are + //simply never cached. + this.dataHeights = new WeakMap(); + + this.estimateHeight = this.table.options.rowHeight || DEFAULT_ESTIMATE_HEIGHT; + this.measuredSum = 0; + this.measuredCount = 0; + //INVARIANT: calibrated once from the first painted window then frozen. A + //drifting mean re-prices every unmeasured row and lurches totalHeight by + //100k+ px at scale. Reset only by clearRows. + this.estimateFrozen = false; + //Per-render snapshot of estimateHeight honoured by _cumHeight/_heightOf so + //every probe within one render is self-consistent. Null outside renders. + this.lockedEstimate = null; + + //Cached holder dimensions, maintained by resize(), so the scroll path does + //not read clientHeight (which forces style+layout once anything is dirty). + this.lastClientWidth = 0; + this.lastClientHeight = 0; + + //True while a scroll-driven render runs: gates the estimate flush (a + //mid-scroll mean shift would tug against the user) and the DOM-truth clamp. + this.inScrollDrivenRender = false; + + //Above-viewport measurement compensation: _setHeight accumulates + //(real - priced) deltas for rows above the viewport top and the render + //applies the sum to scrollTop so visible content stays pinned. + this.pendingScrollAdjust = 0; + this.renderVisTop = -1; //-1 = not in a scroll-driven render + + //Every programmatic scrollTop write records its value here first so the + //echoed scroll event is recognised and swallowed instead of scheduling a + //redundant render. NaN = nothing pending. Note the base class's + //scrollToRowPosition writes scrollTop directly, so an unrecognised write is + //always tolerated (it just renders, which is harmless). + this.pendingProgrammaticScrollTop = NaN; + + //Deferred-render bookkeeping (resize only in this stage; fling deferral is + //a separate, optional follow-up). + this.rafScheduled = false; + + //Scratch buffers reused across renders to avoid per-frame garbage. + this.detachRangesScratch = []; + this.attachRangesScratch = []; } ////////////////////////////////////// ///////// Public Functions /////////// ////////////////////////////////////// + initialize(){ + //Cancel deferred work on teardown: RowManager.destroy() does not call into + //the renderer, so subscribe like the other core features do. + this.subscribe("table-destroy", this._clearDeferred.bind(this)); + + this.lastClientWidth = this.elementVertical.clientWidth; + this.lastClientHeight = this.elementVertical.clientHeight; + } + clearRows(){ var element = this.tableElement; - // element.children.detach(); - while(element.firstChild) element.removeChild(element.firstChild); + this._clearDeferred(); + this._detachAllRendered(); element.style.paddingTop = ""; element.style.paddingBottom = ""; @@ -48,57 +193,81 @@ export default class VirtualDomVertical extends Renderer{ element.style.display = ""; element.style.visibility = ""; - this.elementVertical.scrollTop = 0; this.elementVertical.scrollLeft = 0; this.scrollTop = 0; this.scrollLeft = 0; - this.vDomTop = 0; - this.vDomBottom = 0; - this.vDomTopPad = 0; - this.vDomBottomPad = 0; - this.vDomScrollPosTop = 0; - this.vDomScrollPosBottom = 0; + this._resetHeightIndex(0); + + //New dataset: previously measured heights are meaningless. + this.dataHeights = new WeakMap(); + //Recalibrate the estimate from the next dataset's first window. + this.estimateFrozen = false; + + this._setScrollTop(0); } renderRows(){ - this._virtualRenderFill(); + //Zero scrollTop only on a genuinely fresh render (post-clearRows/setData); + //re-renders routed through here keep their position. + if(this.rowsCountCached === 0){ + this._setScrollTop(0); + } + + this._renderWindow(); } rerenderRows(callback){ - var scrollTop = this.elementVertical.scrollTop; - var topRow = false; - var topOffset = false; - - var left = this.table.rowManager.scrollLeft; - - var rows = this.rows(); - - for(var i = this.vDomTop; i <= this.vDomBottom; i++){ + var left = this.table.rowManager.scrollLeft, + scrollTop = this.elementVertical.scrollTop, + anchorIndex = false, + anchorOffset = 0, + rows = this.rows(); + //Find the rendered row nearest the current scroll position BEFORE anything + //is detached, so the window can be restored around it afterwards. This + //mirrors what the previous implementation did and is why no separate + //anchoring module is needed. + for(let i = this.vDomTop; i <= this.vDomBottom; i++){ if(rows[i]){ - var diff = scrollTop - rows[i].getElement().offsetTop; + let diff = scrollTop - rows[i].getElement().offsetTop; - if(topOffset === false || Math.abs(diff) < topOffset){ - topOffset = diff; - topRow = i; + if(anchorIndex === false || Math.abs(diff) < Math.abs(anchorOffset)){ + anchorOffset = diff; + anchorIndex = i; }else{ break; } } } - rows.forEach((row) => { - row.deinitializeHeight(); - }); + this._detachAllRendered(); if(callback){ callback(); } - if(this.rows().length){ - this._virtualRenderFill((topRow === false ? this.rows.length - 1 : topRow), true, topOffset || 0); + var rowsAfter = this.rows(); + + //Rebuild the positional index for the new order, seeded from the durable + //cache so previously measured rows keep their real height. + this._rebuildIndexFromCache(rowsAfter); + + //Rendered rows re-measure on attach (confirming the seed); off-screen rows + //keep the seeded value until they enter the window. + for(let row of rowsAfter){ + if(row.deinitializeHeight){ + row.deinitializeHeight(); + } + } + + if(rowsAfter.length){ + if(anchorIndex === false || anchorIndex >= rowsAfter.length){ + this._renderWindow(); + }else{ + this._anchorRowAt(anchorIndex, rowsAfter[anchorIndex], anchorOffset); + } }else{ this.clear(); this.table.rowManager.tableEmpty(); @@ -111,532 +280,768 @@ export default class VirtualDomVertical extends Renderer{ this.table.rowManager.scrollHorizontal(left); } + //`top`/`dir` are ignored: the live scrollTop is authoritative and the window is + //derived from it, so no direction bookkeeping is needed. scrollRows(top, dir){ - var topDiff = top - this.vDomScrollPosTop; - var bottomDiff = top - this.vDomScrollPosBottom; - var margin = this.vDomWindowBuffer * 2; - var rows = this.rows(); - - this.scrollTop = top; - - if(-topDiff > margin || bottomDiff > margin){ - //if big scroll redraw table; - var left = this.table.rowManager.scrollLeft; - this._virtualRenderFill(Math.floor((this.elementVertical.scrollTop / this.elementVertical.scrollHeight) * rows.length)); - this.scrollColumns(left); - }else{ + var scrollTop = this.elementVertical.scrollTop; - if(dir){ - //scrolling up - if(topDiff < 0){ - this._addTopRow(rows, -topDiff); - } + //Echo suppression: a scroll event matching a value we just wrote is the + //browser echoing our own write; the caller already rendered, so re-rendering + //here would visibly shift the window. +/-1px because browsers round + //fractional scrollTop writes. One-shot. + if(Math.abs(scrollTop - this.pendingProgrammaticScrollTop) <= 1){ + this.pendingProgrammaticScrollTop = NaN; + return; + } - if(bottomDiff < 0){ - //hide bottom row if needed - if(this.vDomScrollHeight - this.scrollTop > this.vDomWindowBuffer){ - this._removeBottomRow(rows, -bottomDiff); - }else{ - this.vDomScrollPosBottom = this.scrollTop; - } - } - }else{ + this.pendingProgrammaticScrollTop = NaN; + this.scrollTop = scrollTop; - if(bottomDiff >= 0){ - this._addBottomRow(rows, bottomDiff); - } + this.inScrollDrivenRender = true; - //scrolling down - if(topDiff >= 0){ - //hide top row if needed - if(this.scrollTop > this.vDomWindowBuffer){ - this._removeTopRow(rows, topDiff); - }else{ - this.vDomScrollPosTop = this.scrollTop; - } - } - } + try{ + this._renderWindow(); + }finally{ + this.inScrollDrivenRender = false; } } + //DO NOT RERENDER SYNCHRONOUSLY HERE — RowManager calls this from inside + //adjustTableSize(). resize(){ - this.vDomWindowBuffer = this.table.options.renderVerticalBuffer || this.elementVertical.clientHeight; - } + var holder = this.elementVertical, + cw = holder.clientWidth, + ch = holder.clientHeight, + widthChanged = Math.abs(cw - this.lastClientWidth) > RESIZE_WIDTH_THRESHOLD_PX, + heightChanged = Math.abs(ch - this.lastClientHeight) > RESIZE_WIDTH_THRESHOLD_PX; + + //A width change re-wraps text, so every measured height is stale. + if(this.lastClientWidth !== 0 && widthChanged){ + this._invalidateMeasuredHeights(); + } - scrollToRowNearestTop(row){ - var rowIndex = this.rows().indexOf(row); + this.lastClientWidth = cw; + this.lastClientHeight = ch; + + if(!widthChanged && !heightChanged){ + return; + } - return !(Math.abs(this.vDomTop - rowIndex) > Math.abs(this.vDomBottom - rowIndex)); + //A grown viewport can expose padding past the overscan, so re-render — but + //deferred, never synchronously (see the contract note above). + this._scheduleRender(); } scrollToRow(row){ var index = this.rows().indexOf(row); if(index > -1){ - this._virtualRenderFill(index, true); + this._anchorRowAt(index, row, 0); } } + scrollToRowNearestTop(row){ + var index = this.rows().indexOf(row); + + return Math.abs(this.vDomTop - index) <= Math.abs(this.vDomBottom - index); + } + visibleRows(includingBuffer){ - var topEdge = this.elementVertical.scrollTop, - bottomEdge = this.elementVertical.clientHeight + topEdge, - topFound = false, - topRow = 0, - bottomRow = 0, - rows = this.rows(); + var rows = this.rows(); + + if(this.vDomBottom < this.vDomTop){ + return []; + } if(includingBuffer){ - topRow = this.vDomTop; - bottomRow = this.vDomBottom; - }else{ - for(var i = this.vDomTop; i <= this.vDomBottom; i++){ - if(rows[i]){ - if(!topFound){ - if((topEdge - rows[i].getElement().offsetTop) >= 0){ - topRow = i; - }else{ - topFound = true; - - if(bottomEdge - rows[i].getElement().offsetTop >= 0){ - bottomRow = i; - }else{ - break; - } - } - }else{ - if(bottomEdge - rows[i].getElement().offsetTop >= 0){ - bottomRow = i; - }else{ - break; - } - } - } + return rows.slice(this.vDomTop, this.vDomBottom + 1); + } + + var top = this.elementVertical.scrollTop, + bottom = top + (this.lastClientHeight || this.elementVertical.clientHeight), + result = [], + //One Fenwick seed then exact O(1) accumulation per row, since + //rowTop + heightOf(i) === cumHeight(i + 1) by construction. + rowTop = this._cumHeight(this.vDomTop); + + for(let i = this.vDomTop; i <= this.vDomBottom; i++){ + let rowBottom = rowTop + this._heightOf(i); + + if(rowBottom > top && rowTop < bottom && rows[i]){ + result.push(rows[i]); } + + rowTop = rowBottom; } - return rows.slice(topRow, bottomRow + 1); + return result; } ////////////////////////////////////// //////// Internal Rendering ////////// ////////////////////////////////////// - //full virtual render - _virtualRenderFill(position, forceMove, offset) { - var element = this.tableElement, + _renderWindow(){ + var rows = this.rows(); + + if(this.rowsCountCached !== rows.length){ + this._resetHeightIndex(rows.length); + } + + //Lock the estimate for this call; try/finally so a throw still unlocks. + this.lockedEstimate = this.estimateHeight; + + try{ + this._renderWindowLocked(rows); + }finally{ + this.lockedEstimate = null; + } + } + + _renderWindowLocked(rows){ + var element = this.tableElement, holder = this.elementVertical, - topPad = 0, - rowsHeight = 0, - rowHeight = 0, - heightOccupied = 0, - topPadHeight = 0, - i = 0, - rows = this.rows(), - rowsCount = rows.length, - index = 0, - row, - rowFragment, - renderedRows = [], - totalRowsRendered = 0, - rowsToRender = 0, - fixedHeight = this.table.rowManager.fixedHeight, - containerHeight = this.elementVertical.clientHeight, - avgRowHeight = this.table.options.rowHeight, - resized = true; - - position = position || 0; - - offset = offset || 0; - - if(!position){ - this.clear(); - }else { - while(element.firstChild) element.removeChild(element.firstChild); + //Cached dimension rather than a live read: this runs on every scroll frame + //and clientHeight forces style+layout when anything dirtied it earlier. + clientHeight = this.lastClientHeight > 0 ? this.lastClientHeight : holder.clientHeight; + + if(!rows.length){ + this._detachAllRendered(); + element.style.paddingTop = "0px"; + element.style.paddingBottom = "0px"; + //Dispatch even when empty: GroupRows relies on this to fix minWidth when + //no data rows are visible. + this.dispatch("render-virtual-fill"); + return; + } - //check if position is too close to bottom of table - heightOccupied = (rowsCount - position + 1) * this.vDomRowHeight; + var scrollTop = holder.scrollTop, + lastIdx = rows.length - 1; + + //Pre-render clamp: if the document shrank (sort/filter) pull scrollTop into + //range so the findRowAt math is valid, and write it to the DOM rather than + //just locally. Structural renders trust the model; scroll-driven renders + //trust the DOM (clamping to an undershooting estimate would bounce the user + //off the bottom). Skipped on scroll frames far from the end, where it + //provably cannot fire — that saves a scrollHeight read per frame. + if(!(this.inScrollDrivenRender && scrollTop + (2 * clientHeight) <= this._totalHeight())){ + let maxScroll = this.inScrollDrivenRender + ? Math.max(0, holder.scrollHeight - clientHeight) + : Math.max(0, this._totalHeight() - clientHeight); + + if(scrollTop > maxScroll){ + this._setScrollTop(maxScroll); + scrollTop = maxScroll; + } + } - if(heightOccupied < containerHeight){ - position -= Math.ceil((containerHeight - heightOccupied) / this.vDomRowHeight); - if(position < 0){ - position = 0; + //Row-domain window selection: find the visible range, then expand by + //overscan on each side. + var overscanRows = this._resolveOverscanRows(clientHeight), + visTop = this._findRowAt(scrollTop), + visBottom = this._findRowAt(scrollTop + clientHeight), + newTop = Math.max(0, visTop - overscanRows), + newBottom = Math.min(lastIdx, visBottom + overscanRows); + + //Arm the above-viewport measurement accumulator. Only scroll-driven renders + //compensate; structural renders are anchor-corrected instead. + this.pendingScrollAdjust = 0; + this.renderVisTop = this.inScrollDrivenRender ? visTop : -1; + + var windowFilled = this._diffRender(rows, newTop, newBottom); + + //Coverage iteration: if the locked estimate over-counted heights the + //rendered window can stop short of a viewport edge (blank padding shows). + //Re-check both edges against the just-measured rows and extend. Bounded; + //converges in 1-2 passes. + var coverageIter = 0; + + while(coverageIter++ < MAX_COVERAGE_ITER){ + let extended = false, + viewportBottomY = scrollTop + clientHeight; + + if(newBottom < lastIdx && this._cumHeight(newBottom + 1) < viewportBottomY){ + let desired = Math.min(lastIdx, this._findRowAt(viewportBottomY) + overscanRows); + + if(desired > newBottom){ + this._attachRanges(rows, [[newBottom + 1, desired]], newTop); + newBottom = desired; + this.renderedRange.top = newTop; + this.renderedRange.bottom = newBottom; + extended = true; } } - //calculate initial pad - topPad = Math.min(Math.max(Math.floor(this.vDomWindowBuffer / this.vDomRowHeight), this.vDomWindowMinMarginRows), position); - position -= topPad; - } + if(newTop > 0 && this._cumHeight(newTop) > scrollTop){ + let desired = Math.max(0, this._findRowAt(scrollTop) - overscanRows); + + if(desired < newTop){ + //The OLD renderedRange.top must still be in place here: + //_attachRanges uses it to route this range to insertBefore. + this._attachRanges(rows, [[desired, newTop - 1]], desired); + newTop = desired; + this.renderedRange.top = newTop; + this.renderedRange.bottom = newBottom; + extended = true; + } + } - if(rowsCount && Helpers.elVisible(this.elementVertical)){ - this.vDomTop = position; - this.vDomBottom = position -1; + if(!extended){ + break; + } + } - if(fixedHeight || this.table.options.maxHeight) { - if(avgRowHeight) { - rowsToRender = (containerHeight / avgRowHeight) + (this.vDomWindowBuffer / avgRowHeight); + //Padding, recomputed from the index (never incrementally adjusted). + var paddingTop = this._cumHeight(newTop), + //Forced to 0 at the last row: float drift in the subtraction would show as + //a hairline gap at the very bottom. + paddingBottom = newBottom === lastIdx ? 0 : Math.max(0, this._totalHeight() - this._cumHeight(newBottom + 1)); + + element.style.paddingTop = paddingTop + "px"; + element.style.paddingBottom = paddingBottom + "px"; + + this.vDomTop = newTop; + this.vDomBottom = newBottom; + this.renderedRange.top = newTop; + this.renderedRange.bottom = newBottom; + + //Absorb above-viewport measurement deltas into scrollTop so visible content + //stays pinned. The scrollHeight read must observe the paddings just written + //and is only paid when deltas actually occurred. + if(this.renderVisTop >= 0){ + if(this.pendingScrollAdjust !== 0){ + let domMax = Math.max(0, holder.scrollHeight - clientHeight), + corrected = Math.max(0, Math.min(scrollTop + this.pendingScrollAdjust, domMax)); + + if(Math.abs(corrected - holder.scrollTop) > 0.5){ + this._setScrollTop(corrected); } - rowsToRender = Math.max(this.vDomWindowMinTotalRows, Math.ceil(rowsToRender)); - } - else { - rowsToRender = rowsCount; } - while(((rowsToRender == rowsCount || rowsHeight <= containerHeight + this.vDomWindowBuffer) || totalRowsRendered < this.vDomWindowMinTotalRows) && this.vDomBottom < rowsCount -1) { - renderedRows = []; - rowFragment = document.createDocumentFragment(); + this.pendingScrollAdjust = 0; + this.renderVisTop = -1; + } + + //Flush the estimate only on structural renders: a mid-scroll mean shift + //would either jump or tug against the user. + if(!this.inScrollDrivenRender){ + this._flushEstimateUpdate(); + } - i = 0; + //Fired after every fill (structural render, or a scroll that replaced the + //whole window) but not after incremental scroll ticks, matching the previous + //implementation's contract. + if(!this.inScrollDrivenRender || windowFilled){ + this.dispatch("render-virtual-fill"); + } + } - while ((i < rowsToRender) && this.vDomBottom < rowsCount -1) { - index = this.vDomBottom + 1, - row = rows[index]; + //Reconcile the rendered range to [newTop, newBottom]: detach rows that left, + //attach rows that entered. Returns true when the window was replaced wholesale + //(the equivalent of the old full fill), which gates the render-virtual-fill + //dispatch on scroll renders. + _diffRender(rows, newTop, newBottom){ + var oldTop = this.renderedRange.top, + oldBottom = this.renderedRange.bottom, + oldEmpty = oldBottom < oldTop, + newEmpty = newBottom < newTop, + wasFill = false, + detachRanges = this.detachRangesScratch, + attachRanges = this.attachRangesScratch; + + if(oldEmpty && newEmpty){ + return false; + } - this.styleRow(row, index); + detachRanges.length = 0; + attachRanges.length = 0; + + if(oldEmpty){ + wasFill = true; + attachRanges.push([newTop, newBottom]); + }else if(newEmpty){ + detachRanges.push([oldTop, oldBottom]); + }else if(newBottom < oldTop || newTop > oldBottom){ + wasFill = true; + detachRanges.push([oldTop, oldBottom]); + attachRanges.push([newTop, newBottom]); + }else{ + if(newTop > oldTop){ + detachRanges.push([oldTop, newTop - 1]); + }else if(newTop < oldTop){ + attachRanges.push([newTop, oldTop - 1]); + } - row.initialize(false, true); - if(!row.heightInitialized && !this.table.options.rowHeight){ - row.clearCellHeight(); - } + if(newBottom < oldBottom){ + detachRanges.push([newBottom + 1, oldBottom]); + }else if(newBottom > oldBottom){ + attachRanges.push([oldBottom + 1, newBottom]); + } + } - rowFragment.appendChild(row.getElement()); - renderedRows.push(row); - this.vDomBottom ++; - i++; - } + for(let range of detachRanges){ + for(let i = range[0]; i <= range[1]; i++){ + let row = rows[i], + el = row ? row.getElement() : null; - if(!renderedRows.length){ - break; + if(el && el.parentNode){ + el.parentNode.removeChild(el); } + } + } - element.appendChild(rowFragment); + this.renderedRange.top = newTop; + this.renderedRange.bottom = newBottom; - // NOTE: The next 4 loops are separate on purpose - // This is to batch up the dom writes and reads which drastically improves performance + if(attachRanges.length){ + this._attachRanges(rows, attachRanges, newTop); + } - renderedRows.forEach((row) => { - row.rendered(); - }); + return wasFill; + } - const rowsNeedingHeightInit = []; - renderedRows.forEach((row) => { - //(re)calculate the height of any row that has not been sized yet, or - //whose cached height is invalid/zero (e.g. it was first measured while - //detached), otherwise its bad height poisons the padding calculations. - if(!row.heightInitialized || !row.getHeight()) { - row.calcHeight(true); - rowsNeedingHeightInit.push(row); - } - }); + //Attach the rows in `ranges`: build cells off-DOM inside per-range fragments + //(writes only), then run the measurement phases ONCE over the union with reads + //and writes batched, so the whole call costs a single forced reflow: + // A. rendered() - per-cell callbacks, before measurement + // B. clearCellHeight() - writes + // C. calcHeight(true) - reads offsetHeight, THE layout flush + // D. setCellHeight() - writes + // E. getHeight() - cached from C, feeds the height index + _attachRanges(rows, ranges, newTop){ + var element = this.tableElement, + attached = []; + + for(let range of ranges){ + let fragment = document.createDocumentFragment(), + rangeStart = attached.length; + + for(let i = range[0]; i <= range[1]; i++){ + let row = rows[i]; + + if(!row){ + continue; + } - rowsNeedingHeightInit.forEach((row) => { - row.setCellHeight(); - }); + let wasUninitialized = !row.initialized; - renderedRows.forEach((row) => { - rowHeight = row.getHeight() || this.vDomRowHeight; + //A row's index, hence its even/odd class, is scroll-invariant, so + //scroll re-attaches keep the class they already have. + if(wasUninitialized || !this.inScrollDrivenRender){ + this.styleRow(row, i); + } - if(totalRowsRendered < topPad){ - topPadHeight += rowHeight; - }else { - rowsHeight += rowHeight; - } + if(wasUninitialized){ + row.initialize(false, true); //inFragment: build cells off-DOM + } - if(rowHeight > this.vDomWindowBuffer){ - this.vDomWindowBuffer = rowHeight * 2; - } - totalRowsRendered++; - }); + let el = row.getElement(); - resized = this.table.rowManager.adjustTableSize(); - containerHeight = this.elementVertical.clientHeight; - if(resized && (fixedHeight || this.table.options.maxHeight)) - { - avgRowHeight = rowsHeight / totalRowsRendered; - rowsToRender = Math.max(this.vDomWindowMinTotalRows, Math.ceil((containerHeight / avgRowHeight) + (this.vDomWindowBuffer / avgRowHeight))); + if(el.parentNode && el.parentNode !== fragment){ + el.parentNode.removeChild(el); } - } - - if(!position){ - this.vDomTopPad = 0; - //adjust row height to match average of rendered elements - this.vDomRowHeight = Math.floor((rowsHeight + topPadHeight) / totalRowsRendered); - this.vDomBottomPad = this.vDomRowHeight * (rowsCount - this.vDomBottom -1); - 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); + fragment.appendChild(el); + + //Measure when the height was never initialized OR the cached height is + //invalid/zero. The second half matters: a row first measured while + //detached caches a 0 outerHeight yet is still flagged + //heightInitialized, so keying on that flag alone leaves it permanently + //unmeasured and the coordinate space stuck on estimates. + // + //"Settled" must be captured HERE: phase D flips heightInitialized to + //true, so reading it in phase E would also match rows that were just + //re-measured and still need their index refresh. Group rows have no + //heightInitialized and so are never settled — they always re-measure. + let hasValidHeight = row.getHeight() > 0; + + attached.push({ + row:row, + index:i, + wasUninitialized:wasUninitialized, + needsMeasure:!row.heightInitialized || !hasValidHeight, + wasSettled:row.heightInitialized === true && hasValidHeight && this.isMeasured[i] === 1, + }); } - - element.style.paddingTop = this.vDomTopPad+"px"; - element.style.paddingBottom = this.vDomBottomPad+"px"; - if(forceMove){ - this.scrollTop = this.vDomTopPad + (topPadHeight) + offset - (this.elementVertical.scrollWidth > this.elementVertical.clientWidth ? this.elementVertical.offsetHeight - containerHeight : 0); + if(attached.length === rangeStart){ + continue; } - this.scrollTop = Math.min(this.scrollTop, this.elementVertical.scrollHeight - containerHeight); + //Insertion point. renderedRange/newTop are fixed for the whole call, so + //per-range evaluation is order independent. + let insertAtTop = range[1] < newTop || (newTop <= range[0] && range[0] < this.renderedRange.top); - //adjust for horizontal scrollbar if present (and not at top of table) - if(this.elementVertical.scrollWidth > this.elementVertical.clientWidth && forceMove){ - this.scrollTop += this.elementVertical.offsetHeight - containerHeight; + if(insertAtTop && element.firstChild){ + element.insertBefore(fragment, element.firstChild); + }else{ + element.appendChild(fragment); } + } - this.vDomScrollPosTop = this.scrollTop; - this.vDomScrollPosBottom = this.scrollTop; + if(!attached.length){ + return; + } - holder.scrollTop = this.scrollTop; + //Phase A. On scroll renders, first attach only: cell DOM persists across + //detach/attach, and re-dispatching cellRendered per cell per frame was the + //single biggest live-tick cost. Structural renders fire for all rows. + for(let entry of attached){ + if(!this.inScrollDrivenRender || entry.wasUninitialized){ + entry.row.rendered(); + } + } - this.dispatch("render-virtual-fill"); + //Phases B-D, guarded by heightInitialized. Never skipped for speed: + //unmeasured rows would render at un-normalized heights AND leave the index + //out of sync with the real DOM stack. + if(!this.table.options.rowHeight){ + for(let entry of attached){ + if(entry.needsMeasure){ + entry.row.clearCellHeight(); + } + } } - } - _addTopRow(rows, fillableSpace){ - var table = this.tableElement, - addedRows = [], - paddingAdjust = 0, - index = this.vDomTop -1, - i = 0, - working = true; + for(let entry of attached){ + if(entry.needsMeasure){ + entry.row.calcHeight(true); + } + } - while(working){ - if(this.vDomTop){ - let row = rows[index], - rowHeight, initialized; + for(let entry of attached){ + if(entry.needsMeasure){ + entry.row.setCellHeight(); + } + } - if(row && i < this.vDomMaxRenderChain){ - rowHeight = row.getHeight() || this.vDomRowHeight; - initialized = row.initialized; + //Phase E: feed the height index from the value cached by phase C — no new + //offsetHeight read, no extra reflow. Settled rows are skipped outright: + //their height cannot have changed without something clearing + //heightInitialized first. + for(let entry of attached){ + if(entry.wasSettled){ + continue; + } - if(fillableSpace >= rowHeight){ + let h = entry.row.getHeight(); - this.styleRow(row, index); - table.insertBefore(row.getElement(), table.firstChild); + if(h > 0){ + this._setHeight(entry.index, h, entry.row.data); + } + } + } - if(!row.initialized || !row.heightInitialized){ - addedRows.push(row); - } + _detachAllRendered(){ + this.tableElement.replaceChildren(); - row.initialize(); + this.vDomTop = 0; + this.vDomBottom = -1; + this.renderedRange.top = 0; + this.renderedRange.bottom = -1; + } - if(!initialized){ - rowHeight = row.getElement().offsetHeight; + ////////////////////////////////////// + //////// Height bookkeeping ////////// + ////////////////////////////////////// - if(rowHeight > this.vDomWindowBuffer){ - this.vDomWindowBuffer = rowHeight * 2; - } - } + //Reset the positional index to "all rows unmeasured". Reallocates only on a + //length change. estimateHeight is untouched. + _resetHeightIndex(rowsCount){ + if(this.measuredHeight.length !== rowsCount){ + this.measuredHeight = new Float64Array(rowsCount); + this.isMeasured = new Uint8Array(rowsCount); + this.fenwickMeasured.resize(rowsCount); + this.fenwickUnmeasuredCount.resize(rowsCount); + }else{ + this.measuredHeight.fill(0); + this.isMeasured.fill(0); + this.fenwickMeasured.resetZero(); + this.fenwickUnmeasuredCount.resetZero(); + } - fillableSpace -= rowHeight; - paddingAdjust += rowHeight; + this.fenwickUnmeasuredCount.bulkInitConstant(1); - this.vDomTop--; - index--; - i++; + this.measuredSum = 0; + this.measuredCount = 0; + this.rowsCountCached = rowsCount; + } - }else{ - working = false; - } + //Reset the index for a new display order, then seed it from the durable cache + //so previously measured rows keep their real height. Uncached rows stay on the + //estimate until they enter the window. + _rebuildIndexFromCache(rows){ + this._resetHeightIndex(rows.length); - }else{ - working = false; - } + for(let i = 0; i < rows.length; i++){ + let dataKey = rows[i] ? rows[i].data : undefined; - }else{ - working = false; + if(dataKey === undefined){ + continue; } - } - - for (let row of addedRows){ - row.clearCellHeight(); - } - this._quickNormalizeRowHeight(addedRows); - - if(paddingAdjust){ - this.vDomTopPad -= paddingAdjust; - - if(this.vDomTopPad < 0){ - this.vDomTopPad = index * this.vDomRowHeight; - } + let h = this.dataHeights.get(dataKey); - if(index < 1){ - this.vDomTopPad = 0; + if(h === undefined || h <= 0){ + continue; } - table.style.paddingTop = this.vDomTopPad + "px"; - this.vDomScrollPosTop -= paddingAdjust; + //Direct first-measurement writes; the arrays were just zeroed. + this.fenwickMeasured.update(i, h); + this.fenwickUnmeasuredCount.update(i, -1); + this.isMeasured[i] = 1; + this.measuredHeight[i] = h; + this.measuredSum += h; + this.measuredCount += 1; } } - _removeTopRow(rows, fillableSpace){ - var removableRows = [], - paddingAdjust = 0, - i = 0, - working = true; + //Invalidate every measured height (the holder width changed, so text wrapping + //may differ). The next render re-measures from the DOM. + _invalidateMeasuredHeights(){ + this._resetHeightIndex(this.measuredHeight.length); + this.dataHeights = new WeakMap(); - while(working){ - let row = rows[this.vDomTop], - rowHeight; + for(let row of this.rows()){ + if(row.deinitializeHeight){ + row.deinitializeHeight(); + } + } + } - if(row && i < this.vDomMaxRenderChain){ - rowHeight = row.getHeight() || this.vDomRowHeight; + //Record a measurement: trees, running stats and the durable cache. Never + //mutates estimateHeight (see _flushEstimateUpdate). + _setHeight(i, h, dataKey){ + if(i < 0 || i >= this.measuredHeight.length || !Number.isFinite(h) || h <= 0){ + return; + } - if(fillableSpace >= rowHeight){ - this.vDomTop++; + var wasMeasured = this.isMeasured[i] === 1, + oldH = this.measuredHeight[i]; - fillableSpace -= rowHeight; - paddingAdjust += rowHeight; + if(wasMeasured && oldH === h){ + return; + } - removableRows.push(row); - i++; - }else{ - working = false; - } - }else{ - working = false; - } + //Durable cache write only on first/changed measurements; this is per-row + //hot path. Group rows pass undefined and are skipped. + if(dataKey !== undefined){ + this.dataHeights.set(dataKey, h); } - for (let row of removableRows){ - let rowEl = row.getElement(); + //Above-viewport size deltas shift everything visible, so accumulate them + //for the render to absorb into scrollTop. + if(this.renderVisTop >= 0 && i < this.renderVisTop){ + let prior = wasMeasured ? oldH : (this.lockedEstimate === null ? this.estimateHeight : this.lockedEstimate); - if(rowEl.parentNode){ - rowEl.parentNode.removeChild(rowEl); - } + this.pendingScrollAdjust += h - prior; } - if(paddingAdjust){ - this.vDomTopPad += paddingAdjust; - this.tableElement.style.paddingTop = this.vDomTopPad + "px"; - this.vDomScrollPosTop += this.vDomTop ? paddingAdjust : paddingAdjust + this.vDomWindowBuffer; + if(wasMeasured){ + this.fenwickMeasured.update(i, h - oldH); + this.measuredSum += h - oldH; + }else{ + this.fenwickMeasured.update(i, h); + this.fenwickUnmeasuredCount.update(i, -1); + this.isMeasured[i] = 1; + this.measuredSum += h; + this.measuredCount += 1; } + + this.measuredHeight[i] = h; } - _addBottomRow(rows, fillableSpace){ - var table = this.tableElement, - addedRows = [], - paddingAdjust = 0, - index = this.vDomBottom + 1, - i = 0, - working = true; + //One-shot estimate calibration; no-op once frozen. + _flushEstimateUpdate(){ + if(this.estimateFrozen || this.measuredCount === 0){ + return; + } + + this.estimateHeight = Math.max(1, this.measuredSum / this.measuredCount); + this.estimateFrozen = true; + } - while(working){ - let row = rows[index], - rowHeight, initialized; + //cumHeight(i) = sum of heights[0..i) + _cumHeight(i){ + if(i <= 0){ + return 0; + } - if(row && i < this.vDomMaxRenderChain){ - rowHeight = row.getHeight() || this.vDomRowHeight; - initialized = row.initialized; + var n = this.measuredHeight.length, + ci = i > n ? n : i, + est = this.lockedEstimate === null ? this.estimateHeight : this.lockedEstimate; - if(fillableSpace >= rowHeight){ + return this.fenwickMeasured.prefixSum(ci) + (this.fenwickUnmeasuredCount.prefixSum(ci) * est); + } - this.styleRow(row, index); - table.appendChild(row.getElement()); + _totalHeight(){ + return this._cumHeight(this.measuredHeight.length); + } - if(!row.initialized || !row.heightInitialized){ - addedRows.push(row); - } + //Index of the row at document Y — binary search over the cumHeight oracle. + _findRowAt(y){ + var n = this.measuredHeight.length; - row.initialize(); + if(n === 0 || y <= 0){ + return 0; + } - if(!initialized){ - rowHeight = row.getElement().offsetHeight; + if(y >= this._totalHeight()){ + return n - 1; + } - if(rowHeight > this.vDomWindowBuffer){ - this.vDomWindowBuffer = rowHeight * 2; - } - } + //Largest i with cumHeight(i) <= y, then i-1 is the row containing y. + var lo = 0, + hi = n; - fillableSpace -= rowHeight; - paddingAdjust += rowHeight; + while(lo < hi){ + let mid = (lo + hi) >>> 1; - this.vDomBottom++; - index++; - i++; - }else{ - working = false; - } + if(this._cumHeight(mid) <= y){ + lo = mid + 1; }else{ - working = false; + hi = mid; } } - for (let row of addedRows){ - row.clearCellHeight(); + return Math.max(0, lo - 1); + } + + //Per-row height (measured if known, else the estimate). O(1) — reads the + //shadow arrays, no tree query. By construction + //cumHeight(i) + heightOf(i) === cumHeight(i + 1). + _heightOf(i){ + if(i < 0 || i >= this.measuredHeight.length){ + return 0; } - this._quickNormalizeRowHeight(addedRows); + var est = this.lockedEstimate === null ? this.estimateHeight : this.lockedEstimate; - if(paddingAdjust){ - this.vDomBottomPad -= paddingAdjust; + return this.isMeasured[i] === 1 ? this.measuredHeight[i] : est; + } - if(this.vDomBottomPad < 0 || index == rows.length -1){ - this.vDomBottomPad = 0; - } + //Adaptive overscan for the current viewport (~quarter viewport of rows), + //clamped. An explicit renderVerticalBuffer is honoured as a pixel override, + //converted to rows. + _resolveOverscanRows(clientHeight){ + var est = Math.max(1, this.estimateHeight), + buffer = this.table.options.renderVerticalBuffer; - table.style.paddingBottom = this.vDomBottomPad + "px"; - this.vDomScrollPosBottom += paddingAdjust; + if(buffer){ + return Math.max(OVERSCAN_MIN, Math.ceil(buffer / est)); } + + return Math.max(OVERSCAN_MIN, Math.min(OVERSCAN_MAX, Math.round(clientHeight / 4 / est))); } - _removeBottomRow(rows, fillableSpace){ - var removableRows = [], - paddingAdjust = 0, - i = 0, - working = true; + ////////////////////////////////////// + //////// Scroll positioning ////////// + ////////////////////////////////////// - while(working){ - let row = rows[this.vDomBottom], - rowHeight; + //The single way to write scrollTop programmatically: records the value first so + //scrollRows can swallow the echoed event. + _setScrollTop(top){ + this.pendingProgrammaticScrollTop = top; + this.scrollTop = top; + this.elementVertical.scrollTop = top; + } - if(row && i < this.vDomMaxRenderChain){ - rowHeight = row.getHeight() || this.vDomRowHeight; + //Anchor the row at `index` at `offsetFromHolderTop` inside the holder. + //One estimate-placed render cannot reach a far row (the browser clamps + //scrollTop to the current document height), so: place via cumHeight, render, + //then snap to DOM truth once the row is in the window, else re-place. Bounded, + //and bails when the window stops moving. Synchronous, so the corrected window + //is in the DOM on the same paint — the base class's scrollToRowPosition reads + //rowEl.offsetTop straight after calling scrollToRow. + _anchorRowAt(index, row, offsetFromHolderTop){ + var holder = this.elementVertical, + clientHeight = holder.clientHeight || this.lastClientHeight, + prevTop = -2, + prevBottom = -2; + + if(this.rowsCountCached !== this.rows().length){ + this._resetHeightIndex(this.rows().length); + } - if(fillableSpace >= rowHeight){ - this.vDomBottom --; + for(let i = 0; i < MAX_RECONCILE; i++){ + this.lockedEstimate = this.estimateHeight; - fillableSpace -= rowHeight; - paddingAdjust += rowHeight; + let placed; - removableRows.push(row); - i++; - }else{ - working = false; - } - }else{ - working = false; + try{ + let target = this._cumHeight(index) - offsetFromHolderTop, + maxScroll = Math.max(0, this._totalHeight() - clientHeight); + + placed = Math.max(0, Math.min(target, maxScroll)); + }finally{ + this.lockedEstimate = null; } - } - for (let row of removableRows){ - let rowEl = row.getElement(); + this._setScrollTop(placed); + this._renderWindow(); - if(rowEl.parentNode){ - rowEl.parentNode.removeChild(rowEl); - } - } + let el = row.getElement(); - if(paddingAdjust){ - this.vDomBottomPad += paddingAdjust; + if(el && el.parentNode){ + //The anchor is in the window: snap to DOM truth, converged. + let desired = el.offsetTop - offsetFromHolderTop, + maxScrollPost = Math.max(0, holder.scrollHeight - clientHeight), + corrected = Math.max(0, Math.min(desired, maxScrollPost)); - if(this.vDomBottomPad < 0){ - this.vDomBottomPad = 0; + if(Math.abs(corrected - holder.scrollTop) > 0.5){ + this._setScrollTop(corrected); + this._renderWindow(); + } + + break; } - this.tableElement.style.paddingBottom = this.vDomBottomPad + "px"; - this.vDomScrollPosBottom -= paddingAdjust; + //Still outside the window. If it did not move since the last iteration + //the coordinate space is stable and re-placing would land identically. + if(this.renderedRange.top === prevTop && this.renderedRange.bottom === prevBottom){ + break; + } + + prevTop = this.renderedRange.top; + prevBottom = this.renderedRange.bottom; } } - _quickNormalizeRowHeight(rows){ - for(let row of rows){ - row.calcHeight(); + ////////////////////////////////////// + //////// Deferred work /////////////// + ////////////////////////////////////// + + //Book a render on the next frame, coalescing multiple requests. Falls back to + //a synchronous call where requestAnimationFrame is unavailable (jsdom), so no + //code path can throw a ReferenceError. + _scheduleRender(){ + if(this.rafScheduled){ + return; } - for(let row of rows){ - row.setCellHeight(); + if(typeof requestAnimationFrame !== "function"){ + this._renderWindow(); + return; } + + this.rafScheduled = true; + + this.rafHandle = requestAnimationFrame(() => { + this.rafScheduled = false; + this.rafHandle = null; + + if(!this.table.destroyed){ + this._renderWindow(); + } + }); + } + + _clearDeferred(){ + if(this.rafHandle && typeof cancelAnimationFrame === "function"){ + cancelAnimationFrame(this.rafHandle); + } + + this.rafHandle = null; + this.rafScheduled = false; } } diff --git a/src/js/core/rendering/renderers/VirtualDomVerticalLegacy.js b/src/js/core/rendering/renderers/VirtualDomVerticalLegacy.js new file mode 100644 index 000000000..a880a3930 --- /dev/null +++ b/src/js/core/rendering/renderers/VirtualDomVerticalLegacy.js @@ -0,0 +1,642 @@ +import Renderer from '../Renderer.js'; +import Helpers from '../../tools/Helpers.js'; + +export default class VirtualDomVerticalLegacy extends Renderer{ + constructor(table){ + super(table); + + this.verticalFillMode = "fill"; + + this.scrollTop = 0; + this.scrollLeft = 0; + + this.vDomRowHeight = 20; //approximation of row heights for padding + + this.vDomTop = 0; //hold position for first rendered row in the virtual DOM + this.vDomBottom = 0; //hold position for last rendered row in the virtual DOM + + this.vDomScrollPosTop = 0; //last scroll position of the vDom top; + this.vDomScrollPosBottom = 0; //last scroll position of the vDom bottom; + + this.vDomTopPad = 0; //hold value of padding for top of virtual DOM + this.vDomBottomPad = 0; //hold value of padding for bottom of virtual DOM + + this.vDomMaxRenderChain = 90; //the maximum number of dom elements that can be rendered in 1 go + + this.vDomWindowBuffer = 0; //window row buffer before removing elements, to smooth scrolling + + this.vDomWindowMinTotalRows = 20; //minimum number of rows to be generated in virtual dom (prevent buffering issues on tables with tall rows) + this.vDomWindowMinMarginRows = 5; //minimum number of rows to be generated in virtual dom margin + + this.vDomTopNewRows = []; //rows to normalize after appending to optimize render speed + this.vDomBottomNewRows = []; //rows to normalize after appending to optimize render speed + } + + ////////////////////////////////////// + ///////// Public Functions /////////// + ////////////////////////////////////// + + clearRows(){ + var element = this.tableElement; + + // element.children.detach(); + while(element.firstChild) element.removeChild(element.firstChild); + + element.style.paddingTop = ""; + element.style.paddingBottom = ""; + element.style.minHeight = ""; + element.style.display = ""; + element.style.visibility = ""; + + this.elementVertical.scrollTop = 0; + this.elementVertical.scrollLeft = 0; + + this.scrollTop = 0; + this.scrollLeft = 0; + + this.vDomTop = 0; + this.vDomBottom = 0; + this.vDomTopPad = 0; + this.vDomBottomPad = 0; + this.vDomScrollPosTop = 0; + this.vDomScrollPosBottom = 0; + } + + renderRows(){ + this._virtualRenderFill(); + } + + rerenderRows(callback){ + var scrollTop = this.elementVertical.scrollTop; + var topRow = false; + var topOffset = false; + + var left = this.table.rowManager.scrollLeft; + + var rows = this.rows(); + + for(var i = this.vDomTop; i <= this.vDomBottom; i++){ + + if(rows[i]){ + var diff = scrollTop - rows[i].getElement().offsetTop; + + if(topOffset === false || Math.abs(diff) < topOffset){ + topOffset = diff; + topRow = i; + }else{ + break; + } + } + } + + rows.forEach((row) => { + row.deinitializeHeight(); + }); + + if(callback){ + callback(); + } + + if(this.rows().length){ + this._virtualRenderFill((topRow === false ? this.rows.length - 1 : topRow), true, topOffset || 0); + }else{ + this.clear(); + this.table.rowManager.tableEmpty(); + } + + this.scrollColumns(left); + } + + scrollColumns(left){ + this.table.rowManager.scrollHorizontal(left); + } + + scrollRows(top, dir){ + var topDiff = top - this.vDomScrollPosTop; + var bottomDiff = top - this.vDomScrollPosBottom; + var margin = this.vDomWindowBuffer * 2; + var rows = this.rows(); + + this.scrollTop = top; + + if(-topDiff > margin || bottomDiff > margin){ + //if big scroll redraw table; + var left = this.table.rowManager.scrollLeft; + this._virtualRenderFill(Math.floor((this.elementVertical.scrollTop / this.elementVertical.scrollHeight) * rows.length)); + this.scrollColumns(left); + }else{ + + if(dir){ + //scrolling up + if(topDiff < 0){ + this._addTopRow(rows, -topDiff); + } + + if(bottomDiff < 0){ + //hide bottom row if needed + if(this.vDomScrollHeight - this.scrollTop > this.vDomWindowBuffer){ + this._removeBottomRow(rows, -bottomDiff); + }else{ + this.vDomScrollPosBottom = this.scrollTop; + } + } + }else{ + + if(bottomDiff >= 0){ + this._addBottomRow(rows, bottomDiff); + } + + //scrolling down + if(topDiff >= 0){ + //hide top row if needed + if(this.scrollTop > this.vDomWindowBuffer){ + this._removeTopRow(rows, topDiff); + }else{ + this.vDomScrollPosTop = this.scrollTop; + } + } + } + } + } + + resize(){ + this.vDomWindowBuffer = this.table.options.renderVerticalBuffer || this.elementVertical.clientHeight; + } + + scrollToRowNearestTop(row){ + var rowIndex = this.rows().indexOf(row); + + return !(Math.abs(this.vDomTop - rowIndex) > Math.abs(this.vDomBottom - rowIndex)); + } + + scrollToRow(row){ + var index = this.rows().indexOf(row); + + if(index > -1){ + this._virtualRenderFill(index, true); + } + } + + visibleRows(includingBuffer){ + var topEdge = this.elementVertical.scrollTop, + bottomEdge = this.elementVertical.clientHeight + topEdge, + topFound = false, + topRow = 0, + bottomRow = 0, + rows = this.rows(); + + if(includingBuffer){ + topRow = this.vDomTop; + bottomRow = this.vDomBottom; + }else{ + for(var i = this.vDomTop; i <= this.vDomBottom; i++){ + if(rows[i]){ + if(!topFound){ + if((topEdge - rows[i].getElement().offsetTop) >= 0){ + topRow = i; + }else{ + topFound = true; + + if(bottomEdge - rows[i].getElement().offsetTop >= 0){ + bottomRow = i; + }else{ + break; + } + } + }else{ + if(bottomEdge - rows[i].getElement().offsetTop >= 0){ + bottomRow = i; + }else{ + break; + } + } + } + } + } + + return rows.slice(topRow, bottomRow + 1); + } + + ////////////////////////////////////// + //////// Internal Rendering ////////// + ////////////////////////////////////// + + //full virtual render + _virtualRenderFill(position, forceMove, offset) { + var element = this.tableElement, + holder = this.elementVertical, + topPad = 0, + rowsHeight = 0, + rowHeight = 0, + heightOccupied = 0, + topPadHeight = 0, + i = 0, + rows = this.rows(), + rowsCount = rows.length, + index = 0, + row, + rowFragment, + renderedRows = [], + totalRowsRendered = 0, + rowsToRender = 0, + fixedHeight = this.table.rowManager.fixedHeight, + containerHeight = this.elementVertical.clientHeight, + avgRowHeight = this.table.options.rowHeight, + resized = true; + + position = position || 0; + + offset = offset || 0; + + if(!position){ + this.clear(); + }else { + while(element.firstChild) element.removeChild(element.firstChild); + + //check if position is too close to bottom of table + heightOccupied = (rowsCount - position + 1) * this.vDomRowHeight; + + if(heightOccupied < containerHeight){ + position -= Math.ceil((containerHeight - heightOccupied) / this.vDomRowHeight); + if(position < 0){ + position = 0; + } + } + + //calculate initial pad + topPad = Math.min(Math.max(Math.floor(this.vDomWindowBuffer / this.vDomRowHeight), this.vDomWindowMinMarginRows), position); + position -= topPad; + } + + if(rowsCount && Helpers.elVisible(this.elementVertical)){ + this.vDomTop = position; + this.vDomBottom = position -1; + + if(fixedHeight || this.table.options.maxHeight) { + if(avgRowHeight) { + rowsToRender = (containerHeight / avgRowHeight) + (this.vDomWindowBuffer / avgRowHeight); + } + rowsToRender = Math.max(this.vDomWindowMinTotalRows, Math.ceil(rowsToRender)); + } + else { + rowsToRender = rowsCount; + } + + while(((rowsToRender == rowsCount || rowsHeight <= containerHeight + this.vDomWindowBuffer) || totalRowsRendered < this.vDomWindowMinTotalRows) && this.vDomBottom < rowsCount -1) { + renderedRows = []; + rowFragment = document.createDocumentFragment(); + + i = 0; + + while ((i < rowsToRender) && this.vDomBottom < rowsCount -1) { + index = this.vDomBottom + 1, + row = rows[index]; + + this.styleRow(row, index); + + row.initialize(false, true); + if(!row.heightInitialized && !this.table.options.rowHeight){ + row.clearCellHeight(); + } + + rowFragment.appendChild(row.getElement()); + renderedRows.push(row); + this.vDomBottom ++; + i++; + } + + if(!renderedRows.length){ + break; + } + + element.appendChild(rowFragment); + + // NOTE: The next 4 loops are separate on purpose + // This is to batch up the dom writes and reads which drastically improves performance + + renderedRows.forEach((row) => { + row.rendered(); + }); + + const rowsNeedingHeightInit = []; + renderedRows.forEach((row) => { + //(re)calculate the height of any row that has not been sized yet, or + //whose cached height is invalid/zero (e.g. it was first measured while + //detached), otherwise its bad height poisons the padding calculations. + if(!row.heightInitialized || !row.getHeight()) { + row.calcHeight(true); + rowsNeedingHeightInit.push(row); + } + }); + + rowsNeedingHeightInit.forEach((row) => { + row.setCellHeight(); + }); + + renderedRows.forEach((row) => { + rowHeight = row.getHeight() || this.vDomRowHeight; + + if(totalRowsRendered < topPad){ + topPadHeight += rowHeight; + }else { + rowsHeight += rowHeight; + } + + if(rowHeight > this.vDomWindowBuffer){ + this.vDomWindowBuffer = rowHeight * 2; + } + totalRowsRendered++; + }); + + resized = this.table.rowManager.adjustTableSize(); + containerHeight = this.elementVertical.clientHeight; + if(resized && (fixedHeight || this.table.options.maxHeight)) + { + avgRowHeight = rowsHeight / totalRowsRendered; + rowsToRender = Math.max(this.vDomWindowMinTotalRows, Math.ceil((containerHeight / avgRowHeight) + (this.vDomWindowBuffer / avgRowHeight))); + } + } + + if(!position){ + this.vDomTopPad = 0; + //adjust row height to match average of rendered elements + this.vDomRowHeight = Math.floor((rowsHeight + topPadHeight) / totalRowsRendered); + this.vDomBottomPad = this.vDomRowHeight * (rowsCount - this.vDomBottom -1); + + 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); + } + + element.style.paddingTop = this.vDomTopPad+"px"; + element.style.paddingBottom = this.vDomBottomPad+"px"; + + if(forceMove){ + this.scrollTop = this.vDomTopPad + (topPadHeight) + offset - (this.elementVertical.scrollWidth > this.elementVertical.clientWidth ? this.elementVertical.offsetHeight - containerHeight : 0); + } + + this.scrollTop = Math.min(this.scrollTop, this.elementVertical.scrollHeight - containerHeight); + + //adjust for horizontal scrollbar if present (and not at top of table) + if(this.elementVertical.scrollWidth > this.elementVertical.clientWidth && forceMove){ + this.scrollTop += this.elementVertical.offsetHeight - containerHeight; + } + + this.vDomScrollPosTop = this.scrollTop; + this.vDomScrollPosBottom = this.scrollTop; + + holder.scrollTop = this.scrollTop; + + this.dispatch("render-virtual-fill"); + } + } + + _addTopRow(rows, fillableSpace){ + var table = this.tableElement, + addedRows = [], + paddingAdjust = 0, + index = this.vDomTop -1, + i = 0, + working = true; + + while(working){ + if(this.vDomTop){ + let row = rows[index], + rowHeight, initialized; + + if(row && i < this.vDomMaxRenderChain){ + rowHeight = row.getHeight() || this.vDomRowHeight; + initialized = row.initialized; + + if(fillableSpace >= rowHeight){ + + this.styleRow(row, index); + table.insertBefore(row.getElement(), table.firstChild); + + if(!row.initialized || !row.heightInitialized){ + addedRows.push(row); + } + + row.initialize(); + + if(!initialized){ + rowHeight = row.getElement().offsetHeight; + + if(rowHeight > this.vDomWindowBuffer){ + this.vDomWindowBuffer = rowHeight * 2; + } + } + + fillableSpace -= rowHeight; + paddingAdjust += rowHeight; + + this.vDomTop--; + index--; + i++; + + }else{ + working = false; + } + + }else{ + working = false; + } + + }else{ + working = false; + } + } + + for (let row of addedRows){ + row.clearCellHeight(); + } + + this._quickNormalizeRowHeight(addedRows); + + if(paddingAdjust){ + this.vDomTopPad -= paddingAdjust; + + if(this.vDomTopPad < 0){ + this.vDomTopPad = index * this.vDomRowHeight; + } + + if(index < 1){ + this.vDomTopPad = 0; + } + + table.style.paddingTop = this.vDomTopPad + "px"; + this.vDomScrollPosTop -= paddingAdjust; + } + } + + _removeTopRow(rows, fillableSpace){ + var removableRows = [], + paddingAdjust = 0, + i = 0, + working = true; + + while(working){ + let row = rows[this.vDomTop], + rowHeight; + + if(row && i < this.vDomMaxRenderChain){ + rowHeight = row.getHeight() || this.vDomRowHeight; + + if(fillableSpace >= rowHeight){ + this.vDomTop++; + + fillableSpace -= rowHeight; + paddingAdjust += rowHeight; + + removableRows.push(row); + i++; + }else{ + working = false; + } + }else{ + working = false; + } + } + + for (let row of removableRows){ + let rowEl = row.getElement(); + + if(rowEl.parentNode){ + rowEl.parentNode.removeChild(rowEl); + } + } + + if(paddingAdjust){ + this.vDomTopPad += paddingAdjust; + this.tableElement.style.paddingTop = this.vDomTopPad + "px"; + this.vDomScrollPosTop += this.vDomTop ? paddingAdjust : paddingAdjust + this.vDomWindowBuffer; + } + } + + _addBottomRow(rows, fillableSpace){ + var table = this.tableElement, + addedRows = [], + paddingAdjust = 0, + index = this.vDomBottom + 1, + i = 0, + working = true; + + while(working){ + let row = rows[index], + rowHeight, initialized; + + if(row && i < this.vDomMaxRenderChain){ + rowHeight = row.getHeight() || this.vDomRowHeight; + initialized = row.initialized; + + if(fillableSpace >= rowHeight){ + + this.styleRow(row, index); + table.appendChild(row.getElement()); + + if(!row.initialized || !row.heightInitialized){ + addedRows.push(row); + } + + row.initialize(); + + if(!initialized){ + rowHeight = row.getElement().offsetHeight; + + if(rowHeight > this.vDomWindowBuffer){ + this.vDomWindowBuffer = rowHeight * 2; + } + } + + fillableSpace -= rowHeight; + paddingAdjust += rowHeight; + + this.vDomBottom++; + index++; + i++; + }else{ + working = false; + } + }else{ + working = false; + } + } + + for (let row of addedRows){ + row.clearCellHeight(); + } + + this._quickNormalizeRowHeight(addedRows); + + if(paddingAdjust){ + this.vDomBottomPad -= paddingAdjust; + + if(this.vDomBottomPad < 0 || index == rows.length -1){ + this.vDomBottomPad = 0; + } + + table.style.paddingBottom = this.vDomBottomPad + "px"; + this.vDomScrollPosBottom += paddingAdjust; + } + } + + _removeBottomRow(rows, fillableSpace){ + var removableRows = [], + paddingAdjust = 0, + i = 0, + working = true; + + while(working){ + let row = rows[this.vDomBottom], + rowHeight; + + if(row && i < this.vDomMaxRenderChain){ + rowHeight = row.getHeight() || this.vDomRowHeight; + + if(fillableSpace >= rowHeight){ + this.vDomBottom --; + + fillableSpace -= rowHeight; + paddingAdjust += rowHeight; + + removableRows.push(row); + i++; + }else{ + working = false; + } + }else{ + working = false; + } + } + + for (let row of removableRows){ + let rowEl = row.getElement(); + + if(rowEl.parentNode){ + rowEl.parentNode.removeChild(rowEl); + } + } + + if(paddingAdjust){ + this.vDomBottomPad += paddingAdjust; + + if(this.vDomBottomPad < 0){ + this.vDomBottomPad = 0; + } + + this.tableElement.style.paddingBottom = this.vDomBottomPad + "px"; + this.vDomScrollPosBottom -= paddingAdjust; + } + } + + _quickNormalizeRowHeight(rows){ + for(let row of rows){ + row.calcHeight(); + } + + for(let row of rows){ + row.setCellHeight(); + } + } +} From b041dfc11fe5944718a76ed4937e34889b8288fa Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:51:31 +0100 Subject: [PATCH 4/6] fix(render): correct window reconciliation in the rewritten renderer Three defects found by tracing the render path frame by frame: 1. Insertion routing. _diffRender advanced renderedRange BEFORE calling _attachRanges, but _attachRanges decides prepend-vs-append by comparing the incoming range against the range still rendered. With it already advanced the prepend test could never be true, so rows extending the window upward were appended BELOW it and the DOM fell out of index order. This was the dominant bug: scroll-up jump 604px -> 36px. 2. Measurement guard. Rows were gated on !heightInitialized alone, but a row first measured while detached caches a zero outerHeight and is still flagged initialized, so it stayed permanently unmeasured (measuredCount stuck at 18 of 2000) and the coordinate space never left the estimate. Now matches the legacy guard: !heightInitialized || !getHeight(). 3. Height fed to the index was read before setCellHeight() normalized the cell heights, so the index disagreed with the DOM. Re-read after normalization. Also merges the overflow-anchor fix, without which the browser's own scroll anchoring fought the padding writes and pinned scrollTop at the bottom. 500k bench vs stock (custom renderer target in brackets): steady uniform churn 8982 -> 5361 [1794] steady variable churn 2715 -> 1487 [466] fling uniform churn 14205 -> 6474 [0] fling uniform p95 4.10 -> 2.50 [0.10] initial render 98 -> 100 [98] Remaining: scroll-jump canaries report 36px/57px against a 20px tolerance, and frozen-rows tracks a row across a 500px scroll that no longer stays in the smaller window (test encodes the old 3-viewport buffer; rows and the frozen row verified correct after the scroll). --- .../rendering/renderers/VirtualDomVertical.js | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/js/core/rendering/renderers/VirtualDomVertical.js b/src/js/core/rendering/renderers/VirtualDomVertical.js index 458643b23..ac2e5d738 100644 --- a/src/js/core/rendering/renderers/VirtualDomVertical.js +++ b/src/js/core/rendering/renderers/VirtualDomVertical.js @@ -590,13 +590,18 @@ export default class VirtualDomVertical extends Renderer{ } } - this.renderedRange.top = newTop; - this.renderedRange.bottom = newBottom; - + //_attachRanges routes each range to insertBefore-vs-append by comparing it + //against the range that is STILL rendered, so renderedRange must not be + //advanced until after the attach — otherwise an upward extension fails the + //prepend test and gets appended below the window, putting the DOM out of + //index order. if(attachRanges.length){ this._attachRanges(rows, attachRanges, newTop); } + this.renderedRange.top = newTop; + this.renderedRange.bottom = newBottom; + return wasFill; } @@ -724,7 +729,14 @@ export default class VirtualDomVertical extends Renderer{ continue; } - let h = entry.row.getHeight(); + //Read the height AFTER normalization. calcHeight(true) in phase C runs + //before setCellHeight() in phase D, and normalizing the cell heights + //changes the row's final height — so the value getHeight() cached in + //phase C is pre-normalization. Feeding that to the index leaves it + //systematically disagreeing with the DOM, which drifts the rendered + //block away from its computed position on every scroll. Rows that were + //not re-measured this pass keep their cached value (no new layout read). + let h = entry.needsMeasure ? entry.row.getElement().offsetHeight : entry.row.getHeight(); if(h > 0){ this._setHeight(entry.index, h, entry.row.data); From 28dd528354e729b316c89d7dc920ca2e58d9eb61 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:49:53 +0100 Subject: [PATCH 5/6] perf(render): descend the height trees to find the row at a scroll offset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _findRowAt binary-searched over _cumHeight — about 19 probes, each walking about 19 tree levels, twice per scroll frame. A linear combination of two Fenwick trees over the same index space is itself a valid Fenwick tree, so the combined node value can be formed per node and the tree descended directly in O(log n). Measured at 500k rows: _cumHeight calls per scroll tick 45.9 -> 12, time inside _findRowAt 0.031 -> 0.006 ms. The descent accumulates nodes in ascending index order whereas prefixSum walks descending, so the two sums diverge in the last bits of a float at or near a row boundary — measured at 2.6% of 3.69M boundary-heavy probes. _findRowAt reconciles against _cumHeight, so the answer is identical to the binary search it replaces. The new spec keeps that binary search as a reference oracle, over 6 height distributions and 17 sizes straddling powers of two. --- .../rendering/renderers/VirtualDomVertical.js | 71 +++++++-- .../rendering/VirtualDomVerticalIndex.spec.js | 150 ++++++++++++++++++ 2 files changed, 208 insertions(+), 13 deletions(-) create mode 100644 test/unit/core/rendering/VirtualDomVerticalIndex.spec.js diff --git a/src/js/core/rendering/renderers/VirtualDomVertical.js b/src/js/core/rendering/renderers/VirtualDomVertical.js index ac2e5d738..1f6295d3f 100644 --- a/src/js/core/rendering/renderers/VirtualDomVertical.js +++ b/src/js/core/rendering/renderers/VirtualDomVertical.js @@ -43,11 +43,14 @@ class Fenwick{ constructor(n){ this.n = n; this.tree = new Float64Array(n + 1); + //Highest power of two <= n: the first step size of a tree descent. + this.highBit = n <= 0 ? 0 : 1 << (31 - Math.clz32(n)); } resize(n){ this.n = n; this.tree = new Float64Array(n + 1); + this.highBit = n <= 0 ? 0 : 1 << (31 - Math.clz32(n)); } resetZero(){ @@ -95,6 +98,41 @@ class Fenwick{ return s; } + + //Largest count c in [0, n] with combinedPrefixSum(c) <= target, where the + //combined array is this[i] + other[i] * weight. + // + //A linear combination of two Fenwick trees over the same index space is itself + //a valid Fenwick tree, so the combined node value can be formed per node and + //the tree descended directly: O(log n), against O(log^2 n) for a binary search + //that calls prefixSum at every probe. + // + //Descent accumulates nodes in ascending index order whereas prefixSum walks + //descending, so the two sums can differ in the last bits of a float. Callers + //needing an answer consistent with prefixSum must reconcile ties (see + //_findRowAt). + lowerBoundCombined(target, other, weight){ + let pos = 0, + acc = 0, + step = this.highBit; + + while(step > 0){ + let next = pos + step; + + if(next <= this.n){ + let sum = acc + this.tree[next] + other.tree[next] * weight; + + if(sum <= target){ + acc = sum; + pos = next; + } + } + + step >>= 1; + } + + return pos; + } } export default class VirtualDomVertical extends Renderer{ @@ -890,7 +928,9 @@ export default class VirtualDomVertical extends Renderer{ return this._cumHeight(this.measuredHeight.length); } - //Index of the row at document Y — binary search over the cumHeight oracle. + //Index of the row at document Y — a descent of the height trees. Runs twice per + //scroll frame, so the O(log n) descent replaces what was a binary search + //calling _cumHeight per probe: at 500k rows that was ~19 probes x ~19 levels. _findRowAt(y){ var n = this.measuredHeight.length; @@ -902,21 +942,26 @@ export default class VirtualDomVertical extends Renderer{ return n - 1; } - //Largest i with cumHeight(i) <= y, then i-1 is the row containing y. - var lo = 0, - hi = n; - - while(lo < hi){ - let mid = (lo + hi) >>> 1; + //The row containing y is the largest i with cumHeight(i) <= y. + var est = this.lockedEstimate === null ? this.estimateHeight : this.lockedEstimate, + i = this.fenwickMeasured.lowerBoundCombined(y, this.fenwickUnmeasuredCount, est); + + //The descent sums the same node values as _cumHeight but in the opposite + //order, so on a y at or near a row boundary it can land one row either side. + //Settle it against _cumHeight, the ordering every other coordinate read + //uses, so the answer is identical to the binary search this replaced. Not + //belt-and-braces: measured at 2.6% of probes over ~3.7M boundary-heavy + //cases, and a one-row error here shows as a blank band. Each loop iterates + //at most once in practice, keeping this O(log n). + while(i > 0 && this._cumHeight(i) > y){ + i--; + } - if(this._cumHeight(mid) <= y){ - lo = mid + 1; - }else{ - hi = mid; - } + while(i + 1 < n && this._cumHeight(i + 1) <= y){ + i++; } - return Math.max(0, lo - 1); + return i; } //Per-row height (measured if known, else the estimate). O(1) — reads the diff --git a/test/unit/core/rendering/VirtualDomVerticalIndex.spec.js b/test/unit/core/rendering/VirtualDomVerticalIndex.spec.js new file mode 100644 index 000000000..3932c940e --- /dev/null +++ b/test/unit/core/rendering/VirtualDomVerticalIndex.spec.js @@ -0,0 +1,150 @@ +import VirtualDomVertical from "../../../../src/js/core/rendering/renderers/VirtualDomVertical.js"; + +// Pure-unit. The height index and _findRowAt read only instance fields, so a +// minimal fake table is enough to construct the renderer — no DOM, no Tabulator. +function makeRenderer(){ + const el = {}; + + return new VirtualDomVertical({ + options:{rowHeight:null}, + rowManager:{element:el, tableElement:el}, + columnManager:{element:el}, + }); +} + +// Seed the coordinate model: `heights[i]` is the true height of row i, and rows +// where `measured[i]` is falsy stay priced at the estimate. +function seed(heights, measured, estimate){ + const r = makeRenderer(); + + r.estimateHeight = estimate; + r.estimateFrozen = true; + r._resetHeightIndex(heights.length); + + for(let i = 0; i < heights.length; i++){ + if(measured[i]){ + r._setHeight(i, heights[i], undefined); + } + } + + return r; +} + +// The implementation _findRowAt is replacing: binary search over the _cumHeight +// oracle. Kept here as the reference the optimised version must match exactly. +function referenceFindRowAt(r, y){ + const n = r.measuredHeight.length; + + if(n === 0 || y <= 0){ + return 0; + } + + if(y >= r._totalHeight()){ + return n - 1; + } + + let lo = 0, + hi = n; + + while(lo < hi){ + const mid = (lo + hi) >>> 1; + + if(r._cumHeight(mid) <= y){ + lo = mid + 1; + }else{ + hi = mid; + } + } + + return Math.max(0, lo - 1); +} + +// Deterministic PRNG so a failure is reproducible from the seed alone. +function rng(seedValue){ + let s = seedValue >>> 0; + + return () => { + s = (s * 1664525 + 1013904223) >>> 0; + return s / 4294967296; + }; +} + +function priced(r, i){ + return r.isMeasured[i] === 1 ? r.measuredHeight[i] : r.estimateHeight; +} + +// Every y worth probing: each row's start, interior, and the boundary either side. +function probePoints(r){ + const n = r.measuredHeight.length, + points = [-5, -1, 0]; + + let cum = 0; + + for(let i = 0; i < n; i++){ + const h = priced(r, i); + + points.push(cum - 1, cum, cum + 0.5, cum + h / 2, cum + h - 1); + cum += h; + } + + points.push(cum - 1, cum, cum + 1, cum + 1000); + + return points; +} + +describe("VirtualDomVertical._findRowAt", () => { + const cases = [ + ["all rows measured, uniform", (n, rand) => ({heights:Array.from({length:n}, () => 25), measured:Array.from({length:n}, () => 1), estimate:25})], + ["all rows measured, variable", (n, rand) => ({heights:Array.from({length:n}, () => 10 + Math.floor(rand() * 90)), measured:Array.from({length:n}, () => 1), estimate:25})], + ["no rows measured", (n) => ({heights:Array.from({length:n}, () => 25), measured:Array.from({length:n}, () => 0), estimate:25})], + ["mixed measured and estimated", (n, rand) => ({heights:Array.from({length:n}, () => 10 + Math.floor(rand() * 90)), measured:Array.from({length:n}, () => (rand() < 0.5 ? 1 : 0)), estimate:25})], + ["fractional heights", (n, rand) => ({heights:Array.from({length:n}, () => 20 + rand() * 10), measured:Array.from({length:n}, () => 1), estimate:24.5})], + ["fractional estimate, partly measured", (n, rand) => ({heights:Array.from({length:n}, () => 18.3 + rand() * 7), measured:Array.from({length:n}, () => (rand() < 0.3 ? 1 : 0)), estimate:23.7})], + ]; + + // Sizes chosen to straddle powers of two, where a Fenwick descent's step + // bound is most likely to be wrong. + const sizes = [0, 1, 2, 3, 7, 8, 9, 15, 16, 17, 31, 33, 64, 100, 1000, 1024, 1025]; + + for(const [name, build] of cases){ + for(const n of sizes){ + it(`matches the reference binary search: ${name}, n=${n}`, () => { + const rand = rng(n * 7919 + name.length), + spec = build(n, rand), + r = seed(spec.heights, spec.measured, spec.estimate); + + for(const y of probePoints(r)){ + expect(r._findRowAt(y)).toBe(referenceFindRowAt(r, y)); + } + }); + } + } + + it("returns an index whose row actually spans y", () => { + const rand = rng(4871), + n = 500, + heights = Array.from({length:n}, () => 10 + Math.floor(rand() * 90)), + measured = Array.from({length:n}, () => (rand() < 0.6 ? 1 : 0)), + r = seed(heights, measured, 25); + + for(let i = 0; i < n; i++){ + const start = r._cumHeight(i), + h = priced(r, i); + + // Interior of the row must resolve to the row itself. + expect(r._findRowAt(start + h / 2)).toBe(i); + } + }); + + it("honours lockedEstimate over estimateHeight", () => { + const n = 64, + r = seed(Array.from({length:n}, () => 25), Array.from({length:n}, () => 0), 25); + + r.lockedEstimate = 50; + + expect(r._findRowAt(0)).toBe(0); + expect(r._findRowAt(125)).toBe(2); + expect(r._findRowAt(149)).toBe(2); + expect(r._findRowAt(150)).toBe(3); + }); +}); From b5f02541b614d44e6c8866c54a7a9c6c5f822ed7 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:57:35 +0100 Subject: [PATCH 6/6] fix(render): resync columns when a row re-enters the vertical window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With renderHorizontal:"virtual", a row that leaves the vertical window is detached but not deinitialized, so it keeps `initialized === true` and the cell set it held when it left. VirtualDomHorizontal's addColRight/addColLeft update row.modules.vdomHoz only for the rows visible at that moment, so a row re-entering the window after a horizontal scroll carried a stale column window: wrong columns, misaligned with the header. Row.initialize's else-branch is the resync hook — it falls through to rerenderRowCells, whose leftCol/rightCol guard rebuilds the cells only when the column window really moved. The diff path called initialize only for rows it had never initialized, so that branch was never reached. The legacy renderer called it unconditionally on its incremental scroll paths. Deliberately no second argument: VirtualDomHorizontal.rerenderRowCells reads it as `force`, and a forced rebuild of every re-attached row on every scroll tick is the cost the diff path exists to avoid. Row.create is a guarded no-op and the base rerenderRowCells is empty, so with the default horizontal renderer this costs nothing: at 500k rows steady-scroll churn stays 5361, nodes 1789, p95 0.9 ms. --- .../rendering/renderers/VirtualDomVertical.js | 12 +++ .../VirtualDomVerticalAttach.spec.js | 96 +++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 test/unit/core/rendering/VirtualDomVerticalAttach.spec.js diff --git a/src/js/core/rendering/renderers/VirtualDomVertical.js b/src/js/core/rendering/renderers/VirtualDomVertical.js index 1f6295d3f..b65e2ba5e 100644 --- a/src/js/core/rendering/renderers/VirtualDomVertical.js +++ b/src/js/core/rendering/renderers/VirtualDomVertical.js @@ -676,6 +676,18 @@ export default class VirtualDomVertical extends Renderer{ if(wasUninitialized){ row.initialize(false, true); //inFragment: build cells off-DOM + }else{ + //An already-initialized row falls straight through Row.initialize to + //rerenderRowCells, which is how renderHorizontal:"virtual" resyncs a + //row whose cached column window went stale while the row sat outside + //the vertical window: addColRight/addColLeft only update the rows that + //were visible at the time, so a row re-entering the window keeps the + //column set it had when it left. Deliberately no second argument — + //VirtualDomHorizontal.rerenderRowCells reads it as `force`, and a + //forced rebuild of every re-attached row on every scroll tick is the + //cost this diff path exists to avoid. Left falsy, reinitializeRow's + //leftCol/rightCol guard makes it a no-op unless the window really moved. + row.initialize(); } let el = row.getElement(); diff --git a/test/unit/core/rendering/VirtualDomVerticalAttach.spec.js b/test/unit/core/rendering/VirtualDomVerticalAttach.spec.js new file mode 100644 index 000000000..403e90b77 --- /dev/null +++ b/test/unit/core/rendering/VirtualDomVerticalAttach.spec.js @@ -0,0 +1,96 @@ +import VirtualDomVertical from "../../../../src/js/core/rendering/renderers/VirtualDomVertical.js"; + +// Pure-unit. _attachRanges needs a real element to append fragments into, so use +// a document element for the row container; everything else is a stub. jsdom has +// no layout, so offsetHeight is 0 throughout — these tests assert which lifecycle +// calls the attach path makes, not any geometry. +function makeRenderer(rows){ + const tableElement = document.createElement("div"); + + const renderer = new VirtualDomVertical({ + options:{rowHeight:null}, + rowManager:{element:document.createElement("div"), tableElement:tableElement}, + columnManager:{element:document.createElement("div")}, + }); + + renderer._resetHeightIndex(rows.length); + renderer.styleRow = () => {}; + + return renderer; +} + +// A stand-in for Row that records how initialize() was called. Row.initialize +// itself is what routes an already-initialized row to +// columnManager.renderer.rerenderRowCells, so recording the arguments here pins +// the contract the attach path relies on. +function makeRow(index, initialized){ + return { + type:"row", + data:{id:index}, + element:document.createElement("div"), + initialized:initialized, + heightInitialized:initialized, + initializeCalls:[], + initialize(...args){ + this.initializeCalls.push(args); + this.initialized = true; + }, + getElement(){ + return this.element; + }, + getHeight(){ + return this.initialized ? 20 : 0; + }, + rendered(){}, + clearCellHeight(){}, + calcHeight(){}, + setCellHeight(){}, + }; +} + +describe("VirtualDomVertical attach lifecycle", function(){ + it("builds cells off-DOM for a row it has never initialized", function(){ + const rows = [makeRow(0, false)], + renderer = makeRenderer(rows); + + renderer._attachRanges(rows, [[0, 0]], 0); + + expect(rows[0].initializeCalls).toEqual([[false, true]]); + }); + + // A row that leaves the vertical window is detached but NOT deinitialized, so + // it keeps `initialized === true` and keeps the cell set it had when it left. + // Meanwhile VirtualDomHorizontal's addColRight/addColLeft update + // row.modules.vdomHoz only for the rows visible at that moment. So with + // renderHorizontal:"virtual", a row re-entering the vertical window after a + // horizontal scroll holds a stale column window, and the only thing that + // resyncs it is Row.initialize falling through to rerenderRowCells. + it("still routes an already-initialized row through initialize, so the horizontal renderer can resync its columns", function(){ + const rows = [makeRow(0, true)], + renderer = makeRenderer(rows); + + renderer.inScrollDrivenRender = true; + + renderer._attachRanges(rows, [[0, 0]], 0); + + expect(rows[0].initializeCalls.length).toBe(1); + }); + + // VirtualDomHorizontal.rerenderRowCells(row, force) reads Row.initialize's + // second argument as `force`, and a forced rebuild discards and regenerates + // every cell. Passing it for a re-attached row would rebuild the whole window + // on every scroll tick — the exact cost the diff path exists to avoid — and + // would defeat reinitializeRow's leftCol/rightCol guard. + it("does not force a cell rebuild when re-attaching an already-initialized row", function(){ + const rows = [makeRow(0, true)], + renderer = makeRenderer(rows); + + renderer.inScrollDrivenRender = true; + + renderer._attachRanges(rows, [[0, 0]], 0); + + const [force] = rows[0].initializeCalls[0]; + + expect(force).toBeFalsy(); + }); +});