Files
meshcore-analyzer/public
TeTeHacko ab62e86d2d fix(live): wire the view toggles before init() awaits — they are inert for ~100 ms (#1940)
Follow-up to the #1939 discussion, where @efiten asked for this PR. The
multibyte E2E assertion that has been failing intermittently on master
is a symptom of this; with this change the unmodified test passes
reliably (3/3 idle, 8/8 under a 24-core load run that previously failed
it 2 in 6).

## The defect

`init()` writes the whole controls panel with `app.innerHTML` and only
restores toggle state and attaches the `change` listeners ~330 lines
later, behind two awaits (line numbers on master, as verified in the
#1939 thread):

| line | |
|---|---|
| 1104 | `app.innerHTML = …` — the checkboxes are in the DOM, clickable
|
| 1256 | `await (await fetch('/api/config/map')).json()` |
| 1543 | `await loadNodes()` |
| 1612–1614 | `.checked = <pref>` and `addEventListener('change', …)` |

**A click inside that window is silently lost.** Measured on master,
localhost, clicking `#liveMultibyteToggle` on the first animation frame
in which it exists:

| run | click at | immediately after | after 2.5 s |
|---|---|---|---|
| 1 | 481 ms | `checked=true`, `localStorage=null` | `checked=false`,
`localStorage=null` |
| 2 | 505 ms | `checked=true`, `localStorage=null` | `checked=false`,
`localStorage=null` |
| 3 | 438 ms | `checked=true`, `localStorage=null` | `checked=false`,
`localStorage=null` |

No handler runs, nothing reaches localStorage, and the later `.checked =
<pref>` reverts the click with no feedback. Separately, the restored
state itself appears only **93–112 ms (3–5 rendered frames)** after the
control is painted — `ghost` and `colorHash` default ON, so they visibly
flick on for every visitor.

## The fix

- **`wireLiveControls()`** — synchronous, right after `app.innerHTML`:
restores `.checked` and attaches listeners for the eight persisted
toggles, as one table instead of eight near-identical blocks. The
matrix↔heat interlock applies from the first paint too.
- **`applyLiveControlEffects()`** — after the awaits: applies the
effects that need state built there (matrix theme, rain canvas).
- **`syncHeatToggleToMatrix()`** — the interlock, extracted; it
previously existed as two identical copies.
- Heat gets a module-level mirror (`heatEnabled`) like the other seven
toggles, so the layer is only built when wanted. Previously it was built
unconditionally and torn down ~270 lines later — invisible (no await in
between, so no frame composited; the cost is only ~9 ms at 1000 nodes),
but any throw between the two calls left the layer visible against the
stored preference. `showHeatMap()` now guards on the map existing
instead of relying on `nodeData` being empty at that moment.

## Verification

- Click on the first painted frame now persists, 3/3 (`localStorage`
written, survives).
- Restored state present on the first painted frame: 0 unchecked frames
in 5 runs (was 3–5).
- All four heat×matrix load combinations render identically to master
(layer present/absent, checked, disabled).
- `test-live-multibyte-only-e2e.js` unmodified: 3/3, plus 8/8 under CPU
load.
- With stored matrix ON, the heat toggle is `checked=false,
disabled=true` from the first frame.

## The probe (as requested)

<details><summary>~30-line Playwright harness that demonstrates the
inert control</summary>

```js
const { chromium } = require('playwright');
(async () => {
  const b = await chromium.launch();
  for (let run = 0; run < 3; run++) {
    const ctx = await b.newContext({ viewport: { width: 1400, height: 900 } });
    const p = await ctx.newPage();
    await p.addInitScript(() => {
      window.__r = { clickedAt: null, afterClick: null, lsAfterClick: null, final: null, lsFinal: null };
      const tick = () => {
        const el = document.getElementById('liveMultibyteToggle');
        if (el && window.__r.clickedAt === null) {
          window.__r.clickedAt = performance.now();
          el.click();
          window.__r.afterClick = el.checked;
          window.__r.lsAfterClick = localStorage.getItem('live-multibyte-only');
          return;
        }
        requestAnimationFrame(tick);
      };
      requestAnimationFrame(tick);
    });
    await p.goto('http://localhost:13581/#/live', { waitUntil: 'domcontentloaded' });
    await p.waitForTimeout(2500);
    const r = await p.evaluate(() => {
      const el = document.getElementById('liveMultibyteToggle');
      window.__r.final = el ? el.checked : null;
      window.__r.lsFinal = localStorage.getItem('live-multibyte-only');
      return window.__r;
    });
    console.log(`run ${run+1}: click at ${Math.round(r.clickedAt)}ms -> checked=${r.afterClick}, ls=${r.lsAfterClick}` +
                ` || after 2.5s: checked=${r.final}, ls=${r.lsFinal}`);
    await ctx.close();
  }
  await b.close();
})();
```
</details>

## Deliberately out of scope (each verified, none regressed here)

- `#liveAudioToggle` has the same window (MeshAudio persists
`live-audio-enabled`), but its restore runs through
`MeshAudio.restore()` and a slider panel — its own change.
- `#liveGeoFilterToggle` stays hidden until its own config fetch, so its
window is not user-reachable; the fullscreen control is created by
Leaflet after the map exists.
- Pre-existing: `clearNodeMarkers()` (VCR resume path) drops the heat
layer and nothing rebuilds it. `heatEnabled` is the right gate for
fixing that, but it is a separate behaviour change.

Happy to also submit the deterministic version of the multibyte test (it
forces the window open by delaying `/api/config/map`, so it fails on
this bug 3/3 instead of intermittently) as a follow-up if wanted.
2026-09-03 18:19:54 +02:00
..