Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'fix(sheets): eliminate virtual scroll jumpiness' (#77) from fix/scroll-jumpiness into main

scott d66817e2 721f581b

+771 -3
+18 -3
src/sheets/main.ts
··· 364 364 renderGrid(); 365 365 }); 366 366 } 367 + let _isRendering = false; 368 + 367 369 function renderGrid() { 368 - // Reset scroll cache so direct calls always re-render 369 - if (typeof _lastRenderedRange !== 'undefined') _lastRenderedRange = { startRow: -1, endRow: -1 }; 370 + _isRendering = true; 370 371 const sheet = getActiveSheet(); 371 372 const rowCount = sheet.get('rowCount') || DEFAULT_ROWS; 372 373 const colCount = sheet.get('colCount') || DEFAULT_COLS; ··· 618 619 } 619 620 620 621 html += '</tbody>'; 622 + 623 + // Preserve scroll position across innerHTML replacement to prevent jumpiness 624 + const savedScrollTop = sheetContainer ? sheetContainer.scrollTop : 0; 625 + const savedScrollLeft = sheetContainer ? sheetContainer.scrollLeft : 0; 621 626 grid.innerHTML = html; 627 + if (sheetContainer) { 628 + sheetContainer.scrollTop = savedScrollTop; 629 + sheetContainer.scrollLeft = savedScrollLeft; 630 + } 631 + 632 + // Update the rendered range cache so scroll handler can skip no-op re-renders 633 + _lastRenderedRange = { startRow: renderStartRow, endRow: renderEndRow }; 634 + _isRendering = false; 622 635 623 636 attachGridEvents(); 624 637 updateSelectionVisuals(); ··· 4582 4595 let _lastRenderedRange = { startRow: -1, endRow: -1 }; 4583 4596 if (sheetContainer) { 4584 4597 sheetContainer.addEventListener('scroll', () => { 4598 + // Skip if we're inside a renderGrid() call (scroll position restore fires this) 4599 + if (_isRendering) return; 4585 4600 if (_scrollRenderTimer) return; 4586 4601 _scrollRenderTimer = requestAnimationFrame(() => { 4587 4602 _scrollRenderTimer = null; 4603 + if (_isRendering) return; 4588 4604 if (editingCell) return; 4589 4605 // Skip re-render if visible range hasn't changed (prevents scroll jank) 4590 4606 const sheet = getActiveSheet(); ··· 4599 4615 bufferRows: 5, 4600 4616 }); 4601 4617 if (range.startRow === _lastRenderedRange.startRow && range.endRow === _lastRenderedRange.endRow) return; 4602 - _lastRenderedRange = range; 4603 4618 renderGrid(); 4604 4619 }); 4605 4620 });
+753
tests/scroll-stability.test.ts
··· 1 + /** 2 + * Scroll stability tests — verify that virtual scrolling produces 3 + * consistent total heights and correct spacer calculations regardless 4 + * of scroll position. These tests encode the invariants that prevent 5 + * scroll jumpiness when renderGrid() replaces innerHTML. 6 + */ 7 + 8 + import { describe, it, expect } from 'vitest'; 9 + import { calculateVisibleRange } from '../src/sheets/virtual-scroll.js'; 10 + 11 + // ============================================================ 12 + // Helpers — simulate renderGrid()'s spacer logic 13 + // ============================================================ 14 + 15 + interface SpacerResult { 16 + topSpacerHeight: number; 17 + renderedRowsHeight: number; 18 + bottomSpacerHeight: number; 19 + totalHeight: number; 20 + renderStartRow: number; 21 + renderEndRow: number; 22 + } 23 + 24 + /** 25 + * Simulates the spacer calculation from renderGrid(). 26 + * Given a visible range and row height function, computes the 27 + * exact heights that renderGrid() would produce. 28 + */ 29 + function computeSpacerHeights( 30 + totalRows: number, 31 + getRowHeight: (r: number) => number, 32 + visibleRange: { startRow: number; endRow: number }, 33 + freezeRows: number = 0, 34 + hiddenRows: Set<number> = new Set(), 35 + ): SpacerResult { 36 + const renderStartRow = Math.max(freezeRows + 1, visibleRange.startRow); 37 + const renderEndRow = visibleRange.endRow; 38 + 39 + // Top spacer: sum of non-hidden rows from (freezeRows+1) to (renderStartRow-1) 40 + let topSpacerHeight = 0; 41 + for (let r = freezeRows + 1; r < renderStartRow; r++) { 42 + if (!hiddenRows.has(r)) topSpacerHeight += getRowHeight(r); 43 + } 44 + 45 + // Rendered rows: frozen rows + visible body rows 46 + let renderedRowsHeight = 0; 47 + // Frozen rows 48 + for (let r = 1; r <= freezeRows; r++) { 49 + if (!hiddenRows.has(r)) renderedRowsHeight += getRowHeight(r); 50 + } 51 + // Body rows in visible range 52 + for (let r = renderStartRow; r <= renderEndRow; r++) { 53 + if (!hiddenRows.has(r)) renderedRowsHeight += getRowHeight(r); 54 + } 55 + 56 + // Bottom spacer: sum of non-hidden rows from (renderEndRow+1) to totalRows 57 + let bottomSpacerHeight = 0; 58 + for (let r = renderEndRow + 1; r <= totalRows; r++) { 59 + if (!hiddenRows.has(r)) bottomSpacerHeight += getRowHeight(r); 60 + } 61 + 62 + const totalHeight = topSpacerHeight + renderedRowsHeight + bottomSpacerHeight; 63 + return { topSpacerHeight, renderedRowsHeight, bottomSpacerHeight, totalHeight, renderStartRow, renderEndRow }; 64 + } 65 + 66 + /** Sum all row heights (excluding hidden rows) — the ground truth total */ 67 + function totalRowHeights( 68 + totalRows: number, 69 + getRowHeight: (r: number) => number, 70 + hiddenRows: Set<number> = new Set(), 71 + ): number { 72 + let sum = 0; 73 + for (let r = 1; r <= totalRows; r++) { 74 + if (!hiddenRows.has(r)) sum += getRowHeight(r); 75 + } 76 + return sum; 77 + } 78 + 79 + // ============================================================ 80 + // HEIGHT INVARIANT: top spacer + rendered + bottom spacer = total 81 + // ============================================================ 82 + 83 + describe('spacer height invariant', () => { 84 + const uniform26 = (_r: number) => 26; 85 + 86 + it('holds at scrollTop=0 with uniform heights', () => { 87 + const rows = 200; 88 + const expected = totalRowHeights(rows, uniform26); 89 + const range = calculateVisibleRange({ 90 + scrollTop: 0, 91 + viewportHeight: 600, 92 + totalRows: rows, 93 + getRowHeight: uniform26, 94 + bufferRows: 5, 95 + }); 96 + const result = computeSpacerHeights(rows, uniform26, range); 97 + expect(result.totalHeight).toBe(expected); 98 + }); 99 + 100 + it('holds at various scroll positions with uniform heights', () => { 101 + const rows = 500; 102 + const expected = totalRowHeights(rows, uniform26); 103 + 104 + for (const scrollTop of [0, 100, 500, 2000, 5000, 10000, 12000]) { 105 + const range = calculateVisibleRange({ 106 + scrollTop, 107 + viewportHeight: 600, 108 + totalRows: rows, 109 + getRowHeight: uniform26, 110 + bufferRows: 5, 111 + }); 112 + const result = computeSpacerHeights(rows, uniform26, range); 113 + expect(result.totalHeight).toBe(expected); 114 + } 115 + }); 116 + 117 + it('holds with variable row heights at many scroll positions', () => { 118 + const rows = 300; 119 + const variableHeight = (r: number) => { 120 + if (r % 10 === 0) return 52; // every 10th row is double 121 + if (r % 7 === 0) return 40; 122 + return 26; 123 + }; 124 + const expected = totalRowHeights(rows, variableHeight); 125 + 126 + for (let scrollTop = 0; scrollTop <= 10000; scrollTop += 200) { 127 + const range = calculateVisibleRange({ 128 + scrollTop, 129 + viewportHeight: 600, 130 + totalRows: rows, 131 + getRowHeight: variableHeight, 132 + bufferRows: 5, 133 + }); 134 + const result = computeSpacerHeights(rows, variableHeight, range); 135 + expect(result.totalHeight).toBe(expected); 136 + } 137 + }); 138 + 139 + it('holds with frozen rows', () => { 140 + const rows = 200; 141 + const freezeRows = 3; 142 + const expected = totalRowHeights(rows, uniform26); 143 + 144 + for (const scrollTop of [0, 200, 1000, 3000]) { 145 + const range = calculateVisibleRange({ 146 + scrollTop, 147 + viewportHeight: 600, 148 + totalRows: rows, 149 + getRowHeight: uniform26, 150 + bufferRows: 5, 151 + }); 152 + const result = computeSpacerHeights(rows, uniform26, range, freezeRows); 153 + expect(result.totalHeight).toBe(expected); 154 + } 155 + }); 156 + 157 + it('holds with hidden rows', () => { 158 + const rows = 200; 159 + const hidden = new Set([5, 10, 15, 20, 25, 50, 100]); 160 + const expected = totalRowHeights(rows, uniform26, hidden); 161 + 162 + for (const scrollTop of [0, 200, 1000, 3000]) { 163 + const range = calculateVisibleRange({ 164 + scrollTop, 165 + viewportHeight: 600, 166 + totalRows: rows, 167 + getRowHeight: uniform26, 168 + bufferRows: 5, 169 + }); 170 + const result = computeSpacerHeights(rows, uniform26, range, 0, hidden); 171 + expect(result.totalHeight).toBe(expected); 172 + } 173 + }); 174 + 175 + it('holds with frozen rows + hidden rows + variable heights', () => { 176 + const rows = 300; 177 + const freezeRows = 4; 178 + const hidden = new Set([6, 12, 18, 24, 30, 60, 90, 150]); 179 + const variableHeight = (r: number) => (r % 5 === 0 ? 40 : 26); 180 + const expected = totalRowHeights(rows, variableHeight, hidden); 181 + 182 + for (let scrollTop = 0; scrollTop <= 8000; scrollTop += 500) { 183 + const range = calculateVisibleRange({ 184 + scrollTop, 185 + viewportHeight: 600, 186 + totalRows: rows, 187 + getRowHeight: variableHeight, 188 + bufferRows: 5, 189 + }); 190 + const result = computeSpacerHeights(rows, variableHeight, range, freezeRows, hidden); 191 + expect(result.totalHeight).toBe(expected); 192 + } 193 + }); 194 + }); 195 + 196 + // ============================================================ 197 + // CONSISTENCY: same scroll position → same total height 198 + // ============================================================ 199 + 200 + describe('scroll position consistency', () => { 201 + const uniform26 = (_r: number) => 26; 202 + 203 + it('same scrollTop always produces identical visible range', () => { 204 + const scrollTop = 1500; 205 + const results: Array<{ startRow: number; endRow: number }> = []; 206 + 207 + for (let i = 0; i < 10; i++) { 208 + results.push( 209 + calculateVisibleRange({ 210 + scrollTop, 211 + viewportHeight: 600, 212 + totalRows: 200, 213 + getRowHeight: uniform26, 214 + bufferRows: 5, 215 + }), 216 + ); 217 + } 218 + 219 + for (let i = 1; i < results.length; i++) { 220 + expect(results[i].startRow).toBe(results[0].startRow); 221 + expect(results[i].endRow).toBe(results[0].endRow); 222 + } 223 + }); 224 + 225 + it('total height is identical across all scroll positions (uniform)', () => { 226 + const rows = 200; 227 + const expected = totalRowHeights(rows, uniform26); 228 + const totals = new Set<number>(); 229 + 230 + for (let scrollTop = 0; scrollTop <= rows * 26; scrollTop += 13) { 231 + const range = calculateVisibleRange({ 232 + scrollTop, 233 + viewportHeight: 600, 234 + totalRows: rows, 235 + getRowHeight: uniform26, 236 + bufferRows: 5, 237 + }); 238 + const result = computeSpacerHeights(rows, uniform26, range); 239 + totals.add(result.totalHeight); 240 + } 241 + 242 + expect(totals.size).toBe(1); 243 + expect(totals.has(expected)).toBe(true); 244 + }); 245 + 246 + it('total height is identical across all scroll positions (variable)', () => { 247 + const rows = 150; 248 + const varHeight = (r: number) => { 249 + if (r <= 5) return 50; // tall header rows 250 + if (r % 20 === 0) return 60; // periodic tall rows 251 + return 26; 252 + }; 253 + const expected = totalRowHeights(rows, varHeight); 254 + const totals = new Set<number>(); 255 + 256 + for (let scrollTop = 0; scrollTop <= 6000; scrollTop += 50) { 257 + const range = calculateVisibleRange({ 258 + scrollTop, 259 + viewportHeight: 600, 260 + totalRows: rows, 261 + getRowHeight: varHeight, 262 + bufferRows: 5, 263 + }); 264 + const result = computeSpacerHeights(rows, varHeight, range); 265 + totals.add(result.totalHeight); 266 + } 267 + 268 + expect(totals.size).toBe(1); 269 + expect(totals.has(expected)).toBe(true); 270 + }); 271 + }); 272 + 273 + // ============================================================ 274 + // RANGE TRANSITIONS: smooth range changes during scroll 275 + // ============================================================ 276 + 277 + describe('range transitions during scrolling', () => { 278 + const uniform26 = (_r: number) => 26; 279 + 280 + it('ranges overlap during gradual scroll (no gaps)', () => { 281 + let prevRange = calculateVisibleRange({ 282 + scrollTop: 0, 283 + viewportHeight: 600, 284 + totalRows: 500, 285 + getRowHeight: uniform26, 286 + bufferRows: 5, 287 + }); 288 + 289 + for (let scrollTop = 26; scrollTop <= 5000; scrollTop += 26) { 290 + const range = calculateVisibleRange({ 291 + scrollTop, 292 + viewportHeight: 600, 293 + totalRows: 500, 294 + getRowHeight: uniform26, 295 + bufferRows: 5, 296 + }); 297 + // Ranges must overlap or be adjacent — no gap in rendered rows 298 + expect(range.startRow).toBeLessThanOrEqual(prevRange.endRow + 1); 299 + expect(range.endRow).toBeGreaterThanOrEqual(prevRange.startRow - 1); 300 + prevRange = range; 301 + } 302 + }); 303 + 304 + it('startRow never decreases when scrolling down', () => { 305 + let prevStart = 1; 306 + for (let scrollTop = 0; scrollTop <= 5000; scrollTop += 50) { 307 + const range = calculateVisibleRange({ 308 + scrollTop, 309 + viewportHeight: 600, 310 + totalRows: 500, 311 + getRowHeight: uniform26, 312 + bufferRows: 5, 313 + }); 314 + expect(range.startRow).toBeGreaterThanOrEqual(prevStart); 315 + prevStart = range.startRow; 316 + } 317 + }); 318 + 319 + it('endRow never increases when scrolling up', () => { 320 + let prevEnd = 500; 321 + for (let scrollTop = 5000; scrollTop >= 0; scrollTop -= 50) { 322 + const range = calculateVisibleRange({ 323 + scrollTop, 324 + viewportHeight: 600, 325 + totalRows: 500, 326 + getRowHeight: uniform26, 327 + bufferRows: 5, 328 + }); 329 + expect(range.endRow).toBeLessThanOrEqual(prevEnd); 330 + prevEnd = range.endRow; 331 + } 332 + }); 333 + }); 334 + 335 + // ============================================================ 336 + // TOP SPACER ACCURACY: spacer pixel offset matches row positions 337 + // ============================================================ 338 + 339 + describe('top spacer accuracy', () => { 340 + it('top spacer equals sum of all rows above the rendered range', () => { 341 + const rows = 300; 342 + const varHeight = (r: number) => (r % 3 === 0 ? 40 : 26); 343 + 344 + for (const scrollTop of [0, 500, 1500, 3000, 5000]) { 345 + const range = calculateVisibleRange({ 346 + scrollTop, 347 + viewportHeight: 600, 348 + totalRows: rows, 349 + getRowHeight: varHeight, 350 + bufferRows: 5, 351 + }); 352 + 353 + const result = computeSpacerHeights(rows, varHeight, range); 354 + 355 + // Verify top spacer is exactly the sum of rows before renderStartRow 356 + let expectedTop = 0; 357 + for (let r = 1; r < result.renderStartRow; r++) { 358 + expectedTop += varHeight(r); 359 + } 360 + expect(result.topSpacerHeight).toBe(expectedTop); 361 + } 362 + }); 363 + 364 + it('bottom spacer equals sum of all rows after the rendered range', () => { 365 + const rows = 300; 366 + const varHeight = (r: number) => (r % 3 === 0 ? 40 : 26); 367 + 368 + for (const scrollTop of [0, 500, 1500, 3000, 5000]) { 369 + const range = calculateVisibleRange({ 370 + scrollTop, 371 + viewportHeight: 600, 372 + totalRows: rows, 373 + getRowHeight: varHeight, 374 + bufferRows: 5, 375 + }); 376 + 377 + const result = computeSpacerHeights(rows, varHeight, range); 378 + 379 + let expectedBottom = 0; 380 + for (let r = result.renderEndRow + 1; r <= rows; r++) { 381 + expectedBottom += varHeight(r); 382 + } 383 + expect(result.bottomSpacerHeight).toBe(expectedBottom); 384 + } 385 + }); 386 + }); 387 + 388 + // ============================================================ 389 + // BUFFER ROWS: ensure buffer provides smooth transitions 390 + // ============================================================ 391 + 392 + describe('buffer rows behavior', () => { 393 + const uniform26 = (_r: number) => 26; 394 + 395 + it('buffer=0 renders only visible rows', () => { 396 + const range = calculateVisibleRange({ 397 + scrollTop: 520, // row 21 398 + viewportHeight: 260, // ~10 rows 399 + totalRows: 100, 400 + getRowHeight: uniform26, 401 + bufferRows: 0, 402 + }); 403 + // Should be ~10 rows centered on the visible area 404 + expect(range.endRow - range.startRow + 1).toBeLessThanOrEqual(12); 405 + }); 406 + 407 + it('larger buffer renders more rows', () => { 408 + const small = calculateVisibleRange({ 409 + scrollTop: 520, 410 + viewportHeight: 260, 411 + totalRows: 100, 412 + getRowHeight: uniform26, 413 + bufferRows: 2, 414 + }); 415 + const large = calculateVisibleRange({ 416 + scrollTop: 520, 417 + viewportHeight: 260, 418 + totalRows: 100, 419 + getRowHeight: uniform26, 420 + bufferRows: 10, 421 + }); 422 + expect(large.endRow - large.startRow).toBeGreaterThan(small.endRow - small.startRow); 423 + }); 424 + 425 + it('buffer does not exceed total rows', () => { 426 + const range = calculateVisibleRange({ 427 + scrollTop: 0, 428 + viewportHeight: 260, 429 + totalRows: 5, 430 + getRowHeight: uniform26, 431 + bufferRows: 100, 432 + }); 433 + expect(range.startRow).toBe(1); 434 + expect(range.endRow).toBe(5); 435 + }); 436 + }); 437 + 438 + // ============================================================ 439 + // EDGE CASES 440 + // ============================================================ 441 + 442 + describe('edge cases', () => { 443 + const uniform26 = (_r: number) => 26; 444 + 445 + it('handles 1 row', () => { 446 + const range = calculateVisibleRange({ 447 + scrollTop: 0, 448 + viewportHeight: 600, 449 + totalRows: 1, 450 + getRowHeight: uniform26, 451 + bufferRows: 5, 452 + }); 453 + expect(range.startRow).toBe(1); 454 + expect(range.endRow).toBe(1); 455 + const result = computeSpacerHeights(1, uniform26, range); 456 + expect(result.totalHeight).toBe(26); 457 + expect(result.topSpacerHeight).toBe(0); 458 + expect(result.bottomSpacerHeight).toBe(0); 459 + }); 460 + 461 + it('handles scrollTop beyond total content height', () => { 462 + const range = calculateVisibleRange({ 463 + scrollTop: 100000, 464 + viewportHeight: 600, 465 + totalRows: 100, 466 + getRowHeight: uniform26, 467 + bufferRows: 5, 468 + }); 469 + expect(range.endRow).toBe(100); 470 + expect(range.startRow).toBeGreaterThanOrEqual(1); 471 + const result = computeSpacerHeights(100, uniform26, range); 472 + expect(result.totalHeight).toBe(2600); 473 + }); 474 + 475 + it('handles very tall rows', () => { 476 + const tallHeight = (_r: number) => 500; 477 + const range = calculateVisibleRange({ 478 + scrollTop: 2500, 479 + viewportHeight: 600, 480 + totalRows: 50, 481 + getRowHeight: tallHeight, 482 + bufferRows: 5, 483 + }); 484 + const result = computeSpacerHeights(50, tallHeight, range); 485 + expect(result.totalHeight).toBe(50 * 500); 486 + }); 487 + 488 + it('handles viewport taller than all content', () => { 489 + const range = calculateVisibleRange({ 490 + scrollTop: 0, 491 + viewportHeight: 10000, 492 + totalRows: 10, 493 + getRowHeight: uniform26, 494 + bufferRows: 5, 495 + }); 496 + expect(range.startRow).toBe(1); 497 + expect(range.endRow).toBe(10); 498 + const result = computeSpacerHeights(10, uniform26, range); 499 + expect(result.topSpacerHeight).toBe(0); 500 + expect(result.bottomSpacerHeight).toBe(0); 501 + expect(result.totalHeight).toBe(260); 502 + }); 503 + 504 + it('handles all rows hidden', () => { 505 + const rows = 20; 506 + const hidden = new Set(Array.from({ length: 20 }, (_, i) => i + 1)); 507 + const expected = totalRowHeights(rows, uniform26, hidden); 508 + expect(expected).toBe(0); 509 + 510 + const range = calculateVisibleRange({ 511 + scrollTop: 0, 512 + viewportHeight: 600, 513 + totalRows: rows, 514 + getRowHeight: uniform26, 515 + bufferRows: 5, 516 + }); 517 + const result = computeSpacerHeights(rows, uniform26, range, 0, hidden); 518 + expect(result.totalHeight).toBe(0); 519 + }); 520 + 521 + it('handles alternating hidden rows', () => { 522 + const rows = 100; 523 + const hidden = new Set(Array.from({ length: 50 }, (_, i) => i * 2 + 1)); // odd rows hidden 524 + const expected = totalRowHeights(rows, uniform26, hidden); 525 + 526 + for (const scrollTop of [0, 300, 800]) { 527 + const range = calculateVisibleRange({ 528 + scrollTop, 529 + viewportHeight: 600, 530 + totalRows: rows, 531 + getRowHeight: uniform26, 532 + bufferRows: 5, 533 + }); 534 + const result = computeSpacerHeights(rows, uniform26, range, 0, hidden); 535 + expect(result.totalHeight).toBe(expected); 536 + } 537 + }); 538 + 539 + it('handles extremely large row count', () => { 540 + const range = calculateVisibleRange({ 541 + scrollTop: 500000, 542 + viewportHeight: 600, 543 + totalRows: 100000, 544 + getRowHeight: uniform26, 545 + bufferRows: 5, 546 + }); 547 + // Should return a bounded range, not try to render all rows 548 + expect(range.endRow - range.startRow).toBeLessThan(50); 549 + expect(range.startRow).toBeGreaterThan(1); 550 + expect(range.endRow).toBeLessThanOrEqual(100000); 551 + }); 552 + 553 + it('handles negative scrollTop gracefully', () => { 554 + const range = calculateVisibleRange({ 555 + scrollTop: -100, 556 + viewportHeight: 600, 557 + totalRows: 100, 558 + getRowHeight: uniform26, 559 + bufferRows: 5, 560 + }); 561 + expect(range.startRow).toBe(1); 562 + }); 563 + 564 + it('handles zero viewportHeight', () => { 565 + const range = calculateVisibleRange({ 566 + scrollTop: 500, 567 + viewportHeight: 0, 568 + totalRows: 100, 569 + getRowHeight: uniform26, 570 + bufferRows: 5, 571 + }); 572 + expect(range.startRow).toBeGreaterThanOrEqual(1); 573 + expect(range.endRow).toBeLessThanOrEqual(100); 574 + const result = computeSpacerHeights(100, uniform26, range); 575 + expect(result.totalHeight).toBe(2600); 576 + }); 577 + }); 578 + 579 + // ============================================================ 580 + // SCROLL POSITION ↔ SPACER ALIGNMENT 581 + // ============================================================ 582 + 583 + describe('scroll position to spacer alignment', () => { 584 + it('top spacer height matches the cumulative height of rows above visible range', () => { 585 + const varHeight = (r: number) => { 586 + if (r <= 10) return 50; 587 + if (r <= 50) return 30; 588 + return 26; 589 + }; 590 + const rows = 200; 591 + 592 + // Scroll to a position within the variable-height section 593 + const scrollTop = 800; 594 + const range = calculateVisibleRange({ 595 + scrollTop, 596 + viewportHeight: 600, 597 + totalRows: rows, 598 + getRowHeight: varHeight, 599 + bufferRows: 5, 600 + }); 601 + 602 + const result = computeSpacerHeights(rows, varHeight, range); 603 + 604 + // The top spacer should position content so that scrollTop 605 + // lands on the correct row — verify the spacer is within one 606 + // row height of the scrollTop (accounting for buffer) 607 + let cumHeight = 0; 608 + let rowAtScrollTop = 1; 609 + for (let r = 1; r <= rows; r++) { 610 + if (cumHeight + varHeight(r) > scrollTop) { 611 + rowAtScrollTop = r; 612 + break; 613 + } 614 + cumHeight += varHeight(r); 615 + } 616 + 617 + // The rendered range should include the row at scrollTop 618 + expect(result.renderStartRow).toBeLessThanOrEqual(rowAtScrollTop); 619 + expect(result.renderEndRow).toBeGreaterThanOrEqual(rowAtScrollTop); 620 + }); 621 + }); 622 + 623 + // ============================================================ 624 + // RANGE CACHE BEHAVIOR 625 + // ============================================================ 626 + 627 + describe('range cache optimization', () => { 628 + const uniform26 = (_r: number) => 26; 629 + 630 + it('identical scroll positions produce identical ranges', () => { 631 + const params = { 632 + scrollTop: 1000, 633 + viewportHeight: 600, 634 + totalRows: 200, 635 + getRowHeight: uniform26, 636 + bufferRows: 5, 637 + }; 638 + 639 + const range1 = calculateVisibleRange(params); 640 + const range2 = calculateVisibleRange(params); 641 + 642 + expect(range1.startRow).toBe(range2.startRow); 643 + expect(range1.endRow).toBe(range2.endRow); 644 + }); 645 + 646 + it('small scroll changes within same row do not change range', () => { 647 + const base = calculateVisibleRange({ 648 + scrollTop: 1000, 649 + viewportHeight: 600, 650 + totalRows: 200, 651 + getRowHeight: uniform26, 652 + bufferRows: 5, 653 + }); 654 + 655 + // Scroll 1px — should not change range (still same row) 656 + const shifted = calculateVisibleRange({ 657 + scrollTop: 1001, 658 + viewportHeight: 600, 659 + totalRows: 200, 660 + getRowHeight: uniform26, 661 + bufferRows: 5, 662 + }); 663 + 664 + expect(shifted.startRow).toBe(base.startRow); 665 + expect(shifted.endRow).toBe(base.endRow); 666 + }); 667 + 668 + it('scrolling exactly one row height shifts range by 1', () => { 669 + const base = calculateVisibleRange({ 670 + scrollTop: 1000, 671 + viewportHeight: 600, 672 + totalRows: 200, 673 + getRowHeight: uniform26, 674 + bufferRows: 5, 675 + }); 676 + 677 + const shifted = calculateVisibleRange({ 678 + scrollTop: 1000 + 26, 679 + viewportHeight: 600, 680 + totalRows: 200, 681 + getRowHeight: uniform26, 682 + bufferRows: 5, 683 + }); 684 + 685 + // Range should shift by at most 1 row 686 + expect(Math.abs(shifted.startRow - base.startRow)).toBeLessThanOrEqual(1); 687 + expect(Math.abs(shifted.endRow - base.endRow)).toBeLessThanOrEqual(1); 688 + }); 689 + }); 690 + 691 + // ============================================================ 692 + // RENDERED ROW CORRECTNESS 693 + // ============================================================ 694 + 695 + describe('rendered row correctness', () => { 696 + const uniform26 = (_r: number) => 26; 697 + 698 + it('all rows within visible viewport are rendered', () => { 699 + const scrollTop = 1000; 700 + const viewportHeight = 600; 701 + const range = calculateVisibleRange({ 702 + scrollTop, 703 + viewportHeight, 704 + totalRows: 200, 705 + getRowHeight: uniform26, 706 + bufferRows: 5, 707 + }); 708 + 709 + // Find which rows are actually visible 710 + const firstVisibleRow = Math.floor(scrollTop / 26) + 1; 711 + const lastVisibleRow = Math.ceil((scrollTop + viewportHeight) / 26); 712 + 713 + expect(range.startRow).toBeLessThanOrEqual(firstVisibleRow); 714 + expect(range.endRow).toBeGreaterThanOrEqual(lastVisibleRow); 715 + }); 716 + 717 + it('all rows within visible viewport are rendered (variable heights)', () => { 718 + const varHeight = (r: number) => (r % 5 === 0 ? 52 : 26); 719 + const scrollTop = 1500; 720 + const viewportHeight = 600; 721 + 722 + const range = calculateVisibleRange({ 723 + scrollTop, 724 + viewportHeight, 725 + totalRows: 200, 726 + getRowHeight: varHeight, 727 + bufferRows: 5, 728 + }); 729 + 730 + // Walk to find first visible row 731 + let cumH = 0; 732 + let firstVisible = 1; 733 + for (let r = 1; r <= 200; r++) { 734 + if (cumH + varHeight(r) > scrollTop) { 735 + firstVisible = r; 736 + break; 737 + } 738 + cumH += varHeight(r); 739 + } 740 + 741 + // Walk to find last visible row 742 + let lastVisible = firstVisible; 743 + let filled = 0; 744 + for (let r = firstVisible; r <= 200; r++) { 745 + filled += varHeight(r); 746 + lastVisible = r; 747 + if (filled >= viewportHeight) break; 748 + } 749 + 750 + expect(range.startRow).toBeLessThanOrEqual(firstVisible); 751 + expect(range.endRow).toBeGreaterThanOrEqual(lastVisible); 752 + }); 753 + });