Skip to content

fix(#1925): stop theme-refresh from discarding the neighbor-graph filter - #1944

Merged
efiten merged 1 commit into
Kpa-clawbot:masterfrom
efiten:fix/1925-theme-refresh-rerender
Sep 3, 2026
Merged

fix(#1925): stop theme-refresh from discarding the neighbor-graph filter#1944
efiten merged 1 commit into
Kpa-clawbot:masterfrom
efiten:fix/1925-theme-refresh-rerender

Conversation

@efiten

@efiten efiten commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

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:

  1. app.js starts /api/config/theme without gating navigation on it, deliberately.
  2. When it resolves, _customizerV2.init() runs applyCSS(), which dispatches theme-changed.
  3. app.js:1188 debounces that by 300 ms and dispatches theme-refresh.
  4. analytics.js:230 answered it with renderTab(_currentTab).

For the neighbor-graph tab step 4 is destructive. renderTab replaces el.innerHTML, so the role checkboxes are recreated with their defaults and companion is silently re-checked, and _ngState is rebuilt from the full 1400-node graph. The count is back over the 1000 limit, so #ngSkipMsg returns 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:

lands result
before the first uncheck harmless, test passes
between an uncheck and the next waitForFunction poll mode 1, the 15 s timeout
after waitForFunction succeeded, before the follow-up evaluate mode 2, "expected #ngSkipMsg gone again" (#1942)

Measured on an idle machine: the test's final evaluate at 542 ms, theme-refresh at 636 ms, #ngSkipMsg re-added at 667 ms. It passes locally by about 90 ms. On a loaded runner the test's Playwright round trips stretch while theme-refresh still 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 at analytics.js:3375, inside startGraphRenderer. When _ngState is null it falls through to the old path.

Verification

Measured, not asserted:

  • Deterministic reproduction (hold /api/config/theme until 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.
  • The unmodified E2E test passes against the fixed build.
  • With the tab open and a filter applied, a theme-refresh leaves the filter intact, the canvas present, and produces no page errors.
  • The regression test added here fails on unmodified master with 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-changed fires 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.

…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
efiten merged commit e2df9bb into Kpa-clawbot:master Sep 3, 2026
6 checks passed
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>
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.

Neighbor-graph filter E2E fails under CI load: skip-message transition is undone by a later re-render (#1758 lifecycle)

1 participant