Compare commits

..

1 Commits

Author SHA1 Message Date
efiten
8c82172feb fix: null-guard animLayer and liveAnimCount in nextHop after destroy
Async timers (setInterval/setTimeout) started by animateHop() can fire
after destroy() has nulled animLayer and removed DOM elements. This
caused three console errors on the Live page when navigating away mid-
animation. Guards added at each async callback site.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 01:22:08 +00:00
5 changed files with 69 additions and 78 deletions

View File

@@ -807,7 +807,6 @@ window.addEventListener('DOMContentLoaded', () => {
// User's localStorage preferences take priority over server config
const userTheme = (() => { try { return JSON.parse(localStorage.getItem('meshcore-user-theme') || '{}'); } catch { return {}; } })();
window._SITE_CONFIG_ORIGINAL_HOME = JSON.parse(JSON.stringify(window.SITE_CONFIG.home || {}));
mergeUserHomeConfig(window.SITE_CONFIG, userTheme);
// Apply CSS variable overrides from theme config (skipped if user has local overrides)

View File

@@ -450,8 +450,7 @@
function mergeSection(key) {
return Object.assign({}, DEFAULTS[key], cfg[key] || {}, local[key] || {});
}
var serverHome = window._SITE_CONFIG_ORIGINAL_HOME || cfg.home || {};
var mergedHome = Object.assign({}, DEFAULTS.home, serverHome, local.home || {});
var mergedHome = mergeSection('home');
var localTsMode = localStorage.getItem('meshcore-timestamp-mode');
var localTsTimezone = localStorage.getItem('meshcore-timestamp-timezone');
var localTsFormat = localStorage.getItem('meshcore-timestamp-format');
@@ -1203,19 +1202,19 @@
var tmp = state.home.steps[i];
state.home.steps[i] = state.home.steps[j];
state.home.steps[j] = tmp;
render(container); autoSave();
render(container);
});
});
container.querySelectorAll('[data-rm-step]').forEach(function (btn) {
btn.addEventListener('click', function () {
state.home.steps.splice(parseInt(btn.dataset.rmStep), 1);
render(container); autoSave();
render(container);
});
});
var addStepBtn = document.getElementById('addStep');
if (addStepBtn) addStepBtn.addEventListener('click', function () {
state.home.steps.push({ emoji: '📌', title: '', description: '' });
render(container); autoSave();
render(container);
});
// Checklist
@@ -1228,13 +1227,13 @@
container.querySelectorAll('[data-rm-check]').forEach(function (btn) {
btn.addEventListener('click', function () {
state.home.checklist.splice(parseInt(btn.dataset.rmCheck), 1);
render(container); autoSave();
render(container);
});
});
var addCheckBtn = document.getElementById('addCheck');
if (addCheckBtn) addCheckBtn.addEventListener('click', function () {
state.home.checklist.push({ question: '', answer: '' });
render(container); autoSave();
render(container);
});
// Footer links
@@ -1247,13 +1246,13 @@
container.querySelectorAll('[data-rm-link]').forEach(function (btn) {
btn.addEventListener('click', function () {
state.home.footerLinks.splice(parseInt(btn.dataset.rmLink), 1);
render(container); autoSave();
render(container);
});
});
var addLinkBtn = document.getElementById('addLink');
if (addLinkBtn) addLinkBtn.addEventListener('click', function () {
state.home.footerLinks.push({ label: '', url: '' });
render(container); autoSave();
render(container);
});
// Export copy

View File

