Skip to content

Let focus travel with the tab it moved - #287

Merged
fstubner merged 1 commit into
mainfrom
fix/tab-move-focus
Aug 29, 2026
Merged

fstubner merged 1 commit into
mainfrom
fix/tab-move-focus

Conversation

@fstubner

Copy link
Copy Markdown
Owner

Ctrl+Shift+Arrow moved the tab but left focus on the index it came from, so a second press moved whatever had slid into that slot. Two tabs ping-ponged instead of one walking down the strip:

start                [Discover, Scan, Scan]  focus index 0 (Discover)
Ctrl+Shift+Right #1  [Scan, Discover, Scan]  focus index 0 (Scan)
Ctrl+Shift+Right #2  [Discover, Scan, Scan]  focus index 0 (Discover)

The focus-follow code was what broke focus-follow

onMoveTab(tab.id, to);
const sibling = event.currentTarget.parentElement?.children[to];
if (sibling instanceof HTMLElement) sibling.focus();

That runs synchronously, before React re-renders, so children[to] reads the pre-move DOM and grabs the neighbour — the tab about to slide into the slot being vacated. React then reorders and carries that neighbour back to the old index, with focus sitting on it.

The fix is to delete it. Focus is already on this tab's own element, and the list is keyed by tab.id, so React reorders by moving that node rather than recreating it. Focus travels with the tab without any help.

Why the existing test did not catch it

renderStrip's onMoveTab is a mock, so the tabs never actually move. Everything that depends on the re-render — focus above all — is invisible to it, and "moves the focused tab with Ctrl+Shift+Arrow" passed happily throughout while asserting only that the callback fired.

The new test renders a strip that really reorders, then checks the moved element still holds focus and that a second press carries the same tab onward rather than swapping the pair back.

Verification

  • The new test fails against the old code on exactly the focus assertion (document.activeElement is the ARP tab, not the moved Scan tab) and passes with the fix. That is the regression pinned, not just a green suite.
  • 186 unit tests pass (184 + 2 new), tsc exit 0, eslint clean.
  • Outstanding: not yet confirmed in the running app. The bug was originally observed there with native keystrokes, and this fix has so far only been proven at the unit level. I will drive the real app and post the before/after here before this is merged — a jsdom reorder is not proof that WebView2 behaves the same way, and this repo's own walkthrough warns that synthetic and native input can diverge.

Sequencing note

#286 records this under Known gaps in ux-walkthrough.md. Whichever of the two lands second needs to move that entry out of Known gaps and into the Managing tabs steps as working behaviour — this branch is cut from main, so it does not contain that text yet.

Ctrl+Shift+Arrow moved the tab but left focus on the index it came from, so a
second press moved whatever had slid into that slot. Two tabs ping-ponged
instead of one walking down the strip:

  start                [Discover, Scan, Scan]  focus index 0 (Discover)
  Ctrl+Shift+Right #1  [Scan, Discover, Scan]  focus index 0 (Scan)
  Ctrl+Shift+Right #2  [Discover, Scan, Scan]  focus index 0 (Discover)

The focus-follow code was what broke focus-follow:

  onMoveTab(tab.id, to);
  const sibling = event.currentTarget.parentElement?.children[to];
  if (sibling instanceof HTMLElement) sibling.focus();

It ran synchronously, before React re-rendered, so `children[to]` read the
pre-move DOM and grabbed the neighbour -- the tab about to slide into the slot
being vacated. React then reordered and carried that neighbour back to the old
index, with focus on it.

The fix is to delete it. Focus is already on this tab's own element, and the
list is keyed by tab id, so React reorders by moving that node rather than
recreating it; focus travels with the tab without help.

The existing test asserted only that onMoveTab was called, so it passed
throughout -- its onMoveTab is a mock and the tabs never actually move, which
makes everything that depends on the re-render invisible to it. The new test
renders a strip that really reorders, and checks the moved element still holds
focus and that a second press carries the same tab onward rather than swapping
the pair back. It fails against the old code on exactly that assertion.
@fstubner

