diff --git a/public/packets.js b/public/packets.js index d0e8acf..0004e29 100644 --- a/public/packets.js +++ b/public/packets.js @@ -40,6 +40,21 @@ clearTimeout(_renderTimer); _renderTimer = setTimeout(() => renderTableRows(), 200); } + + // Coalesce WS-triggered renders into one per animation frame (#396). + // Multiple WS batches arriving within the same frame only trigger a single + // renderTableRows() call on the next rAF, preventing rapid full rebuilds. + function scheduleWSRender() { + _wsRenderDirty = true; + if (_wsRafId) return; // already scheduled + _wsRafId = requestAnimationFrame(function () { + _wsRafId = null; + if (_wsRenderDirty) { + _wsRenderDirty = false; + renderTableRows(); + } + }); + } const PANEL_WIDTH_KEY = 'meshcore-panel-width'; const PANEL_CLOSE_HTML = ''; @@ -59,6 +74,8 @@ let _lastVisibleEnd = -1; // last rendered end index (for dirty checking) let _vsScrollHandler = null; // scroll listener reference let _wsRenderTimer = null; // debounce timer for WS-triggered renders + let _wsRafId = null; // rAF id for coalescing WS-triggered renders (#396) + let _wsRenderDirty = false; // dirty flag for rAF render coalescing (#396) let _observerFilterSet = null; // cached Set from filters.observer, hoisted above loops (#427) function closeDetailPanel() { @@ -461,9 +478,8 @@ if (packets.length > PACKET_LIMIT) packets.length = PACKET_LIMIT; } totalCount += filtered.length; - // Debounce WS-triggered renders to avoid rapid full rebuilds - clearTimeout(_wsRenderTimer); - _wsRenderTimer = setTimeout(function () { renderTableRows(); }, 200); + // Coalesce WS-triggered renders via rAF (#396) + scheduleWSRender(); }); }); } @@ -474,6 +490,8 @@ wsHandler = null; detachVScrollListener(); clearTimeout(_wsRenderTimer); + if (_wsRafId) { cancelAnimationFrame(_wsRafId); _wsRafId = null; } + _wsRenderDirty = false; _displayPackets = []; _rowCounts = []; _rowCountsDirty = false; diff --git a/test-frontend-helpers.js b/test-frontend-helpers.js index 6e8ed4c..c1f057c 100644 --- a/test-frontend-helpers.js +++ b/test-frontend-helpers.js @@ -3193,20 +3193,24 @@ console.log('\n=== channels.js: formatHashHex (issue #465) ==='); 'destroy must reset observerMap to empty Map'); }); - test('WS handler debounces render via _wsRenderTimer', () => { + test('WS handler coalesces render via rAF (#396)', () => { const wsBlock = src.slice(src.indexOf('wsHandler = debouncedOnWS'), src.indexOf('function destroy()')); - assert.ok(wsBlock.includes('_wsRenderTimer'), - 'WS handler must debounce renders via _wsRenderTimer'); - assert.ok(wsBlock.includes('clearTimeout(_wsRenderTimer)'), - 'WS handler must clear pending timer before scheduling new render'); - assert.ok(/setTimeout\(function \(\) \{ renderTableRows\(\); \}/.test(wsBlock), - 'WS handler must schedule renderTableRows via setTimeout'); + assert.ok(wsBlock.includes('scheduleWSRender()'), + 'WS handler must coalesce renders via scheduleWSRender()'); + // Verify scheduleWSRender uses requestAnimationFrame + const schedFn = src.slice(src.indexOf('function scheduleWSRender()'), src.indexOf('function scheduleWSRender()') + 300); + assert.ok(schedFn.includes('requestAnimationFrame'), + 'scheduleWSRender must use requestAnimationFrame for coalescing'); + assert.ok(schedFn.includes('_wsRenderDirty'), + 'scheduleWSRender must use dirty flag pattern'); }); - test('destroy clears _wsRenderTimer', () => { - const destroyBlock = src.slice(src.indexOf('function destroy()'), src.indexOf('function destroy()') + 500); - assert.ok(destroyBlock.includes('clearTimeout(_wsRenderTimer)'), - 'destroy must clear _wsRenderTimer to prevent stale renders after navigation'); + test('destroy clears rAF and dirty flag (#396)', () => { + const destroyBlock = src.slice(src.indexOf('function destroy()'), src.indexOf('function destroy()') + 600); + assert.ok(destroyBlock.includes('cancelAnimationFrame(_wsRafId)'), + 'destroy must cancel pending rAF to prevent stale renders after navigation'); + assert.ok(destroyBlock.includes('_wsRenderDirty = false'), + 'destroy must reset dirty flag'); }); } // ===== NODES.JS: shared sandbox factory =====