@@ -22,9 +22,9 @@
<meta name="twitter:title" content="CoreScope">
<meta name="twitter:description" content="Real-time MeshCore LoRa mesh network analyzer — live packet visualization, node tracking, channel decryption, and route analysis.">
<meta name="twitter:image" content="https://raw.githubusercontent.com/Kpa-clawbot/corescope/master/public/og-image.png">
<link rel="stylesheet" href="style.css?v=1775076186">
<link rel="stylesheet" href="home.css?v=1775076186">
<link rel="stylesheet" href="live.css?v=1775076186">
<link rel="stylesheet" href="style.css?v=1775078686">
<link rel="stylesheet" href="home.css?v=1775078686">
<link rel="stylesheet" href="live.css?v=1775078686">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="anonymous">
@@ -85,30 +85,30 @@
<main id="app" role="main"></main>
<script src="vendor/qrcode.js"></script>
<script src="roles.js?v=1775076186"></script>
<script src="customize.js?v=1775076186" onerror="console.error('Failed to load:', this.src)"></script>
<script src="region-filter.js?v=1775076186"></script>
<script src="hop-resolver.js?v=1775076186"></script>
<script src="hop-display.js?v=1775076186"></script>
<script src="app.js?v=1775076186"></script>
<script src="home.js?v=1775076186"></script>
<script src="packet-filter.js?v=1775076186"></script>
<script src="packets.js?v=1775076186"></script>
<script src="geo-filter-overlay.js?v=1775076186"></script>
<script src="map.js?v=1775076186" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1775076186" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1775076186" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1775076186" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1775076186" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=1775076186" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=1775076186" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v2-constellation.js?v=1775076186" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-lab.js?v=1775076186" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1775076186" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1775076186" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1775076186" onerror="console.error('Failed to load:', this.src)"></script>
<script src="compare.js?v=1775076186" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1775076186" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1775076186" onerror="console.error('Failed to load:', this.src)"></script>
<script src="roles.js?v=1775078686"></script>
<script src="customize.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
<script src="region-filter.js?v=1775078686"></script>
<script src="hop-resolver.js?v=1775078686"></script>
<script src="hop-display.js?v=1775078686"></script>
<script src="app.js?v=1775078686"></script>
<script src="home.js?v=1775078686"></script>
<script src="packet-filter.js?v=1775078686"></script>
<script src="packets.js?v=1775078686"></script>
<script src="geo-filter-overlay.js?v=1775078686"></script>
<script src="map.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v2-constellation.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-lab.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
<script src="compare.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
</body>
</html>

View File

@@ -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);

View File

@@ -1960,43 +1960,6 @@ console.log('\n=== customize.js: initState merge behavior ===');
assert.strictEqual(state.theme.accent, '#abcdef');
assert.strictEqual(state.theme.navBg, '#fedcba');
});
test('initState uses _SITE_CONFIG_ORIGINAL_HOME to bypass contaminated SITE_CONFIG.home', () => {
// Simulates: app.js called mergeUserHomeConfig which mutated SITE_CONFIG.home.steps = []
// The original server steps must still be recoverable via _SITE_CONFIG_ORIGINAL_HOME
const ctx = makeSandbox();
ctx.setTimeout = function (fn) { fn(); return 1; };
ctx.clearTimeout = function () {};
// SITE_CONFIG.home is contaminated — steps wiped by mergeUserHomeConfig at page load
ctx.window.SITE_CONFIG = {
home: {
heroTitle: 'Server Hero',
steps: [] // contaminated — user had steps:[] in localStorage at page load
}
};
// app.js snapshots original before mutation
ctx.window._SITE_CONFIG_ORIGINAL_HOME = {
heroTitle: 'Server Hero',
steps: [{ emoji: '🧪', title: 'Original Step', description: 'from server' }]
};
const ex = loadCustomizeExports(ctx);
ex.initState();
const state = ex.getState();
assert.strictEqual(state.home.steps.length, 1, 'should restore from snapshot, not contaminated SITE_CONFIG');
assert.strictEqual(state.home.steps[0].title, 'Original Step');
});
test('initState uses DEFAULTS.home when no SITE_CONFIG and no snapshot', () => {
const ctx = makeSandbox();
ctx.setTimeout = function (fn) { fn(); return 1; };
ctx.clearTimeout = function () {};
// No SITE_CONFIG at all — pure DEFAULTS
const ex = loadCustomizeExports(ctx);
ex.initState();
const state = ex.getState();
assert.ok(state.home.steps.length > 0, 'should use DEFAULTS.home.steps when no server config');
assert.strictEqual(state.home.steps[0].title, 'Join the Bay Area MeshCore Discord');
});
}
// ===== APP.JS: home rehydration merge =====
@@ -2733,6 +2696,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)}`);