chore: speedup frequent element text normalization (#29113)

We cache `ElementText` for frequent operations, but then call
`normalizeWhitespace` on it every time which burns a lot of CPU.
This commit is contained in:
Dmitry Gozman 2024-01-22 21:33:56 -08:00 committed by GitHub
parent fbf87ef904
commit 9b974e0026
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 16 additions and 13 deletions

View File

@ -1385,7 +1385,7 @@ function createTextMatcher(selector: string, internal: boolean): { matcher: Text
selector = normalizeWhiteSpace(selector); selector = normalizeWhiteSpace(selector);
if (strict) { if (strict) {
if (internal) if (internal)
return { kind: 'strict', matcher: (elementText: ElementText) => normalizeWhiteSpace(elementText.full) === selector }; return { kind: 'strict', matcher: (elementText: ElementText) => elementText.normalized === selector };
const strictTextNodeMatcher = (elementText: ElementText) => { const strictTextNodeMatcher = (elementText: ElementText) => {
if (!selector && !elementText.immediate.length) if (!selector && !elementText.immediate.length)
@ -1395,7 +1395,7 @@ function createTextMatcher(selector: string, internal: boolean): { matcher: Text
return { matcher: strictTextNodeMatcher, kind: 'strict' }; return { matcher: strictTextNodeMatcher, kind: 'strict' };
} }
selector = selector.toLowerCase(); selector = selector.toLowerCase();
return { kind: 'lax', matcher: (elementText: ElementText) => normalizeWhiteSpace(elementText.full).toLowerCase().includes(selector) }; return { kind: 'lax', matcher: (elementText: ElementText) => elementText.normalized.toLowerCase().includes(selector) };
} }
class ExpectedTextMatcher { class ExpectedTextMatcher {

View File

@ -585,7 +585,7 @@ class TextAssertionTool implements RecorderTool {
name: 'assertText', name: 'assertText',
selector: this._hoverHighlight.selector, selector: this._hoverHighlight.selector,
signals: [], signals: [],
text: normalizeWhiteSpace(elementText(this._textCache, target).full), text: elementText(this._textCache, target).normalized,
substring: true, substring: true,
}; };
} }
@ -653,7 +653,7 @@ class TextAssertionTool implements RecorderTool {
if (!target) if (!target)
return; return;
action.text = newValue; action.text = newValue;
const targetText = normalizeWhiteSpace(elementText(this._textCache, target).full); const targetText = elementText(this._textCache, target).normalized;
const matches = newValue && targetText.includes(newValue); const matches = newValue && targetText.includes(newValue);
textElement.classList.toggle('does-not-match', !matches); textElement.classList.toggle('does-not-match', !matches);
}; };

View File

@ -450,7 +450,7 @@ const textEngine: SelectorEngine = {
if (args.length !== 1 || typeof args[0] !== 'string') if (args.length !== 1 || typeof args[0] !== 'string')
throw new Error(`"text" engine expects a single string`); throw new Error(`"text" engine expects a single string`);
const text = normalizeWhiteSpace(args[0]).toLowerCase(); const text = normalizeWhiteSpace(args[0]).toLowerCase();
const matcher = (elementText: ElementText) => normalizeWhiteSpace(elementText.full).toLowerCase().includes(text); const matcher = (elementText: ElementText) => elementText.normalized.toLowerCase().includes(text);
return elementMatchesText((evaluator as SelectorEvaluatorImpl)._cacheText, element, matcher) === 'self'; return elementMatchesText((evaluator as SelectorEvaluatorImpl)._cacheText, element, matcher) === 'self';
}, },
}; };
@ -486,7 +486,7 @@ const hasTextEngine: SelectorEngine = {
if (shouldSkipForTextMatching(element)) if (shouldSkipForTextMatching(element))
return false; return false;
const text = normalizeWhiteSpace(args[0]).toLowerCase(); const text = normalizeWhiteSpace(args[0]).toLowerCase();
const matcher = (elementText: ElementText) => normalizeWhiteSpace(elementText.full).toLowerCase().includes(text); const matcher = (elementText: ElementText) => elementText.normalized.toLowerCase().includes(text);
return matcher(elementText((evaluator as SelectorEvaluatorImpl)._cacheText, element)); return matcher(elementText((evaluator as SelectorEvaluatorImpl)._cacheText, element));
}, },
}; };

