From 2eb7fdbf40e1bc85d6302482a38565537b99393e Mon Sep 17 00:00:00 2001 From: dborup Date: Tue, 29 Sep 2026 08:34:22 +0000 Subject: [PATCH 1/2] test(live): reproduce persisted view toggles wired after init awaits (#125) #88 moved only "Multibyte only" ahead of Live init's awaits. Heat, inferred hops, Realistic, Color by hash, Favorites, Foreign, Matrix and Rain are still restored and wired after await fetch('/api/config/map') and await loadNodes(). test-issue-125-live-toggles-early-e2e.js holds /api/config/map (the test-live-multibyte-only-e2e.js technique) and checks each toggle while init is held and after it, plus the map effects (heat layer, matrix theme, rain canvas), the Heat/Matrix interlock and one listener after SPA round trips. Against master: 2 passed, 12 failed (every toggle shows its default during init and drops a click made then; the heat layer is built although Heat was switched off; Matrix saved or clicked ON leaves Heat enabled during init). test-issue-125-live-toggles-wiring.js is the structural guard (fails if the wiring moves behind an await, drops a toggle, or a second change listener appears): 0 passed, 4 failed on master. Registered in test-all.sh, the CI unit step and the Playwright step. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019TcZHooUiiknVWbECVWzk8 --- .github/workflows/deploy.yml | 2 + test-all.sh | 1 + test-issue-125-live-toggles-early-e2e.js | 268 +++++++++++++++++++++++ test-issue-125-live-toggles-wiring.js | 71 ++++++ 4 files changed, 342 insertions(+) create mode 100644 test-issue-125-live-toggles-early-e2e.js create mode 100644 test-issue-125-live-toggles-wiring.js diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 833573c2b..d707b258b 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-125-live-toggles-wiring.js node test-coverage-gate.js node test-node-reach-coverage.js node test-reach-rank.js @@ -502,6 +503,7 @@ jobs: BASE_URL=http://localhost:13581 node test-issue-1128-multi-viewport-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1136-live-region-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-live-multibyte-only-e2e.js 2>&1 | tee -a e2e-output.txt + BASE_URL=http://localhost:13581 node test-issue-125-live-toggles-early-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1150-404-state-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1146-path-link-contrast-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 node test-issue-1705-subpath-contrast-e2e.js 2>&1 | tee -a e2e-output.txt diff --git a/test-all.sh b/test-all.sh index 884db1880..a60c0e9f9 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-125-live-toggles-wiring.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-125-live-toggles-early-e2e.js b/test-issue-125-live-toggles-early-e2e.js new file mode 100644 index 000000000..7485246af --- /dev/null +++ b/test-issue-125-live-toggles-early-e2e.js @@ -0,0 +1,268 @@ +/** + * E2E (#125): every persisted Live view toggle is restored and wired before + * Live init awaits anything. + * + * live.js renders the controls panel, then awaits /api/config/map and + * loadNodes(). #88 moved only "Multibyte only" ahead of those awaits; the + * other persisted toggles (Heat, Inferred/ghost hops, Realistic, Color by + * hash, Favorites, Foreign, Matrix, Rain) were restored and wired after + * them, so during init they showed their default state and a click made then + * was not saved and was reverted when init caught up. + * + * Same hold technique as test-live-multibyte-only-e2e.js: /api/config/map is + * intercepted, which freezes init at its first await right after the panel is + * rendered; window._liveWSHandler() marks a finished init. + * + * Checks, per toggle: the saved (non-default) value is shown while init is + * held; a click then saves at once, exactly once, and survives init. Then + * the effects that need the map: a Heat click during init decides whether + * the heat layer exists after init; Matrix saved or clicked ON disables Heat + * from the first paint and applies the theme after init; Rain clicked ON + * shows its canvas. Finally three SPA round trips leave one listener per + * toggle (one click writes once). + * + * Usage: BASE_URL=http://localhost:13581 node test-issue-125-live-toggles-early-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'); } + +// id, localStorage key, value shown when nothing is saved +const TOGGLES = [ + { id: 'liveHeatToggle', key: 'meshcore-live-heatmap', dflt: true }, + { id: 'liveGhostToggle', key: 'live-ghost-hops', dflt: true }, + { id: 'liveRealisticToggle', key: 'live-realistic-propagation', dflt: false }, + { id: 'liveColorHashToggle', key: 'meshcore-color-packets-by-hash', dflt: true }, + { id: 'liveFavoritesToggle', key: 'live-favorites-only', dflt: false }, + { id: 'liveForeignToggle', key: 'live-highlight-foreign', dflt: false }, + { id: 'liveMatrixToggle', key: 'live-matrix-mode', dflt: false }, + { id: 'liveMatrixRainToggle', key: 'live-matrix-rain', dflt: false }, +]; +const KEYS = TOGGLES.map((t) => t.key); + +// A context whose first page load starts from `seed` ({key: 'true'|'false'}; +// other toggle keys removed), counting writes per key and recording errors. +async function newLivePage(browser, errors, seed) { + const ctx = await browser.newContext({ viewport: { width: 1400, height: 900 } }); + const page = await ctx.newPage(); + page.setDefaultTimeout(15000); + 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; // external tile/CDN noise + errors.push('console.error: ' + m.text()); + }); + await page.addInitScript(([keys, seedObj]) => { + if (!sessionStorage.getItem('t125-seeded')) { + sessionStorage.setItem('t125-seeded', '1'); + for (const k of keys) { + if (Object.prototype.hasOwnProperty.call(seedObj, k)) localStorage.setItem(k, seedObj[k]); + else localStorage.removeItem(k); + } + } + window.__t125Writes = {}; + const setItem = Storage.prototype.setItem; + Storage.prototype.setItem = function (k, v) { + if (this === window.localStorage && keys.includes(k)) window.__t125Writes[k] = (window.__t125Writes[k] || 0) + 1; + return setItem.call(this, k, v); + }; + window.addEventListener('unhandledrejection', (e) => { + console.error('unhandledrejection: ' + (e.reason && e.reason.message || e.reason)); + }); + }, [KEYS, seed || {}]); + return { ctx, page }; +} + +async function holdLiveInit(page) { + let release, hit; + const released = new Promise((r) => { release = r; }); + const intercepted = new Promise((r) => { hit = r; }); + await page.route('**/api/config/map', async (route) => { + hit(); + await released; + await route.continue().catch((e) => { if (!page.isClosed()) throw e; }); + }, { times: 1 }); + return { + release, + async reached() { + let timer; + const timeout = new Promise((_, rej) => { timer = setTimeout(() => rej(new Error( + 'Live init never requested /api/config/map within 15s; the hold point this test relies on has moved')), 15000); }); + try { await Promise.race([intercepted, timeout]); } finally { clearTimeout(timer); } + }, + }; +} + +async function readState(page) { + return page.evaluate((toggles) => { + const out = { liveReady: !!window._liveWSHandler(), writes: Object.assign({}, window.__t125Writes), t: {} }; + for (const t of toggles) { + const cb = document.getElementById(t.id); + out.t[t.id] = { exists: !!cb, checked: cb ? cb.checked : null, disabled: cb ? cb.disabled : null, stored: localStorage.getItem(t.key) }; + } + const mapEl = document.getElementById('liveMap'); + out.matrixTheme = !!(mapEl && mapEl.classList.contains('matrix-theme')); + out.heatLayer = !!document.querySelector('#liveMap .leaflet-heatmap-layer'); + out.rainCanvas = !!document.getElementById('matrixRainCanvas'); + return out; + }, TOGGLES); +} + +// load() with init held, inspect(page, state) while held, then release and +// wait for init to finish (even when a check failed). +async function withInitHeld(page, load, inspect) { + const hold = await holdLiveInit(page); + let failure = null; + try { + await load(); + await hold.reached(); + await page.locator('#liveHeatToggle').waitFor({ state: 'attached' }); + const st = await readState(page); + assert(!st.liveReady, 'Live finished init while it should be held; the readiness signal moved'); + await inspect(page, st); + } catch (e) { + failure = e; + } finally { + hold.release(); + } + const ready = await page.waitForFunction(() => !!window._liveWSHandler(), null, { timeout: 15000 }).then(() => true, () => false); + if (failure) throw failure; + if (!ready) throw new Error('Live init did not finish after it was released'); +} + +// A user click while init is held: the controls body may be collapsed, so +// click the input programmatically the same way a label click would. +async function clickDuringInit(page, id) { + await page.evaluate((i) => document.getElementById(i).click(), id); +} + +const loadLive = (page) => () => page.goto(BASE + '/#/live', { waitUntil: 'domcontentloaded' }); + +(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=== #125 live toggles wired before init awaits, against ' + BASE + ' ==='); + + await step('with nothing saved, every toggle shows its default while init is held', async () => { + const { ctx, page } = await newLivePage(browser, errors, {}); + try { + await withInitHeld(page, loadLive(page), async (p, st) => { + for (const t of TOGGLES) { + assert(st.t[t.id].exists, t.id + ' missing'); + assert(st.t[t.id].checked === t.dflt, t.id + ' shows ' + st.t[t.id].checked + ' during init, default is ' + t.dflt); + } + }); + const st = await readState(page); + for (const t of TOGGLES) assert(!st.writes[t.key], 'init wrote ' + t.key + ' on its own'); + } finally { await ctx.close(); } + }); + + for (const t of TOGGLES) { + await step(t.id + ': the saved value is shown during init, and a click then is saved once and kept', async () => { + const saved = String(!t.dflt); + const { ctx, page } = await newLivePage(browser, errors, { [t.key]: saved }); + const want = t.dflt; // the click flips the saved (non-default) value back + try { + await withInitHeld(page, loadLive(page), async (p, st) => { + assert(st.t[t.id].checked === !t.dflt, t.id + ' shows ' + st.t[t.id].checked + ' during init, saved ' + saved); + await clickDuringInit(p, t.id); + const after = await readState(p); + assert(after.t[t.id].checked === want, 'the click did not flip ' + t.id); + assert(after.t[t.id].stored === String(want), 'a click during init must save at once (stored=' + after.t[t.id].stored + ')'); + assert(after.writes[t.key] === 1, 'one click must write ' + t.key + ' once (writes=' + after.writes[t.key] + ')'); + }); + const st = await readState(page); + assert(st.t[t.id].checked === want, 'init reverted the click on ' + t.id); + assert(st.t[t.id].stored === String(want), 'setting changed after init (stored=' + st.t[t.id].stored + ')'); + } finally { await ctx.close(); } + }); + } + + await step('Heat clicked OFF during init: no heat layer after init; clicked back ON: layer shown', async () => { + const { ctx, page } = await newLivePage(browser, errors, { 'meshcore-live-heatmap': 'true' }); + try { + await withInitHeld(page, loadLive(page), async (p) => { await clickDuringInit(p, 'liveHeatToggle'); }); + let st = await readState(page); + assert(!st.heatLayer, 'heat layer present after init although Heat was switched off during init'); + await page.evaluate(() => document.getElementById('liveHeatToggle').click()); + st = await readState(page); + assert(st.heatLayer, 'heat layer missing after switching Heat on after init'); + } finally { await ctx.close(); } + }); + + await step('Matrix saved ON: Heat unchecked and disabled from the first paint; theme applied after init', async () => { + const { ctx, page } = await newLivePage(browser, errors, { 'live-matrix-mode': 'true', 'meshcore-live-heatmap': 'true' }); + try { + await withInitHeld(page, loadLive(page), async (p, st) => { + assert(st.t.liveHeatToggle.checked === false && st.t.liveHeatToggle.disabled === true, + 'with Matrix saved ON, Heat must be unchecked and disabled during init (checked=' + st.t.liveHeatToggle.checked + ', disabled=' + st.t.liveHeatToggle.disabled + ')'); + }); + const st = await readState(page); + assert(st.matrixTheme, 'matrix theme not applied after init'); + assert(!st.heatLayer, 'heat layer shown while Matrix is on'); + assert(st.t.liveHeatToggle.disabled, 'Heat enabled while Matrix is on'); + } finally { await ctx.close(); } + }); + + await step('Matrix and Rain clicked ON during init: interlock at once, theme and rain after init; Matrix OFF re-enables Heat', async () => { + const { ctx, page } = await newLivePage(browser, errors, {}); + try { + await withInitHeld(page, loadLive(page), async (p) => { + await clickDuringInit(p, 'liveMatrixToggle'); + await clickDuringInit(p, 'liveMatrixRainToggle'); + const st = await readState(p); + assert(st.t.liveHeatToggle.disabled && !st.t.liveHeatToggle.checked, 'Matrix clicked ON did not disable Heat during init'); + }); + let st = await readState(page); + assert(st.matrixTheme, 'matrix theme not applied after init'); + assert(st.rainCanvas, 'rain canvas missing after init'); + assert(!st.heatLayer, 'heat layer shown while Matrix is on'); + await page.evaluate(() => document.getElementById('liveMatrixToggle').click()); + st = await readState(page); + assert(!st.matrixTheme, 'matrix theme still applied after switching Matrix off'); + assert(!st.t.liveHeatToggle.disabled, 'Heat still disabled after switching Matrix off'); + } finally { await ctx.close(); } + }); + + await step('three SPA round trips keep one listener per toggle', async () => { + const { ctx, page } = await newLivePage(browser, errors, {}); + try { + await page.goto(BASE + '/#/live', { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => !!window._liveWSHandler(), null, { timeout: 15000 }); + for (let i = 0; i < 3; i++) { + await page.evaluate(() => { location.hash = '#/packets'; }); + await page.waitForSelector('#pktTable', { state: 'attached' }); + await page.evaluate(() => { location.hash = '#/live'; }); + await page.waitForFunction(() => !!window._liveWSHandler(), null, { timeout: 15000 }); + } + for (const t of TOGGLES) { + if (t.id === 'liveHeatToggle') continue; // disabled while Matrix is on; covered above + const before = (await readState(page)).writes[t.key] || 0; + await page.evaluate((id) => document.getElementById(id).click(), t.id); + const after = (await readState(page)).writes[t.key] || 0; + assert(after - before === 1, 'one click on ' + t.id + ' after 3 round trips wrote ' + (after - before) + ' times'); + } + } finally { await ctx.close(); } + }); + + await step('no page errors, console errors or unhandled rejections', 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); +})(); diff --git a/test-issue-125-live-toggles-wiring.js b/test-issue-125-live-toggles-wiring.js new file mode 100644 index 000000000..7ea7edd66 --- /dev/null +++ b/test-issue-125-live-toggles-wiring.js @@ -0,0 +1,71 @@ +/* test-issue-125-live-toggles-wiring.js — structural guard for #125. + * + * The persisted Live view toggles must be restored and wired before init() + * awaits anything (see test-issue-125-live-toggles-early-e2e.js for the + * behaviour). This fails if the wiring call moves behind an await, if a + * toggle is dropped from the wiring table, or if a second change listener + * for one of these toggles is added elsewhere in live.js. + */ +'use strict'; +const fs = require('fs'); +const assert = require('assert'); + +console.log('--- test-issue-125-live-toggles-wiring.js ---'); +let passed = 0, failed = 0; +function test(name, fn) { + try { fn(); passed++; console.log(' ✅ ' + name); } + catch (e) { failed++; console.log(' ❌ ' + name + ': ' + e.message); } +} + +const SRC = fs.readFileSync(__dirname + '/public/live.js', 'utf8'); +const IDS = ['liveHeatToggle', 'liveGhostToggle', 'liveRealisticToggle', 'liveColorHashToggle', + 'liveFavoritesToggle', 'liveForeignToggle', 'liveMultibyteToggle', 'liveMatrixToggle', 'liveMatrixRainToggle']; + +function blockAt(src, start) { + const open = src.indexOf('{', start); + let depth = 0; + for (let i = open; i < src.length; i++) { + if (src[i] === '{') depth++; + else if (src[i] === '}') { depth--; if (depth === 0) return src.slice(start, i + 1); } + } + throw new Error('unbalanced block'); +} +function fn(name) { + const i = SRC.search(new RegExp('function ' + name + '\\s*\\(')); + assert(i !== -1, name + '() not found in live.js'); + return blockAt(SRC, i); +} + +test('init() calls wireLiveControls() before its first await', () => { + const init = fn('init'); + const wire = init.indexOf('wireLiveControls();'); + const firstAwait = init.search(/\bawait\b/); + assert(wire !== -1, 'init() does not call wireLiveControls()'); + assert(firstAwait !== -1, 'init() has no await any more; update this test'); + assert(wire < firstAwait, 'wireLiveControls() runs after the first await in init()'); + assert(init.indexOf('app.innerHTML') < wire, 'wireLiveControls() must run after the markup exists'); +}); + +test('wireLiveControls() restores and wires every persisted view toggle', () => { + const wire = fn('wireLiveControls'); + for (const id of IDS) assert(wire.includes("'" + id + "'"), id + ' is not in wireLiveControls()'); +}); + +test('no toggle gets a change listener outside wireLiveControls()', () => { + const wire = fn('wireLiveControls'); + const rest = SRC.replace(wire, ''); + for (const id of IDS) { + // Outside the wiring the id may only be looked up without a listener + // (e.g. the Heat/Matrix interlock). + const re = new RegExp("getElementById\\('" + id + "'\\)\\s*\\.addEventListener|const (\\w+) = document\\.getElementById\\('" + id + "'\\);[\\s\\S]{0,400}?\\1\\.addEventListener\\('change'", 'g'); + assert(!re.test(rest), id + ' gets a change listener outside wireLiveControls()'); + } +}); + +test('the heat layer is built after init only when Heat is enabled', () => { + const init = fn('init'); + assert(!/await loadNodes\(\);\s*showHeatMap\(\);/.test(init), 'init() builds the heat layer unconditionally after loadNodes()'); +}); + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) process.exit(1); From 6b276d08140e61a873aaca70657afef56ccc517e Mon Sep 17 00:00:00 2001 From: dborup Date: Tue, 29 Sep 2026 08:36:49 +0000 Subject: [PATCH 2/2] fix(live): wire every persisted view toggle before Live init awaits (#125) wireLiveControls() restores and wires Heat, inferred hops, Realistic, Color by hash, Favorites, Foreign, Multibyte, Matrix and Rain from one table, synchronously right after the markup is written and before init()'s first await. A click during init is saved at once and kept. - Heat gets a module-level mirror (heatEnabled) like the others. - The Heat/Matrix interlock is one function, syncHeatToggleToMatrix(), applied from the first paint and on every Matrix change. - applyLiveControlEffects() runs after loadNodes(): it builds the heat layer only when Heat is on and Matrix off, applies the matrix theme and starts the rain. showHeatMap() is a no-op until the map exists, so a Heat click during init only records the choice. - Each mounted checkbox is wired once (data-live-wired); init() writes new markup on every mount. - Audio and the geo-filter/region controls are unchanged (audio needs a user gesture; the others stay hidden or inert until their fetches). Test fixes: Foreign's default is ON (live.js reads it as !== 'false'), and the structural test ignores full-line comments when looking for the first await. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019TcZHooUiiknVWbECVWzk8 --- public/live.js | 201 ++++++++++++----------- test-issue-125-live-toggles-early-e2e.js | 2 +- test-issue-125-live-toggles-wiring.js | 3 +- 3 files changed, 109 insertions(+), 97 deletions(-) diff --git a/public/live.js b/public/live.js index 3f0806702..b7d38aa89 100644 --- a/public/live.js +++ b/public/live.js @@ -48,6 +48,9 @@ let _lastAnimFrame = 0; let nodeActivity = {}; let recentPaths = []; + // Heat is a mirror like the toggles below (#125): wireLiveControls() + // restores from it and its change handler writes it back. + let heatEnabled = localStorage.getItem('meshcore-live-heatmap') !== 'false'; let showGhostHops = localStorage.getItem('live-ghost-hops') !== 'false'; let realisticPropagation = localStorage.getItem('live-realistic-propagation') === 'true'; let showOnlyFavorites = localStorage.getItem('live-favorites-only') === 'true'; @@ -1123,6 +1126,102 @@ startReplay(); } + /** + * Restore and wire the persisted Live view toggles (#125). + * + * MUST run synchronously right after init() writes the markup, before init + * awaits anything: until it runs the checkboxes are painted and clickable + * but show their default state, and a click is neither saved nor kept. + * Restoring state and attaching listeners never sits behind an await; the + * visible EFFECT of a setting that needs the map, markers or heat layer is + * applied again by applyLiveControlEffects() once init has built them. + * + * Idempotent per element (data-live-wired). init() writes new markup on + * every mount, so each mounted checkbox has exactly one listener. + * + * Not covered, deliberately: #liveAudioToggle (MeshAudio.restore() and its + * slider panel; audio needs a user gesture anyway) and the geo-filter and + * region controls, which stay hidden or inert until their own fetches. + */ + function wireLiveControls() { + const TOGGLES = [ + { id: 'liveHeatToggle', restore: () => heatEnabled, onChange: (v) => { + heatEnabled = v; + localStorage.setItem('meshcore-live-heatmap', heatEnabled); + if (v) showHeatMap(); else hideHeatMap(); + } }, + { id: 'liveGhostToggle', restore: () => showGhostHops, onChange: (v) => { + showGhostHops = v; + localStorage.setItem('live-ghost-hops', showGhostHops); + } }, + { id: 'liveRealisticToggle', restore: () => realisticPropagation, onChange: (v) => { + realisticPropagation = v; + localStorage.setItem('live-realistic-propagation', realisticPropagation); + } }, + { id: 'liveColorHashToggle', restore: () => colorByHash, onChange: (v) => { + colorByHash = v; + localStorage.setItem('meshcore-color-packets-by-hash', colorByHash); + window.dispatchEvent(new Event('storage')); + } }, + { id: 'liveFavoritesToggle', restore: () => showOnlyFavorites, onChange: (v) => { + showOnlyFavorites = v; + localStorage.setItem('live-favorites-only', showOnlyFavorites); + applyFavoritesFilter(); + } }, + { id: 'liveForeignToggle', restore: () => highlightForeign, onChange: (v) => { + highlightForeign = v; + localStorage.setItem('live-highlight-foreign', highlightForeign); + } }, + { id: 'liveMultibyteToggle', restore: () => multibyteOnly, onChange: (v) => { + multibyteOnly = v; + localStorage.setItem('live-multibyte-only', multibyteOnly); + rebuildFeedList(); + } }, + { id: 'liveMatrixToggle', restore: () => matrixMode, onChange: (v) => { + matrixMode = v; + localStorage.setItem('live-matrix-mode', matrixMode); + applyMatrixTheme(matrixMode); + syncHeatToggleToMatrix(matrixMode); + } }, + { id: 'liveMatrixRainToggle', restore: () => matrixRain, onChange: (v) => { + matrixRain = v; + localStorage.setItem('live-matrix-rain', matrixRain); + if (matrixRain) startMatrixRain(); else stopMatrixRain(); + } }, + ]; + for (const t of TOGGLES) { + const el = document.getElementById(t.id); + if (!el) { console.warn('[live] control not found: ' + t.id); continue; } + if (el.dataset.liveWired === '1') continue; + el.dataset.liveWired = '1'; + el.checked = t.restore(); + el.addEventListener('change', (e) => t.onChange(e.target.checked)); + } + // The Heat/Matrix interlock holds from the first paint. + syncHeatToggleToMatrix(matrixMode); + } + + // Matrix mode owns the heat map: while it is on, the heat layer is hidden + // and its toggle unchecked and disabled. Safe before the map exists. + function syncHeatToggleToMatrix(on) { + const ht = document.getElementById('liveHeatToggle'); + if (on) { + hideHeatMap(); + if (ht) { ht.checked = false; ht.disabled = true; } + } else if (ht) { + ht.disabled = false; // recover from stale state + } + } + + // Effects of the restored toggles that need what init() builds behind its + // awaits: the map, the node markers and the heat layer. + function applyLiveControlEffects() { + if (heatEnabled && !matrixMode) showHeatMap(); else hideHeatMap(); + applyMatrixTheme(matrixMode); + syncHeatToggleToMatrix(matrixMode); + if (matrixRain) startMatrixRain(); + } + async function init(app) { app.innerHTML = `
@@ -1276,18 +1375,9 @@
`; - // "Multibyte only" is restored and wired before the first await. The - // controls are visible and clickable as soon as they are rendered, and the - // feed already filters on the saved multibyteOnly. Wired after the awaits - // below, the box showed OFF while init ran even when saved ON, and a click - // made then was not saved and was reset when init caught up. - const multibyteToggle = document.getElementById('liveMultibyteToggle'); - multibyteToggle.checked = multibyteOnly; - multibyteToggle.addEventListener('change', (e) => { - multibyteOnly = e.target.checked; - localStorage.setItem('live-multibyte-only', multibyteOnly); - rebuildFeedList(); - }); + // The persisted view toggles are restored and wired before the first + // await (#88, #125): see wireLiveControls(). + wireLiveControls(); // Fetch configurable map defaults (#115) let mapCenter = [37.45, -122.0]; @@ -1581,7 +1671,7 @@ AreaFilter.init(document.getElementById('liveAreaFilter')); AreaFilter.onChange(function () { loadNodes(); }); await loadNodes(); - showHeatMap(); + applyLiveControlEffects(); connectWS(); initResizeHandler(); initVCRHeightTracker(); @@ -1610,52 +1700,6 @@ map.on('zoomend', rescaleMarkers); - // Heat map toggle — persist in localStorage - const liveHeatEl = document.getElementById('liveHeatToggle'); - if (localStorage.getItem('meshcore-live-heatmap') === 'false') { liveHeatEl.checked = false; hideHeatMap(); } - else if (localStorage.getItem('meshcore-live-heatmap') === 'true') { liveHeatEl.checked = true; } - liveHeatEl.addEventListener('change', (e) => { - localStorage.setItem('meshcore-live-heatmap', e.target.checked); - if (e.target.checked) showHeatMap(); else hideHeatMap(); - }); - - const ghostToggle = document.getElementById('liveGhostToggle'); - ghostToggle.checked = showGhostHops; - ghostToggle.addEventListener('change', (e) => { - showGhostHops = e.target.checked; - localStorage.setItem('live-ghost-hops', showGhostHops); - }); - - const realisticToggle = document.getElementById('liveRealisticToggle'); - realisticToggle.checked = realisticPropagation; - realisticToggle.addEventListener('change', (e) => { - realisticPropagation = e.target.checked; - localStorage.setItem('live-realistic-propagation', realisticPropagation); - }); - - const colorHashToggle = document.getElementById('liveColorHashToggle'); - colorHashToggle.checked = colorByHash; - colorHashToggle.addEventListener('change', (e) => { - colorByHash = e.target.checked; - localStorage.setItem('meshcore-color-packets-by-hash', colorByHash); - window.dispatchEvent(new Event('storage')); - }); - - const favoritesToggle = document.getElementById('liveFavoritesToggle'); - favoritesToggle.checked = showOnlyFavorites; - favoritesToggle.addEventListener('change', (e) => { - showOnlyFavorites = e.target.checked; - localStorage.setItem('live-favorites-only', showOnlyFavorites); - applyFavoritesFilter(); - }); - - const foreignToggle = document.getElementById('liveForeignToggle'); - foreignToggle.checked = highlightForeign; - foreignToggle.addEventListener('change', (e) => { - highlightForeign = e.target.checked; - localStorage.setItem('live-highlight-foreign', highlightForeign); - }); - // Region filter (#1045): dropdown of observer IATA regions (function initLiveRegionFilter() { var rfEl = document.getElementById('liveRegionFilter'); @@ -1962,41 +2006,6 @@ refresh(); })(); - const matrixToggle = document.getElementById('liveMatrixToggle'); - matrixToggle.checked = matrixMode; - matrixToggle.addEventListener('change', (e) => { - matrixMode = e.target.checked; - localStorage.setItem('live-matrix-mode', matrixMode); - applyMatrixTheme(matrixMode); - if (matrixMode) { - hideHeatMap(); - const ht = document.getElementById('liveHeatToggle'); - if (ht) { ht.checked = false; ht.disabled = true; } - } else { - const ht = document.getElementById('liveHeatToggle'); - if (ht) { ht.disabled = false; } - } - }); - applyMatrixTheme(matrixMode); - if (matrixMode) { - hideHeatMap(); - const ht = document.getElementById('liveHeatToggle'); - if (ht) { ht.checked = false; ht.disabled = true; } - } else { - // Ensure heat toggle is enabled if matrix mode is off (recover from stale state) - const ht = document.getElementById('liveHeatToggle'); - if (ht) { ht.disabled = false; } - } - - const rainToggle = document.getElementById('liveMatrixRainToggle'); - rainToggle.checked = matrixRain; - rainToggle.addEventListener('change', (e) => { - matrixRain = e.target.checked; - localStorage.setItem('live-matrix-rain', matrixRain); - if (matrixRain) startMatrixRain(); else stopMatrixRain(); - }); - if (matrixRain) startMatrixRain(); - // Audio toggle const audioToggle = document.getElementById('liveAudioToggle'); const audioControls = document.getElementById('audioControls'); @@ -4394,6 +4403,7 @@ } function showHeatMap() { + if (!map) return; // a Heat click during init: applyLiveControlEffects() builds it if (heatLayer) { map.removeLayer(heatLayer); heatLayer = null; } const points = []; Object.values(nodeData).forEach(n => { @@ -4421,7 +4431,8 @@ } function hideHeatMap() { - if (heatLayer) { map.removeLayer(heatLayer); heatLayer = null; } + if (heatLayer && map) map.removeLayer(heatLayer); + heatLayer = null; } /** Extract channel row style from a packet (shared by feed item builders). */ diff --git a/test-issue-125-live-toggles-early-e2e.js b/test-issue-125-live-toggles-early-e2e.js index 7485246af..6e59d6236 100644 --- a/test-issue-125-live-toggles-early-e2e.js +++ b/test-issue-125-live-toggles-early-e2e.js @@ -42,7 +42,7 @@ const TOGGLES = [ { id: 'liveRealisticToggle', key: 'live-realistic-propagation', dflt: false }, { id: 'liveColorHashToggle', key: 'meshcore-color-packets-by-hash', dflt: true }, { id: 'liveFavoritesToggle', key: 'live-favorites-only', dflt: false }, - { id: 'liveForeignToggle', key: 'live-highlight-foreign', dflt: false }, + { id: 'liveForeignToggle', key: 'live-highlight-foreign', dflt: true }, { id: 'liveMatrixToggle', key: 'live-matrix-mode', dflt: false }, { id: 'liveMatrixRainToggle', key: 'live-matrix-rain', dflt: false }, ]; diff --git a/test-issue-125-live-toggles-wiring.js b/test-issue-125-live-toggles-wiring.js index 7ea7edd66..fdb03900f 100644 --- a/test-issue-125-live-toggles-wiring.js +++ b/test-issue-125-live-toggles-wiring.js @@ -37,7 +37,8 @@ function fn(name) { } test('init() calls wireLiveControls() before its first await', () => { - const init = fn('init'); + // full-line comments may mention "await"; only code counts + const init = fn('init').split('\n').filter((l) => !/^\s*\/\//.test(l)).join('\n'); const wire = init.indexOf('wireLiveControls();'); const firstAwait = init.search(/\bawait\b/); assert(wire !== -1, 'init() does not call wireLiveControls()');