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:
Dmitry Gozman 2023-06-16 11:39:39 -07:00 коммит произвёл GitHub
Родитель 11770156eb
Коммит de422b5afb
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
5 изменённых файлов: 73 добавлений и 50 удалений

Просмотреть файл

@ -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[] = [];