Skip to content

fix(live): wire the view toggles before init() awaits — they are inert for ~100 ms - #1940

Merged
efiten merged 1 commit into
Kpa-clawbot:masterfrom
TeTeHacko:fix/live-controls-inert-window
Sep 3, 2026
Merged

fix(live): wire the view toggles before init() awaits — they are inert for ~100 ms#1940
efiten merged 1 commit into
Kpa-clawbot:masterfrom
TeTeHacko:fix/live-controls-inert-window

Conversation

@TeTeHacko

Copy link
Copy Markdown
Contributor

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)

~30-line Playwright harness that demonstrates the inert control
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();
})();

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.

…ert for ~100ms

init() writes the whole controls panel with app.innerHTML and only restored
toggle state and attached the change listeners ~330 lines later, behind
`await fetch('/api/config/map')` and `await loadNodes()`. Between the panel
being painted and that code running, the checkboxes existed, were clickable,
and had no handler.

Measured on master, localhost, clicking #liveMultibyteToggle on the first
animation frame in which it exists (3 runs of 3, clicks at 438-505ms):

  immediately after the click:  checked=true,  localStorage=null
  2.5s later:                   checked=false, localStorage=null

The click flips the DOM, writes nothing, and is silently undone by the later
`.checked = <pref>`. Separately, the restored state itself appeared only
93-112ms (3-5 rendered frames) after the control was painted -- the reason
test-live-multibyte-only-e2e.js has been failing intermittently on master
(it is intermittently right, not flaky; see Kpa-clawbot#1939 discussion).

The split:

  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 that could drift.

Heat additionally gets a module-level mirror (heatEnabled) like the other
seven toggles, so the layer is only built when the user wants it; previously
it was built unconditionally and torn down ~270 lines later, which was
invisible (no await in between) but meant 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.

With the change, on the same harness: the click persists (3/3), the restored
state is present on the first painted frame (0 unchecked frames in 5 runs),
all four heat x matrix load combinations render identically to master, and
test-live-multibyte-only-e2e.js passes 3/3 unmodified -- including 8/8 under
a 24-core CPU-load run that previously failed it 2 in 6.
efiten added a commit that referenced this pull request Sep 3, 2026
…done (#1945)

Closes #1943. The colour picker's keyboard navigation is broken, and the
E2E flake that has been failing unrelated PRs (#1940, #1941, and master
pushes `589fa987` and `859173f1`) was reporting it correctly.

## Cause

`showPopover` deferred focusing the first swatch with an uncancellable
`setTimeout(..., 0)` at `channel-color-picker.js:146`, and nothing
cleared it on hide. The file contained **zero** `clearTimeout` calls.
Reopen the popover while a swatch still holds focus and that timer lands
after the user has already pressed an arrow key, pulling focus back to
the first swatch.

Proven, not argued. Instrumenting `HTMLElement.prototype.focus` with a
stack trace, on one open:

```
focus(#f97316) @10205ms  <- the keydown handler
focus(#ef4444) @10208ms  <- channel-color-picker.js:146:58
```

Three milliseconds apart.

## The user-visible bug

Worse than a flaky test. **Open the picker, arrow to a colour, press
Enter, and the first colour is assigned instead of the one you chose.**
Holding the timing still made the existing suite say so directly:

```
✗ Enter should assign focused color (#f97316), got #ef4444
```

## Why the test looked flaky

The revert happens on **every** open. Only whether the assertion reads
before or after it varies, which is why an idle machine passes and a
loaded runner does not.

#1939 (mine) assumed the opposite: a race in which the handler had not
yet moved focus, cured by waiting for it. #1943 has the measurement that
disproves it. The failing step took **16 ms** while that wait has a **3
second** budget, so the wait was resolving successfully and then the
value was reverted underneath it. It never helped. Its comment is
corrected in this PR rather than left to mislead the next reader.

## Fix

Keep a handle for the timer, cancel a pending one on both show and hide,
and inside it do nothing when the popover has since been hidden or when
focus already sits inside it.

A fresh open still focuses the first swatch, which is what the
accessibility behaviour is for. An open that inherits focus, or a user
who has already navigated, is left alone.

## Verification

- The **regression test added here fails on unmodified master** with `a
late focus timer must not move focus after the user did` and passes with
the fix.
- It is **deterministic, not load-dependent**: it reproduces the exact
sequence the stack trace identified (open, Escape, reopen, ArrowRight
before the timer lands) rather than waiting for contention. It also
asserts the Enter path, so the user-visible half is covered and not just
focus position.
- Full suite: 10 of 10, three consecutive runs.

## Note on the other flake

This is one of two E2E failures blocking the queue. The other, #1925, is
a different mechanism in a different file and is fixed separately in
#1944. Together they should leave the E2E suite deterministic again.

Same shape as @TeTeHacko's finding in #1940: something is operable
before its setup has finished. That is now three instances in this
codebase, so it may be worth a look as a pattern rather than three
separate fixes.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@efiten

efiten commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Recycling this (close + reopen) to re-test it against current master. Nothing is wrong with your work and nothing here needs changing from you.

Your Playwright failure was the colour picker's ArrowRight cycles focus across swatches step, which this PR does not touch. Both of those turned out to be real product bugs rather than flaky tests, and both are now fixed in master: #1945 closes #1943, and each fix ships a regression test that fails on the previous master and passes with the fix, so they pin the cause instead of waiting for load.

A plain re-run would not help, and that is measured rather than assumed: a re-run reuses the merge commit from the original run, so it would test your branch against the old master again. Close and reopen is what forces a fresh merge ref.

Sorry for the noise on your notifications, and for the delay: your PR was red for days because of code that was not yours.

@efiten efiten closed this Sep 3, 2026
@efiten efiten reopened this Sep 3, 2026
@efiten
efiten merged commit ab62e86 into Kpa-clawbot:master Sep 3, 2026
11 of 12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants