Skip to content

fix(#1943): cancel the deferred swatch focus so arrow keys are not undone - #1945

Merged
efiten merged 1 commit into
Kpa-clawbot:masterfrom
efiten:fix/1943-picker-focus-steal
Sep 3, 2026
Merged

fix(#1943): cancel the deferred swatch focus so arrow keys are not undone#1945
efiten merged 1 commit into
Kpa-clawbot:masterfrom
efiten:fix/1943-picker-focus-steal

Conversation

@efiten

@efiten efiten commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

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.

… are not undone

The colour picker's keyboard navigation does not work, and the E2E flake that
has been failing unrelated PRs (Kpa-clawbot#1940, Kpa-clawbot#1941, and master pushes 589fa98 and
859173f) 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. When the popover is reopened
while a swatch still holds focus, that timer lands after the user has already
pressed an arrow key, and pulls focus back to the first swatch.

Proven rather than argued. Instrumenting HTMLElement.prototype.focus with a
stack trace gives, on one open:

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

Three milliseconds apart. The user-visible consequence is worse than a flaky
test: arrow to a colour, press Enter, and the FIRST colour is assigned. The
existing suite showed it as "Enter should assign focused color (#f97316), got
#ef4444" once the timing was held still.

This is why the test failed intermittently: the revert happens on every open,
and only whether the assertion reads before or after it varies. Kpa-clawbot#1939 assumed a
race in which focus had not yet moved and added a wait for it; that wait
resolved successfully and then the value was reverted underneath it, so it never
helped. Its comment is corrected here.

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; an open
that inherits focus, or a user who has already navigated, is left alone.

Verified: the regression test added here fails on unmodified master ("a late
focus timer must not move focus after the user did") and passes with the fix,
and it is deterministic rather than load-dependent, reproducing the sequence
(open, Escape, reopen, ArrowRight before the timer lands) that the stack trace
identified. Full suite 10 of 10 across 3 consecutive runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQS3XLoPD98yu9pxdRujqg
@efiten
efiten merged commit 5d2e14a into Kpa-clawbot:master Sep 3, 2026
6 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.

Colour picker: ArrowRight focus is taken back, and #1939's fix was based on a wrong premise

1 participant