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..b65e2ba5e 100644 --- a/src/js/core/rendering/renderers/VirtualDomVertical.js +++ b/src/js/core/rendering/renderers/VirtualDomVertical.js @@ -1,46 +1,229 @@ 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); + //Highest power of two <= n: the first step size of a tree descent. + this.highBit = n <= 0 ? 0 : 1 << (31 - Math.clz32(n)); + } - this.verticalFillMode = "fill"; + resize(n){ + this.n = n; + this.tree = new Float64Array(n + 1); + this.highBit = n <= 0 ? 0 : 1 << (31 - Math.clz32(n)); + } - 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; + } - this.vDomScrollPosTop = 0; //last scroll position of the vDom top; - this.vDomScrollPosBottom = 0; //last scroll position of the vDom bottom; + let i = i0 + 1; + + 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; + + if(i > this.n){ + i = this.n; + }else if(i <= 0){ + return 0; + } + + let s = 0; + + while(i > 0){ + s += this.tree[i]; + i -= i & -i; + } + + 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; + } +} - this.vDomMaxRenderChain = 90; //the maximum number of dom elements that can be rendered in 1 go +export default class VirtualDomVertical extends Renderer{ + constructor(table){ + super(table); - this.vDomWindowBuffer = 0; //window row buffer before removing elements, to smooth scrolling + this.verticalFillMode = "fill"; - 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.scrollTop = 0; + this.scrollLeft = 0; - this.vDomTopNewRows = []; //rows to normalize after appending to optimize render speed - this.vDomBottomNewRows = []; //rows to normalize after appending to optimize render speed + //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 +231,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 +318,799 @@ 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; + } - i = 0; + //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(); + } - while ((i < rowsToRender) && this.vDomBottom < rowsCount -1) { - index = this.vDomBottom + 1, - row = rows[index]; + //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"); + } + } - this.styleRow(row, 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; + } - row.initialize(false, true); - if(!row.heightInitialized && !this.table.options.rowHeight){ - row.clearCellHeight(); - } + 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]); + } - rowFragment.appendChild(row.getElement()); - renderedRows.push(row); - this.vDomBottom ++; - i++; - } + if(newBottom < oldBottom){ + detachRanges.push([newBottom + 1, oldBottom]); + }else if(newBottom > oldBottom){ + attachRanges.push([oldBottom + 1, newBottom]); + } + } - if(!renderedRows.length){ - break; + for(let range of detachRanges){ + for(let i = range[0]; i <= range[1]; i++){ + let row = rows[i], + el = row ? row.getElement() : null; + + if(el && el.parentNode){ + el.parentNode.removeChild(el); } + } + } - element.appendChild(rowFragment); + //_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); + } - // NOTE: The next 4 loops are separate on purpose - // This is to batch up the dom writes and reads which drastically improves performance + this.renderedRange.top = newTop; + this.renderedRange.bottom = newBottom; - 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; + + //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(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(); + } - renderedRows.forEach((row) => { - rowHeight = row.getHeight() || this.vDomRowHeight; + let el = row.getElement(); - if(totalRowsRendered < topPad){ - topPadHeight += rowHeight; - }else { - rowsHeight += rowHeight; - } + if(el.parentNode && el.parentNode !== fragment){ + el.parentNode.removeChild(el); + } - if(rowHeight > this.vDomWindowBuffer){ - this.vDomWindowBuffer = rowHeight * 2; - } - totalRowsRendered++; + 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, }); + } - 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(attached.length === rangeStart){ + continue; } - 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); + //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); - 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); + if(insertAtTop && element.firstChild){ + element.insertBefore(fragment, element.firstChild); + }else{ + element.appendChild(fragment); } - - 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){ + return; + } + + //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.scrollTop = Math.min(this.scrollTop, this.elementVertical.scrollHeight - containerHeight); + //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(); + } + } + } - //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; + for(let entry of attached){ + if(entry.needsMeasure){ + entry.row.calcHeight(true); } + } - this.vDomScrollPosTop = this.scrollTop; - this.vDomScrollPosBottom = this.scrollTop; + for(let entry of attached){ + if(entry.needsMeasure){ + entry.row.setCellHeight(); + } + } - holder.scrollTop = this.scrollTop; + //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; + } - this.dispatch("render-virtual-fill"); + //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); + } } } - _addTopRow(rows, fillableSpace){ - var table = this.tableElement, - addedRows = [], - paddingAdjust = 0, - index = this.vDomTop -1, - i = 0, - working = true; + _detachAllRendered(){ + this.tableElement.replaceChildren(); - while(working){ - if(this.vDomTop){ - let row = rows[index], - rowHeight, initialized; + this.vDomTop = 0; + this.vDomBottom = -1; + this.renderedRange.top = 0; + this.renderedRange.bottom = -1; + } - if(row && i < this.vDomMaxRenderChain){ - rowHeight = row.getHeight() || this.vDomRowHeight; - initialized = row.initialized; + ////////////////////////////////////// + //////// Height bookkeeping ////////// + ////////////////////////////////////// - if(fillableSpace >= rowHeight){ + //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(); + } - this.styleRow(row, index); - table.insertBefore(row.getElement(), table.firstChild); + this.fenwickUnmeasuredCount.bulkInitConstant(1); - if(!row.initialized || !row.heightInitialized){ - addedRows.push(row); - } + this.measuredSum = 0; + this.measuredCount = 0; + this.rowsCountCached = rowsCount; + } - row.initialize(); + //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); - if(!initialized){ - rowHeight = row.getElement().offsetHeight; + for(let i = 0; i < rows.length; i++){ + let dataKey = rows[i] ? rows[i].data : undefined; - if(rowHeight > this.vDomWindowBuffer){ - this.vDomWindowBuffer = rowHeight * 2; - } - } + if(dataKey === undefined){ + continue; + } - fillableSpace -= rowHeight; - paddingAdjust += rowHeight; + let h = this.dataHeights.get(dataKey); - this.vDomTop--; - index--; - i++; + if(h === undefined || h <= 0){ + continue; + } - }else{ - working = false; - } + //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; + } + } - }else{ - working = false; - } + //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(); - }else{ - working = false; + for(let row of this.rows()){ + if(row.deinitializeHeight){ + row.deinitializeHeight(); } } + } + + //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; + } + + var wasMeasured = this.isMeasured[i] === 1, + oldH = this.measuredHeight[i]; - for (let row of addedRows){ - row.clearCellHeight(); + if(wasMeasured && oldH === h){ + return; } - this._quickNormalizeRowHeight(addedRows); + //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); + } - if(paddingAdjust){ - this.vDomTopPad -= paddingAdjust; + //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(this.vDomTopPad < 0){ - this.vDomTopPad = index * this.vDomRowHeight; - } + this.pendingScrollAdjust += h - prior; + } - if(index < 1){ - this.vDomTopPad = 0; - } + 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; + } - table.style.paddingTop = this.vDomTopPad + "px"; - this.vDomScrollPosTop -= paddingAdjust; + this.measuredHeight[i] = h; + } + + //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; } - _removeTopRow(rows, fillableSpace){ - var removableRows = [], - paddingAdjust = 0, - i = 0, - working = true; + //cumHeight(i) = sum of heights[0..i) + _cumHeight(i){ + if(i <= 0){ + return 0; + } - while(working){ - let row = rows[this.vDomTop], - rowHeight; + var n = this.measuredHeight.length, + ci = i > n ? n : i, + est = this.lockedEstimate === null ? this.estimateHeight : this.lockedEstimate; - if(row && i < this.vDomMaxRenderChain){ - rowHeight = row.getHeight() || this.vDomRowHeight; + return this.fenwickMeasured.prefixSum(ci) + (this.fenwickUnmeasuredCount.prefixSum(ci) * est); + } - if(fillableSpace >= rowHeight){ - this.vDomTop++; + _totalHeight(){ + return this._cumHeight(this.measuredHeight.length); + } - fillableSpace -= rowHeight; - paddingAdjust += rowHeight; + //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; - removableRows.push(row); - i++; - }else{ - working = false; - } - }else{ - working = false; - } + if(n === 0 || y <= 0){ + return 0; } - for (let row of removableRows){ - let rowEl = row.getElement(); - - if(rowEl.parentNode){ - rowEl.parentNode.removeChild(rowEl); - } + if(y >= this._totalHeight()){ + return n - 1; } - if(paddingAdjust){ - this.vDomTopPad += paddingAdjust; - this.tableElement.style.paddingTop = this.vDomTopPad + "px"; - this.vDomScrollPosTop += this.vDomTop ? paddingAdjust : paddingAdjust + this.vDomWindowBuffer; + //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--; } - } - _addBottomRow(rows, fillableSpace){ - var table = this.tableElement, - addedRows = [], - paddingAdjust = 0, - index = this.vDomBottom + 1, - i = 0, - working = true; + while(i + 1 < n && this._cumHeight(i + 1) <= y){ + i++; + } - while(working){ - let row = rows[index], - rowHeight, initialized; + return i; + } - if(row && i < this.vDomMaxRenderChain){ - rowHeight = row.getHeight() || this.vDomRowHeight; - initialized = row.initialized; + //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; + } - if(fillableSpace >= rowHeight){ + var est = this.lockedEstimate === null ? this.estimateHeight : this.lockedEstimate; - this.styleRow(row, index); - table.appendChild(row.getElement()); + return this.isMeasured[i] === 1 ? this.measuredHeight[i] : est; + } - if(!row.initialized || !row.heightInitialized){ - addedRows.push(row); - } + //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; - row.initialize(); + if(buffer){ + return Math.max(OVERSCAN_MIN, Math.ceil(buffer / est)); + } - if(!initialized){ - rowHeight = row.getElement().offsetHeight; + return Math.max(OVERSCAN_MIN, Math.min(OVERSCAN_MAX, Math.round(clientHeight / 4 / est))); + } - if(rowHeight > this.vDomWindowBuffer){ - this.vDomWindowBuffer = rowHeight * 2; - } - } + ////////////////////////////////////// + //////// Scroll positioning ////////// + ////////////////////////////////////// - fillableSpace -= rowHeight; - paddingAdjust += 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; + } - this.vDomBottom++; - index++; - i++; - }else{ - working = false; - } - }else{ - working = false; - } + //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); } - for (let row of addedRows){ - row.clearCellHeight(); - } + for(let i = 0; i < MAX_RECONCILE; i++){ + this.lockedEstimate = this.estimateHeight; - this._quickNormalizeRowHeight(addedRows); + let placed; - if(paddingAdjust){ - this.vDomBottomPad -= paddingAdjust; + try{ + let target = this._cumHeight(index) - offsetFromHolderTop, + maxScroll = Math.max(0, this._totalHeight() - clientHeight); - if(this.vDomBottomPad < 0 || index == rows.length -1){ - this.vDomBottomPad = 0; + placed = Math.max(0, Math.min(target, maxScroll)); + }finally{ + this.lockedEstimate = null; } - table.style.paddingBottom = this.vDomBottomPad + "px"; - this.vDomScrollPosBottom += paddingAdjust; - } - } - - _removeBottomRow(rows, fillableSpace){ - var removableRows = [], - paddingAdjust = 0, - i = 0, - working = true; + this._setScrollTop(placed); + this._renderWindow(); - while(working){ - let row = rows[this.vDomBottom], - rowHeight; + let el = row.getElement(); - if(row && i < this.vDomMaxRenderChain){ - rowHeight = row.getHeight() || this.vDomRowHeight; + 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(fillableSpace >= rowHeight){ - this.vDomBottom --; + if(Math.abs(corrected - holder.scrollTop) > 0.5){ + this._setScrollTop(corrected); + this._renderWindow(); + } - fillableSpace -= rowHeight; - paddingAdjust += rowHeight; + break; + } - removableRows.push(row); - i++; - }else{ - working = false; - } - }else{ - working = false; + //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; } + } - for (let row of removableRows){ - let rowEl = row.getElement(); + ////////////////////////////////////// + //////// Deferred work /////////////// + ////////////////////////////////////// - if(rowEl.parentNode){ - rowEl.parentNode.removeChild(rowEl); - } + //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; } - if(paddingAdjust){ - this.vDomBottomPad += paddingAdjust; + if(typeof requestAnimationFrame !== "function"){ + this._renderWindow(); + return; + } - if(this.vDomBottomPad < 0){ - this.vDomBottomPad = 0; - } + this.rafScheduled = true; - this.tableElement.style.paddingBottom = this.vDomBottomPad + "px"; - this.vDomScrollPosBottom -= paddingAdjust; - } + this.rafHandle = requestAnimationFrame(() => { + this.rafScheduled = false; + this.rafHandle = null; + + if(!this.table.destroyed){ + this._renderWindow(); + } + }); } - _quickNormalizeRowHeight(rows){ - for(let row of rows){ - row.calcHeight(); + _clearDeferred(){ + if(this.rafHandle && typeof cancelAnimationFrame === "function"){ + cancelAnimationFrame(this.rafHandle); } - for(let row of rows){ - row.setCellHeight(); - } + 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(); + } + } +} 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{ 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"); + }); +}); 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(); + }); +}); 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); + }); +});