Copy link
Copy Markdown
Owner Author

Confirmed in the running app with native keystrokes (not synthetic), which is how the bug was originally established — the walkthrough warns the two diverge in this harness.

before                         after
start  focus 0 (Discover)      start  focus 0 (Discover)
       [Discover, Scan, Scan]         [Discover, Scan, Scan]
#1     focus 0 (Scan)          #1     focus 1 (Discover)
       [Scan, Discover, Scan]         [Scan, Discover, Scan]
#2     focus 0 (Discover)      #2     focus 2 (Discover)
       [Discover, Scan, Scan]         [Scan, Scan, Discover]

Before, the pair ping-ponged and focus never left index 0. After, the Discover tab walks 0 → 1 → 2 and keeps focus throughout — a repeated press carries the same tab, which is what the code always claimed.

Method: Vite served this branch from an isolated worktree on 1420 so the app loaded the fixed source, verified by fetching the module through Vite and confirming the children[to] lookup is gone from the live code (it survives only inside the new comment; the children[target] at line 71 is the plain-arrow selection path, where the strip does not reorder and the lookup is correct).

The outstanding app-level check from the PR description is now done.

@fstubner
fstubner merged commit 54d6bd5 into main Aug 29, 2026
15 checks passed
fstubner added a commit that referenced this pull request Aug 29, 2026
#287 fixed the focus bug this branch recorded under Known gaps, so the entry
describes behaviour that no longer exists. Step 3 states the working behaviour
instead, with the native-key observation that confirmed it.
fstubner added a commit that referenced this pull request Aug 29, 2026
* Walk the tab strip, and record the keyboard bug it turned up

The desktop walkthrough covered launching, running, reading and exporting a
result, but said nothing about reordering or closing tabs -- so the acceptance
gate structurally could not catch a regression in any of it.

Written the way the rest of the file claims to be: by driving the running app
and recording what it did, not by reading the components. Every figure quoted
is from a run at 1100x800 with three tabs open.

That turned up a defect. Ctrl+Shift+Arrow moves the tab but leaves focus on the
index it left, so a second press moves whatever slid into that slot and two
tabs ping-pong instead of one walking down the strip. Reproduced with both
synthetic and native key events, so it is not the selenium artifact the file
already warns about. `tabStripKeyboard.ts` and #281 both state that focus
follows the tab; that is the intent, not the behaviour. Recorded under Known
gaps rather than fixed here, so the fix is reviewed on its own.

Also observed and now written down: the strip reorders live rather than on
release, a cancelled drag reverts to the pre-press order, plain arrows move the
selection and wrap while Ctrl+Shift moves the tab and clamps, the close menu
disables rather than hides items that would do nothing, and closing every tab
lands on "No Tabs Open" rather than a blank window.

* Correct the tab fixture, and record that reordering renames tabs

The fixture in "Managing tabs" wrote the three tabs as `Discover
192.168.1.0/24` and two `Scan 127.0.0.1`. The strip does not label them that
way: duplicates carry `#` and their position in the strip, so they read
`Scan: 127.0.0.1 #2` and `#3`, and there is no `#1`.

That is not just a wrong fixture. The number is a position, not a name, so it
moves when anything around it moves -- and drag-to-reorder is the headline of
the section that got this wrong. Observed: moving the Discover tab one place
right turns `#2` into `#1` and leaves the two duplicates reading `#1` and `#3`
with no `#2` between them.

Found by an independent acceptance pass, which is the argument for running one:
the section was written from the app, and this still went unnoticed because I
had read the labels as the values I typed into the form rather than as what the
strip rendered.

* Record Ctrl+Shift+Arrow as working, now that it is

#287 fixed the focus bug this branch recorded under Known gaps, so the entry
describes behaviour that no longer exists. Step 3 states the working behaviour
instead, with the native-key observation that confirmed it.
@fstubner
fstubner deleted the fix/tab-move-focus branch August 29, 2026 22:37
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.

1 participant