chore: speedup multiple roleUtils calls (#23745)
When generating a selector, we tend to match by role and call various roleUtils methods multiple times. Apply the usual pattern for "nested operations counter" and aggressively cache the results.
This commit is contained in:
Родитель
11770156eb
Коммит
de422b5afb
|
@ -46,6 +46,7 @@ function enclosingShadowHost(element: Element): Element | undefined {
|
|||
return parentElementOrShadowHost(element);
|
||||
}
|
||||
|
||||
// Assumption: if scope is provided, element must be inside scope's subtree.
|
||||
export function closestCrossShadow(element: Element | undefined, css: string, scope?: Document | Element): Element | undefined {
|
||||
while (element) {
|
||||
const closest = element.closest(css);
|
||||
|
|
|
@ -1341,8 +1341,7 @@ export class InjectedScript {
|
|||
}
|
||||
|
||||
getElementAccessibleName(element: Element, includeHidden?: boolean): string {
|
||||
const hiddenCache = new Map<Element, boolean>();
|
||||
return getElementAccessibleName(element, !!includeHidden, hiddenCache);
|
||||
return getElementAccessibleName(element, !!includeHidden);
|
||||
}
|
||||
|
||||
getAriaRole(element: Element) {
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
|
||||
import type { SelectorEngine, SelectorRoot } from './selectorEngine';
|
||||
import { matchesAttributePart } from './selectorUtils';
|
||||
import { getAriaChecked, getAriaDisabled, getAriaExpanded, getAriaLevel, getAriaPressed, getAriaRole, getAriaSelected, getElementAccessibleName, isElementHiddenForAria, kAriaCheckedRoles, kAriaExpandedRoles, kAriaLevelRoles, kAriaPressedRoles, kAriaSelectedRoles } from './roleUtils';
|
||||
import { beginAriaCaches, endAriaCaches, getAriaChecked, getAriaDisabled, getAriaExpanded, getAriaLevel, getAriaPressed, getAriaRole, getAriaSelected, getElementAccessibleName, isElementHiddenForAria, kAriaCheckedRoles, kAriaExpandedRoles, kAriaLevelRoles, kAriaPressedRoles, kAriaSelectedRoles } from './roleUtils';
|
||||
import { parseAttributeSelector, type AttributeSelectorPart, type AttributeSelectorOperator } from '../../utils/isomorphic/selectorParser';
|
||||
import { normalizeWhiteSpace } from '../../utils/isomorphic/stringUtils';
|
||||
|
||||
|
@ -125,7 +125,6 @@ function validateAttributes(attrs: AttributeSelectorPart[], role: string): RoleE
|
|||
}
|
||||
|
||||
function queryRole(scope: SelectorRoot, options: RoleEngineOptions, internal: boolean): Element[] {
|
||||
const hiddenCache = new Map<Element, boolean>();
|
||||
const result: Element[] = [];
|
||||
const match = (element: Element) => {
|
||||
if (getAriaRole(element) !== options.role)
|
||||
|
@ -143,13 +142,13 @@ function queryRole(scope: SelectorRoot, options: RoleEngineOptions, internal: bo
|
|||
if (options.disabled !== undefined && getAriaDisabled(element) !== options.disabled)
|
||||
return;
|
||||
if (!options.includeHidden) {
|
||||
const isHidden = isElementHiddenForAria(element, hiddenCache);
|
||||
const isHidden = isElementHiddenForAria(element);
|
||||
if (isHidden)
|
||||
return;
|
||||
}
|
||||
if (options.name !== undefined) {
|
||||
// Always normalize whitespace in the accessible name.
|
||||
const accessibleName = normalizeWhiteSpace(getElementAccessibleName(element, !!options.includeHidden, hiddenCache));
|
||||
const accessibleName = normalizeWhiteSpace(getElementAccessibleName(element, !!options.includeHidden));
|
||||
if (typeof options.name === 'string')
|
||||
options.name = normalizeWhiteSpace(options.name);
|
||||
// internal:role assumes that [name="foo"i] also means substring.
|
||||
|
@ -185,7 +184,12 @@ export function createRoleEngine(internal: boolean): SelectorEngine {
|
|||
if (!role)
|
||||
throw new Error(`Role must not be empty`);
|
||||
const options = validateAttributes(parsed.attributes, role);
|
||||
return queryRole(scope, options, internal);
|
||||
beginAriaCaches();
|
||||
try {
|
||||
return queryRole(scope, options, internal);
|
||||
} finally {
|
||||
endAriaCaches();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -235,7 +235,7 @@ function getAriaBoolean(attr: string | null) {
|
|||
// Not implemented:
|
||||
// `Any descendants of elements that have the characteristic "Children Presentational: True"`
|
||||
// https://www.w3.org/TR/wai-aria-1.2/#aria-hidden
|
||||
export function isElementHiddenForAria(element: Element, cache: Map<Element, boolean>): boolean {
|
||||
export function isElementHiddenForAria(element: Element): boolean {
|
||||
if (['STYLE', 'SCRIPT', 'NOSCRIPT', 'TEMPLATE'].includes(element.tagName))
|
||||
return true;
|
||||
const style = getElementComputedStyle(element);
|
||||
|
@ -243,7 +243,7 @@ export function isElementHiddenForAria(element: Element, cache: Map<Element, boo
|
|||
if (style?.display === 'contents' && !isSlot) {
|
||||
// display:contents is not rendered itself, but its child nodes are.
|
||||
for (let child = element.firstChild; child; child = child.nextSibling) {
|
||||
if (child.nodeType === 1 /* Node.ELEMENT_NODE */ && !isElementHiddenForAria(child as Element, cache))
|
||||
if (child.nodeType === 1 /* Node.ELEMENT_NODE */ && !isElementHiddenForAria(child as Element))
|
||||
return false;
|
||||
if (child.nodeType === 3 /* Node.TEXT_NODE */ && isVisibleTextNode(child as Text))
|
||||
return false;
|
||||
|
@ -255,12 +255,13 @@ export function isElementHiddenForAria(element: Element, cache: Map<Element, boo
|
|||
const isOptionInsideSelect = element.nodeName === 'OPTION' && !!element.closest('select');
|
||||
if (!isOptionInsideSelect && !isSlot && !isElementStyleVisibilityVisible(element, style))
|
||||
return true;
|
||||
return belongsToDisplayNoneOrAriaHiddenOrNonSlotted(element, cache);
|
||||
return belongsToDisplayNoneOrAriaHiddenOrNonSlotted(element);
|
||||
}
|
||||
|
||||
function belongsToDisplayNoneOrAriaHiddenOrNonSlotted(element: Element, cache: Map<Element, boolean>): boolean {
|
||||
if (!cache.has(element)) {
|
||||
let hidden = false;
|
||||
function belongsToDisplayNoneOrAriaHiddenOrNonSlotted(element: Element): boolean {
|
||||
let hidden = cacheIsHidden?.get(element);
|
||||
if (hidden === undefined) {
|
||||
hidden = false;
|
||||
|
||||
// When parent has a shadow root, all light dom children must be assigned to a slot,
|
||||
// otherwise they are not rendered and considered hidden for aria.
|
||||
|
@ -278,11 +279,11 @@ function belongsToDisplayNoneOrAriaHiddenOrNonSlotted(element: Element, cache: M
|
|||
if (!hidden) {
|
||||
const parent = parentElementOrShadowHost(element);
|
||||
if (parent)
|
||||
hidden = belongsToDisplayNoneOrAriaHiddenOrNonSlotted(parent, cache);
|
||||
hidden = belongsToDisplayNoneOrAriaHiddenOrNonSlotted(parent);
|
||||
}
|
||||
cache.set(element, hidden);
|
||||
cacheIsHidden?.set(element, hidden);
|
||||
}
|
||||
return cache.get(element)!;
|
||||
return hidden;
|
||||
}
|
||||
|
||||
function getIdRefs(element: Element, ref: string | null): Element[] {
|
||||
|
@ -325,14 +326,14 @@ function queryInAriaOwned(element: Element, selector: string): Element[] {
|
|||
function getPseudoContent(pseudoStyle: CSSStyleDeclaration | undefined) {
|
||||
if (!pseudoStyle)
|
||||
return '';
|
||||
const content = pseudoStyle.getPropertyValue('content');
|
||||
const content = pseudoStyle.content;
|
||||
if ((content[0] === '\'' && content[content.length - 1] === '\'') ||
|
||||
(content[0] === '"' && content[content.length - 1] === '"')) {
|
||||
const unquoted = content.substring(1, content.length - 1);
|
||||
// SPEC DIFFERENCE.
|
||||
// Spec says "CSS textual content, without a space", but we account for display
|
||||
// to pass "name_file-label-inline-block-styles-manual.html"
|
||||
const display = pseudoStyle.getPropertyValue('display') || 'inline';
|
||||
const display = pseudoStyle.display || 'inline';
|
||||
if (display !== 'inline')
|
||||
return ' ' + unquoted + ' ';
|
||||
return unquoted;
|
||||
|
@ -360,31 +361,37 @@ function allowsNameFromContent(role: string, targetDescendant: boolean) {
|
|||
return alwaysAllowsNameFromContent || descendantAllowsNameFromContent;
|
||||
}
|
||||
|
||||
export function getElementAccessibleName(element: Element, includeHidden: boolean, hiddenCache: Map<Element, boolean>): string {
|
||||
// https://w3c.github.io/accname/#computation-steps
|
||||
export function getElementAccessibleName(element: Element, includeHidden: boolean): string {
|
||||
const cache = (includeHidden ? cacheAccessibleNameHidden : cacheAccessibleName);
|
||||
let accessibleName = cache?.get(element);
|
||||
|
||||
// step 1.
|
||||
// https://w3c.github.io/aria/#namefromprohibited
|
||||
const elementProhibitsNaming = ['caption', 'code', 'definition', 'deletion', 'emphasis', 'generic', 'insertion', 'mark', 'paragraph', 'presentation', 'strong', 'subscript', 'suggestion', 'superscript', 'term', 'time'].includes(getAriaRole(element) || '');
|
||||
if (elementProhibitsNaming)
|
||||
return '';
|
||||
if (accessibleName === undefined) {
|
||||
// https://w3c.github.io/accname/#computation-steps
|
||||
accessibleName = '';
|
||||
|
||||
// step 2.
|
||||
const accessibleName = normalizeAccessbileName(getElementAccessibleNameInternal(element, {
|
||||
includeHidden,
|
||||
hiddenCache,
|
||||
visitedElements: new Set(),
|
||||
embeddedInLabelledBy: 'none',
|
||||
embeddedInLabel: 'none',
|
||||
embeddedInTextAlternativeElement: false,
|
||||
embeddedInTargetElement: 'self',
|
||||
}));
|
||||
// step 1.
|
||||
// https://w3c.github.io/aria/#namefromprohibited
|
||||
const elementProhibitsNaming = ['caption', 'code', 'definition', 'deletion', 'emphasis', 'generic', 'insertion', 'mark', 'paragraph', 'presentation', 'strong', 'subscript', 'suggestion', 'superscript', 'term', 'time'].includes(getAriaRole(element) || '');
|
||||
|
||||
if (!elementProhibitsNaming) {
|
||||
// step 2.
|
||||
accessibleName = normalizeAccessbileName(getElementAccessibleNameInternal(element, {
|
||||
includeHidden,
|
||||
visitedElements: new Set(),
|
||||
embeddedInLabelledBy: 'none',
|
||||
embeddedInLabel: 'none',
|
||||
embeddedInTextAlternativeElement: false,
|
||||
embeddedInTargetElement: 'self',
|
||||
}));
|
||||
}
|
||||
|
||||
cache?.set(element, accessibleName);
|
||||
}
|
||||
return accessibleName;
|
||||
}
|
||||
|
||||
type AccessibleNameOptions = {
|
||||
includeHidden: boolean,
|
||||
hiddenCache: Map<Element, boolean>,
|
||||
visitedElements: Set<Element>,
|
||||
embeddedInLabelledBy: 'none' | 'self' | 'descendant',
|
||||
embeddedInLabel: 'none' | 'self' | 'descendant',
|
||||
|
@ -404,7 +411,7 @@ function getElementAccessibleNameInternal(element: Element, options: AccessibleN
|
|||
};
|
||||
|
||||
// step 2a.
|
||||
if (!options.includeHidden && options.embeddedInLabelledBy !== 'self' && isElementHiddenForAria(element, options.hiddenCache)) {
|
||||
if (!options.includeHidden && options.embeddedInLabelledBy !== 'self' && isElementHiddenForAria(element)) {
|
||||
options.visitedElements.add(element);
|
||||
return '';
|
||||
}
|
||||
|
@ -668,7 +675,7 @@ function getElementAccessibleNameInternal(element: Element, options: AccessibleN
|
|||
if (skipSlotted && (node as Element | Text).assignedSlot)
|
||||
return;
|
||||
if (node.nodeType === 1 /* Node.ELEMENT_NODE */) {
|
||||
const display = getElementComputedStyle(node as Element)?.getPropertyValue('display') || 'inline';
|
||||
const display = getElementComputedStyle(node as Element)?.display || 'inline';
|
||||
let token = getElementAccessibleNameInternal(node as Element, childOptions);
|
||||
// SPEC DIFFERENCE.
|
||||
// Spec says "append the result to the accumulated text", assuming "with space".
|
||||
|
@ -828,3 +835,23 @@ function hasExplicitAriaDisabled(element: Element | undefined): boolean {
|
|||
// aria-disabled works across shadow boundaries.
|
||||
return hasExplicitAriaDisabled(parentElementOrShadowHost(element));
|
||||
}
|
||||
|
||||
let cacheAccessibleName: Map<Element, string> | undefined;
|
||||
let cacheAccessibleNameHidden: Map<Element, string> | undefined;
|
||||
let cacheIsHidden: Map<Element, boolean> | undefined;
|
||||
let cachesCounter = 0;
|
||||
|
||||
export function beginAriaCaches() {
|
||||
++cachesCounter;
|
||||
cacheAccessibleName ??= new Map();
|
||||
cacheAccessibleNameHidden ??= new Map();
|
||||
cacheIsHidden ??= new Map();
|
||||
}
|
||||
|
||||
export function endAriaCaches() {
|
||||
if (!--cachesCounter) {
|
||||
cacheAccessibleName = undefined;
|
||||
cacheAccessibleNameHidden = undefined;
|
||||
cacheIsHidden = undefined;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
import { cssEscape, escapeForAttributeSelector, escapeForTextSelector, normalizeWhiteSpace } from '../../utils/isomorphic/stringUtils';
|
||||
import { closestCrossShadow, isInsideScope, parentElementOrShadowHost } from './domUtils';
|
||||
import type { InjectedScript } from './injectedScript';
|
||||
import { getAriaRole, getElementAccessibleName } from './roleUtils';
|
||||
import { getAriaRole, getElementAccessibleName, beginAriaCaches, endAriaCaches } from './roleUtils';
|
||||
import { elementText } from './selectorUtils';
|
||||
|
||||
type SelectorToken = {
|
||||
|
@ -28,8 +28,6 @@ type SelectorToken = {
|
|||
|
||||
const cacheAllowText = new Map<Element, SelectorToken[] | null>();
|
||||
const cacheDisallowText = new Map<Element, SelectorToken[] | null>();
|
||||
const cacheAccesibleName = new Map<Element, string>();
|
||||
const cacheAccesibleNameHidden = new Map<Element, boolean>();
|
||||
|
||||
const kTextScoreRange = 10;
|
||||
const kExactPenalty = kTextScoreRange / 2;
|
||||
|
@ -70,6 +68,7 @@ export type GenerateSelectorOptions = {
|
|||
|
||||
export function generateSelector(injectedScript: InjectedScript, targetElement: Element, options: GenerateSelectorOptions): { selector: string, elements: Element[] } {
|
||||
injectedScript._evaluator.begin();
|
||||
beginAriaCaches();
|
||||
try {
|
||||
targetElement = closestCrossShadow(targetElement, 'button,select,input,[role=button],[role=checkbox],[role=radio],a,[role=link]', options.root) || targetElement;
|
||||
const targetTokens = generateSelectorFor(injectedScript, targetElement, options);
|
||||
|
@ -82,8 +81,7 @@ export function generateSelector(injectedScript: InjectedScript, targetElement:
|
|||
} finally {
|
||||
cacheAllowText.clear();
|
||||
cacheDisallowText.clear();
|
||||
cacheAccesibleName.clear();
|
||||
cacheAccesibleNameHidden.clear();
|
||||
endAriaCaches();
|
||||
injectedScript._evaluator.end();
|
||||
}
|
||||
}
|
||||
|
@ -274,7 +272,7 @@ function buildTextCandidates(injectedScript: InjectedScript, element: Element, i
|
|||
|
||||
const ariaRole = getAriaRole(element);
|
||||
if (ariaRole && !['none', 'presentation'].includes(ariaRole)) {
|
||||
const ariaName = getAccessibleName(element);
|
||||
const ariaName = getElementAccessibleName(element, false);
|
||||
if (ariaName) {
|
||||
candidates.push([{ engine: 'internal:role', selector: `${ariaRole}[name=${escapeForAttributeSelector(ariaName, false)}]`, score: kRoleWithNameScore }]);
|
||||
candidates.push([{ engine: 'internal:role', selector: `${ariaRole}[name=${escapeForAttributeSelector(ariaName, true)}]`, score: kRoleWithNameScoreExact }]);
|
||||
|
@ -289,12 +287,6 @@ function makeSelectorForId(id: string) {
|
|||
return /^[a-zA-Z][a-zA-Z0-9\-\_]+$/.test(id) ? '#' + id : `[id="${cssEscape(id)}"]`;
|
||||
}
|
||||
|
||||
function getAccessibleName(element: Element) {
|
||||
if (!cacheAccesibleName.has(element))
|
||||
cacheAccesibleName.set(element, getElementAccessibleName(element, false, cacheAccesibleNameHidden));
|
||||
return cacheAccesibleName.get(element)!;
|
||||
}
|
||||
|
||||
function cssFallback(injectedScript: InjectedScript, targetElement: Element, options: GenerateSelectorOptions): SelectorToken[] {
|
||||
const root: Node = options.root ?? targetElement.ownerDocument;
|
||||
const tokens: string[] = [];
|
||||
|
|
Загрузка…
Ссылка в новой задаче