View File

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
import { cssEscape, escapeForAttributeSelector, escapeForTextSelector, normalizeWhiteSpace, quoteCSSAttributeValue } from '../../utils/isomorphic/stringUtils'; import { cssEscape, escapeForAttributeSelector, escapeForTextSelector, quoteCSSAttributeValue } 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, beginAriaCaches, endAriaCaches } from './roleUtils'; import { getAriaRole, getElementAccessibleName, beginAriaCaches, endAriaCaches } from './roleUtils';
@ -237,7 +237,7 @@ function buildNoTextCandidates(injectedScript: InjectedScript, element: Element,
const labels = getElementLabels(injectedScript._evaluator._cacheText, element); const labels = getElementLabels(injectedScript._evaluator._cacheText, element);
for (const label of labels) { for (const label of labels) {
const labelText = label.full.trim(); const labelText = label.normalized;
candidates.push({ engine: 'internal:label', selector: escapeForTextSelector(labelText, true), score: kLabelScoreExact }); candidates.push({ engine: 'internal:label', selector: escapeForTextSelector(labelText, true), score: kLabelScoreExact });
for (const alternative of suitableTextAlternatives(labelText)) for (const alternative of suitableTextAlternatives(labelText))
candidates.push({ engine: 'internal:label', selector: escapeForTextSelector(alternative.text, false), score: kLabelScore - alternative.scoreBouns }); candidates.push({ engine: 'internal:label', selector: escapeForTextSelector(alternative.text, false), score: kLabelScore - alternative.scoreBouns });
@ -281,7 +281,7 @@ function buildTextCandidates(injectedScript: InjectedScript, element: Element, i
candidates.push([{ engine: 'internal:attr', selector: `[alt=${escapeForAttributeSelector(alternative.text, false)}]`, score: kAltTextScore - alternative.scoreBouns }]); candidates.push([{ engine: 'internal:attr', selector: `[alt=${escapeForAttributeSelector(alternative.text, false)}]`, score: kAltTextScore - alternative.scoreBouns }]);
} }
const text = normalizeWhiteSpace(elementText(injectedScript._evaluator._cacheText, element).full); const text = elementText(injectedScript._evaluator._cacheText, element).normalized;
if (text) { if (text) {
const alternatives = suitableTextAlternatives(text); const alternatives = suitableTextAlternatives(text);
if (isTargetNode) { if (isTargetNode) {

View File

@ -15,6 +15,7 @@
*/ */
import type { AttributeSelectorPart } from '../../utils/isomorphic/selectorParser'; import type { AttributeSelectorPart } from '../../utils/isomorphic/selectorParser';
import { normalizeWhiteSpace } from '../../utils/isomorphic/stringUtils';
import { getAriaLabelledByElements } from './roleUtils'; import { getAriaLabelledByElements } from './roleUtils';
export function matchesComponentAttribute(obj: any, attr: AttributeSelectorPart) { export function matchesComponentAttribute(obj: any, attr: AttributeSelectorPart) {
@ -56,17 +57,17 @@ export function shouldSkipForTextMatching(element: Element | ShadowRoot) {
return element.nodeName === 'SCRIPT' || element.nodeName === 'NOSCRIPT' || element.nodeName === 'STYLE' || document.head && document.head.contains(element); return element.nodeName === 'SCRIPT' || element.nodeName === 'NOSCRIPT' || element.nodeName === 'STYLE' || document.head && document.head.contains(element);
} }
export type ElementText = { full: string, immediate: string[] }; export type ElementText = { full: string, normalized: string, immediate: string[] };
export type TextMatcher = (text: ElementText) => boolean; export type TextMatcher = (text: ElementText) => boolean;
export function elementText(cache: Map<Element | ShadowRoot, ElementText>, root: Element | ShadowRoot): ElementText { export function elementText(cache: Map<Element | ShadowRoot, ElementText>, root: Element | ShadowRoot): ElementText {
let value = cache.get(root); let value = cache.get(root);
if (value === undefined) { if (value === undefined) {
value = { full: '', immediate: [] }; value = { full: '', normalized: '', immediate: [] };
if (!shouldSkipForTextMatching(root)) { if (!shouldSkipForTextMatching(root)) {
let currentImmediate = ''; let currentImmediate = '';
if ((root instanceof HTMLInputElement) && (root.type === 'submit' || root.type === 'button')) { if ((root instanceof HTMLInputElement) && (root.type === 'submit' || root.type === 'button')) {
value = { full: root.value, immediate: [root.value] }; value = { full: root.value, normalized: normalizeWhiteSpace(root.value), immediate: [root.value] };
} else { } else {
for (let child = root.firstChild; child; child = child.nextSibling) { for (let child = root.firstChild; child; child = child.nextSibling) {
if (child.nodeType === Node.TEXT_NODE) { if (child.nodeType === Node.TEXT_NODE) {
@ -84,6 +85,8 @@ export function elementText(cache: Map<Element | ShadowRoot, ElementText>, root:
value.immediate.push(currentImmediate); value.immediate.push(currentImmediate);
if ((root as Element).shadowRoot) if ((root as Element).shadowRoot)
value.full += elementText(cache, (root as Element).shadowRoot!).full; value.full += elementText(cache, (root as Element).shadowRoot!).full;
if (value.full)
value.normalized = normalizeWhiteSpace(value.full);
} }
} }
cache.set(root, value); cache.set(root, value);
@ -111,7 +114,7 @@ export function getElementLabels(textCache: Map<Element | ShadowRoot, ElementTex
return labels.map(label => elementText(textCache, label)); return labels.map(label => elementText(textCache, label));
const ariaLabel = element.getAttribute('aria-label'); const ariaLabel = element.getAttribute('aria-label');
if (ariaLabel !== null && !!ariaLabel.trim()) if (ariaLabel !== null && !!ariaLabel.trim())
return [{ full: ariaLabel, immediate: [ariaLabel] }]; return [{ full: ariaLabel, normalized: normalizeWhiteSpace(ariaLabel), immediate: [ariaLabel] }];
// https://html.spec.whatwg.org/multipage/forms.html#category-label // https://html.spec.whatwg.org/multipage/forms.html#category-label
const isNonHiddenInput = element.nodeName === 'INPUT' && (element as HTMLInputElement).type !== 'hidden'; const isNonHiddenInput = element.nodeName === 'INPUT' && (element as HTMLInputElement).type !== 'hidden';