fix(live): wire the view toggles before init() awaits — they are inert for ~100 ms - #1940
Conversation
…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.
…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>
|
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 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. |
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 withapp.innerHTMLand only restores toggle state and attaches thechangelisteners ~330 lines later, behind two awaits (line numbers on master, as verified in the #1939 thread):app.innerHTML = …— the checkboxes are in the DOM, clickableawait (await fetch('/api/config/map')).json()await loadNodes().checked = <pref>andaddEventListener('change', …)A click inside that window is silently lost. Measured on master, localhost, clicking
#liveMultibyteToggleon the first animation frame in which it exists:checked=true,localStorage=nullchecked=false,localStorage=nullchecked=true,localStorage=nullchecked=false,localStorage=nullchecked=true,localStorage=nullchecked=false,localStorage=nullNo 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 —ghostandcolorHashdefault ON, so they visibly flick on for every visitor.The fix
wireLiveControls()— synchronous, right afterapp.innerHTML: restores.checkedand 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.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 onnodeDatabeing empty at that moment.Verification
localStoragewritten, survives).test-live-multibyte-only-e2e.jsunmodified: 3/3, plus 8/8 under CPU load.checked=false, disabled=truefrom the first frame.The probe (as requested)
~30-line Playwright harness that demonstrates the inert control
Deliberately out of scope (each verified, none regressed here)
#liveAudioTogglehas the same window (MeshAudio persistslive-audio-enabled), but its restore runs throughMeshAudio.restore()and a slider panel — its own change.#liveGeoFilterTogglestays hidden until its own config fetch, so its window is not user-reachable; the fullscreen control is created by Leaflet after the map exists.clearNodeMarkers()(VCR resume path) drops the heat layer and nothing rebuilds it.heatEnabledis 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.