From 8e42febc9c0fd8a73828863f3f49a060de80f5ce Mon Sep 17 00:00:00 2001 From: Kpa-clawbot Date: Fri, 3 Apr 2026 13:55:23 -0700 Subject: [PATCH] fix: virtual scroll height accounts for expanded group rows (#410) (#547) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes #410 — virtual scroll height miscalculation for expanded group rows. ## Root Cause When WebSocket messages add children to an already-expanded packet group, `_rowCounts` becomes stale during the 200ms render debounce window. Scroll events during this window call `renderVisibleRows()` with stale row counts, causing wrong total height, spacer heights, and visible range calculations. ## Changes **public/packets.js:** - Added `_rowCountsDirty` flag to track when row counts need recomputation - Added `_invalidateRowCounts()` — marks row counts as stale and clears cumulative cache - Added `_refreshRowCountsIfDirty()` — lazily recomputes `_rowCounts` from `_displayPackets` - Called `_invalidateRowCounts()` when WS handler adds children to expanded groups (line ~402) - Called `_refreshRowCountsIfDirty()` at top of `renderVisibleRows()` before using row counts - Reset `_rowCountsDirty` in all cleanup paths (destroy, empty display) **test-packets.js:** - Added 4 regression tests for `_invalidateRowCounts` / `_refreshRowCountsIfDirty` ## Complexity O(n) recomputation of `_rowCounts` when dirty (same as existing `renderTableRows` path). Only triggers when WS modifies expanded group children, which is infrequent relative to scroll events. Co-authored-by: you --- public/packets.js | 27 +++++++++++++++++++++++++++ test-packets.js | 27 +++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/public/packets.js b/public/packets.js index 12432e2c..5dcf3273 100644 --- a/public/packets.js +++ b/public/packets.js @@ -53,6 +53,7 @@ let _displayPackets = []; // filtered packets for current view let _displayGrouped = false; // whether _displayPackets is in grouped mode let _rowCounts = []; // per-entry DOM row counts (1 for flat, 1+children for expanded groups) + let _rowCountsDirty = false; // set when _rowCounts may be stale (e.g. WS added children) (#410) let _cumulativeOffsetsCache = null; // cached cumulative offsets, invalidated on _rowCounts change let _lastVisibleStart = -1; // last rendered start index (for dirty checking) let _lastVisibleEnd = -1; // last rendered end index (for dirty checking) @@ -396,6 +397,9 @@ existing._children.unshift(p); if (existing._children.length > 200) existing._children.length = 200; sortGroupChildren(existing); + // Invalidate row counts — child count changed, so virtual scroll + // heights are stale until next renderTableRows() (#410) + _invalidateRowCounts(); } } else { // New group @@ -442,6 +446,7 @@ clearTimeout(_wsRenderTimer); _displayPackets = []; _rowCounts = []; + _rowCountsDirty = false; _cumulativeOffsetsCache = null; _observerFilterSet = null; _lastVisibleStart = -1; @@ -1123,6 +1128,21 @@ `; } + // Mark _rowCounts as stale so renderVisibleRows() recomputes them lazily. + // Called when expanded group children change outside renderTableRows() (#410). + function _invalidateRowCounts() { + _rowCountsDirty = true; + _cumulativeOffsetsCache = null; + } + + // Recompute _rowCounts from _displayPackets if they've been invalidated. + function _refreshRowCountsIfDirty() { + if (!_rowCountsDirty || !_displayPackets.length) return; + _rowCounts = _displayPackets.map(function(p) { return _getRowCount(p); }); + _cumulativeOffsetsCache = null; + _rowCountsDirty = false; + } + // Compute the number of DOM rows a single entry produces. // Used by both row counting and renderVisibleRows to avoid divergence (#424). function _getRowCount(p) { @@ -1161,6 +1181,9 @@ const scrollContainer = document.getElementById('pktLeft'); if (!scrollContainer) return; + // Recompute row counts if they were invalidated (e.g. WS added children) (#410) + _refreshRowCountsIfDirty(); + // Compute total DOM rows accounting for expanded groups const offsets = _cumulativeRowOffsets(); const totalDomRows = offsets[offsets.length - 1]; @@ -1317,6 +1340,7 @@ if (!displayPackets.length) { _displayPackets = []; _rowCounts = []; + _rowCountsDirty = false; _cumulativeOffsetsCache = null; _observerFilterSet = null; _lastVisibleStart = -1; @@ -1336,6 +1360,7 @@ _displayGrouped = groupByHash; _observerFilterSet = filters.observer ? new Set(filters.observer.split(',')) : null; _rowCounts = displayPackets.map(p => _getRowCount(p)); + _rowCountsDirty = false; _cumulativeOffsetsCache = null; attachVScrollListener(); @@ -2044,6 +2069,8 @@ renderPath, _getRowCount, _cumulativeRowOffsets, + _invalidateRowCounts, + _refreshRowCountsIfDirty, buildGroupRowHtml, buildFlatRowHtml, }; diff --git a/test-packets.js b/test-packets.js index 94317b40..59586f17 100644 --- a/test-packets.js +++ b/test-packets.js @@ -757,6 +757,33 @@ console.log('\n=== packets.js: page registration ==='); }); } +console.log('\n=== packets.js: _invalidateRowCounts / _refreshRowCountsIfDirty (#410) ==='); +{ + const ctx = loadPacketsSandbox(); + const api = ctx._packetsTestAPI; + + test('_invalidateRowCounts and _refreshRowCountsIfDirty are exported', () => { + assert(typeof api._invalidateRowCounts === 'function'); + assert(typeof api._refreshRowCountsIfDirty === 'function'); + }); + + test('_invalidateRowCounts does not throw', () => { + api._invalidateRowCounts(); + }); + + test('_refreshRowCountsIfDirty does not throw when no display packets', () => { + api._invalidateRowCounts(); + api._refreshRowCountsIfDirty(); + }); + + test('_cumulativeRowOffsets returns valid offsets after invalidation cycle', () => { + // Even with no display packets, should return valid array + const offsets = api._cumulativeRowOffsets(); + assert(Array.isArray(offsets)); + assert(offsets[0] === 0); + }); +} + // ===== SUMMARY ===== console.log(`\n${'='.repeat(40)}`); console.log(`packets.js tests: ${passed} passed, ${failed} failed`);