Skip to content

FIX Restore keyboard focus around Converter Registry dialogs - #2717

Open
Dmitry Voropaev (v0ropaev) wants to merge 7 commits into
microsoft:mainfrom
v0ropaev:fix/converter-registry-focus
Open

Dmitry Voropaev (v0ropaev) wants to merge 7 commits into
microsoft:mainfrom
v0ropaev:fix/converter-registry-focus

Conversation

@v0ropaev

Copy link
Copy Markdown
Contributor

Description

Fixes #2701. Frontend only.

Two keyboard-accessibility defects in the Converter Registry, and the fix for each turned out to need something the other does not:

  • Focus restoration. The Add and Remove dialogs open from state rather than a DialogTrigger, so Fluent has nothing to restore to and focus lands on <body>. The three triggers (New Converter, Create First Converter, per-row Remove) now carry useRestoreFocusTarget(), the convention this repo already uses for state-controlled dialogs (FeedbackDialog.tsx:180-182). That covers dismissal. It does not cover the two paths where the trigger unmounts — a successful removal and the first creation — so those restore explicitly with the requestAnimationFrame(() => ref.current?.focus()) idiom already used at ScenarioRunPage.tsx:169.
  • The in-flight window on a duplicate name. The primary action is disabled while the POST is in flight, so focus leaves the dialog for the whole request and Escape stops dismissing it (Tabster handles Escape on the surface). The button now uses disabled + disabledFocusable, so it stays focusable while inert, and the error MessageBar gets role="alert" and takes focus when an error appears.

What I measured, because the diff does not show it

My claim comment on the issue said useRestoreFocusTarget, and an earlier draft of this analysis — done against jsdom — concluded the hook only helps keyboard dismissal. A real browser disagrees, so for the record, in Chromium via --project=mock:

  • hook only: mouse Cancel and Escape both restore correctly; the two unmount paths still land on <body>;
  • explicit restore only: everything works, but it duplicates what Fluent already does on dismissal.

Hence both. In jsdom the hook contributes nothing measurable (all 13 ConverterRegistry tests pass with the three spreads deleted), which is exactly how the first analysis went wrong.

On the disabled button: Chromium runs the unfocusing steps asynchronously. On main, with the POST held open, document.activeElement is the disabled button at t=0 but <body> at t=100ms and t=1000ms, and Escape after that does nothing — so "Escape stops working" lasts the whole request, and moving focus only once the error arrives would repair the end state, not the window. disabledFocusable keeps the double-submit guard intact (useARIAButtonProps drops the native attribute, sets aria-disabled and replaces the handlers with undefined) and the styling is unchanged. One semantic consequence worth your call: the button is now aria-disabled rather than disabled in the "no converter types" case too, so it stays in the tab order.

Tests and Documentation

10 new jest tests, 4 new Playwright tests (frontend/e2e/registry.spec.ts, mock project — no backend).

  • npx jest src/components/Registry — 3 suites, 24 tests pass. 9 of the 10 new jest tests are red on unmodified main (Received element with focus: <body>).
  • The tenth, "should leave focus alone when a dialog opens while the removal refresh is in flight", is green on main and red only against my own first attempt, where the deferred restore could steal focus into the background while a dialog was open. It is a guard against a defect this design could introduce, not a regression test for BUG Converter Registry dialogs lose keyboard focus after dismissal and duplicate errors #2701 — flagging it rather than counting it.
  • npx playwright test --project=mock e2e/registry.spec.ts — 6/6 pass, all 4 new tests red on unmodified main in Chromium (two on toBeFocused, one on focus being outside the dialog mid-request, one on the missing role="alert"). jsdom does not blur a button that becomes disabled, which is why the duplicate-name half needed a browser.
  • Full npx jest — 2221 of 2222 pass. The one failure (LabelsBar > should dismiss the picker when the user clicks away) reproduces on this machine with my change stashed, and main's Frontend Tests run is green, so it is environmental here rather than pre-existing upstream.
  • npx jest --coverage — 93.30% statements / 86.39% branches / 91.44% functions / 94.59% lines against the configured 85/85/90/90; no new uncovered branch.
  • npm run lint (0 warnings), npm run type-check, npx tsc --noEmit -p tsconfig.test.json, pre-commit run --files … — all clean.

The existing mock of CreateConverterDialog in ConverterRegistry.test.tsx was upgraded from a bare <div role="dialog"> to a real Fluent Dialog (still a mock, still no API calls), because a plain div has no Tabster restore source. Existing assertions on it are unchanged.

No documentation or JupyText changes: frontend-only, no API surface.

