From ceb9dd2a6a20f051ba11b477ea2ea6d37a10d26b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 18 Jul 2026 06:52:05 +0000 Subject: [PATCH 1/2] fix: stop Escape focus restore from closing the app-mode menu Scope dismiss deferred a focus restore onto the answer-options trigger. When a mode-menu open landed in the same frame window, that restore stole focus, blur-dismissed the menu, and flaked the Documents mode switch in Production UI CI. Skip restore when the mode menu is open or focus already moved, and wait for the scope popover to hide before opening the menu. Co-authored-by: BigSimmo --- .../master-search-header.tsx | 8 ++-- src/components/use-dismissable-layer.ts | 20 ++++++++- tests/restore-focus-unless-moved.dom.test.tsx | 45 +++++++++++++++++++ tests/ui-smoke.spec.ts | 6 +++ 4 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 tests/restore-focus-unless-moved.dom.test.tsx diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 099cb89ba..45995577f 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -33,7 +33,7 @@ import { import { DocumentTagCloud } from "@/components/DocumentTagCloud"; import { PrivacyInputNotice } from "@/components/privacy-input-notice"; -import { useDismissableLayer } from "@/components/use-dismissable-layer"; +import { restoreFocusUnlessMoved, useDismissableLayer } from "@/components/use-dismissable-layer"; import { useHideOnScroll } from "@/components/clinical-dashboard/use-hide-on-scroll"; import { AnswerFollowUpSuggestions } from "@/components/clinical-dashboard/answer-follow-up-suggestions"; import { @@ -747,13 +747,15 @@ export function MasterSearchHeader({ if (scopeOpen || !restoreActionMenuFocusRef.current) return; restoreActionMenuFocusRef.current = false; window.requestAnimationFrame(() => { - actionMenuTriggerRef.current?.focus({ preventScroll: true }); + restoreFocusUnlessMoved(actionMenuTriggerRef.current); }); }, [scopeOpen]); const closeScopeSheet = useCallback(() => { setScopeSheetOpen(false); - window.requestAnimationFrame(() => actionMenuTriggerRef.current?.focus()); + window.requestAnimationFrame(() => { + restoreFocusUnlessMoved(actionMenuTriggerRef.current); + }); }, []); useEffect(() => { diff --git a/src/components/use-dismissable-layer.ts b/src/components/use-dismissable-layer.ts index 783294083..40dd7d748 100644 --- a/src/components/use-dismissable-layer.ts +++ b/src/components/use-dismissable-layer.ts @@ -15,6 +15,24 @@ function eventHitsRef(event: Event, ref: DismissableLayerRef) { return target instanceof Node && element.contains(target); } +/** + * Restore focus after a dismissable surface closes, but do not steal focus if the + * user/test already moved it (e.g. opened the app-mode menu in the same frame + * window as Escape's deferred restore). + */ +export function restoreFocusUnlessMoved(target: HTMLElement | null | undefined) { + if (!target) return false; + // Matching global-search-shell's focus=1 guard: an open mode menu means focus + // was intentionally moved off the prior surface. + if (document.getElementById("app-mode-menu")) return false; + const active = document.activeElement; + if (active instanceof HTMLElement && active !== document.body && active !== target) { + return false; + } + target.focus({ preventScroll: true }); + return true; +} + export function useDismissableLayer({ enabled, refs, @@ -40,7 +58,7 @@ export function useDismissableLayer({ onDismiss("escape"); window.requestAnimationFrame(() => { window.requestAnimationFrame(() => { - restoreFocusRef?.current?.focus({ preventScroll: true }); + restoreFocusUnlessMoved(restoreFocusRef?.current); }); }); } diff --git a/tests/restore-focus-unless-moved.dom.test.tsx b/tests/restore-focus-unless-moved.dom.test.tsx new file mode 100644 index 000000000..f2d227d3b --- /dev/null +++ b/tests/restore-focus-unless-moved.dom.test.tsx @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { restoreFocusUnlessMoved } from "../src/components/use-dismissable-layer"; + +describe("restoreFocusUnlessMoved (jsdom)", () => { + afterEach(() => { + document.getElementById("app-mode-menu")?.remove(); + document.body.replaceChildren(); + vi.restoreAllMocks(); + }); + + it("restores focus when nothing else holds it", () => { + const target = document.createElement("button"); + target.focus = vi.fn(); + document.body.append(target); + + expect(restoreFocusUnlessMoved(target)).toBe(true); + expect(target.focus).toHaveBeenCalledWith({ preventScroll: true }); + }); + + it("skips restore while the app-mode menu is open", () => { + const menu = document.createElement("div"); + menu.id = "app-mode-menu"; + document.body.append(menu); + + const target = document.createElement("button"); + target.focus = vi.fn(); + document.body.append(target); + + expect(restoreFocusUnlessMoved(target)).toBe(false); + expect(target.focus).not.toHaveBeenCalled(); + }); + + it("skips restore when focus already moved to another control", () => { + const target = document.createElement("button"); + target.focus = vi.fn(); + const other = document.createElement("button"); + other.setAttribute("aria-label", "Mode Answer"); + document.body.append(target, other); + other.focus(); + + expect(restoreFocusUnlessMoved(target)).toBe(false); + expect(target.focus).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 0ae320ca6..eda0a8c00 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -163,6 +163,12 @@ async function switchToDocumentSearchMode(page: Page) { } await expect(appModeMenu).toBeEnabled(); await waitForReactEventHandler(appModeMenu, "onClick"); + // Scope/Escape deferred focus restore can race a mode-menu open; wait for the + // scope surface to fully dismiss before opening the mode menu. + await page + .getByTestId("scope-command-popover") + .waitFor({ state: "hidden", timeout: uiAssertionTimeoutMs }) + .catch(() => undefined); await appModeMenu.click({ force: true }); const appModeGroup = page.getByRole("menu", { name: "Choose app mode" }); await expect(appModeGroup).toBeVisible({ timeout: uiAssertionTimeoutMs }); From 2b8ddb80c6c601cd356cd8789c42c0a0307f7ac6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 18 Jul 2026 07:01:42 +0000 Subject: [PATCH 2/2] fix(test): fail closed when scope popover does not dismiss Only wait for the scope popover when it is visible, and let that wait throw if dismissal times out so the mode-menu open cannot recreate the Escape focus-restore race. Co-authored-by: BigSimmo --- tests/ui-smoke.spec.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index eda0a8c00..6924ff2f5 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -163,12 +163,12 @@ async function switchToDocumentSearchMode(page: Page) { } await expect(appModeMenu).toBeEnabled(); await waitForReactEventHandler(appModeMenu, "onClick"); - // Scope/Escape deferred focus restore can race a mode-menu open; wait for the - // scope surface to fully dismiss before opening the mode menu. - await page - .getByTestId("scope-command-popover") - .waitFor({ state: "hidden", timeout: uiAssertionTimeoutMs }) - .catch(() => undefined); + // Scope/Escape deferred focus restore can race a mode-menu open; if the scope + // surface is open, wait for it to dismiss before opening the mode menu. + const scopePopover = page.getByTestId("scope-command-popover"); + if (await isVisibleWithoutThrow(scopePopover)) { + await scopePopover.waitFor({ state: "hidden", timeout: uiAssertionTimeoutMs }); + } await appModeMenu.click({ force: true }); const appModeGroup = page.getByRole("menu", { name: "Choose app mode" }); await expect(appModeGroup).toBeVisible({ timeout: uiAssertionTimeoutMs });