Skip to main content

max / goingson

Index VirtualScroller rows by prefix sum, fixing a range off-by-one Every height query walked all rows, up to five scans per scroll frame. A cached offset table, rebuilt only when the item list or a measured height changes, makes a range query a binary search. The linear start-index scan also returned items.length when scrollTop sat past the content, so with overscan 0 the range was empty and the list rendered blank. The offset lookup clamps to a real row. Covered by a new VirtualScroller suite in the frontend runner.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-25 17:16 UTC
Signed with PGP, not checked
Commit: 2b105b8856efa1abc8e326f8306a3079a53a7162
Parent: 12b5c47
2 files changed, +201 insertions, -63 deletions
@@ -38,6 +38,8 @@
38 38 // State
39 39 this.items = [];
40 40 this.heightCache = new Map(); // itemId -> measured height
41 + this._offsetCache = null; // cumulative row offsets, see _offsets()
42 + this._offsetsDirty = true;
41 43 this.scrollTop = 0;
42 44 this.viewportHeight = 0;
43 45 this.startIndex = 0;
@@ -104,17 +106,77 @@
104 106 return this.estimatedRowHeight;
105 107 }
106 108
109 + /**
110 + * Mark the cached offset table stale. Cheap; the table is rebuilt lazily on
111 + * the next query rather than eagerly here.
112 + * @private
113 + */
114 + _invalidateOffsets() {
115 + this._offsetsDirty = true;
116 + }
117 +
118 + /**
119 + * Cumulative row offsets: `offsets[i]` is the top of row `i`, and
120 + * `offsets[items.length]` is the total content height. Rebuilt only when
121 + * the item list or a measured height changed, so a scroll frame that
122 + * crosses no boundary costs nothing and a range query costs O(log n)
123 + * instead of walking every row.
124 + * @private
125 + * @returns {number[]} Offset table of length items.length + 1
126 + */
127 + _offsets() {
128 + if (!this._offsetsDirty && this._offsetCache
129 + && this._offsetCache.length === this.items.length + 1) {
130 + return this._offsetCache;
131 + }
132 + const offsets = new Array(this.items.length + 1);
133 + let total = 0;
134 + for (let i = 0; i < this.items.length; i++) {
135 + offsets[i] = total;
136 + total += this._getItemHeight(this.items[i], i);
137 + }
138 + offsets[this.items.length] = total;
139 + this._offsetCache = offsets;
140 + this._offsetsDirty = false;
141 + return offsets;
142 + }
143 +
107 144 /**
108 145 * Calculate total content height.
109 146 * @private
110 147 * @returns {number} Total height in pixels
111 148 */
112 149 _getTotalHeight() {
113 - let total = 0;
114 - for (let i = 0; i < this.items.length; i++) {
115 - total += this._getItemHeight(this.items[i], i);
150 + return this._offsets()[this.items.length];
151 + }
152 +
153 + /**
154 + * Index of the row containing `offset`, by binary search over the offset
155 + * table. Clamped to a real row: an offset past the end of the content
156 + * yields the last row, never one past it.
157 + * @private
158 + * @param {number} offset - Pixel offset from the top of the content
159 + * @returns {number} Row index in [0, items.length - 1]
160 + */
161 + _indexAtOffset(offset) {
162 + const offsets = this._offsets();
163 + const last = this.items.length - 1;
164 + if (last < 0) return 0;
165 + if (offset <= 0) return 0;
166 + if (offset >= offsets[last]) return last;
167 +
168 + // Largest i such that offsets[i] <= offset.
169 + let lo = 0;
170 + let hi = last;
171 + while (lo < hi) {
172 + const mid = (lo + hi + 1) >> 1;
173 + if (offsets[mid] <= offset) {
174 + lo = mid;
175 + } else {
176 + hi = mid - 1;
177 + }
116 178 }
117 - return total;
179 + return lo;
118 180 }
119 181
120 182 /**
@@ -129,47 +191,20 @@
129 191
130 192 const scrollTop = this.container.scrollTop;
131 193 const viewportHeight = this.container.clientHeight;
194 + const offsets = this._offsets();
132 195
133 - let accumulatedHeight = 0;
134 - let startIndex = 0;
135 - let topOffset = 0;
196 + // First row intersecting the viewport, then pull back by the overscan.
197 + // _indexAtOffset clamps to a real row, so scrolling past the end of the
198 + // content (over-scroll, or a list that shrank under a stale scrollTop)
199 + // still renders the tail instead of blanking the list.
200 + const firstVisible = this._indexAtOffset(scrollTop);
201 + const startIndex = Math.max(0, firstVisible - this.overscan);
202 + const topOffset = offsets[startIndex];
136 203
137 - // Find start index
138 - for (let i = 0; i < this.items.length; i++) {
139 - const height = this._getItemHeight(this.items[i], i);
140 - if (accumulatedHeight + height > scrollTop) {
141 - startIndex = i;
142 - topOffset = accumulatedHeight;
143 - break;
144 - }
145 - accumulatedHeight += height;
146 - if (i === this.items.length - 1) {
147 - startIndex = this.items.length;
148 - topOffset = accumulatedHeight;
149 - }
150 - }
151 -
152 - // Apply overscan to start
153 - startIndex = Math.max(0, startIndex - this.overscan);
154 -
155 - // Recalculate topOffset for adjusted start
156 - topOffset = 0;
157 - for (let i = 0; i < startIndex; i++) {
158 - topOffset += this._getItemHeight(this.items[i], i);
159 - }
160 -
161 - // Find end index
162 - let endIndex = startIndex;
163 - let renderedHeight = 0;
164 - const targetHeight = viewportHeight + (this.overscan * 2 * this.estimatedRowHeight);
165 -
166 - for (let i = startIndex; i < this.items.length && renderedHeight < targetHeight; i++) {
167 - renderedHeight += this._getItemHeight(this.items[i], i);
168 - endIndex = i + 1;
169 - }
170 -
171 - // Apply overscan to end
172 - endIndex = Math.min(this.items.length, endIndex + this.overscan);
204 + // Last row intersecting the viewport, then push out by the overscan.
205 + const viewportBottom = scrollTop + viewportHeight;
206 + const lastVisible = this._indexAtOffset(viewportBottom);
207 + const endIndex = Math.min(this.items.length, lastVisible + 1 + this.overscan);
173 208
174 209 return { startIndex, endIndex, topOffset };
175 210 }
@@ -201,6 +236,7 @@
201 236 }
202 237 }
203 238 }
239 + if (changed) this._invalidateOffsets();
204 240 return changed;
205 241 }
206 242
@@ -212,7 +248,11 @@
212 248 _render(forceRender = false) {
213 249 if (this.isDestroyed) return;
214 250
251 + const previousItems = this.items;
215 252 this.items = this.getItems() || [];
253 + // getItems() may hand back a new array, a mutated one, or the very same
254 + // reference. Only the last case is safely cacheable.
255 + if (this.items !== previousItems) this._invalidateOffsets();
216 256
217 257 if (this.items.length === 0) {
218 258 this.topSpacer.style.height = '0px';
@@ -244,12 +284,9 @@
244 284 this._hasRendered = true;
245 285
246 286 // Set spacers from the values we just computed, no need to recompute.
287 + const offsets = this._offsets();
247 288 this.topSpacer.style.height = `${topOffset}px`;
248 - let bottomHeight = 0;
249 - for (let i = endIndex; i < this.items.length; i++) {
250 - bottomHeight += this._getItemHeight(this.items[i], i);
251 - }
252 - this.bottomSpacer.style.height = `${bottomHeight}px`;
289 + this.bottomSpacer.style.height = `${offsets[this.items.length] - offsets[endIndex]}px`;
253 290
254 291 // Measure after layout. Only re-update spacers if measurement
255 292 // actually changed cached heights for items above the viewport
@@ -282,17 +319,10 @@
282 319 * @private
283 320 */
284 321 _updateSpacers() {
285 - let topHeight = 0;
286 - for (let i = 0; i < this.startIndex; i++) {
287 - topHeight += this._getItemHeight(this.items[i], i);
288 - }
289 - this.topSpacer.style.height = `${topHeight}px`;
290 -
291 - let bottomHeight = 0;
292 - for (let i = this.endIndex; i < this.items.length; i++) {
293 - bottomHeight += this._getItemHeight(this.items[i], i);
294 - }
295 - this.bottomSpacer.style.height = `${bottomHeight}px`;
322 + const offsets = this._offsets();
323 + this.topSpacer.style.height = `${offsets[this.startIndex]}px`;
324 + this.bottomSpacer.style.height =
325 + `${offsets[this.items.length] - offsets[this.endIndex]}px`;
296 326 }
297 327
298 328 /**
@@ -334,6 +364,9 @@
334 364 // Re-arm the infinite-scroll trigger: the data set just changed (likely
335 365 // a freshly appended page), so a new near-tail render may need more.
336 366 this._loadingMore = false;
367 + // refresh() means "the data changed", which includes in-place mutation
368 + // of the same array; drop the offset table rather than trust it.
369 + this._invalidateOffsets();
337 370 this._render(true);
338 371 }
339 372
@@ -346,10 +379,7 @@
346 379 if (this.isDestroyed) return;
347 380 if (index < 0 || index >= this.items.length) return;
348 381
349 - let targetScrollTop = 0;
350 - for (let i = 0; i < index; i++) {
351 - targetScrollTop += this._getItemHeight(this.items[i], i);
352 - }
382 + let targetScrollTop = this._offsets()[index];
353 383
354 384 const itemHeight = this._getItemHeight(this.items[index], index);
355 385 const viewportHeight = this.container.clientHeight;
@@ -390,6 +420,7 @@
390 420 */
391 421 clearHeightCache() {
392 422 this.heightCache.clear();
423 + this._invalidateOffsets();
393 424 this._hasRendered = false;
394 425 this.refresh();
395 426 }
@@ -83,6 +83,19 @@
83 83 require('../selection-manager'); // GoingsOn.SelectionManager
84 84 require('../whats-new'); // GoingsOn.whatsNew (changelog parser/renderer)
85 85
86 + // VirtualScroller touches two browser APIs the mock document above does not
87 + // provide. Both are stubbed synchronously so a construct-and-render is a
88 + // straight-line call in tests.
89 + globalThis.ResizeObserver = class {
90 + observe() {}
91 + disconnect() {}
92 + };
93 + globalThis.requestAnimationFrame = (fn) => { fn(); return 0; };
94 + globalThis.cancelAnimationFrame = () => {};
95 + window.ResizeObserver = globalThis.ResizeObserver;
96 +
97 + require('../virtual-scroller'); // GoingsOn.VirtualScroller
98 +
86 99 // Test: AppStateManager / GoingsOn.state
87 100
88 101 describe('GoingsOn.state', () => {
@@ -611,6 +624,100 @@
611 624 });
612 625 });
613 626
627 + // Test: VirtualScroller visible-range arithmetic
628 +
629 + describe('VirtualScroller', () => {
630 + const ROW = 50;
631 +
632 + // A container just real enough for _calculateVisibleRange: it only reads
633 + // scrollTop and clientHeight, and _createStructure only appends.
634 + function makeScroller(itemCount, { overscan = 0, viewportHeight = 200, scrollTop = 0 } = {}) {
635 + const container = createMockElement('div');
636 + container.scrollTop = scrollTop;
637 + container.clientHeight = viewportHeight;
638 + const items = Array.from({ length: itemCount }, (_, i) => ({ id: `item-${i}` }));
639 + const scroller = new GoingsOn.VirtualScroller({
640 + container,
641 + renderItem: (item) => `<div>${item.id}</div>`,
642 + getItems: () => items,
643 + rowHeight: { estimated: ROW, measure: false },
644 + overscan,
645 + });
646 + return scroller;
647 + }
648 +
649 + test('offset table gives each row its cumulative top', () => {
650 + const s = makeScroller(10);
651 + const offsets = s._offsets();
652 + assertEqual(offsets.length, 11);
653 + assertEqual(offsets[0], 0);
654 + assertEqual(offsets[3], 150);
655 + assertEqual(offsets[10], 500, 'last entry is the total content height');
656 + assertEqual(s._getTotalHeight(), 500);
657 + });
658 +
659 + test('_indexAtOffset lands on the row containing the offset', () => {
660 + const s = makeScroller(10);
661 + assertEqual(s._indexAtOffset(0), 0);
662 + assertEqual(s._indexAtOffset(49), 0);
663 + assertEqual(s._indexAtOffset(50), 1, 'a boundary belongs to the row it starts');
664 + assertEqual(s._indexAtOffset(51), 1);
665 + assertEqual(s._indexAtOffset(475), 9);
666 + });
667 +
668 + test('_indexAtOffset clamps past the end instead of returning one past the last row', () => {
669 + const s = makeScroller(10);
670 + // Regression: the old linear scan returned items.length here, which with
671 + // overscan 0 produced startIndex === endIndex and rendered nothing.
672 + assertEqual(s._indexAtOffset(500), 9);
673 + assertEqual(s._indexAtOffset(99999), 9);
674 + });
675 +
676 + test('scrolling past the end still renders the tail rather than blanking', () => {
677 + const s = makeScroller(10, { overscan: 0, scrollTop: 100000 });
678 + const { startIndex, endIndex } = s._calculateVisibleRange();
679 + assertEqual(startIndex, 9);
680 + assertEqual(endIndex, 10);
681 + assert(endIndex > startIndex, 'visible range must not be empty');
682 + });
683 +
684 + test('visible range covers the viewport at a scroll offset', () => {
685 + const s = makeScroller(100, { overscan: 0, viewportHeight: 200, scrollTop: 125 });
686 + const { startIndex, endIndex, topOffset } = s._calculateVisibleRange();
687 + assertEqual(startIndex, 2, 'row 2 spans 100-150 and contains scrollTop 125');
688 + assertEqual(endIndex, 7, 'row 6 spans 300-350 and contains the viewport bottom 325');
689 + assertEqual(topOffset, 100);
690 + });
691 +
692 + test('overscan widens the range on both sides and is clamped at the list edges', () => {
693 + const s = makeScroller(100, { overscan: 3, viewportHeight: 200, scrollTop: 500 });
694 + const { startIndex, endIndex } = s._calculateVisibleRange();
695 + assertEqual(startIndex, 7, 'first visible row 10, minus 3 overscan');
696 + assertEqual(endIndex, 18, 'last visible row 14, plus one, plus 3 overscan');
697 +
698 + const atTop = makeScroller(100, { overscan: 5, scrollTop: 0 });
699 + assertEqual(atTop._calculateVisibleRange().startIndex, 0, 'clamped at 0, not negative');
700 +
701 + const atEnd = makeScroller(10, { overscan: 5, scrollTop: 450 });
702 + assertEqual(atEnd._calculateVisibleRange().endIndex, 10, 'clamped at items.length');
703 + });
704 +
705 + test('empty list yields an empty range', () => {
706 + const s = makeScroller(0);
707 + assertDeepEqual(s._calculateVisibleRange(), { startIndex: 0, endIndex: 0, topOffset: 0 });
708 + });
709 +
710 + test('measured heights feed back into the offset table', () => {
711 + const s = makeScroller(10);
712 + s.heightCache.set('item-0', 120);
713 + s._invalidateOffsets();
714 + assertEqual(s._offsets()[1], 120, 'row 1 starts after the measured row 0');
715 + assertEqual(s._getTotalHeight(), 120 + 9 * ROW);
716 + assertEqual(s._indexAtOffset(119), 0);
717 + assertEqual(s._indexAtOffset(120), 1);
718 + });
719 + });
720 +
614 721 // Report
615 722
616 723 const success = report();