From 8b173f524dc3895352b26638ff713e160c5bfd3b Mon Sep 17 00:00:00 2001 From: dborup Date: Wed, 7 Oct 2026 11:16:01 +0200 Subject: [PATCH 1/3] fix(path-inspector): remove unreachable map drawing branch (#143) --- public/path-inspector.js | 17 +++-------------- test-path-inspector-e2e.js | 11 +++++++++++ test-path-inspector.js | 32 +++++++++++++++++++++++++++++++- 3 files changed, 45 insertions(+), 15 deletions(-) diff --git a/public/path-inspector.js b/public/path-inspector.js index 75e06feb9..91a974dc7 100644 --- a/public/path-inspector.js +++ b/public/path-inspector.js @@ -176,20 +176,9 @@ function showOnMap(candidate) { // Store pending route for map init to pick up. window._pendingPathInspectorRoute = candidate; - // Switch to map page if not there; map init will draw the route. - if (location.hash.indexOf('#/map') !== 0) { - location.hash = '#/map'; - } else { - // Already on map — draw directly. - delete window._pendingPathInspectorRoute; - if (window.routeLayer) window.routeLayer.clearLayers(); - // Pass FULL path as hopKeys (not slice(1)) — drawPacketRoute resolves - // each entry against nodes[] for plotting. The 2nd arg is the origin - // OBJECT (with pubkey/lat/lon/name); pass null since the origin is - // already the first hop in the path itself, and drawPacketRoute draws - // a marker for every resolved hop. - if (window.drawPacketRoute) window.drawPacketRoute(candidate.path, null); - } + // This standalone page navigates to the map; its embedded inspector + // has a separate handler in map.js. + location.hash = '#/map'; } function escapeAttr(s) { diff --git a/test-path-inspector-e2e.js b/test-path-inspector-e2e.js index a17afa3ef..94ceae7cb 100644 --- a/test-path-inspector-e2e.js +++ b/test-path-inspector-e2e.js @@ -113,6 +113,17 @@ async function findCandidatePair(request) { assert(await page.evaluate(() => document.body.classList.contains('mc-route-active')), 'route view not active'); }); + await step('standalone "Show on Map" navigates, consumes the handoff, and draws the route', async () => { + assert(pair, 'no candidate to show'); + await page.goto(BASE + '/#/tools/path-inspector?prefixes=' + pair.prefixes, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#path-inspector-results button[data-idx="0"]'); + await page.click('#path-inspector-results button[data-idx="0"]'); + await page.waitForFunction(() => location.hash === '#/map'); + await page.waitForSelector('#leaflet-map .leaflet-overlay-pane path.mc-rt-edge', { state: 'visible' }); + assert(await page.evaluate(() => document.body.classList.contains('mc-route-active')), 'route view not active'); + assert(await page.evaluate(() => !window._pendingPathInspectorRoute), 'pending route was not consumed'); + }); + await step('Tools landing links to the Path Inspector and to Trace', async () => { await page.goto(BASE + '/#/tools', { waitUntil: 'domcontentloaded' }); await page.waitForSelector('.tools-landing', { state: 'visible' }); diff --git a/test-path-inspector.js b/test-path-inspector.js index 3d818b856..14530da64 100644 --- a/test-path-inspector.js +++ b/test-path-inspector.js @@ -103,4 +103,34 @@ function createSandbox() { // Anti-tautology: if validation were removed (always return null), the odd-length test would fail. // Mental revert: validatePrefixes = () => null; → testValidateOdd would fail because err would be null. -console.log('\nAll path-inspector tests passed!'); +// The standalone inspector hands off to map init; map.js owns its embedded +// inspector and must not be reached through nonexistent window drawing APIs. +assert(!/window\.(?:drawPacketRoute|routeLayer)\b/.test(src), + 'standalone inspector must not depend on private map drawing globals'); +console.log('✓ no obsolete map drawing globals'); + +(async function testShowOnMapHandoff() { + const sb = createSandbox(); + const candidate = { + path: ['aa'.repeat(32), 'bb'.repeat(32)], + names: ['Test A', 'Test B'], score: 1, evidence: { perHop: [] } + }; + let click; + const button = { dataset: { idx: '0' }, addEventListener: (event, fn) => { click = fn; } }; + const results = { + innerHTML: '', + querySelectorAll: selector => selector === 'button[data-idx]' ? [button] : [] + }; + sb.document.getElementById = id => id === 'path-inspector-results' ? results : + { textContent: '', addEventListener: () => {} }; + sb.fetch = async () => ({ ok: true, json: async () => ({ candidates: [candidate], stats: {} }) }); + sb.location.hash = '#/tools/path-inspector?prefixes=aa,bb'; + sb.window.PathInspector.init({ innerHTML: '' }); + await new Promise(resolve => setImmediate(resolve)); + assert.strictEqual(typeof click, 'function', 'candidate button must be wired'); + click(); + assert.strictEqual(sb.window._pendingPathInspectorRoute, candidate, 'preserve the full candidate'); + assert.strictEqual(sb.location.hash, '#/map'); + console.log('✓ candidate button hands the complete route to map init'); + console.log('\nAll path-inspector tests passed!'); +})().catch(error => { console.error(error); process.exitCode = 1; }); From cad2fe4b2b4e4700f7d746dafa5ce2ec4101a12d Mon Sep 17 00:00:00 2001 From: dborup Date: Wed, 7 Oct 2026 14:48:00 +0000 Subject: [PATCH 2/3] test(map): reproduce leaving the map during an animated zoom (#345) Leaflet 1.9.4 ends an animated zoom from a 250 ms setTimeout that map.remove() does not cancel. Leaving the map mid-zoom (e.g. right after a route's fitBounds) then throws "Cannot read properties of undefined (reading '_leaflet_pos')". This is the page error that failed test-path-inspector-e2e.js in CI on #345. Co-Authored-By: Claude Opus 5.5 --- test-issue-123-map-lifecycle-e2e.js | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test-issue-123-map-lifecycle-e2e.js b/test-issue-123-map-lifecycle-e2e.js index ba762d267..a7f26ad58 100644 --- a/test-issue-123-map-lifecycle-e2e.js +++ b/test-issue-123-map-lifecycle-e2e.js @@ -254,6 +254,32 @@ const settle = (page, ms) => page.waitForTimeout(ms || 400); }); } + // #345 CI: Leaflet 1.9.4 ends an animated zoom from a 250 ms setTimeout that + // map.remove() does not cancel; leaving mid-zoom threw "_leaflet_pos" (e.g. + // right after a route's fitBounds). + await step('leaving during an animated zoom: Leaflet\'s transition-end timer is inert', async () => { + const { ctx, page } = await newPage(browser, errors); + const errsBefore = errors.length; + try { + await page.goto(BASE + '/#/map'); + await mapLoaded(page); + const animating = await page.evaluate(() => new Promise((resolve) => { + const m = window.__mc_map; + m.setZoom(m.getZoom() + 1, { animate: true }); + const t0 = Date.now(); + (function poll() { + if (m._animatingZoom) { location.hash = '#/packets'; return resolve(true); } + if (Date.now() - t0 > 200) return resolve(false); + requestAnimationFrame(poll); + })(); + })); + assert(animating, 'fixture: the zoom did not animate'); + 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). From 184fa4ea0a06be471d1c9f843a31cce6d85d38b3 Mon Sep 17 00:00:00 2001 From: dborup Date: Wed, 7 Oct 2026 14:48:00 +0000 Subject: [PATCH 3/3] fix(map): make Leaflet's zoom transition-end timer inert on destroy (#345) destroy() clears map._animatingZoom before map.remove(), so the transition-end callback that Leaflet 1.9.4 schedules returns early instead of reading the removed map pane. Co-Authored-By: Claude Opus 5.5 --- public/map.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/public/map.js b/public/map.js index b94c2e415..33682bfe4 100644 --- a/public/map.js +++ b/public/map.js @@ -2614,6 +2614,10 @@ topRoutesLayer = null; topRoutesEdges = null; if (map) { + // Leaflet 1.9.4 ends an animated zoom from a 250 ms setTimeout that + // remove() does not cancel; it then reads the removed map pane + // ("_leaflet_pos"). With the flag cleared that callback returns early. + map._animatingZoom = false; map.remove(); map = null; }