From e47c39ffda01951be41c49536732203131ea34ea Mon Sep 17 00:00:00 2001 From: efiten Date: Thu, 2 Apr 2026 03:52:34 +0200 Subject: [PATCH] fix: null-guard animLayer and liveAnimCount in nextHop after destroy (#462) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - `nextHop()` schedules `setInterval`/`setTimeout` callbacks that can fire after `destroy()` has set `animLayer = null` and removed DOM elements - This caused three console errors on the Live page when navigating away mid-animation: `Cannot read properties of null (reading 'hasLayer')` and `Cannot set properties of null (setting 'textContent')` - Added null guards at each async callback site; no behavioral change when the page is active ## Changes - `public/live.js`: early return if `animLayer` is null at start of `nextHop()`; null-safe `animLayer.hasLayer` checks in `setInterval`/`setTimeout`; null-safe `liveAnimCount` element access - `public/index.html`: cache buster bumped - `test-frontend-helpers.js`: 4 source-inspection tests verifying the null guards are present ## Test plan - [ ] Open Live page, trigger some packet animations, navigate away quickly — no console errors - [ ] `node test-frontend-helpers.js` passes (233 tests, 0 failures) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Sonnet 4.6 --- public/index.html | 56 ++++++++++++++++++++-------------------- public/live.js | 8 +++--- test-frontend-helpers.js | 28 ++++++++++++++++++++ 3 files changed, 61 insertions(+), 31 deletions(-) diff --git a/public/index.html b/public/index.html index cd8df9f..a64f36a 100644 --- a/public/index.html +++ b/public/index.html @@ -22,9 +22,9 @@ - - - + + + @@ -85,30 +85,30 @@
- - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/live.js b/public/live.js index 2be005b..5086160 100644 --- a/public/live.js +++ b/public/live.js @@ -1850,9 +1850,11 @@ function nextHop() { if (hopIndex >= hopPositions.length) { activeAnims = Math.max(0, activeAnims - 1); - document.getElementById('liveAnimCount').textContent = activeAnims; + const countEl = document.getElementById('liveAnimCount'); + if (countEl) countEl.textContent = activeAnims; return; } + if (!animLayer) return; // Audio hook: notify per-hop callback if (onHop) try { onHop(hopIndex, hopPositions.length, hopPositions[hopIndex]); } catch (e) {} const hp = hopPositions[hopIndex]; @@ -1865,11 +1867,11 @@ }).addTo(animLayer); let pulseUp = true; const pulseTimer = setInterval(() => { - if (!animLayer.hasLayer(ghost)) { clearInterval(pulseTimer); return; } + if (!animLayer || !animLayer.hasLayer(ghost)) { clearInterval(pulseTimer); return; } ghost.setStyle({ fillOpacity: pulseUp ? 0.6 : 0.25, opacity: pulseUp ? 0.7 : 0.4 }); pulseUp = !pulseUp; }, 600); - setTimeout(() => { clearInterval(pulseTimer); if (animLayer.hasLayer(ghost)) animLayer.removeLayer(ghost); }, 3000); + setTimeout(() => { clearInterval(pulseTimer); if (animLayer && animLayer.hasLayer(ghost)) animLayer.removeLayer(ghost); }, 3000); } } else { pulseNode(hp.key, hp.pos, typeName); diff --git a/test-frontend-helpers.js b/test-frontend-helpers.js index 5c72224..b0153a6 100644 --- a/test-frontend-helpers.js +++ b/test-frontend-helpers.js @@ -2788,6 +2788,34 @@ console.log('\n=== packets.js: savedTimeWindowMin defaults ==='); }); } +// ===== live.js: nextHop null guards ===== +console.log('\n=== live.js: nextHop null guards ==='); +{ + const liveSource = fs.readFileSync('public/live.js', 'utf8'); + + test('nextHop guards animLayer null before use', () => { + assert.ok(liveSource.includes('if (!animLayer) return;'), + 'nextHop must return early when animLayer is null (post-destroy)'); + }); + + test('nextHop setInterval guards animLayer null', () => { + assert.ok(liveSource.includes('if (!animLayer || !animLayer.hasLayer(ghost))'), + 'setInterval in nextHop must guard animLayer null'); + }); + + test('nextHop setTimeout guards animLayer null', () => { + assert.ok(liveSource.includes('if (animLayer && animLayer.hasLayer(ghost)) animLayer.removeLayer(ghost)'), + 'setTimeout in nextHop must guard animLayer null'); + }); + + test('nextHop guards liveAnimCount element null', () => { + assert.ok(liveSource.includes('const countEl = document.getElementById(\'liveAnimCount\')'), + 'nextHop must null-check liveAnimCount element'); + assert.ok(liveSource.includes('if (countEl) countEl.textContent = activeAnims'), + 'nextHop must conditionally update liveAnimCount'); + }); +} + // ===== SUMMARY ===== Promise.allSettled(pendingTests).then(() => { console.log(`\n${'═'.repeat(40)}`);