From 8671d2abeafb6b70c72d9d741171fafca5648701 Mon Sep 17 00:00:00 2001 From: andreivieru Date: Wed, 4 Nov 2026 22:51:29 -0500 Subject: [PATCH] Avoid visibility reads for non-default focusable candidates Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 37912007-07c2-40e4-81bd-df708aa99fe2 --- src/Focusable.ts | 3 +- tests/FocusableDefault.test.tsx | 237 ++++++++++++++++++++++++++++++++ 2 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 tests/FocusableDefault.test.tsx diff --git a/src/Focusable.ts b/src/Focusable.ts index af2d7d3b..f09c5de1 100644 --- a/src/Focusable.ts +++ b/src/Focusable.ts @@ -156,10 +156,11 @@ export class FocusableAPI implements Types.FocusableAPI { { ...options, acceptCondition: (el) => + !!this.getProps(el).isDefault && this.isFocusable( el, options.includeProgrammaticallyFocusable - ) && !!this.getProps(el).isDefault, + ), }, out ) || null diff --git a/tests/FocusableDefault.test.tsx b/tests/FocusableDefault.test.tsx new file mode 100644 index 00000000..95bf54b8 --- /dev/null +++ b/tests/FocusableDefault.test.tsx @@ -0,0 +1,237 @@ +/*! + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import * as React from "react"; +import { getTabsterAttribute } from "tabster"; +import * as BroTest from "./utils/BroTest"; + +describe("Focusable default search", () => { + beforeEach(async () => { + await BroTest.bootstrapTabsterPage({ + mover: true, + groupper: true, + modalizer: true, + }); + }); + + it("does not read styles for buttons that are not default candidates", async () => { + await new BroTest.BroTest( +
+
+ {Array.from({ length: 13 }, (_, index) => ( + + ))} +
+
+ ) + .eval(() => { + const vars = getTabsterTestVariables(); + const container = vars.dom!.getElementById( + document, + "container" + )!; + const original = window.getComputedStyle; + let styleReads = 0; + window.getComputedStyle = (...args) => { + if (container.contains(args[0])) { + styleReads++; + } + return original.apply(window, args); + }; + + try { + return { + result: vars.core!.focusable.findDefault({ + container, + }), + styleReads, + }; + } finally { + window.getComputedStyle = original; + } + }) + .check((result) => { + expect(result).toEqual({ result: null, styleReads: 0 }); + }); + }); + + it("still checks visibility and disabled state for actual default candidates", async () => { + const defaultAttribute = getTabsterAttribute({ + focusable: { isDefault: true }, + }); + await new BroTest.BroTest( +
+
+ + +
+ +
+ + +
+
+ ) + .eval(() => { + const vars = getTabsterTestVariables(); + const container = vars.dom!.getElementById( + document, + "container" + )!; + return vars.core!.focusable.findDefault({ container })?.id; + }) + .check((result) => { + expect(result).toBe("default"); + }); + }); + + it("preserves programmatic focusability", async () => { + await new BroTest.BroTest( +
+
+ +
+
+ ) + .eval(() => { + const vars = getTabsterTestVariables(); + const container = vars.dom!.getElementById( + document, + "container" + )!; + const focusable = vars.core!.focusable; + return { + ordinary: focusable.findDefault({ container }), + programmatic: focusable.findDefault({ + container, + includeProgrammaticallyFocusable: true, + })?.id, + }; + }) + .check((result) => { + expect(result).toEqual({ + ordinary: null, + programmatic: "programmatic", + }); + }); + }); + + it("observes default-property changes delivered by real DOM updates", async () => { + await new BroTest.BroTest( +
+
+ + +
+
+ ) + .eval(() => { + const vars = getTabsterTestVariables(); + const container = vars.dom!.getElementById( + document, + "container" + )!; + const initial = vars.core!.focusable.findDefault({ + container, + })?.id; + vars.setTabsterAttribute!( + vars.dom!.getElementById(document, "first")!, + { focusable: { isDefault: true } } + ); + vars.setTabsterAttribute!( + vars.dom!.getElementById(document, "second")!, + { focusable: { isDefault: false } } + ); + return initial; + }) + .check((result) => { + expect(result).toBe("second"); + }) + .wait(100) + .eval(() => { + const vars = getTabsterTestVariables(); + return vars.core!.focusable.findDefault({ + container: vars.dom!.getElementById(document, "container")!, + })?.id; + }) + .check((result) => { + expect(result).toBe("first"); + }); + }); + + it("uses the current virtual parent on each search", async () => { + await new BroTest.BroTest( + <> +
+
+ +
+ + ) + .eval(() => { + const vars = getTabsterTestVariables(); + const core = vars.core!.core; + const container = vars.dom!.getElementById( + document, + "container" + )!; + const virtualRoot = vars.dom!.getElementById( + document, + "virtual-root" + )!; + const original = core.getParent; + let linked = true; + core.getParent = (node) => + node === container && linked ? virtualRoot : original(node); + + try { + const first = core.focusable.findDefault({ + container, + })?.id; + linked = false; + const unlinked = core.focusable.findDefault({ + container, + }); + return { first, unlinked }; + } finally { + core.getParent = original; + } + }) + .check((result) => { + expect(result).toEqual({ + first: "default", + unlinked: null, + }); + }); + }); +});