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 committed by GitHub
parent 11770156eb
commit de422b5afb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 73 additions and 50 deletions

View file

@ -46,6 +46,7 @@ function enclosingShadowHost(element: Element): Element | undefined {
return parentElementOrShadowHost(element); 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 { export function closestCrossShadow(element: Element | undefined, css: string, scope?: Document | Element): Element | undefined {
while (element) { while (element) {
const closest = element.closest(css); const closest = element.closest(css);

View file

@ -1341,8 +1341,7 @@ export class InjectedScript {
} }
getElementAccessibleName(element: Element, includeHidden?: boolean): string { getElementAccessibleName(element: Element, includeHidden?: boolean): string {
const hiddenCache = new Map<Element, boolean>(); return getElementAccessibleName(element, !!includeHidden);
return getElementAccessibleName(element, !!includeHidden, hiddenCache);
} }
getAriaRole(element: Element) { getAriaRole(element: Element) {

View file

@ -16,7 +16,7 @@
import type { SelectorEngine, SelectorRoot } from './selectorEngine'; import type { SelectorEngine, SelectorRoot } from './selectorEngine';
import { matchesAttributePart } from './selectorUtils'; 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 { parseAttributeSelector, type AttributeSelectorPart, type AttributeSelectorOperator } from '../../utils/isomorphic/selectorParser';
import { normalizeWhiteSpace } from '../../utils/isomorphic/stringUtils'; 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[] { function queryRole(scope: SelectorRoot, options: RoleEngineOptions, internal: boolean): Element[] {
const hiddenCache = new Map<Element, boolean>();
const result: Element[] = []; const result: Element[] = [];
const match = (element: Element) => { const match = (element: Element) => {
if (getAriaRole(element) !== options.role) 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) if (options.disabled !== undefined && getAriaDisabled(element) !== options.disabled)
return; return;
if (!options.includeHidden) { if (!options.includeHidden) {
const isHidden = isElementHiddenForAria(element, hiddenCache); const isHidden = isElementHiddenForAria(element);
if (isHidden) if (isHidden)
return; return;
} }
if (options.name !== undefined) { if (options.name !== undefined) {
// Always normalize whitespace in the accessible name. // 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') if (typeof options.name === 'string')
options.name = normalizeWhiteSpace(options.name); options.name = normalizeWhiteSpace(options.name);
// internal:role assumes that [name="foo"i] also means substring. // internal:role assumes that [name="foo"i] also means substring.
@ -185,7 +184,12 @@ export function createRoleEngine(internal: boolean): SelectorEngine {
if (!role) if (!role)
throw new Error(`Role must not be empty`); throw new Error(`Role must not be empty`);
const options = validateAttributes(parsed.attributes, role); const options = validateAttributes(parsed.attributes, role);
return queryRole(scope, options, internal); beginAriaCaches();
try {
return queryRole(scope, options, internal);
} finally {
endAriaCaches();
}
} }
}; };
} }

View file

@ -235,7 +235,7 @@ function getAriaBoolean(attr: string | null) {
// Not implemented: // Not implemented:
// `Any descendants of elements that have the characteristic "Children Presentational: True"` // `Any descendants of elements that have the characteristic "Children Presentational: True"`
// https://www.w3.org/TR/wai-aria-1.2/#aria-hidden // 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)) if (['STYLE', 'SCRIPT', 'NOSCRIPT', 'TEMPLATE'].includes(element.tagName))
return true; return true;
const style = getElementComputedStyle(element); const style = getElementComputedStyle(element);
@ -243,7 +243,7 @@ export function isElementHiddenForAria(element: Element, cache: Map<Element, boo
if (style?.display === 'contents' && !isSlot) { if (style?.display === 'contents' && !isSlot) {
// display:contents is not rendered itself, but its child nodes are. // display:contents is not rendered itself, but its child nodes are.
for (let child = element.firstChild; child; child = child.nextSibling) { 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; return false;
if (child.nodeType === 3 /* Node.TEXT_NODE */ && isVisibleTextNode(child as Text)) if (child.nodeType === 3 /* Node.TEXT_NODE */ && isVisibleTextNode(child as Text))
return false; return false;
@ -255,12 +255,13 @@ export function isElementHiddenForAria(element: Element, cache: Map<Element, boo
const isOptionInsideSelect = element.nodeName === 'OPTION' && !!element.closest('select'); const isOptionInsideSelect = element.nodeName === 'OPTION' && !!element.closest('select');
if (!isOptionInsideSelect && !isSlot && !isElementStyleVisibilityVisible(element, style)) if (!isOptionInsideSelect && !isSlot && !isElementStyleVisibilityVisible(element, style))
return true; return true;
return belongsToDisplayNoneOrAriaHiddenOrNonSlotted(element, cache); return belongsToDisplayNoneOrAriaHiddenOrNonSlotted(element);
} }
function belongsToDisplayNoneOrAriaHiddenOrNonSlotted(element: Element, cache: Map<Element, boolean>): boolean { function belongsToDisplayNoneOrAriaHiddenOrNonSlotted(element: Element): boolean {
if (!cache.has(element)) { let hidden = cacheIsHidden?.get(element);
let hidden = false; if (hidden === undefined) {
hidden = false;
// When parent has a shadow root, all light dom children must be assigned to a slot, // 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. // otherwise they are not rendered and considered hidden for aria.
@ -278,11 +279,11 @@ function belongsToDisplayNoneOrAriaHiddenOrNonSlotted(element: Element, cache: M
if (!hidden) { if (!hidden) {
const parent = parentElementOrShadowHost(element); const parent = parentElementOrShadowHost(element);
if (parent) 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[] { function getIdRefs(element: Element, ref: string | null): Element[] {
@ -325,14 +326,14 @@ function queryInAriaOwned(element: Element, selector: string): Element[] {
function getPseudoContent(pseudoStyle: CSSStyleDeclaration | undefined) { function getPseudoContent(pseudoStyle: CSSStyleDeclaration | undefined) {
if (!pseudoStyle) if (!pseudoStyle)
return ''; return '';
const content = pseudoStyle.getPropertyValue('content'); const content = pseudoStyle.content;
if ((content[0] === '\'' && content[content.length - 1] === '\'') || if ((content[0] === '\'' && content[content.length - 1] === '\'') ||
(content[0] === '"' && content[content.length - 1] === '"')) { (content[0] === '"' && content[content.length - 1] === '"')) {
const unquoted = content.substring(1, content.length - 1); const unquoted = content.substring(1, content.length - 1);
// SPEC DIFFERENCE. // SPEC DIFFERENCE.
// Spec says "CSS textual content, without a space", but we account for display // Spec says "CSS textual content, without a space", but we account for display
// to pass "name_file-label-inline-block-styles-manual.html" // to pass "name_file-label-inline-block-styles-manual.html"
const display = pseudoStyle.getPropertyValue('display') || 'inline'; const display = pseudoStyle.display || 'inline';
if (display !== 'inline') if (display !== 'inline')
return ' ' + unquoted + ' '; return ' ' + unquoted + ' ';
return unquoted; return unquoted;
@ -360,31 +361,37 @@ function allowsNameFromContent(role: string, targetDescendant: boolean) {
return alwaysAllowsNameFromContent || descendantAllowsNameFromContent; return alwaysAllowsNameFromContent || descendantAllowsNameFromContent;
} }
export function getElementAccessibleName(element: Element, includeHidden: boolean, hiddenCache: Map<Element, boolean>): string { export function getElementAccessibleName(element: Element, includeHidden: boolean): string {
// https://w3c.github.io/accname/#computation-steps const cache = (includeHidden ? cacheAccessibleNameHidden : cacheAccessibleName);
let accessibleName = cache?.get(element);
// step 1. if (accessibleName === undefined) {
// https://w3c.github.io/aria/#namefromprohibited // https://w3c.github.io/accname/#computation-steps
const elementProhibitsNaming = ['caption', 'code', 'definition', 'deletion', 'emphasis', 'generic', 'insertion', 'mark', 'paragraph', 'presentation', 'strong', 'subscript', 'suggestion', 'superscript', 'term', 'time'].includes(getAriaRole(element) || ''); accessibleName = '';
if (elementProhibitsNaming)
return '';
// step 2. // step 1.
const accessibleName = normalizeAccessbileName(getElementAccessibleNameInternal(element, { // https://w3c.github.io/aria/#namefromprohibited
includeHidden, const elementProhibitsNaming = ['caption', 'code', 'definition', 'deletion', 'emphasis', 'generic', 'insertion', 'mark', 'paragraph', 'presentation', 'strong', 'subscript', 'suggestion', 'superscript', 'term', 'time'].includes(getAriaRole(element) || '');
hiddenCache,
visitedElements: new Set(), if (!elementProhibitsNaming) {
embeddedInLabelledBy: 'none', // step 2.
embeddedInLabel: 'none', accessibleName = normalizeAccessbileName(getElementAccessibleNameInternal(element, {
embeddedInTextAlternativeElement: false, includeHidden,
embeddedInTargetElement: 'self', visitedElements: new Set(),
})); embeddedInLabelledBy: 'none',
embeddedInLabel: 'none',
embeddedInTextAlternativeElement: false,
embeddedInTargetElement: 'self',
}));
}
cache?.set(element, accessibleName);
}
return accessibleName; return accessibleName;
} }
type AccessibleNameOptions = { type AccessibleNameOptions = {
includeHidden: boolean, includeHidden: boolean,
hiddenCache: Map<Element, boolean>,
visitedElements: Set<Element>, visitedElements: Set<Element>,
embeddedInLabelledBy: 'none' | 'self' | 'descendant', embeddedInLabelledBy: 'none' | 'self' | 'descendant',
embeddedInLabel: 'none' | 'self' | 'descendant', embeddedInLabel: 'none' | 'self' | 'descendant',
@ -404,7 +411,7 @@ function getElementAccessibleNameInternal(element: Element, options: AccessibleN
}; };
// step 2a. // step 2a.
if (!options.includeHidden && options.embeddedInLabelledBy !== 'self' && isElementHiddenForAria(element, options.hiddenCache)) { if (!options.includeHidden && options.embeddedInLabelledBy !== 'self' && isElementHiddenForAria(element)) {
options.visitedElements.add(element); options.visitedElements.add(element);
return ''; return '';
} }
@ -668,7 +675,7 @@ function getElementAccessibleNameInternal(element: Element, options: AccessibleN
if (skipSlotted && (node as Element | Text).assignedSlot) if (skipSlotted && (node as Element | Text).assignedSlot)
return; return;
if (node.nodeType === 1 /* Node.ELEMENT_NODE */) { 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); let token = getElementAccessibleNameInternal(node as Element, childOptions);
// SPEC DIFFERENCE. // SPEC DIFFERENCE.
// Spec says "append the result to the accumulated text", assuming "with space". // 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. // aria-disabled works across shadow boundaries.
return hasExplicitAriaDisabled(parentElementOrShadowHost(element)); 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;
}
}

View file

@ -17,7 +17,7 @@
import { cssEscape, escapeForAttributeSelector, escapeForTextSelector, normalizeWhiteSpace } from '../../utils/isomorphic/stringUtils'; import { cssEscape, escapeForAttributeSelector, escapeForTextSelector, normalizeWhiteSpace } from '../../utils/isomorphic/stringUtils';
import { closestCrossShadow, isInsideScope, parentElementOrShadowHost } from './domUtils'; import { closestCrossShadow, isInsideScope, parentElementOrShadowHost } from './domUtils';
import type { InjectedScript } from './injectedScript'; import type { InjectedScript } from './injectedScript';
import { getAriaRole, getElementAccessibleName } from './roleUtils'; import { getAriaRole, getElementAccessibleName, beginAriaCaches, endAriaCaches } from './roleUtils';
import { elementText } from './selectorUtils'; import { elementText } from './selectorUtils';
type SelectorToken = { type SelectorToken = {
@ -28,8 +28,6 @@ type SelectorToken = {
const cacheAllowText = new Map<Element, SelectorToken[] | null>(); const cacheAllowText = new Map<Element, SelectorToken[] | null>();
const cacheDisallowText = 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 kTextScoreRange = 10;
const kExactPenalty = kTextScoreRange / 2; const kExactPenalty = kTextScoreRange / 2;
@ -70,6 +68,7 @@ export type GenerateSelectorOptions = {
export function generateSelector(injectedScript: InjectedScript, targetElement: Element, options: GenerateSelectorOptions): { selector: string, elements: Element[] } { export function generateSelector(injectedScript: InjectedScript, targetElement: Element, options: GenerateSelectorOptions): { selector: string, elements: Element[] } {
injectedScript._evaluator.begin(); injectedScript._evaluator.begin();
beginAriaCaches();
try { try {
targetElement = closestCrossShadow(targetElement, 'button,select,input,[role=button],[role=checkbox],[role=radio],a,[role=link]', options.root) || targetElement; targetElement = closestCrossShadow(targetElement, 'button,select,input,[role=button],[role=checkbox],[role=radio],a,[role=link]', options.root) || targetElement;
const targetTokens = generateSelectorFor(injectedScript, targetElement, options); const targetTokens = generateSelectorFor(injectedScript, targetElement, options);
@ -82,8 +81,7 @@ export function generateSelector(injectedScript: InjectedScript, targetElement:
} finally { } finally {
cacheAllowText.clear(); cacheAllowText.clear();
cacheDisallowText.clear(); cacheDisallowText.clear();
cacheAccesibleName.clear(); endAriaCaches();
cacheAccesibleNameHidden.clear();
injectedScript._evaluator.end(); injectedScript._evaluator.end();
} }
} }
@ -274,7 +272,7 @@ function buildTextCandidates(injectedScript: InjectedScript, element: Element, i
const ariaRole = getAriaRole(element); const ariaRole = getAriaRole(element);
if (ariaRole && !['none', 'presentation'].includes(ariaRole)) { if (ariaRole && !['none', 'presentation'].includes(ariaRole)) {
const ariaName = getAccessibleName(element); const ariaName = getElementAccessibleName(element, false);
if (ariaName) { 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, false)}]`, score: kRoleWithNameScore }]);
candidates.push([{ engine: 'internal:role', selector: `${ariaRole}[name=${escapeForAttributeSelector(ariaName, true)}]`, score: kRoleWithNameScoreExact }]); 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)}"]`; 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[] { function cssFallback(injectedScript: InjectedScript, targetElement: Element, options: GenerateSelectorOptions): SelectorToken[] {
const root: Node = options.root ?? targetElement.ownerDocument; const root: Node = options.root ?? targetElement.ownerDocument;
const tokens: string[] = []; const tokens: string[] = [];