fix(#1925): stop theme-refresh from discarding the neighbor-graph filter - #1944
Merged
Merged
Conversation
…r-graph filter The E2E test for Kpa-clawbot#1758 has been failing PRs that do not touch the feature (Kpa-clawbot#1942, Kpa-clawbot#1871 before it). It is not a test problem. Cause. Every page load fires exactly one delayed full re-render of the active analytics tab. app.js starts /api/config/theme without gating navigation on it; when it resolves, _customizerV2.init() runs applyCSS(), which dispatches 'theme-changed'; app.js:1188 debounces that by 300ms and dispatches 'theme-refresh'; analytics.js:230 answered it with renderTab(_currentTab). For the neighbor-graph tab that is destructive. renderTab replaces el.innerHTML, so the role checkboxes are recreated with their defaults (companion re-checked), and _ngState is rebuilt from the full 1400-node graph. The node count is over the 1000 limit again, so #ngSkipMsg comes back and the canvas is hidden. The re-entrancy epoch guard cannot prevent this: it stops a superseded tick() loop, and this is a legitimate new top-level render pass that resets the inputs the guard protects. One mechanism, both failure modes, decided by where that single re-render lands: before the first uncheck it is harmless; between an uncheck and the next waitForFunction poll it becomes the timeout (mode 1); after waitForFunction has succeeded but before the follow-up evaluate it becomes "expected #ngSkipMsg gone again" (mode 2), the Kpa-clawbot#1942 signature. On an idle machine the test finishes about 90ms before theme-refresh lands, which is why it passes locally and fails on a loaded runner. Fix. On theme-refresh, restart the renderer instead of rebuilding the tab when the neighbor-graph tab is active and has state. This keeps the filter and still applies the new theme: node colors are read live per frame from window.ROLE_COLORS, role swatches use CSS tokens, stats and the skip message use CSS variables, and the one cached theme value, _labelColor, is re-read on restart (analytics.js:3375, inside startGraphRenderer). If _ngState is null it falls through to the old path. Verified, not assumed: - Deterministic reproduction (hold /api/config/theme until just before the second filter-down, then stall 500ms before the final evaluate, no synthetic events): 2 of 2 fail on unmodified master with the exact Kpa-clawbot#1942 message, 3 of 3 pass with this fix. - The unmodified E2E test passes against the fixed build. - Filter state survives a theme-refresh with the tab open, canvas still present, no page errors. - The regression test added here fails on unmodified master ("theme-refresh reset the role filter (companion re-checked)") and passes with the fix, so it tests the cause rather than waiting for the race. Not done: the same startup re-render silently discards interaction in the first second on ANY analytics tab. This change covers the neighbor-graph tab only. A general fix, such as skipping the startup refresh when the effective config did not change, is worth its own issue. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PQS3XLoPD98yu9pxdRujqg
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>
This was referenced Sep 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1925. This is the flake that failed #1942 and #1871, neither of which touches the feature. It is not a test problem.
Cause
Every page load fires exactly one delayed, full re-render of the active analytics tab:
app.jsstarts/api/config/themewithout gating navigation on it, deliberately._customizerV2.init()runsapplyCSS(), which dispatchestheme-changed.app.js:1188debounces that by 300 ms and dispatchestheme-refresh.analytics.js:230answered it withrenderTab(_currentTab).For the neighbor-graph tab step 4 is destructive.
renderTabreplacesel.innerHTML, so the role checkboxes are recreated with their defaults and companion is silently re-checked, and_ngStateis rebuilt from the full 1400-node graph. The count is back over the 1000 limit, so#ngSkipMsgreturns and the canvas is hidden.The re-entrancy epoch guard cannot prevent it. That guard stops a superseded
tick()loop; this is a legitimate new top-level render pass that resets the very inputs the guard protects downstream of.One mechanism produces both documented failure modes, decided only by where that single re-render lands:
waitForFunctionpollwaitForFunctionsucceeded, before the follow-upevaluateMeasured on an idle machine: the test's final evaluate at 542 ms,
theme-refreshat 636 ms,#ngSkipMsgre-added at 667 ms. It passes locally by about 90 ms. On a loaded runner the test's Playwright round trips stretch whiletheme-refreshstill lands at theme-fetch latency plus 300 ms, so it arrives mid-test.Fix
On
theme-refresh, restart the renderer instead of rebuilding the tab when the neighbor-graph tab is active and has state. Four lines.This stays theme-correct: node colors are read live per frame from
window.ROLE_COLORS, role swatches use.role-swatch--{role}CSS tokens, stats and the skip message use CSS variables, and the one cached theme value,_labelColor = cssVar('--text-primary'), is re-read on restart atanalytics.js:3375, insidestartGraphRenderer. When_ngStateis null it falls through to the old path.Verification
Measured, not asserted:
/api/config/themeuntil just before the second filter-down, then stall 500 ms before the final evaluate; no synthetic events dispatched): 2 of 2 fail on unmodified master with the exact test(#1923 follow-up): pin the packets window in the munger slide-over step #1942 message, 3 of 3 pass with this fix.theme-refreshleaves the filter intact, the canvas present, and produces no page errors.theme-refresh reset the role filter (companion re-checked)and passes with the fix. It dispatches the event directly, so it tests the cause instead of waiting for the race to appear.What this does not cover
The same startup re-render silently discards user interaction in the first second or so on any analytics tab, not just this one. A user who clicks quickly after load loses that click. This change covers the neighbor-graph tab, because that is what #1925 is about and what is failing CI. The general fix, for example skipping the startup refresh when the effective config changed nothing, deserves its own issue rather than being smuggled in here.
Side observation while tracing:
theme-changedfires twice at startup, at about 311 ms and 323 ms. The debounce collapses them, so it is harmless, but it means the customizer pipeline runs twice. I did not identify the second dispatcher.