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); +})();