From 44c367bfb44aabc7584b4f88a3870a0a45e3a319 Mon Sep 17 00:00:00 2001 From: dborup Date: Tue, 29 Sep 2026 08:39:27 +0000 Subject: [PATCH 1/3] test(rx-coverage): reproduce the hardcoded viewport and stale async work (#124) test-issue-124-rx-coverage-viewport.js loads the real public/rx-coverage.js in a vm with a fake Leaflet map (records setView/fitBounds), fetches the test releases one by one, fake storage/location/history and a manual clock. On master 9 of 11 fail: the page always starts at 51.0, 4.8 zoom 8 (no URL viewport, no rx-coverage-view, no /api/config/map), nothing is saved on move and lat/lon/zoom never reach the URL, and after destroy or a quick remount the old mount's map, observer extent and leaderboard still land on the new page. The offline-fallback and "requests unchanged" controls pass. Registered in test-all.sh and the CI unit step. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019TcZHooUiiknVWbECVWzk8 --- .github/workflows/deploy.yml | 1 + test-all.sh | 1 + test-issue-124-rx-coverage-viewport.js | 254 +++++++++++++++++++++++++ 3 files changed, 256 insertions(+) create mode 100644 test-issue-124-rx-coverage-viewport.js diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 833573c2b..3d8897573 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -186,6 +186,7 @@ jobs: node test-issue-1509-nav-active-bg.js node test-issue-1509-detect-preset.js node test-live.js + node test-issue-124-rx-coverage-viewport.js node test-coverage-gate.js node test-node-reach-coverage.js node test-reach-rank.js diff --git a/test-all.sh b/test-all.sh index 884db1880..9f80fb88c 100755 --- a/test-all.sh +++ b/test-all.sh @@ -59,6 +59,7 @@ node test-issue-1883-redirect-history.js node test-issue-1890-og-url.js node test-traces.js node test-live-multibyte-filter.js +node test-issue-124-rx-coverage-viewport.js node test-issue-2052-touch-target-css.js node test-channel-proposals.js node test-packet-detail-channel-xss.js diff --git a/test-issue-124-rx-coverage-viewport.js b/test-issue-124-rx-coverage-viewport.js new file mode 100644 index 000000000..38fbd241d --- /dev/null +++ b/test-issue-124-rx-coverage-viewport.js @@ -0,0 +1,254 @@ +/* test-issue-124-rx-coverage-viewport.js — RX Coverage initial viewport and + * lifecycle (#124). + * + * Loads the REAL public/rx-coverage.js in a vm with a fake Leaflet map that + * records setView/fitBounds, a fetch whose responses the test releases, fake + * localStorage/location/history and a manual clock. + * + * Viewport precedence: valid URL lat/lon/zoom, then a valid + * `rx-coverage-view`, then /api/config/map, then the documented offline + * fallback (51.0, 4.8, zoom 8). Invalid, partial or out-of-range values fall + * through. Only `rx-coverage-view` is read or written, never the main map's + * `map-view`. days/rx stay in the URL as the viewport changes, an + * observer-only rx= link still fits that observer, and delayed responses + * never touch a destroyed or replaced page. + */ +'use strict'; +const vm = require('vm'); +const fs = require('fs'); +const assert = require('assert'); + +const SRC = fs.readFileSync(__dirname + '/public/rx-coverage.js', 'utf8'); +const APP = fs.readFileSync(__dirname + '/public/app.js', 'utf8'); +const FALLBACK = { lat: 51.0, lon: 4.8, zoom: 8 }; + +let passed = 0, failed = 0; +async function test(name, fn) { + try { await fn(); passed++; console.log(' ✅ ' + name); } + catch (e) { failed++; console.log(' ❌ ' + name + ': ' + (e && e.message || e)); } +} +const flush = async () => { for (let i = 0; i < 20; i++) await new Promise((r) => setImmediate(r)); }; + +function makeEnv(opts) { + opts = opts || {}; + const storage = Object.assign({}, opts.storage || {}); + const pending = []; // unreleased fetches + const fetchLog = []; + const timers = []; + const maps = []; + const location = { hash: opts.hash || '#/rx-coverage' }; + + function makeMap() { + const handlers = {}; + const m = { + removed: false, views: [], fits: [], center: null, zoom: null, + setView(c, z) { m.views.push({ lat: c[0], lon: c[1], zoom: z }); m.center = { lat: c[0], lng: c[1] }; m.zoom = z; return m; }, + fitBounds(b) { m.fits.push(b); return m; }, + on(ev, fn) { ev.split(' ').forEach((e) => { (handlers[e] = handlers[e] || []).push(fn); }); return m; }, + fire(ev) { (handlers[ev] || []).forEach((fn) => fn()); }, + getCenter() { return m.center; }, getZoom() { return m.zoom; }, + getBounds() { return { getSouth: () => 0, getWest: () => 0, getNorth: () => 1, getEast: () => 1 }; }, + invalidateSize() {}, remove() { m.removed = true; }, + panTo(ll) { m.center = { lat: ll[0], lng: ll[1] }; }, + }; + maps.push(m); + return m; + } + const layer = () => ({ addTo() { return this; }, clearLayers() {}, bindTooltip() { return this; } }); + const el = () => ({ innerHTML: '', addEventListener() {}, querySelectorAll: () => [], dataset: {} }); + const els = {}; + const sandbox = { + console: { log() {}, warn() {}, error() {} }, + Promise, Math, JSON, Number, String, Array, Object, Date, isFinite, parseFloat, parseInt, URLSearchParams, + window: { MeshConfigReady: Promise.resolve(), MC_CLIENT_RX_COVERAGE: true }, + document: { getElementById: (id) => els[id] || (els[id] = el()), documentElement: {} }, + location, + history: { replaceState: (_s, _t, url) => { location.hash = url; } }, + localStorage: { + getItem: (k) => (k in storage ? storage[k] : null), + setItem: (k, v) => { storage[k] = String(v); }, + removeItem: (k) => { delete storage[k]; }, + }, + getComputedStyle: () => ({ getPropertyValue: () => '' }), + L: { map: () => makeMap(), tileLayer: () => layer(), layerGroup: () => layer(), polygon: () => layer() }, + escapeHtml: (s) => String(s), + debounce: (fn) => fn, + setTimeout: (fn, ms) => { timers.push({ fn, ms }); return timers.length; }, + clearTimeout: () => {}, + fetch: (url) => { + fetchLog.push(url); + return new Promise((resolve, reject) => { pending.push({ url, resolve, reject }); }); + }, + }; + sandbox.getHashParams = () => new URLSearchParams((location.hash.split('?')[1] || '')); + vm.createContext(sandbox); + // parseViewportHash and friends come from app.js in the browser. + const m = APP.match(/function parseViewportHash[\s\S]*?\n}\n/); + if (m) vm.runInContext(m[0], sandbox); + let page; + sandbox.registerPage = (name, obj) => { page = obj; }; + vm.runInContext(SRC, sandbox); + + const respond = (re, body, ok = true) => { + const i = pending.findIndex((p) => re.test(p.url)); + if (i < 0) return false; + const p = pending.splice(i, 1)[0]; + p.resolve({ ok, json: async () => body }); + return true; + }; + const failFetch = (re) => { + const i = pending.findIndex((p) => re.test(p.url)); + if (i < 0) return false; + pending.splice(i, 1)[0].reject(new Error('offline')); + return true; + }; + const runTimers = () => { while (timers.length) timers.shift().fn(); }; + return { sandbox, storage, location, maps, fetchLog, pending, respond, failFetch, runTimers, page: () => page }; +} + +// Mounts the page and lets it reach its first map (answering /api/config/map +// with cfg, or failing it, when it is requested). +async function mount(env, cfg) { + env.page().init({ innerHTML: '' }); + await flush(); + if (cfg === 'fail') env.failFetch(/\/api\/config\/map/); + else if (cfg) env.respond(/\/api\/config\/map/, cfg); + await flush(); +} +const lastView = (env) => { const m = env.maps[env.maps.length - 1]; return m && m.views[0]; }; +function assertView(env, want, tag) { + const v = lastView(env); + assert(v, 'no map view set (' + tag + ')'); + assert(Math.abs(v.lat - want.lat) < 1e-9 && Math.abs(v.lon - want.lon) < 1e-9 && v.zoom === want.zoom, + tag + ': view ' + JSON.stringify(v) + ', want ' + JSON.stringify(want)); +} + +(async () => { + console.log('--- test-issue-124-rx-coverage-viewport.js ---'); + + await test('1. a valid URL viewport wins over storage and config', async () => { + const env = makeEnv({ hash: '#/rx-coverage?lat=55.5&lon=10.25&zoom=11', storage: { 'rx-coverage-view': JSON.stringify({ lat: 1, lng: 2, zoom: 5 }) } }); + await mount(env, { center: [3, 4], zoom: 6 }); + assertView(env, { lat: 55.5, lon: 10.25, zoom: 11 }, 'URL'); + }); + + await test('2. a valid rx-coverage-view wins over config', async () => { + const env = makeEnv({ storage: { 'rx-coverage-view': JSON.stringify({ lat: 56.1, lng: 9.9, zoom: 10 }) } }); + await mount(env, { center: [3, 4], zoom: 6 }); + assertView(env, { lat: 56.1, lon: 9.9, zoom: 10 }, 'storage'); + }); + + await test('3. /api/config/map when there is no URL viewport and nothing saved', async () => { + const env = makeEnv({}); + await mount(env, { center: [55.68, 12.57], zoom: 9 }); + assertView(env, { lat: 55.68, lon: 12.57, zoom: 9 }, 'config'); + }); + + await test('4. the documented offline fallback when config fails or is invalid', async () => { + for (const cfg of ['fail', { center: 'x', zoom: 9 }, { center: [999, 0], zoom: 9 }, {}]) { + const env = makeEnv({}); + await mount(env, cfg); + assertView(env, FALLBACK, 'fallback for ' + JSON.stringify(cfg)); + } + }); + + await test('5. invalid, partial or out-of-range URL and storage values fall through', async () => { + const badHashes = ['?lat=55&lon=10', '?lat=95&lon=10&zoom=9', '?lat=55&lon=190&zoom=9', '?lat=abc&lon=10&zoom=9', '?lat=55&lon=10&zoom=99', '?lat=55&lon=10&zoom=0']; + for (const h of badHashes) { + const env = makeEnv({ hash: '#/rx-coverage' + h, storage: { 'rx-coverage-view': JSON.stringify({ lat: 56.1, lng: 9.9, zoom: 10 }) } }); + await mount(env, { center: [3, 4], zoom: 6 }); + assertView(env, { lat: 56.1, lon: 9.9, zoom: 10 }, 'URL ' + h + ' falls through to storage'); + } + const badSaved = ['{"lat":56}', '{"lat":95,"lng":9,"zoom":10}', '{"lat":56,"lng":9,"zoom":99}', 'not json', '{"lat":"x","lng":9,"zoom":10}', 'null']; + for (const s of badSaved) { + const env = makeEnv({ storage: { 'rx-coverage-view': s } }); + await mount(env, { center: [55.68, 12.57], zoom: 9 }); + assertView(env, { lat: 55.68, lon: 12.57, zoom: 9 }, 'saved ' + s + ' falls through to config'); + } + }); + + await test('6. the main map key map-view is never read or written', async () => { + const env = makeEnv({ storage: { 'map-view': JSON.stringify({ lat: 40, lng: -74, zoom: 13 }) } }); + await mount(env, { center: [55.68, 12.57], zoom: 9 }); + assertView(env, { lat: 55.68, lon: 12.57, zoom: 9 }, 'map-view ignored'); + const m = env.maps[0]; + m.setView([56.2, 10.1], 12); + m.fire('moveend'); + assert.strictEqual(env.storage['map-view'], JSON.stringify({ lat: 40, lng: -74, zoom: 13 }), 'map-view was changed'); + const saved = JSON.parse(env.storage['rx-coverage-view'] || 'null'); + assert(saved && saved.lat === 56.2 && saved.lng === 10.1 && saved.zoom === 12, 'rx-coverage-view not saved: ' + env.storage['rx-coverage-view']); + }); + + await test('7. days and rx stay in the URL as the viewport changes', async () => { + const env = makeEnv({ hash: '#/rx-coverage?days=14&rx=abcdef&lat=55.5&lon=10.25&zoom=11' }); + await mount(env, null); + const m = env.maps[0]; + m.setView([56.25, 10.5], 12); + m.fire('moveend'); + const q = new URLSearchParams(env.location.hash.split('?')[1]); + assert.strictEqual(q.get('days'), '14', 'days lost: ' + env.location.hash); + assert.strictEqual(q.get('rx'), 'abcdef', 'rx lost: ' + env.location.hash); + assert.strictEqual(Number(q.get('lat')), 56.25, 'lat not in URL: ' + env.location.hash); + assert.strictEqual(Number(q.get('lon')), 10.5); + assert.strictEqual(q.get('zoom'), '12'); + }); + + await test('8. an observer-only rx= link still fits that observer; an explicit viewport does not', async () => { + const env = makeEnv({ hash: '#/rx-coverage?rx=abcdef' }); + await mount(env, { center: [55.68, 12.57], zoom: 9 }); + env.runTimers(); + assert(env.respond(/bbox=-90,-180,90,180.*rx=abcdef/, { features: [{ geometry: { coordinates: [[[10, 55], [11, 56]]] } }] }), 'no extent request for the observer'); + await flush(); + assert.strictEqual(env.maps[0].fits.length, 1, 'observer link did not fit the observer'); + + const env2 = makeEnv({ hash: '#/rx-coverage?rx=abcdef&lat=55.5&lon=10.25&zoom=11' }); + await mount(env2, null); + env2.runTimers(); + assert(!env2.pending.some((p) => /bbox=-90,-180,90,180/.test(p.url)), 'an explicit viewport should not be replaced by a fit'); + }); + + await test('9. a config response after destroy creates no map', async () => { + const env = makeEnv({}); + env.page().init({ innerHTML: '' }); + await flush(); + env.page().destroy(); + env.respond(/\/api\/config\/map/, { center: [55.68, 12.57], zoom: 9 }); + await flush(); + assert.strictEqual(env.maps.length, 0, 'a map was created for a destroyed page'); + }); + + await test('10. after a quick remount, the old mount\'s config, extent, coverage and leaderboard responses are ignored', async () => { + const env = makeEnv({ hash: '#/rx-coverage?rx=abcdef' }); + await mount(env, { center: [55.68, 12.57], zoom: 9 }); + env.runTimers(); // old mount: extent + leaderboard requests pending + env.page().destroy(); + env.location.hash = '#/rx-coverage'; + env.page().init({ innerHTML: '' }); + await flush(); + const newMaps = () => env.maps.slice(1); + // old extent response arrives now + env.respond(/bbox=-90,-180,90,180.*rx=abcdef/, { features: [{ geometry: { coordinates: [[[10, 55], [11, 56]]] } }] }); + await flush(); + env.respond(/\/api\/config\/map/, { center: [50, 5], zoom: 7 }); + await flush(); + assert.strictEqual(newMaps().length, 1, 'expected exactly one map for the new mount, got ' + newMaps().length); + assert.strictEqual(newMaps()[0].fits.length, 0, 'the old observer extent fitted the new map'); + // old leaderboard response for the old mount + const board = env.sandbox.document.getElementById('rxBoard'); + board.innerHTML = 'NEW'; + env.respond(/rx-leaderboard/, { observers: [{ pubkey: 'old', name: 'OLD', score: 1, cells: 1, nodes: 1, receptions: 1 }] }); + await flush(); + assert(!/OLD/.test(board.innerHTML), 'the old leaderboard rendered into the new page'); + }); + + await test('11. coverage filtering and leaderboard requests are unchanged', async () => { + const env = makeEnv({ hash: '#/rx-coverage?days=14' }); + await mount(env, { center: [55.68, 12.57], zoom: 9 }); + env.runTimers(); + assert(env.fetchLog.some((u) => /^\/api\/rx-leaderboard\?days=14&limit=25$/.test(u)), 'leaderboard request changed: ' + env.fetchLog); + assert(env.fetchLog.some((u) => /^\/api\/rx-coverage\?bbox=0,0,1,1&z=\d+&days=14$/.test(u)), 'coverage request changed: ' + env.fetchLog); + }); + + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed ? 1 : 0); +})(); From 1ab283a293f9816a9e670746081dd38d46e2f029 Mon Sep 17 00:00:00 2001 From: dborup Date: Tue, 29 Sep 2026 08:41:18 +0000 Subject: [PATCH 2/3] fix(rx-coverage): honour configured and saved viewports, ignore stale async work (#124) Initial viewport, first valid source wins: 1. explicit URL lat/lon/zoom, 2. this page's own rx-coverage-view, 3. /api/config/map, 4. the offline fallback 51.0, 4.8 zoom 8 (the page's original start). All three values must be present, numeric and in range (lat -90..90, lon -180..180, zoom 1..19); anything invalid, partial or out of range falls through. The page saves its view on move under rx-coverage-view and never reads or writes the main map's map-view. syncHash() keeps days and rx and adds lat/lon/zoom once the map exists; rx is now URL-encoded. An observer-only rx= link (no explicit viewport) still fits that observer's coverage. A generation counter, bumped by init() and destroy(), makes the config response, the settle timer, move handlers and the extent, coverage and leaderboard responses of an old mount do nothing on a destroyed or replaced page. Coverage filtering and the leaderboard are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019TcZHooUiiknVWbECVWzk8 --- public/rx-coverage.js | 107 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 94 insertions(+), 13 deletions(-) diff --git a/public/rx-coverage.js b/public/rx-coverage.js index a8f1b2c0b..8db95f527 100644 --- a/public/rx-coverage.js +++ b/public/rx-coverage.js @@ -7,6 +7,54 @@ 'use strict'; (function () { var map = null, covLayer = null, days = 7, selectedRx = '', selectedName = '', boardCache = [], destroyed = false; + // Bumped by every init() and destroy() (#124): async work (config, extent, + // coverage and leaderboard responses, the settle timer, move handlers) + // captures it and does nothing once a newer mount or a destroy happened. + var generation = 0; + + // Initial viewport (#124), first valid one wins: explicit URL lat/lon/zoom, + // this page's own saved view, /api/config/map, then this offline fallback + // (the page's original start view). The page saves under its own key and + // never reads or writes the main map's 'map-view', so the two pages do not + // recenter each other. + var VIEW_KEY = 'rx-coverage-view'; + var FALLBACK_VIEW = { lat: 51.0, lon: 4.8, zoom: 8 }; + var MIN_ZOOM = 1, MAX_ZOOM = 19; + + function isLive(gen) { return !destroyed && gen === generation; } + + // validView returns {lat, lon, zoom} when all three are present, numeric and + // in range; anything invalid, partial or out of range gives null. + function validView(lat, lon, zoom) { + function num(v) { return (v === null || v === undefined || v === '' || typeof v === 'boolean') ? NaN : Number(v); } + lat = num(lat); lon = num(lon); zoom = num(zoom); + if (!isFinite(lat) || !isFinite(lon) || !isFinite(zoom)) return null; + if (lat < -90 || lat > 90 || lon < -180 || lon > 180 || zoom < MIN_ZOOM || zoom > MAX_ZOOM) return null; + return { lat: lat, lon: lon, zoom: zoom }; + } + function urlView() { + try { + var p = new URLSearchParams((location.hash.split('?')[1] || '')); + return validView(p.get('lat'), p.get('lon'), p.get('zoom')); + } catch (e) { return null; } + } + function savedView() { + try { + var s = JSON.parse(localStorage.getItem(VIEW_KEY)); + return s ? validView(s.lat, s.lng, s.zoom) : null; + } catch (e) { return null; } + } + function configView() { + return fetch('/api/config/map').then(function (r) { return r.json(); }).then(function (cfg) { + if (!cfg || !Array.isArray(cfg.center) || cfg.center.length !== 2) return null; + return validView(cfg.center[0], cfg.center[1], cfg.zoom == null ? 9 : cfg.zoom); + }).catch(function () { return null; }); + } + function saveView() { + if (!map) return; + var c = map.getCenter(); + try { localStorage.setItem(VIEW_KEY, JSON.stringify({ lat: c.lat, lng: c.lng, zoom: map.getZoom() })); } catch (e) {} + } function cssColor(varName) { try { return getComputedStyle(document.documentElement).getPropertyValue(varName).trim() || '#888'; } @@ -76,11 +124,12 @@ function drawCoverage() { if (!map || destroyed) return; + var gen = generation; var b = map.getBounds(); var bbox = [b.getSouth(), b.getWest(), b.getNorth(), b.getEast()].join(','); var url = '/api/rx-coverage?bbox=' + bbox + '&z=' + map.getZoom() + '&days=' + days + (selectedRx ? '&rx=' + encodeURIComponent(selectedRx) : ''); fetch(url).then(function (r) { return r.json(); }).then(function (fc) { - if (destroyed || !covLayer) return; + if (!isLive(gen) || !covLayer) return; covLayer.clearLayers(); (fc.features || []).forEach(function (f) { var ring = (f.geometry.coordinates[0] || []).map(function (c) { return [c[1], c[0]]; }); @@ -180,9 +229,10 @@ // resulting moveend redraws the hexes at the fitted resolution. function fitToObserver() { if (!map || !selectedRx) { drawCoverage(); return; } + var gen = generation; var url = '/api/rx-coverage?bbox=-90,-180,90,180&z=' + Math.max(8, map.getZoom()) + '&days=' + days + '&rx=' + encodeURIComponent(selectedRx); fetch(url).then(function (r) { return r.json(); }).then(function (fc) { - if (destroyed || !map) return; + if (!isLive(gen) || !map) return; var minLat = 90, minLon = 180, maxLat = -90, maxLon = -180, any = false; (fc.features || []).forEach(function (f) { (f.geometry.coordinates[0] || []).forEach(function (c) { @@ -194,13 +244,18 @@ if (!any) { drawCoverage(); return; } // observer has no data in window → keep view map.fitBounds([[minLat, minLon], [maxLat, maxLon]], { padding: [30, 30], maxZoom: 15 }); drawCoverage(); // fitBounds may not fire moveend if the view is unchanged - }).catch(function (e) { console.warn('rx-coverage: observer extent fetch failed', e); drawCoverage(); }); + }).catch(function (e) { + if (!isLive(gen)) return; + console.warn('rx-coverage: observer extent fetch failed', e); drawCoverage(); + }); } function loadBoard() { + var gen = generation; fetch('/api/rx-leaderboard?days=' + days + '&limit=25').then(function (r) { return r.json(); }) - .then(function (d) { if (destroyed) return; boardCache = d.observers || []; renderBoard(); }) + .then(function (d) { if (!isLive(gen)) return; boardCache = d.observers || []; renderBoard(); }) .catch(function (e) { + if (!isLive(gen)) return; console.warn('rx-coverage: leaderboard fetch failed', e); var el = document.getElementById('rxBoard'); if (el) el.innerHTML = '
Could not load mobile observers.
'; @@ -214,22 +269,28 @@ loadBoard(); drawCoverage(); syncHash(); } + // days and rx, and the current viewport once the map exists (#124). function syncHash() { - var q = 'days=' + days + (selectedRx ? '&rx=' + selectedRx : ''); + var q = 'days=' + days + (selectedRx ? '&rx=' + encodeURIComponent(selectedRx) : ''); + if (map) { + var c = map.getCenter(); + q += '&lat=' + c.lat.toFixed(5) + '&lon=' + c.lng.toFixed(5) + '&zoom=' + map.getZoom(); + } try { history.replaceState(null, '', '#/rx-coverage?' + q); } catch (e) {} } function init(container) { destroyed = false; + var gen = ++generation; // A direct land on #/rx-coverage can run before MeshConfigReady resolves, at // which point MC_CLIENT_RX_COVERAGE is still undefined and the page would // wrongly show "not enabled". Defer until server config is loaded (#13). Promise.resolve(window.MeshConfigReady).then(function () { - if (!destroyed) start(container); + if (isLive(gen)) start(container, gen); }); } - function start(container) { + function start(container, gen) { if (!window.MC_CLIENT_RX_COVERAGE) { container.innerHTML = '
Coverage is not enabled on this deployment.
'; return; @@ -240,21 +301,41 @@ if (p) { var dd = parseInt(p.get('days'), 10); if ([1, 7, 14, 30].indexOf(dd) >= 0) days = dd; selectedRx = (p.get('rx') || '').toLowerCase(); } } catch (e) {} container.innerHTML = pageHtml(); - map = L.map('rxMap', { zoomControl: true, attributionControl: false }).setView([51.0, 4.8], 8); + var bar = document.getElementById('rxDays'); + if (bar) bar.addEventListener('click', function (e) { var b = e.target.closest('button[data-days]'); if (b) setDays(+b.dataset.days); }); + loadBoard(); + var explicit = urlView(); + var initial = explicit || savedView(); + (initial ? Promise.resolve(initial) : configView()).then(function (view) { + if (isLive(gen)) createMap(view || FALLBACK_VIEW, !!explicit, gen); + }); + } + + // createMap builds the map at the initial view. An observer-only rx= link + // (no explicit URL viewport) still fits that observer's coverage. + function createMap(view, explicitView, gen) { + map = L.map('rxMap', { zoomControl: true, attributionControl: false }).setView([view.lat, view.lon], view.zoom); if (typeof window._applyTilesToNodeMap === 'function') window._applyTilesToNodeMap(map); else L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(map); covLayer = L.layerGroup().addTo(map); // Debounce pan/zoom redraws so dragging the map doesn't fire a storm of // /api/rx-coverage requests (#6). Direct calls (setDays, fit) stay immediate. - map.on('moveend zoomend', debounce(drawCoverage, 200)); - var bar = document.getElementById('rxDays'); - if (bar) bar.addEventListener('click', function (e) { var b = e.target.closest('button[data-days]'); if (b) setDays(+b.dataset.days); }); - setTimeout(function () { if (!destroyed && map) { map.invalidateSize(); if (selectedRx) fitToObserver(); else drawCoverage(); } }, 150); - loadBoard(); + map.on('moveend zoomend', debounce(function () { + if (!isLive(gen) || !map) return; + saveView(); + syncHash(); + drawCoverage(); + }, 200)); + setTimeout(function () { + if (!isLive(gen) || !map) return; + map.invalidateSize(); + if (selectedRx && !explicitView) fitToObserver(); else drawCoverage(); + }, 150); } function destroy() { destroyed = true; + generation++; if (map) { try { map.remove(); } catch (e) {} map = null; } covLayer = null; } From 35f297b08b53239531ce29062ac07a15b37b7bb5 Mon Sep 17 00:00:00 2001 From: dborup Date: Wed, 30 Sep 2026 08:26:50 +0000 Subject: [PATCH 3/3] test(rx-coverage): pin the stale-response guards after a remount (#124) Test 10 releases the old mount's responses while the new map is still null, so the !map / !covLayer checks catch them and the generation checks in fitToObserver and drawCoverage could be removed with every test green. Test 12 remounts with a saved rx-coverage-view, so the new map and coverage layer exist at once, and then releases the old mount's observer-extent and coverage responses. It fails when either guard is reduced to its null check (verified for both). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011FcyXW5RdFzLZhuL1ntAsY --- test-issue-124-rx-coverage-viewport.js | 40 ++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/test-issue-124-rx-coverage-viewport.js b/test-issue-124-rx-coverage-viewport.js index 38fbd241d..bf077c58f 100644 --- a/test-issue-124-rx-coverage-viewport.js +++ b/test-issue-124-rx-coverage-viewport.js @@ -241,6 +241,46 @@ function assertView(env, want, tag) { assert(!/OLD/.test(board.innerHTML), 'the old leaderboard rendered into the new page'); }); + await test('12. after a remount with a saved view (the new map exists at once), the old extent and coverage responses are ignored', async () => { + const saved = { 'rx-coverage-view': JSON.stringify({ lat: 56.1, lng: 9.9, zoom: 10 }) }; + const env = makeEnv({ hash: '#/rx-coverage?rx=abcdef', storage: saved }); + // record what lands on each coverage layer + const layers = []; + env.sandbox.L.layerGroup = () => { + const l = { cleared: 0, added: 0, addTo() { return l; }, clearLayers() { l.cleared++; } }; + layers.push(l); + return l; + }; + env.sandbox.L.polygon = () => { + const pg = { addTo(l) { l.added++; return pg; }, bindTooltip() { return pg; } }; + return pg; + }; + await mount(env, null); + assert.strictEqual(env.maps.length, 1, 'the saved view should create the first map without a config fetch'); + env.runTimers(); // old mount: settle timer -> observer extent request + env.maps[0].fire('moveend'); // old mount: coverage request + assert(env.pending.some((p) => /bbox=-90,-180,90,180.*rx=abcdef/.test(p.url)), 'no old extent request pending'); + assert(env.pending.some((p) => /^\/api\/rx-coverage\?bbox=0,0,1,1/.test(p.url)), 'no old coverage request pending'); + + env.page().destroy(); + env.location.hash = '#/rx-coverage'; + env.page().init({ innerHTML: '' }); + await flush(); + assert.strictEqual(env.maps.length, 2, 'the remount should create its map at once from the saved view'); + const newMap = env.maps[1], newLayer = layers[1]; + assert(newLayer, 'the remount has no coverage layer'); + + // the old mount's extent response arrives while the new map exists + assert(env.respond(/bbox=-90,-180,90,180.*rx=abcdef/, { features: [{ geometry: { coordinates: [[[10, 55], [11, 56]]] } }] })); + await flush(); + assert.strictEqual(newMap.fits.length, 0, 'the old observer extent fitted the new map'); + + // the old mount's coverage response arrives while the new layer exists + assert(env.respond(/^\/api\/rx-coverage\?bbox=0,0,1,1/, { features: [{ properties: {}, geometry: { coordinates: [[[10, 55], [11, 56], [10, 56]]] } }] })); + await flush(); + assert.strictEqual(newLayer.cleared + newLayer.added, 0, 'the old coverage response was drawn on the new layer (cleared ' + newLayer.cleared + ', added ' + newLayer.added + ')'); + }); + await test('11. coverage filtering and leaderboard requests are unchanged', async () => { const env = makeEnv({ hash: '#/rx-coverage?days=14' }); await mount(env, { center: [55.68, 12.57], zoom: 9 });