Notes for review

  • FEAT Add focused enum and word-selection converter inputs #2708 (yours, in flight): CreateConverterDialog.tsx merges clean — I reconstructed your diff onto main and ran git merge-file --diff3; your nearest hunks end at old lines 315 and 455, mine sit at 316 and ~462. CreateConverterDialog.test.tsx conflicts, because both PRs append tests at the same @@ -245,4 @@ anchor; resolution is "keep both". Also: your client-side validation sets the error before setSubmitting(true), so post-merge it renders as role="alert" without stealing focus, which is right — that path never disables the button.
  • FIX: restore keyboard focus to launch button on preview dismissal (#2680) #2686 / Scanner run preview dismissal does not restore keyboard focus #2680: same defect class in ScenarioDetail.tsx, solved there with a manual ref helper and no Fluent hook. On the evidence above, the manual restore is only necessary where the trigger unmounts, and that Launch button does not, so the hook alone would cover it. Two offers: once both land, factor the rAF restore into a small shared helper (ScenarioRunPage.tsx:169 is a third copy of those three lines), and unify on guarded vs unguarded requestAnimationFrame either way — I followed the unguarded in-repo idiom.
  • The ConverterRegistry.tsx half stands alone if you would rather keep FEAT Add focused enum and word-selection converter inputs #2708's file untouched; the two halves fix different defects.
  • Side finding, not fixed here: the app mounts no Fluent AnnounceProvider, so MessageBar's built-in useAnnounce is a no-op and this error was not announced at all before. role="alert" fixes it for this one dialog; app-wide announcements look like their own issue, happy to file it.

Both registry dialogs open from component state rather than from a
DialogTrigger, so nothing in the tree was a Tabster restore target and
every dismissal left document.activeElement on <body>. Mark the three
opening controls the way FeedbackDialog does, which covers Cancel, Escape
and backdrop dismissals, and restore focus explicitly on the two paths
where the control that opened the dialog unmounts with the list: a removed
row, and the empty-state button once the registry holds a converter. A
restore that is still queued when the next dialog opens is dropped, so a
slow refresh cannot pull focus out of that dialog.

On a failed creation the primary action is disabled while the request is in
flight. A browser runs the unfocusing steps for it a tick later, which
drops focus to <body> and out of the open dialog, and Escape then stops
dismissing it because Tabster handles that key on the dialog surface. Keep
the action focusable while disabled, which preserves the double-submit
guard through aria-disabled, and move focus onto the error once it arrives,
exposed as an alert so it is announced.
Comment thread frontend/src/components/Registry/ConverterRegistry.tsx
Comment thread frontend/src/components/Registry/CreateConverterDialog.tsx Outdated
A create request can outlive the dialog it was submitted from, and onCreated
ran unconditionally when it landed. It cleared the single boolean that said a
dialog was open and restored focus to New Converter even when the user had
since opened a converter's Remove dialog: Tabster then marked that
still-visible dialog aria-hidden, and Escape stopped dismissing it.

Mint a token for each dialog opening and keep the token of the dialog on
screen, so a response is matched against the opening it belongs to rather than
against "some dialog is open". A response that outlived its own dialog still
refreshes the list, but only its own opening may clear the dialog state and
restore focus.

That covers a normal success, where the dialog is still open and the trigger
may have unmounted with the list, which is what the explicit restore exists
for; a success after the dialog was dismissed with nothing else open; a success
while the Remove dialog is open; and a success after the create dialog was
reopened. Removal needs no token of its own, because Cancel is disabled and the
Escape handler ignored while its request is in flight.

jsdom has no exit animation, so the mock Playwright project covers the
aria-hidden and Escape consequences in a real browser.
The focus call for a failed creation ran from requestAnimationFrame, which can
run before React has rendered the setError update. errorRef.current was still
null in that case and the call silently did nothing, so the message appeared
while the keyboard stayed on Add Converter. Focus it from an effect instead,
which runs after the commit that adds the message bar.

Each message now records whether it came from a submission, so a
metadata-loading failure, which arrives without the user asking for anything,
cannot take focus. A submission that outlived its own opening of the dialog
drops its failure rather than showing it, and taking focus, in a later one.

A synchronous frame pins the render ordering in jsdom, where the real
requestAnimationFrame lands late enough to hide it. The mock Playwright project
covers the reopened dialog in a real browser.
The success path reset the form unconditionally, so a create response that
outlived its opening wiped the opening the user had moved on to: the typed
registry name, the selected type and its parameter rows all disappeared,
and reset() also cleared a submission error they were reading. Gate the
reset on the opening the request was submitted from, the same way the
failure path already gates setError, and leave onCreated ungated so a late
success still refreshes the list.

The submitting flag needed the same treatment, and clearing it when the
dialog opens rather than when a stale response lands, so a request from a
previous opening no longer re-enables the primary action of an opening that
has its own request in flight.
@v0ropaev

Copy link
Copy Markdown
Contributor Author

Thanks — both are real, and reproducing your first scenario in Chromium showed exactly the chain you described: focus on New Converter, the still-visible "Remove converter?" surface carrying aria-hidden="true", and Escape no longer dismissing it. Addressed in 5afd41c, fdc8f4b and 5d807d0.

1. Late create response (ConverterRegistry.tsx). Each opening now mints a token instead of sharing one boolean, and a response may only close the create dialog and restore focus if the token it was submitted under is still the dialog on screen. A boolean could not express this, because two of the four cases are two different openings of the same dialog, so the check is token identity. The late response still calls loadConverters() unconditionally.

Cases now covered, with the test that pins each:

  • normal success, dialog open, trigger possibly unmounted with the list — the existing "moves focus to New Converter once the first converter is created" tests, still green;
  • late success after Escape with nothing else open — focus untouched (ConverterRegistry.test.tsx:303);
  • late success while the Remove dialog is open — your case (ConverterRegistry.test.tsx:285, e2e registry.spec.ts:311);
  • late success after the create dialog was reopened (ConverterRegistry.test.tsx:324).

The removal path deliberately keeps no token, and there is now a comment saying why: Cancel is disabled and the Escape handler is gated on !removing while the delete is in flight, so that dialog cannot be replaced mid-request.

Measured with ConverterRegistry.tsx reverted: 3 failed / 13 passed in that jest suite, and 1 failed / 7 passed in the Playwright mock project, failing on getByRole('dialog').locator(':focus') — i.e. the aria-hidden-then-Escape chain rather than a proxy for it.

2. Error focus timing (CreateConverterDialog.tsx). requestAnimationFrame is gone; focusing happens in an effect, which runs after the commit that mounts the message bar, so the ref is set. That also matches MessageList.tsx:358-362, the existing pattern here. Submission errors are now separated structurally rather than by timing: the state carries { message, fromSubmit }, the initial types/targets/converters .catch tags fromSubmit: false, submit's catch tags it true, and the effect returns early for anything else — so a metadata-loading failure cannot take focus. "Current open dialog" is enforced by an epoch bumped whenever open changes, captured in submit and checked before setError.

Measured with that file reverted: 2 failed / 9 passed, including a test that pins the exact failure mode you named (a frame callback stubbed to run synchronously, so the pre-fix code focuses nothing).

3. One more thing your first comment led me to, fixed in 5d807d0. Once the reopened dialog correctly stays open, the success path's unconditional reset() becomes visible as its own bug: a response outliving its opening wiped the form the user had moved on to. Verified in Chromium — held the POST, Escape, reopened, selected a type and typed my-second-converter, then released the response: name="" and the parameter rows gone, and reset() also clears a submission error the user is reading. The reset is now gated on the same epoch, onCreated stays ungated. The submitting flag needed the same treatment plus clearing it when the dialog opens, otherwise a stale response re-enables the primary action of an opening whose own request is still in flight. New test at CreateConverterDialog.test.tsx ("should not clear a later opening of the dialog when an earlier creation succeeds"), red without the gate.

Checks on 5d807d0: npx jest src/components/Registry 30 passed; full npx jest 2227 passed / 1 failed (LabelsBar > should dismiss the picker when the user clicks away, which fails on this machine with the whole change stashed and is green in CI on main, so environmental here); npx jest --coverage thresholds met; npx playwright test --project=mock e2e/registry.spec.ts 8 passed; npm run lint, npm run type-check and tsc -p tsconfig.test.json clean.

One unrelated thing I ran into while testing, not touched here: reopening the Add Converter dialog leaves it unfocused — first open focuses Cancel, but after Escape and a second click document.activeElement is BODY and Escape does not dismiss. It reproduces on main (132d1cd) as well as on this branch, so it looks like a separate Fluent/Tabster initial-focus issue on a second open. Happy to file it.

…y-focus

# Conflicts:
#	frontend/src/components/Registry/CreateConverterDialog.test.tsx
#	frontend/src/components/Registry/CreateConverterDialog.tsx
@v0ropaev

Copy link
Copy Markdown
Contributor Author

Rebased on main after #2708 landed (503c32c). Two conflicts in CreateConverterDialog, both resolved keeping your side:

  • submit(): your structured-parameter build stays as written, and the epoch capture moved below it, just before setSubmitting(true). Your client-side validation failure sets the error state with fromSubmit: false, so it renders as role="alert" without taking focus — that path never disables the primary action, so there is nothing to restore from. There is a comment at that line saying so.
  • CreateConverterDialog.test.tsx: both PRs appended tests at the same anchor, so both sets are kept.

Checks on the merge: npx jest src/components/Registry 54 passed; full npx jest 2241 passed with the one environmental LabelsBar failure that also fails here with the change stashed; npx playwright test --project=mock e2e/registry.spec.ts 8 passed; npm run lint, npm run type-check and tsc -p tsconfig.test.json clean.

Comment thread frontend/src/components/Registry/ConverterRegistry.tsx Outdated
The restore ran in a requestAnimationFrame callback, which React does not
wait for. Creating the first converter closes the dialog and starts the
refresh in the same handler, so the callback could land before React
committed the new list: the empty-state button was still connected, took
focus, and lost it to <body> when that same commit unmounted it. The
removal path had the identical defect, and the comment claiming its
pre-refresh restore kept focus off <body> was wrong for the same reason.

Requesting the restore as state and performing it in an effect removes the
race rather than narrowing it: an effect cannot run before the commit that
scheduled it, so the tree it inspects is the one the user is about to see,
and the existing isConnected fallback then picks New Converter. Nothing
clears the state from inside the effect, which react-hooks/set-state-in-effect
would reject.

Both paths get a regression test that runs frame callbacks where they are
registered, the lever this PR already uses for the submission-error fix.
@v0ropaev

Copy link
Copy Markdown
Contributor Author

Reproduced your sequence and fixed it in bc24091a — the restore is now requested as state and performed in an effect, as you suggested, rather than queued on a frame.

Why the frame was wrong, not just early. closeCreateDialog() and void loadConverters() run in the same handler, so React has one commit to make: dialog gone, spinner in, empty-state button unmounted. A frame callback is not ordered against that commit at all — when it wins, trigger.isConnected is still true, focus goes to a node the same commit is about to remove, and lands on <body>. An effect cannot run before the commit that scheduled it, so the tree it inspects is the one the user is about to see and the existing isConnected fallback then picks New Converter. Nothing clears focusRestore from inside the effect — react-hooks/set-state-in-effect would reject that, and a stale value is inert because only a new request re-runs it.

The removal path had the same defect, so it gets the same treatment and its own regression. The comment at the old removeConverter claiming the pre-refresh restore "keeps focus off <body> while the list reloads" was measurably false: with a frame in flight it is exactly what put focus there.

Regression tests. Both run frame callbacks where they are registered, which is the lever this PR already uses at CreateConverterDialog.test.tsx for the submission-error fix, so the new tests read as a matching pair with the one you pointed at. Each asserts the arrangement really was the doomed-node case (expect(trigger.isConnected).toBe(false)) before asserting focus, so they cannot pass vacuously. Against the previous commit both fail with Received element with focus: <body data-tabster="{\"root\":{}}"> — your symptom verbatim.

One thing I want to flag rather than let you find it. I wrote a third test for the openDialogRef.current guard, then deleted it because it did not actually reach the guard. Checking properly: deleting the guard leaves all tests green — and it did so before this change too (16/16 on the previous commit with the guard removed). So the guard was never covered, and with the effect design I could not construct a jsdom case that reaches it: the effect runs at the first commit after the request, and nothing in this component can open a dialog in that same batch. The window it protects is real in a browser — passive effects run after paint, so a fast click can land in between — so I kept it as you asked, but it is defensive rather than tested, and I would rather say so than ship a test that only looks like coverage.

Verification (Node 24.8, frontend/):

  • jest ConverterRegistry.test.tsx → 18 passed; against the previous commit, the two new tests fail
  • jest src/components/Registry → 3 suites, 56 passed; jest src/App.test.tsx → 76 passed
  • tsc --noEmit, eslint src/components/Registry --max-warnings 0 → clean
  • playwright test registry.spec.ts --project mock → 8 passed in real Chromium, including moves focus to New Converter once the first converter is created, which is the test your repro contradicted: it was passing on scheduling luck, and this is what makes it deterministic. Playwright cannot force the frame-vs-commit interleaving, which is why the unit tests are where the regression lives.
  • Mutation checks: reverting the effect to the frame callback fails exactly the two new tests and nothing else; removing the guard fails nothing, as above.

I also considered passing an explicit null target on the two paths that start a refresh, so the fix would not depend on loading gating both the table and the empty state. Measured it: with the effect in place all tests pass either way, so it is unpinned belt-and-braces and I left it out. Happy to add it if you would rather the fix not rest on that gating.

This branch has not been deployed

No deployments
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.

BUG Converter Registry dialogs lose keyboard focus after dismissal and duplicate errors

2 participants