diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index a79c2112e..16072c5b6 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -475,6 +475,7 @@ jobs:
BASE_URL=http://localhost:13581 node test-channel-issue-1111-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-map-modal-fluid-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-map-nodes-pagination-e2e.js 2>&1 | tee -a e2e-output.txt
+ CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-123-map-lifecycle-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-observer-iata-1188-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1639-observers-sort-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1758-ng-filter-rerenders-e2e.js 2>&1 | tee -a e2e-output.txt
diff --git a/public/map.js b/public/map.js
index b59922c58..6807bca32 100644
--- a/public/map.js
+++ b/public/map.js
@@ -29,6 +29,37 @@
let topRoutesGeneration = 0;
let userHasMoved = false;
let controlsCollapsed = false;
+ // #123: lifecycle token. Bumped by every init() and destroy(), so work a
+ // mount started (a response, a timer) can tell it is stale and do nothing.
+ // A bare `if (map)` is not enough: after a quick return it sees the NEXT
+ // mount's map.
+ let mapGeneration = 0;
+ // #123: newest loadNodes() request of this mount; older ones never render.
+ let nodesLoadGeneration = 0;
+ let nodesLoadPromise = null;
+ const mapTimers = new Set(); // pending mapTimeout() ids, cleared by destroy()
+ let areaNodesHandler = null; // AreaFilter listeners, removed by destroy()
+ let areaOutlineHandler = null;
+ let mapThemeObs = null; // theme observer and tile-provider listener
+ let tileProviderHandler = null; // of this mount, removed by destroy()
+
+ // Returns alive(): true while the mount that called mapToken() is current.
+ function mapToken() {
+ const gen = mapGeneration;
+ return function alive() { return gen === mapGeneration; };
+ }
+
+ // setTimeout scoped to the current mount: cleared by destroy() and never
+ // runs on a later mount's map.
+ function mapTimeout(fn, ms) {
+ const alive = mapToken();
+ const id = setTimeout(function () {
+ mapTimers.delete(id);
+ if (alive() && map) fn();
+ }, ms);
+ mapTimers.add(id);
+ return id;
+ }
// Safe escape — falls back to identity if app.js hasn't loaded yet.
// Escape untrusted strings (mesh-advertised node names) before HTML interpolation
@@ -176,6 +207,8 @@
}
async function init(container) {
+ mapGeneration++;
+ const alive = mapToken();
container.innerHTML = `
@@ -291,6 +324,8 @@
if (Array.isArray(mapCfg.center) && mapCfg.center.length === 2) defaultCenter = mapCfg.center;
if (typeof mapCfg.zoom === 'number') defaultZoom = mapCfg.zoom;
} catch {}
+ // #123: the page was left (or left and re-entered) during the fetch.
+ if (!alive()) return;
let initCenter = defaultCenter;
let initZoom = defaultZoom;
// Check URL query params first (from packet detail links). #1709: shared
@@ -373,19 +408,20 @@
window.MC_createLayerControl(map, autoLayerGroup, 'topleft');
}
- const _mapThemeObs = new MutationObserver(function () {
+ mapThemeObs = new MutationObserver(function () {
const dark = document.documentElement.getAttribute('data-theme') === 'dark' ||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
_syncDarkTiles(dark);
});
- _mapThemeObs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
+ mapThemeObs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
// #1420 — re-render when the user picks a different dark provider in the customizer.
- window.addEventListener('mc-tile-provider-changed', function (e) {
+ tileProviderHandler = function (e) {
const dark = document.documentElement.getAttribute('data-theme') === 'dark' ||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (e && e.detail && e.detail.type && e.detail.type !== (dark ? 'dark' : 'light')) return;
_syncDarkTiles(dark);
- });
+ };
+ window.addEventListener('mc-tile-provider-changed', tileProviderHandler);
// #1689 r1 (adv #4): live re-render when the customizer "hide 1-byte
// path hops" toggle flips. Before this listener the toggle only took
@@ -443,7 +479,7 @@
}
// Fix map size on SPA load
- setTimeout(() => map.invalidateSize(), 100);
+ mapTimeout(() => map.invalidateSize(), 100);
// Controls toggle
const toggleBtn = document.getElementById('mapControlsToggle');
@@ -607,7 +643,7 @@
document.getElementById('mcConfiguredScopeFilter').addEventListener('change', e => { filters.configuredScope = e.target.value; localStorage.setItem('meshcore-map-configured-scope-filter', filters.configuredScope); renderMarkers(); });
AreaFilter.init(document.getElementById('mapAreaFilter'));
- AreaFilter.onChange(function () { loadNodes(); });
+ areaNodesHandler = AreaFilter.onChange(function () { loadNodes(); });
// Status filter buttons
document.querySelectorAll('#mcStatusFilter .btn').forEach(btn => {
@@ -636,6 +672,7 @@
(async function () {
try {
const gf = await api('/config/geo-filter', { ttl: 3600 });
+ if (!alive()) return;
if (!gf || !gf.polygon || gf.polygon.length < 3) return;
const geoColor = getComputedStyle(document.documentElement).getPropertyValue('--geo-filter-color').trim() || '#3b82f6';
const latlngs = gf.polygon.map(function (p) { return [p[0], p[1]]; });
@@ -707,7 +744,10 @@
}
return null;
}
+ var refreshGen = 0;
async function refresh() {
+ if (!alive() || !map) return;
+ var gen = ++refreshGen;
var label = document.getElementById('mcSelectedAreaLabel');
var checkbox = document.getElementById('mcSelectedArea');
if (selectedAreaLayer) { map.removeLayer(selectedAreaLayer); selectedAreaLayer = null; }
@@ -717,6 +757,8 @@
return;
}
var areas = await fetchAreaPolygons();
+ // #123: only the newest refresh of the current mount draws.
+ if (!alive() || !map || gen !== refreshGen) return;
var entry = areas.find(function (a) { return a.key === key; });
var latlngs = entry && latLngsFor(entry);
if (!latlngs) {
@@ -740,7 +782,7 @@
if (e.target.checked) { selectedAreaLayer.addTo(map); } else { map.removeLayer(selectedAreaLayer); }
});
}
- AreaFilter.onChange(refresh);
+ areaOutlineHandler = AreaFilter.onChange(refresh);
refresh();
})();
@@ -752,6 +794,7 @@
});
loadNodes().then(() => {
+ if (!alive()) return;
// Check for a wardriving GPS trail (via sessionStorage) — see
// drawGPSTrail. Distinct from map-route-hops: these are raw shared
// coordinates, not mesh hop keys needing node resolution.
@@ -822,6 +865,7 @@
// (unique_prefix vs multi-byte vs gps_preference vs affinity scoring).
// Falls back to naive nodes.filter() scan if the API is unreachable.
let serverResolved = null;
+ const alive = mapToken();
try {
const hopList = (hopKeys || []).join(',');
const apiUrl = '/api/resolve-hops?hops=' + encodeURIComponent(hopList);
@@ -833,6 +877,8 @@
} catch (e) {
console.warn('resolve-hops API call failed, falling back to local nodes scan', e);
}
+ // #123: the map this route was requested for is gone.
+ if (!alive() || !map) return;
// Hide default markers so only the route is visible
if (markerLayer) map.removeLayer(markerLayer);
if (clusterGroup) map.removeLayer(clusterGroup);
@@ -1266,6 +1312,7 @@
// Resolve canonical hops via /api/resolve-hops + naive fallback.
let serverResolved = null;
+ const alive = mapToken();
try {
const apiUrl = '/api/resolve-hops?hops=' + encodeURIComponent(canonicalPath.join(','));
const resp = await fetch(apiUrl, { cache: 'no-cache' });
@@ -1274,6 +1321,7 @@
serverResolved = json && json.resolved ? json.resolved : null;
}
} catch (e) {}
+ if (!alive() || !map) return; // #123
const raw = canonicalPath.map(hop => {
const hopLower = String(hop).toLowerCase();
@@ -1374,6 +1422,7 @@
// the packets-page 'View on map' button would have set in sessionStorage.
// Without an obs param, the first observation is used.
async function loadRouteFromDeepLink() {
+ const alive = mapToken();
try {
const hash = location.hash || '';
const qs = hash.split('?')[1];
@@ -1393,6 +1442,7 @@
return;
}
const data = await resp.json();
+ if (!alive()) return; // #123
const pkt = data.packet || data;
const observations = data.observations || pkt.observations || [];
if (!observations.length) return;
@@ -1605,6 +1655,7 @@
} catch (_) {}
}
}
+ if (!alive()) return; // #123: left during the channel lookups
if (allPaths.length === 0) {
// Polish review (doshi #1423): operator clicked a ?packet= URL
// but every observation had an empty path. Previously this bailed
@@ -1651,13 +1702,16 @@
// deployment's current estimate), the URL alone is enough to
// reproduce the view -- share/bookmark/reload all just work.
async function loadEstimatedNodesFromDeepLink() {
+ const alive = mapToken();
try {
const resp = await fetch('/api/analytics/areas');
+ if (!alive()) return; // #123
if (!resp.ok) {
console.warn('[deep-link] /api/analytics/areas returned ' + resp.status);
return;
}
const data = await resp.json();
+ if (!alive()) return;
const points = (data && Array.isArray(data.estimatedNodes)) ? data.estimatedNodes : [];
drawEstimatedNodes(points);
} catch (e) {
@@ -1665,20 +1719,37 @@
}
}
- async function loadNodes() {
+ // #123: every call is a new request generation; only the newest request
+ // of the current mount renders. A superseded call resolves when the newest
+ // one does, so callers chaining on loadNodes() still see loaded nodes.
+ function loadNodes() {
+ const p = loadNodesRequest(++nodesLoadGeneration, mapToken());
+ nodesLoadPromise = p;
+ return p;
+ }
+
+ async function loadNodesRequest(req, alive) {
+ const current = () => alive() && req === nodesLoadGeneration && !!map;
+ const stale = () => (alive() ? nodesLoadPromise : undefined);
+ if (!map) return;
try {
// Load regions from config + observed IATAs
- try { REGION_NAMES = await api('/config/regions', { ttl: 3600 }); } catch {}
+ let regions = REGION_NAMES;
+ try { regions = await api('/config/regions', { ttl: 3600 }); } catch {}
+ if (!current()) return stale();
+ REGION_NAMES = regions;
const aqs = AreaFilter.areaQueryString();
// Paginate past the server's per-request node cap (listLimits.nodesMax)
// so actively-relaying repeaters that last advertised hours ago still
// appear instead of being truncated by the top-N window. See fetchAllNodes.
const data = await fetchAllNodes(`&lastHeard=${filters.lastHeard}${aqs}`, { ttl: CLIENT_TTL.nodeList });
- nodes = data.nodes || [];
+ if (!current()) return stale();
// Load observers for jump buttons + map markers
const obsData = await api('/observers', { ttl: CLIENT_TTL.observers });
+ if (!current()) return stale();
+ nodes = data.nodes || [];
observers = obsData.observers || [];
buildRoleChecks(data.counts || {});
@@ -1709,7 +1780,7 @@
if (targetNode && targetNode.lat && targetNode.lon) {
map.setView([targetNode.lat, targetNode.lon], 14);
// Delay popup open slightly — Leaflet needs the map to settle after setView
- setTimeout(() => {
+ mapTimeout(() => {
let found = false;
const findIn = function (layer) {
if (found || !layer || !layer.eachLayer) return;
@@ -1748,12 +1819,14 @@
// Don't fitBounds on initial load — respect the Bay Area default or saved view
// Only fitBounds on subsequent data refreshes if user hasn't manually panned
} catch (e) {
+ if (!current()) return stale();
console.error('Map load error:', e);
} finally {
// Always signal data-loaded — even on error — so E2E tests can proceed.
- // Otherwise an api() failure leaves the test waiting forever.
+ // Otherwise an api() failure leaves the test waiting forever. Only the
+ // current request signals it (#123).
var mapContainer = document.getElementById('leaflet-map');
- if (mapContainer) mapContainer.setAttribute('data-loaded', 'true');
+ if (mapContainer && current()) mapContainer.setAttribute('data-loaded', 'true');
}
}
@@ -2252,33 +2325,39 @@
async function selectReferenceNode(pubkey, name) {
selectedReferenceNode = pubkey;
neighborPubkeys = new Set();
+ // #123: fill a local set and publish it only if this mount and this
+ // selection are still current when the lookups return.
+ const alive = mapToken();
+ const found = new Set();
try {
// Use affinity-based neighbor API (server-side disambiguation) instead of
// client-side path walking which fails on hash collisions (#484)
const data = await api('/nodes/' + pubkey + '/neighbors?min_count=3');
for (const n of (data.neighbors || [])) {
- if (n.pubkey) neighborPubkeys.add(n.pubkey);
+ if (n.pubkey) found.add(n.pubkey);
// For ambiguous edges, include all candidates (better to show extra than miss)
- if (n.candidates) n.candidates.forEach(function(c) { if (c.pubkey) neighborPubkeys.add(c.pubkey); });
+ if (n.candidates) n.candidates.forEach(function(c) { if (c.pubkey) found.add(c.pubkey); });
}
// If affinity data is insufficient, fall back to client-side path walking
- if (neighborPubkeys.size === 0) {
+ if (found.size === 0) {
const pathData = await api('/nodes/' + pubkey + '/paths');
const paths = pathData.paths || [];
for (const p of paths) {
const hops = p.hops || [];
for (var i = 0; i < hops.length; i++) {
if (hops[i].pubkey === pubkey) {
- if (i > 0 && hops[i - 1].pubkey) neighborPubkeys.add(hops[i - 1].pubkey);
- if (i < hops.length - 1 && hops[i + 1].pubkey) neighborPubkeys.add(hops[i + 1].pubkey);
+ if (i > 0 && hops[i - 1].pubkey) found.add(hops[i - 1].pubkey);
+ if (i < hops.length - 1 && hops[i + 1].pubkey) found.add(hops[i + 1].pubkey);
}
}
}
}
} catch (e) {
console.warn('Failed to fetch neighbors for', pubkey, ':', e);
- neighborPubkeys = new Set();
+ found.clear();
}
+ if (!alive() || selectedReferenceNode !== pubkey) return;
+ neighborPubkeys = found;
// Update sidebar UI
const refEl = document.getElementById('mcNeighborRef');
const refNameEl = document.getElementById('mcNeighborRefName');
@@ -2366,6 +2445,10 @@
var input = document.getElementById('mapPiInput');
var btn = document.getElementById('mapPiSubmit');
if (!pane || !toggle) return;
+ // #123: loadNodes() calls this on every reload; wire each mount's pane
+ // only once or one click toggles it several times.
+ if (pane.dataset.wired === 'true') return;
+ pane.dataset.wired = 'true';
toggle.addEventListener('click', function () {
pane.classList.toggle('expanded');
@@ -2373,7 +2456,7 @@
toggle.innerHTML = '';
// Invalidate map size after transition.
- setTimeout(function () { if (map) map.invalidateSize(); }, 220);
+ mapTimeout(function () { map.invalidateSize(); }, 220);
});
if (btn && input) {
@@ -2390,7 +2473,7 @@
pane.classList.add('expanded');
toggle.innerHTML = '';
input.value = prefixParam;
- setTimeout(function () { if (map) map.invalidateSize(); }, 220);
+ mapTimeout(function () { map.invalidateSize(); }, 220);
mapPiSubmit(prefixParam);
}
}
@@ -2478,6 +2561,21 @@
}
function destroy() {
+ // #123: make every pending response and timer of this mount inert.
+ mapGeneration++;
+ mapTimers.forEach(clearTimeout);
+ mapTimers.clear();
+ clearTimeout(_zoomResizeTimer);
+ nodesLoadPromise = null;
+ if (areaNodesHandler) AreaFilter.offChange(areaNodesHandler);
+ if (areaOutlineHandler) AreaFilter.offChange(areaOutlineHandler);
+ areaNodesHandler = areaOutlineHandler = null;
+ // #123: one theme observer and tile-provider listener per mount; before
+ // this every mount added both for good, and each kept setting URLs on
+ // its removed map's tile layer.
+ if (mapThemeObs) mapThemeObs.disconnect();
+ if (tileProviderHandler) window.removeEventListener('mc-tile-provider-changed', tileProviderHandler);
+ mapThemeObs = tileProviderHandler = null;
if (wsHandler) offWS(wsHandler);
wsHandler = null;
// #1771 review fix: invalidate the Important Links overlay's in-flight
@@ -2547,9 +2645,11 @@
clearAffinityOverlay();
// Fetch debug data — requires API key stored in localStorage
var apiKey = localStorage.getItem('meshcore-api-key') || '';
+ var alive = mapToken();
fetch('/api/debug/affinity', { headers: { 'X-API-Key': apiKey } })
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(function (data) {
+ if (!alive()) return; // #123
affinityData = data;
renderAffinityOverlay();
})
diff --git a/public/route-view.js b/public/route-view.js
index b9748176a..c0ff3aeab 100644
--- a/public/route-view.js
+++ b/public/route-view.js
@@ -13,6 +13,24 @@
(function () {
'use strict';
+ // #123: map.js destroy() removes the map when the page is left, and Leaflet
+ // fires 'unload' on it. A timer scheduled before that (e.g. the teardown
+ // invalidateSize below) must not touch the removed map: Leaflet throws
+ // "Cannot read properties of undefined (reading '_leaflet_pos')".
+ var watchedMaps = new WeakSet(), removedMaps = new WeakSet();
+ function watchMapRemoval(mapRef) {
+ if (!mapRef || typeof mapRef.once !== 'function' || watchedMaps.has(mapRef)) return;
+ watchedMaps.add(mapRef);
+ mapRef.once('unload', function () { removedMaps.add(mapRef); });
+ }
+ // mapTimeout is setTimeout for callbacks that touch mapRef: inert once the
+ // map has been removed.
+ function mapTimeout(mapRef, fn, ms) {
+ return setTimeout(function () {
+ if (mapRef && !removedMaps.has(mapRef)) fn();
+ }, ms);
+ }
+
function haversineKm(a, b) {
if (a == null || b == null || a.lat == null || b.lat == null) return null;
var R = 6371, dLat = (b.lat - a.lat) * Math.PI / 180;
@@ -503,7 +521,7 @@
// caret-left when expanded). Replaces prior ▶/◀ Misc-Symbols chars. // EMOJI-OK: comment
collapseBtn.innerHTML = '';
- setTimeout(function () { if (mapRef && mapRef.invalidateSize) mapRef.invalidateSize(); }, 280);
+ mapTimeout(mapRef, function () { if (mapRef.invalidateSize) mapRef.invalidateSize(); }, 280);
});
}
// Collapsed-state click-to-expand on the vertical "ROUTE" label
@@ -516,7 +534,7 @@
collapseBtn.setAttribute('aria-label', 'Collapse route panel');
collapseBtn.innerHTML = '';
}
- setTimeout(function () { if (mapRef && mapRef.invalidateSize) mapRef.invalidateSize(); }, 280);
+ mapTimeout(mapRef, function () { if (mapRef.invalidateSize) mapRef.invalidateSize(); }, 280);
}
});
}
@@ -560,7 +578,7 @@
document.body.classList.toggle('mc-rt-mobile-sheet-expanded', expanded);
handle.setAttribute('aria-expanded', String(expanded));
// Force map to recompute size + re-fit after sheet animation settles
- setTimeout(function () {
+ mapTimeout(mapRef, function () {
try {
if (mapRef && typeof mapRef.invalidateSize === 'function') mapRef.invalidateSize();
// Re-fit to current positions so the route stays centered as the
@@ -1043,7 +1061,7 @@
location.hash = '#/map';
// Leaflet cached width while sidebar was open — force re-measure.
if (mapRef && typeof mapRef.invalidateSize === 'function') {
- setTimeout(function () { mapRef.invalidateSize(); }, 50);
+ mapTimeout(mapRef, function () { mapRef.invalidateSize(); }, 50);
}
});
}
@@ -1053,6 +1071,7 @@
function render(mapRef, layer, positions, opts) {
if (!positions || !positions.length) return;
+ watchMapRemoval(mapRef);
opts = opts || {};
var total = positions.length;
@@ -1113,7 +1132,7 @@
// re-measure so markers/tiles re-render at full width.
if (mapRef && typeof mapRef.invalidateSize === 'function') {
// Wait a tick for CSS to recompute (left:320px → unset).
- setTimeout(function () { mapRef.invalidateSize(); }, 50);
+ mapTimeout(mapRef, function () { mapRef.invalidateSize(); }, 50);
}
}
}
@@ -1370,16 +1389,16 @@
// (Function declared but `sidebar` ref deferred until after buildSidebar.)
function exposeRespider() {
if (typeof sidebar !== 'undefined' && sidebar) {
- sidebar._respider = function () { setTimeout(spiderFanMarkers, 200); };
+ sidebar._respider = function () { mapTimeout(mapRef, spiderFanMarkers, 200); };
}
}
// Run spider after Leaflet finishes projecting + on zoom only (pan
// shouldn't re-cluster since relative positions don't change).
- setTimeout(spiderFanMarkers, 400);
+ mapTimeout(mapRef, spiderFanMarkers, 400);
var _spiderDebounce = null;
mapRef.on('zoomend', function () {
if (_spiderDebounce) clearTimeout(_spiderDebounce);
- _spiderDebounce = setTimeout(spiderFanMarkers, 250);
+ _spiderDebounce = mapTimeout(mapRef, spiderFanMarkers, 250);
});
// Sidebar
@@ -1492,7 +1511,7 @@
var _resizeRefitTimer = null;
function onResize() {
if (_resizeRefitTimer) clearTimeout(_resizeRefitTimer);
- _resizeRefitTimer = setTimeout(refit, 200);
+ _resizeRefitTimer = mapTimeout(mapRef, refit, 200);
}
window.__mc_routeResizeRefit = onResize;
window.addEventListener('resize', onResize);
diff --git a/public/style.css b/public/style.css
index 4425f2696..60b3a97d5 100644
--- a/public/style.css
+++ b/public/style.css
@@ -4576,6 +4576,16 @@ th.sort-active { color: var(--accent, #60a5fa); }
.map-side-pane .pane-toggle { cursor: pointer; padding: 8px; font-size: 14px; text-align: center; }
.map-side-pane .pane-content { display: none; }
.map-side-pane.expanded .pane-content { display: block; }
+/* #123: the Map Controls panel and its toggle belong to the Leaflet area, so
+ keep them clear of the Path Inspector pane (collapsed 32px, expanded
+ 320px); otherwise they cover the pane and its toggle. Below 641px the pane
+ is hidden and the mobile rules place the controls. */
+@media (min-width: 641px) {
+ #mapSidePane ~ .map-controls { right: calc(32px + 12px); transition: right 0.2s; }
+ #mapSidePane ~ .map-controls-toggle { right: calc(32px + 10px); transition: right 0.2s; }
+ #mapSidePane.expanded ~ .map-controls { right: calc(320px + 12px); }
+ #mapSidePane.expanded ~ .map-controls-toggle { right: calc(320px + 10px); }
+}
/* #1236: on mobile the path-inspector side pane was eating 32px of horizontal
width (flex:0 0 32px) inside #map-wrap, leaving an unused gutter on the
right of the leaflet canvas. Path Inspector is a desktop-only convenience;
diff --git a/test-issue-123-map-lifecycle-e2e.js b/test-issue-123-map-lifecycle-e2e.js
new file mode 100644
index 000000000..ba762d267
--- /dev/null
+++ b/test-issue-123-map-lifecycle-e2e.js
@@ -0,0 +1,329 @@
+/**
+ * E2E (#123): map page lifecycle and Path Inspector layout.
+ *
+ * 1. Layout: at tablet/desktop widths the Map Controls panel and its toggle
+ * stay inside the Leaflet area, clear of the Path Inspector pane, and the
+ * pane toggle receives a real click, collapsed and expanded, also after a
+ * node reload (one click = one toggle).
+ * 2. Stale async work: responses and timers from a destroyed mount, and the
+ * older of two same-instance node loads, change nothing on the current
+ * page: navigation during the config fetch, a quick return, navigation
+ * right after init (the 100 ms invalidateSize timer), a reverse-resolution
+ * (/api/resolve-hops) response after leaving, and out-of-order node loads.
+ *
+ * Usage: BASE_URL=http://localhost:13581 node test-issue-123-map-lifecycle-e2e.js
+ */
+'use strict';
+const { chromium } = require('playwright');
+
+const BASE = process.env.BASE_URL || 'http://localhost:13581';
+const ORIGIN = new URL(BASE).origin;
+let passed = 0, failed = 0;
+async function step(name, fn) {
+ try { await fn(); passed++; console.log(' ✓ ' + name); }
+ catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); }
+}
+function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
+
+async function newPage(browser, errors, viewport) {
+ const ctx = await browser.newContext({ viewport: viewport || { width: 1440, height: 900 } });
+ const page = await ctx.newPage();
+ page.setDefaultTimeout(20000);
+ page.on('pageerror', (e) => errors.push('pageerror: ' + e.message));
+ page.on('console', (m) => {
+ if (m.type() !== 'error') return;
+ const url = (m.location() && m.location().url) || '';
+ if (url && !url.startsWith(ORIGIN)) return; // tile/CDN noise
+ errors.push('console.error: ' + m.text());
+ });
+ await page.addInitScript(() => {
+ window.addEventListener('unhandledrejection', (e) => {
+ console.error('unhandledrejection: ' + (e.reason && e.reason.message || e.reason));
+ });
+ try { localStorage.removeItem('map-view'); } catch (_) {}
+ });
+ return { ctx, page };
+}
+
+// Holds the next request matching `pattern` until release() is called.
+async function hold(page, pattern, times) {
+ let release, hit;
+ const released = new Promise((r) => { release = r; });
+ const reached = new Promise((r) => { hit = r; });
+ await page.route(pattern, async (route) => {
+ hit();
+ await released;
+ await route.continue().catch(() => {});
+ }, { times: times || 1 });
+ return { release, reached: () => reached };
+}
+
+const mapLoaded = (page) => page.waitForSelector('#leaflet-map[data-loaded="true"]');
+const settle = (page, ms) => page.waitForTimeout(ms || 400);
+
+(async () => {
+ const browser = await chromium.launch({
+ headless: true,
+ executablePath: process.env.CHROMIUM_PATH || undefined,
+ args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
+ });
+ const errors = [];
+ console.log('\n=== #123 map lifecycle and inspector layout against ' + BASE + ' ===');
+
+ for (const [w, h] of [[1440, 900], [1280, 600], [1024, 768], [800, 900], [641, 800]]) {
+ await step(`${w}x${h}: controls stay clear of the Path Inspector, and its toggle takes real clicks`, async () => {
+ const { ctx, page } = await newPage(browser, errors, { width: w, height: h });
+ try {
+ await page.goto(BASE + '/#/map');
+ await mapLoaded(page);
+ if (await page.locator('#mapControls').isHidden()) await page.click('#mapControlsToggle');
+ for (const expanded of [false, true, false]) {
+ const isExpanded = await page.evaluate(() => document.getElementById('mapSidePane').classList.contains('expanded'));
+ if (isExpanded !== expanded) await page.click('#mapPaneToggle'); // a real, unforced click
+ await settle(page, 350);
+ const r = await page.evaluate(() => {
+ const box = (id) => document.getElementById(id).getBoundingClientRect();
+ const map = box('leaflet-map'), pane = box('mapSidePane');
+ const overlaps = (a, b) => a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom;
+ const out = { expanded: document.getElementById('mapSidePane').classList.contains('expanded') };
+ for (const id of ['mapControls', 'mapControlsToggle']) {
+ const b = box(id);
+ out[id] = { inside: b.left >= map.left - 1 && b.right <= map.right + 1, overPane: overlaps(b, pane) };
+ }
+ const t = box('mapPaneToggle');
+ const hitEl = document.elementFromPoint(t.left + t.width / 2, t.top + t.height / 2);
+ out.paneToggleHit = document.getElementById('mapPaneToggle').contains(hitEl);
+ return out;
+ });
+ assert(r.expanded === expanded, `pane should be ${expanded ? 'expanded' : 'collapsed'} after a real click`);
+ for (const id of ['mapControls', 'mapControlsToggle']) {
+ assert(r[id].inside && !r[id].overPane, `${id} overlaps the Path Inspector (${expanded ? 'expanded' : 'collapsed'}): ${JSON.stringify(r[id])}`);
+ }
+ assert(r.paneToggleHit, 'the Path Inspector toggle is covered (' + (expanded ? 'expanded' : 'collapsed') + ')');
+ }
+ } finally { await ctx.close(); }
+ });
+ }
+
+ await step('after node reloads the Path Inspector toggle still toggles once per click', async () => {
+ const { ctx, page } = await newPage(browser, errors);
+ try {
+ await page.goto(BASE + '/#/map');
+ await mapLoaded(page);
+ for (const v of ['7d', '24h', '30d']) {
+ await page.evaluate(() => document.getElementById('leaflet-map').removeAttribute('data-loaded'));
+ await page.selectOption('#mcLastHeard', v);
+ await mapLoaded(page);
+ }
+ const before = await page.evaluate(() => document.getElementById('mapSidePane').classList.contains('expanded'));
+ await page.evaluate(() => document.getElementById('mapPaneToggle').click());
+ const after = await page.evaluate(() => document.getElementById('mapSidePane').classList.contains('expanded'));
+ assert(after !== before, 'one click after 3 node reloads did not toggle the pane (listeners piled up)');
+ } finally { await ctx.close(); }
+ });
+
+ await step('navigating away during the config fetch leaves no map and no errors', async () => {
+ const { ctx, page } = await newPage(browser, errors);
+ const errsBefore = errors.length;
+ try {
+ const cfg = await hold(page, '**/api/config/map');
+ await page.goto(BASE + '/#/map');
+ await cfg.reached();
+ await page.evaluate(() => { location.hash = '#/packets'; });
+ await page.waitForSelector('#pktTable', { state: 'attached' });
+ cfg.release();
+ await settle(page, 800);
+ assert(await page.locator('.leaflet-container').count() === 0, 'a map was created after leaving the page');
+ assert(errors.length === errsBefore, errors.slice(errsBefore).join(' | '));
+ } finally { await ctx.close(); }
+ });
+
+ await step('a quick return: the old mount\'s late config cannot create a second map', async () => {
+ const { ctx, page } = await newPage(browser, errors);
+ const errsBefore = errors.length;
+ try {
+ const first = await hold(page, '**/api/config/map');
+ await page.goto(BASE + '/#/map');
+ await first.reached();
+ await page.evaluate(() => { location.hash = '#/packets'; });
+ await page.waitForSelector('#pktTable', { state: 'attached' });
+ await page.evaluate(() => { location.hash = '#/map'; });
+ await mapLoaded(page);
+ const mapBefore = await page.evaluate(() => { window.__t123Map = window.__mc_map; return !!window.__mc_map; });
+ assert(mapBefore, 'fixture: the second mount has no map');
+ first.release();
+ await settle(page, 1000);
+ const r = await page.evaluate(() => ({
+ containers: document.querySelectorAll('.leaflet-container').length,
+ same: window.__mc_map === window.__t123Map,
+ }));
+ assert(r.containers === 1, r.containers + ' Leaflet containers after the old config arrived');
+ assert(r.same, 'the old mount replaced the current map');
+ assert(errors.length === errsBefore, errors.slice(errsBefore).join(' | '));
+ } finally { await ctx.close(); }
+ });
+
+ await step('leaving right after init: the delayed invalidateSize is inert', async () => {
+ const { ctx, page } = await newPage(browser, errors);
+ const errsBefore = errors.length;
+ try {
+ await page.goto(BASE + '/#/packets');
+ await page.waitForSelector('#pktTable', { state: 'attached' });
+ await page.evaluate(() => new Promise((resolve) => {
+ location.hash = '#/map';
+ const t0 = Date.now();
+ (function poll() {
+ if (document.querySelector('#leaflet-map.leaflet-container')) { location.hash = '#/packets'; return resolve(); }
+ if (Date.now() - t0 > 10000) return resolve();
+ setTimeout(poll, 5);
+ })();
+ }));
+ await settle(page, 800);
+ assert(errors.length === errsBefore, errors.slice(errsBefore).join(' | '));
+ } finally { await ctx.close(); }
+ });
+
+ await step('a reverse-resolution response after leaving touches nothing', async () => {
+ const { ctx, page } = await newPage(browser, errors);
+ const errsBefore = errors.length;
+ try {
+ await page.goto(BASE + '/#/map');
+ await mapLoaded(page);
+ const rh = await hold(page, '**/api/resolve-hops**');
+ await page.evaluate(() => { window.drawPacketRouteMulti([{ path: ['aa', 'bb'] }], null); });
+ await rh.reached();
+ await page.evaluate(() => { location.hash = '#/packets'; });
+ await page.waitForSelector('#pktTable', { state: 'attached' });
+ rh.release();
+ await settle(page, 800);
+ assert(errors.length === errsBefore, errors.slice(errsBefore).join(' | '));
+ } finally { await ctx.close(); }
+ });
+
+ await step('out-of-order node loads: only the newest one renders', async () => {
+ const { ctx, page } = await newPage(browser, errors);
+ try {
+ const node = (pk, lat, lon) => ({ public_key: pk, name: 'n-' + pk.slice(0, 4), role: 'repeater', lat, lon, last_seen: new Date().toISOString(), advert_count: 1 });
+ const OLD = { nodes: [node('a'.repeat(64), 55.1, 12.1)], total: 1, counts: {} };
+ const NEW = { nodes: [node('b'.repeat(64), 55.2, 12.2), node('c'.repeat(64), 55.3, 12.3)], total: 2, counts: {} };
+ await page.goto(BASE + '/#/map');
+ await mapLoaded(page);
+ let releaseOld;
+ const oldHeld = new Promise((r) => { releaseOld = r; });
+ await page.route(/\/api\/nodes\?limit=\d+&offset=0&lastHeard=7d/, async (route) => {
+ await oldHeld;
+ await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(OLD) });
+ });
+ await page.route(/\/api\/nodes\?limit=\d+&offset=0&lastHeard=24h/, (route) =>
+ route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(NEW) }));
+ await page.selectOption('#mcLastHeard', '7d'); // older load, held
+ await page.selectOption('#mcLastHeard', '24h'); // newer load, answered at once
+ await page.waitForFunction(() => (window.__mc_nodes || []).length === 2);
+ releaseOld();
+ await settle(page, 800);
+ const keys = await page.evaluate(() => (window.__mc_nodes || []).map((n) => n.public_key[0]).join(''));
+ assert(keys === 'bc', 'the older node load overwrote the newer one (nodes: ' + keys + ')');
+ } finally { await ctx.close(); }
+ });
+
+ // #123 review round: leaving the map with an open route must not run the
+ // route view's delayed invalidateSize on the removed map (it threw
+ // "Cannot read properties of undefined (reading '_leaflet_pos')").
+ const ROUTE = [
+ { pubkey: 'aa00aa00aa00aa00', name: 'Origin', role: 'companion', lat: 55.60, lon: 12.50, isOrigin: true },
+ { pubkey: 'bb11bb11bb11bb11', name: 'Relay', role: 'repeater', lat: 55.70, lon: 12.60, resolved: true },
+ { pubkey: 'cc22cc22cc22cc22', name: 'Dest', role: 'repeater', lat: 55.80, lon: 12.40, resolved: true, isDest: true },
+ ];
+ for (const [label, w, h] of [['desktop', 1440, 900], ['tablet', 1024, 768]]) {
+ await step(`${label} ${w}x${h}: leaving the map with an open route, the route view's delayed invalidateSize is inert`, async () => {
+ const { ctx, page } = await newPage(browser, errors, { width: w, height: h });
+ const errsBefore = errors.length;
+ try {
+ await page.goto(BASE + '/#/map?route=1');
+ await mapLoaded(page);
+ await page.evaluate((positions) => {
+ window.MeshRouteView.render(window.__mc_map, window.__mc_routeLayer, positions, { timestamp: Date.now() });
+ }, ROUTE);
+ await page.waitForSelector('.mc-rt-sidebar');
+ await settle(page, 700); // the render's own timers run on the live map
+ await page.evaluate(() => { location.hash = '#/packets'; });
+ await page.waitForSelector('#pktTable', { state: 'attached' });
+ await settle(page, 800);
+ assert(errors.length === errsBefore, errors.slice(errsBefore).join(' | '));
+ } finally { await ctx.close(); }
+ });
+ }
+
+ // #123 review round: destroy() must drop the tile-provider listener and the
+ // theme MutationObserver of its mount. Counts only map.js's own callbacks
+ // (they call _syncDarkTiles; live.js has its own tile listener).
+ await step('six mounts leave one tile-provider listener and one theme observer; theme switches hit one map', async () => {
+ const { ctx, page } = await newPage(browser, errors);
+ await page.addInitScript(() => {
+ const own = (fn) => typeof fn === 'function' && /_syncDarkTiles/.test(String(fn));
+ const live = new Set();
+ const add = window.addEventListener, rem = window.removeEventListener;
+ window.addEventListener = function (type, fn, o) {
+ if (type === 'mc-tile-provider-changed' && own(fn)) live.add(fn);
+ return add.call(this, type, fn, o);
+ };
+ window.removeEventListener = function (type, fn, o) {
+ if (type === 'mc-tile-provider-changed') live.delete(fn);
+ return rem.call(this, type, fn, o);
+ };
+ const MO = window.MutationObserver, observing = new Set();
+ window.MutationObserver = class extends MO {
+ constructor(cb) { super(cb); this.__own = own(cb); }
+ observe(t, o) { if (this.__own) observing.add(this); return super.observe(t, o); }
+ disconnect() { observing.delete(this); return super.disconnect(); }
+ };
+ window.__t123 = { listeners: () => live.size, observers: () => observing.size };
+ });
+ try {
+ const mountMap = async () => {
+ await page.evaluate(() => { location.hash = '#/map'; });
+ await mapLoaded(page);
+ };
+ const leave = async () => {
+ await page.evaluate(() => { location.hash = '#/packets'; });
+ await page.waitForSelector('#pktTable', { state: 'attached' });
+ };
+ // setUrl calls caused by two theme switches and one provider change
+ const setUrlCalls = () => page.evaluate(async () => {
+ const proto = L.TileLayer.prototype, orig = proto.setUrl;
+ let n = 0;
+ proto.setUrl = function () { n++; return orig.apply(this, arguments); };
+ const tick = () => new Promise((r) => setTimeout(r, 50));
+ const root = document.documentElement, before = root.getAttribute('data-theme');
+ root.setAttribute('data-theme', 'dark'); await tick();
+ root.setAttribute('data-theme', 'light'); await tick();
+ window.dispatchEvent(new CustomEvent('mc-tile-provider-changed', { detail: {} })); await tick();
+ if (before == null) root.removeAttribute('data-theme'); else root.setAttribute('data-theme', before);
+ await tick();
+ proto.setUrl = orig;
+ return n;
+ });
+ await page.goto(BASE + '/#/packets');
+ await page.waitForSelector('#pktTable', { state: 'attached' });
+ await mountMap();
+ const one = await setUrlCalls();
+ for (let i = 0; i < 5; i++) { await leave(); await mountMap(); }
+ const counts = await page.evaluate(() => ({ l: window.__t123.listeners(), o: window.__t123.observers() }));
+ const six = await setUrlCalls();
+ assert(counts.l === 1, counts.l + ' map tile-provider listeners after 6 mounts, want 1');
+ assert(counts.o === 1, counts.o + ' map theme observers after 6 mounts, want 1');
+ assert(one > 0 && six === one, 'theme and provider changes caused ' + six + ' setUrl calls after 6 mounts, ' + one + ' after 1');
+ await leave();
+ const after = await page.evaluate(() => ({ l: window.__t123.listeners(), o: window.__t123.observers() }));
+ assert(after.l === 0 && after.o === 0, 'after leaving: ' + after.l + ' listeners, ' + after.o + ' observers, want 0');
+ } finally { await ctx.close(); }
+ });
+
+ await step('no page errors, console errors or unhandled rejections overall', async () => {
+ assert(errors.length === 0, errors.slice(0, 5).join(' | '));
+ });
+
+ await browser.close();
+ console.log('\n--- ' + passed + ' passed, ' + failed + ' failed ---');
+ process.exit(failed ? 1 : 0);
+})();