Skip to content

Commit 831ba67

Browse files
fix(a11y): capture overlay invokers before async loads
Pass pre-fetch invokers into openDetail/openCredentialsModal, drop stale openServerDetail responses via a generation counter, and cover the async refresh contract in the Node harness so CodeRabbit findings on tip 40a6658 stay closed. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 40a6658 commit 831ba67

2 files changed

Lines changed: 152 additions & 48 deletions

File tree

src/kater/web/dashboard.py

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1616,6 +1616,9 @@
16161616
let selectedNode = null;
16171617
let detailInvoker = null;
16181618
let credInvoker = null;
1619+
// Bumps on every openServerDetail call so a late response from an older
1620+
// fetch cannot reopen or overwrite the panel after the user moved on.
1621+
let detailRequestGen = 0;
16191622
16201623
// Overview live state.
16211624
let routeFilter = 'all';
@@ -2320,14 +2323,15 @@ class ApiError extends Error {
23202323
23212324
async function openSelectedRoute() {
23222325
if (routeSel < 0 || !routeRows[routeSel]) return;
2323-
const name = routeRows[routeSel].dataset.name;
2326+
const invoker = routeRows[routeSel];
2327+
const name = invoker.dataset.name;
23242328
let node = servers.find(s => s.name === name);
23252329
if (!node) return;
23262330
if (!node.mcp) {
23272331
try { node = await api('/api/mcp/servers/' + encodeURIComponent(node.name)); }
23282332
catch (err) { /* fall back */ }
23292333
}
2330-
openDetail(node);
2334+
openDetail(node, false, invoker);
23312335
}
23322336
23332337
function buildNodes() {
@@ -2351,7 +2355,7 @@ class ApiError extends Error {
23512355
try { node = await api('/api/mcp/servers/' + encodeURIComponent(node.name)); }
23522356
catch (err) { /* fall back */ }
23532357
}
2354-
openDetail(node);
2358+
openDetail(node, false, row);
23552359
}
23562360
23572361
function startAnimationLoop() {}
@@ -2366,14 +2370,16 @@ class ApiError extends Error {
23662370
return '-';
23672371
}
23682372
2369-
function openDetail(node, refresh) {
2373+
function openDetail(node, refresh, invoker) {
23702374
// Track the latest trigger from outside the panel: selecting another server
23712375
// while the panel is open must move the return target to that row, but focus
23722376
// already inside the panel (close button, actions) is not a return target.
23732377
// Background refreshes (WebSocket updates, post-action reloads) pass
23742378
// refresh=true and must not overwrite the invoker: whatever happens to hold
23752379
// focus then (e.g. the command bar) never opened the panel.
2376-
const trigger = document.activeElement;
2380+
// Callers that await a fetch pass the pre-captured invoker so a focus move
2381+
// during the request cannot steal the return target.
2382+
const trigger = invoker !== undefined ? invoker : document.activeElement;
23772383
const openPanel = document.getElementById('detail-panel');
23782384
if (!refresh && trigger && trigger.tagName !== 'BODY'
23792385
&& !openPanel.contains(trigger)) {
@@ -2487,21 +2493,23 @@ class ApiError extends Error {
24872493
if (selectedNode && selectedNode.name) promptCredentials(selectedNode.name);
24882494
}
24892495
2490-
async function promptCredentials(name) {
2496+
async function promptCredentials(name, invoker) {
24912497
// Always work from the full server doc: the catalog payload omits the list
24922498
// of required env vars, the detail endpoint has it.
2499+
// Capture the trigger before the await — focus may move while loading.
2500+
const captured = invoker !== undefined ? invoker : document.activeElement;
24932501
try {
24942502
const server = await api('/api/mcp/servers/' + encodeURIComponent(name));
2495-
if (server && !server.error) openCredentialsModal(server);
2503+
if (server && !server.error) openCredentialsModal(server, captured);
24962504
} catch (e) {
24972505
toast('Could not load ' + name + ': ' + (e.message || 'failed'), 'error');
24982506
}
24992507
}
25002508
2501-
function openCredentialsModal(server) {
2502-
if (!credInvoker && document.activeElement
2503-
&& document.activeElement.tagName !== 'BODY') {
2504-
credInvoker = document.activeElement;
2509+
function openCredentialsModal(server, invoker) {
2510+
const trigger = invoker !== undefined ? invoker : document.activeElement;
2511+
if (!credInvoker && trigger && trigger.tagName !== 'BODY') {
2512+
credInvoker = trigger;
25052513
}
25062514
credServer = server;
25072515
const reqs = server.env_required || [];
@@ -2683,7 +2691,7 @@ class ApiError extends Error {
26832691
return;
26842692
}
26852693
const card = e.target.closest('.server-card');
2686-
if (card && card.dataset.name) openServerDetail(card.dataset.name);
2694+
if (card && card.dataset.name) openServerDetail(card.dataset.name, false, card);
26872695
});
26882696
grid.addEventListener('keydown', (e) => {
26892697
if (e.key === 'Enter' || e.key === ' ') {
@@ -2697,7 +2705,7 @@ class ApiError extends Error {
26972705
const card = e.target.closest('.server-card');
26982706
if (card && card.dataset.name && e.target === card) {
26992707
e.preventDefault();
2700-
openServerDetail(card.dataset.name);
2708+
openServerDetail(card.dataset.name, false, card);
27012709
}
27022710
}
27032711
});
@@ -3572,10 +3580,25 @@ class ApiError extends Error {
35723580
}
35733581
}
35743582
3575-
async function openServerDetail(name, refresh) {
3583+
async function openServerDetail(name, refresh, invoker) {
3584+
// Capture before the await so a focus move during the fetch cannot steal
3585+
// the return target. Background refreshes skip capture entirely.
3586+
const captured = refresh
3587+
? null
3588+
: (invoker !== undefined ? invoker : document.activeElement);
3589+
const gen = ++detailRequestGen;
35763590
try {
35773591
const data = await api('/api/mcp/servers/' + encodeURIComponent(name));
3578-
openDetail(data, refresh);
3592+
if (gen !== detailRequestGen) return;
3593+
if (refresh) {
3594+
// Drop stale refreshes: panel closed, or user selected another server.
3595+
const panel = document.getElementById('detail-panel');
3596+
if (!panel || !panel.classList.contains('open')) return;
3597+
if (!selectedNode || selectedNode.name !== name) return;
3598+
openDetail(data, true);
3599+
return;
3600+
}
3601+
openDetail(data, false, captured);
35793602
} catch (e) {
35803603
toast(name + ': ' + (e.message || 'not found'), 'error');
35813604
}

tests/test_dashboard.py

Lines changed: 114 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,9 @@ def test_pr_view_has_accessible_refresh_button_and_status_region():
251251
def _extract_js_function(source: str, name: str) -> str:
252252
"""Slice a whole `function name(...) {...}` declaration out of the JS."""
253253
start = source.index(f"function {name}(")
254+
# Keep a leading `async` so awaited helpers stay valid when re-run in Node.
255+
if start >= 6 and source[start - 6 : start] == "async ":
256+
start -= 6
254257
depth = 0
255258
for pos in range(source.index("{", start), len(source)):
256259
if source[pos] == "{":
@@ -485,38 +488,69 @@ def test_focus_restoration_logic_is_present():
485488
html = render_dashboard()
486489
assert "let detailInvoker = null;" in html
487490
assert "let credInvoker = null;" in html
491+
assert "let detailRequestGen = 0;" in html
488492
assert "function openDetail" in html
489-
assert "const trigger = document.activeElement;" in html
493+
assert "const trigger = invoker !== undefined ? invoker : document.activeElement;" in html
490494
assert "detailInvoker = trigger;" in html
491495
assert "function closeDetail" in html
492496
assert "invoker.focus()" in html
493497
assert "function openCredentialsModal" in html
494-
assert "credInvoker = document.activeElement" in html
498+
assert "credInvoker = trigger;" in html
495499
assert "function closeCredentialsModal" in html
500+
assert "function openServerDetail" in html
501+
assert "if (gen !== detailRequestGen) return;" in html
502+
# Real callers must keep refresh=true on background reloads.
503+
assert "openServerDetail(name, true)" in html
504+
assert "openServerDetail(data.name, true)" in html
496505

497506

498507
_FOCUS_HARNESS = r"""
508+
const panelClassList = {
509+
open: false,
510+
add(c) { if (c === 'open') this.open = true; },
511+
remove(c) { if (c === 'open') this.open = false; },
512+
contains(c) { return c === 'open' ? this.open : false; },
513+
};
514+
const detailPanel = {
515+
classList: panelClassList,
516+
appendChild() {},
517+
contains() { return false; },
518+
innerHTML: '',
519+
textContent: '',
520+
style: {},
521+
};
499522
const document = {
500523
activeElement: null,
501524
contains(el) { return true; },
502525
getElementById(id) {
526+
if (id === 'detail-panel') return detailPanel;
503527
return {
504-
classList: { remove() {}, add() {} },
528+
classList: { remove() {}, add() {}, contains() { return false; } },
505529
appendChild() {},
506530
contains() { return false; },
507531
innerHTML: '',
508532
textContent: '',
509533
style: {},
534+
disabled: false,
510535
};
511536
}
512537
};
513538
let selectedNode = null;
514539
let writeUrlState = () => {};
515540
const makeBadge = () => ({ classList: { remove() {} } });
516541
const formatLaunch = () => '-';
542+
function toast() {}
517543
518544
let detailInvoker = null;
519545
let credInvoker = null;
546+
let detailRequestGen = 0;
547+
548+
let pendingApi = null;
549+
function api(url) {
550+
return new Promise((resolve, reject) => {
551+
pendingApi = { url, resolve, reject };
552+
});
553+
}
520554
521555
/*__DASHBOARD_JS__*/
522556
@@ -527,36 +561,79 @@ def test_focus_restoration_logic_is_present():
527561
});
528562
const cardA = makeCard();
529563
const cardB = makeCard();
564+
const commandBar = {
565+
tagName: 'INPUT',
566+
focusCalled: false,
567+
focus() { this.focusCalled = true; },
568+
};
530569
531-
document.activeElement = cardA;
532-
openDetail({ name: 'a' });
533-
const afterOpenDetailInvoker = detailInvoker;
534-
535-
// Selecting another server while the panel stays open must retarget focus.
536-
document.activeElement = cardB;
537-
openDetail({ name: 'b' });
538-
const afterReselectInvoker = detailInvoker;
539-
540-
// A background refresh (WebSocket update) while the user works elsewhere
541-
// (e.g. the command bar) must not steal the return target from the last
542-
// explicitly selected row.
543-
const commandBar = { tagName: 'INPUT', focusCalled: false, focus() { this.focusCalled = true; } };
544-
document.activeElement = commandBar;
545-
openDetail({ name: 'b' }, true);
546-
const afterRefreshInvoker = detailInvoker;
547-
548-
closeDetail();
549-
const afterCloseDetailInvoker = detailInvoker;
550-
551-
process.stdout.write(JSON.stringify({
552-
detailInvokerSet: afterOpenDetailInvoker === cardA,
553-
detailInvokerFollowsSelection: afterReselectInvoker === cardB,
554-
detailInvokerSurvivesRefresh: afterRefreshInvoker === cardB,
555-
detailInvokerCleared: afterCloseDetailInvoker === null,
556-
focusCalled: cardB.focusCalled,
557-
staleInvokerFocused: cardA.focusCalled,
558-
refreshFocusStolen: commandBar.focusCalled,
559-
}));
570+
(async () => {
571+
document.activeElement = cardA;
572+
openDetail({ name: 'a' });
573+
const afterOpenDetailInvoker = detailInvoker;
574+
575+
// Selecting another server while the panel stays open must retarget focus.
576+
document.activeElement = cardB;
577+
openDetail({ name: 'b' });
578+
const afterReselectInvoker = detailInvoker;
579+
580+
// A background refresh (WebSocket update) while the user works elsewhere
581+
// (e.g. the command bar) must not steal the return target from the last
582+
// explicitly selected row.
583+
document.activeElement = commandBar;
584+
openDetail({ name: 'b' }, true);
585+
const afterRefreshInvoker = detailInvoker;
586+
587+
// Real caller: openServerDetail captures the invoker before the await.
588+
// Move focus to the command bar while the API response is still pending.
589+
detailInvoker = cardB;
590+
selectedNode = { name: 'b' };
591+
panelClassList.open = true;
592+
document.activeElement = cardB;
593+
const openPromise = openServerDetail('b', false, cardB);
594+
document.activeElement = commandBar;
595+
pendingApi.resolve({
596+
name: 'b', env_required: [], env_configured: true, enabled: true,
597+
});
598+
await openPromise;
599+
const afterAsyncInvoker = detailInvoker;
600+
601+
// Background refresh via the real caller must pass refresh=true and keep
602+
// the prior invoker even when focus sits on the command bar.
603+
document.activeElement = commandBar;
604+
const refreshPromise = openServerDetail('b', true);
605+
pendingApi.resolve({
606+
name: 'b', env_required: [], env_configured: true, enabled: true,
607+
});
608+
await refreshPromise;
609+
const afterCallerRefreshInvoker = detailInvoker;
610+
611+
// Stale refresh after the panel closed must not reopen it.
612+
closeDetail();
613+
const afterCloseDetailInvoker = detailInvoker;
614+
panelClassList.open = false;
615+
selectedNode = null;
616+
const staleRefresh = openServerDetail('b', true);
617+
pendingApi.resolve({ name: 'b', env_required: [], env_configured: true });
618+
await staleRefresh;
619+
const staleRefreshReopened = panelClassList.open;
620+
621+
process.stdout.write(JSON.stringify({
622+
detailInvokerSet: afterOpenDetailInvoker === cardA,
623+
detailInvokerFollowsSelection: afterReselectInvoker === cardB,
624+
detailInvokerSurvivesRefresh: afterRefreshInvoker === cardB,
625+
detailInvokerSurvivesAsyncFetch: afterAsyncInvoker === cardB,
626+
detailInvokerSurvivesCallerRefresh: afterCallerRefreshInvoker === cardB,
627+
detailInvokerCleared: afterCloseDetailInvoker === null,
628+
focusCalled: cardB.focusCalled,
629+
staleInvokerFocused: cardA.focusCalled,
630+
refreshFocusStolen: commandBar.focusCalled,
631+
staleRefreshDropped: staleRefreshReopened === false,
632+
}));
633+
})().catch((err) => {
634+
console.error(err);
635+
process.exit(1);
636+
});
560637
"""
561638

562639

@@ -567,7 +644,8 @@ def test_focus_restoration_behavior_node(tmp_path):
567644
assert node is not None
568645
html = render_dashboard()
569646
dashboard_js = "\n".join(
570-
_extract_js_function(html, name) for name in ("openDetail", "closeDetail")
647+
_extract_js_function(html, name)
648+
for name in ("openDetail", "closeDetail", "openServerDetail")
571649
)
572650
script = tmp_path / "focus_restoration.cjs"
573651
script.write_text(
@@ -582,10 +660,13 @@ def test_focus_restoration_behavior_node(tmp_path):
582660
assert res["detailInvokerSet"] is True
583661
assert res["detailInvokerFollowsSelection"] is True
584662
assert res["detailInvokerSurvivesRefresh"] is True
663+
assert res["detailInvokerSurvivesAsyncFetch"] is True
664+
assert res["detailInvokerSurvivesCallerRefresh"] is True
585665
assert res["detailInvokerCleared"] is True
586666
assert res["focusCalled"] is True
587667
assert res["staleInvokerFocused"] is False
588668
assert res["refreshFocusStolen"] is False
669+
assert res["staleRefreshDropped"] is True
589670

590671

591672
# The credentials modal builds real DOM, so it needs the element shim rather

0 commit comments

Comments
 (0)