Skip to content

Commit

Permalink
feat(selectors): speed up text selector (#5387)
Browse files Browse the repository at this point in the history
- Do not check children when parent does not contain the text we look for.
- Minor caching improvements in evaluator.

This gives up to 5X performance boost on text-heavy pages.
  • Loading branch information
dgozman committed Feb 10, 2021
1 parent 716bd42 commit 6a98241
Show file tree
Hide file tree
Showing 2 changed files with 50 additions and 34 deletions.
50 changes: 33 additions & 17 deletions src/server/injected/injectedScript.ts
Expand Up @@ -156,24 +156,39 @@ export class InjectedScript {
}

private _createTextEngine(shadow: boolean): SelectorEngine {
const queryList = (root: SelectorRoot, selector: string, single: boolean): Element[] => {
const { matcher, strict } = createTextMatcher(selector);
const result: Element[] = [];
let lastDidNotMatchSelf: Element | null = null;

const checkElement = (element: Element) => {
// TODO: replace contains() with something shadow-dom-aware?
if (!strict && lastDidNotMatchSelf && lastDidNotMatchSelf.contains(element))
return false;
const matches = elementMatchesText(this._evaluator, element, matcher);
if (matches === 'none')
lastDidNotMatchSelf = element;
if (matches === 'self')
result.push(element);
return single && result.length > 0;
};

if (root.nodeType === Node.ELEMENT_NODE && checkElement(root as Element))
return result;
const elements = this._evaluator._queryCSS({ scope: root as Document | Element, pierceShadow: shadow }, '*');
for (const element of elements) {
if (checkElement(element))
return result;
}
return result;
};

return {
query: (root: SelectorRoot, selector: string): Element | undefined => {
const matcher = createTextMatcher(selector);
if (root.nodeType === Node.ELEMENT_NODE && elementMatchesText(this._evaluator, root as Element, matcher))
return root as Element;
const elements = this._evaluator._queryCSS({ scope: root as Document | Element, pierceShadow: shadow }, '*');
for (const element of elements) {
if (elementMatchesText(this._evaluator, element, matcher))
return element;
}
return queryList(root, selector, true)[0];
},
queryAll: (root: SelectorRoot, selector: string): Element[] => {
const matcher = createTextMatcher(selector);
const elements = this._evaluator._queryCSS({ scope: root as Document | Element, pierceShadow: shadow }, '*');
const result = elements.filter(e => elementMatchesText(this._evaluator, e, matcher));
if (root.nodeType === Node.ELEMENT_NODE && elementMatchesText(this._evaluator, root as Element, matcher))
result.unshift(root as Element);
return result;
return queryList(root, selector, false);
}
};
}
Expand Down Expand Up @@ -823,11 +838,11 @@ function unescape(s: string): string {
}

type Matcher = (text: string) => boolean;
function createTextMatcher(selector: string): Matcher {
function createTextMatcher(selector: string): { matcher: Matcher, strict: boolean } {
if (selector[0] === '/' && selector.lastIndexOf('/') > 0) {
const lastSlash = selector.lastIndexOf('/');
const re = new RegExp(selector.substring(1, lastSlash), selector.substring(lastSlash + 1));
return text => re.test(text);
return { matcher: text => re.test(text), strict: true };
}
let strict = false;
if (selector.length > 1 && selector[0] === '"' && selector[selector.length - 1] === '"') {
Expand All @@ -841,12 +856,13 @@ function createTextMatcher(selector: string): Matcher {
selector = selector.trim().replace(/\s+/g, ' ');
if (!strict)
selector = selector.toLowerCase();
return text => {
const matcher = (text: string) => {
text = text.trim().replace(/\s+/g, ' ');
if (!strict)
return text.toLowerCase().includes(selector);
return text === selector;
};
return { matcher, strict };
}

export default InjectedScript;
34 changes: 17 additions & 17 deletions src/server/injected/selectorEvaluator.ts
Expand Up @@ -119,7 +119,7 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
const selector = this._checkSelector(s);
this.begin();
try {
return this._cached<boolean>(this._cacheMatches, element, [selector, context], () => {
return this._cached<boolean>(this._cacheMatches, element, [selector, context.scope, context.pierceShadow], () => {
if (Array.isArray(selector))
return this._matchesEngine(isEngine, element, selector, context);
if (!this._matchesSimple(element, selector.simples[selector.simples.length - 1].selector, context))
Expand All @@ -135,7 +135,7 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
const selector = this._checkSelector(s);
this.begin();
try {
return this._cached<Element[]>(this._cacheQuery, selector, [context], () => {
return this._cached<Element[]>(this._cacheQuery, selector, [context.scope, context.pierceShadow], () => {
if (Array.isArray(selector))
return this._queryEngine(isEngine, context, selector);

Expand Down Expand Up @@ -174,7 +174,7 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
}

private _matchesSimple(element: Element, simple: CSSSimpleSelector, context: QueryContext): boolean {
return this._cached<boolean>(this._cacheMatchesSimple, element, [simple, context], () => {
return this._cached<boolean>(this._cacheMatchesSimple, element, [simple, context.scope, context.pierceShadow], () => {
const isPossiblyScopeClause = simple.functions.some(f => f.name === 'scope' || f.name === 'is');
if (!isPossiblyScopeClause && element === context.scope)
return false;
Expand All @@ -192,7 +192,7 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
if (!simple.functions.length)
return this._queryCSS(context, simple.css || '*');

return this._cached<Element[]>(this._cacheQuerySimple, simple, [context], () => {
return this._cached<Element[]>(this._cacheQuerySimple, simple, [context.scope, context.pierceShadow], () => {
let css = simple.css;
const funcs = simple.functions;
if (css === '*' && funcs.length)
Expand Down Expand Up @@ -229,7 +229,7 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
private _matchesParents(element: Element, complex: CSSComplexSelector, index: number, context: QueryContext): boolean {
if (index < 0)
return true;
return this._cached<boolean>(this._cacheMatchesParents, element, [complex, index, context], () => {
return this._cached<boolean>(this._cacheMatchesParents, element, [complex, index, context.scope, context.pierceShadow], () => {
const { selector: simple, combinator } = complex.simples[index];
if (combinator === '>') {
const parent = parentElementOrShadowHostInContext(element, context);
Expand Down Expand Up @@ -303,13 +303,13 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
}

private _callMatches(engine: SelectorEngine, element: Element, args: CSSFunctionArgument[], context: QueryContext): boolean {
return this._cached<boolean>(this._cacheCallMatches, element, [engine, args, context.scope, context.pierceShadow], () => {
return this._cached<boolean>(this._cacheCallMatches, element, [engine, context.scope, context.pierceShadow, ...args], () => {
return engine.matches!(element, args, context, this);
});
}

private _callQuery(engine: SelectorEngine, args: CSSFunctionArgument[], context: QueryContext): Element[] {
return this._cached<Element[]>(this._cacheCallQuery, args, [engine, context.scope, context.pierceShadow], () => {
return this._cached<Element[]>(this._cacheCallQuery, engine, [context.scope, context.pierceShadow, ...args], () => {
return engine.query!(context, args, this);
});
}
Expand All @@ -319,7 +319,7 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
}

_queryCSS(context: QueryContext, css: string): Element[] {
return this._cached<Element[]>(this._cacheQueryCSS, css, [context], () => {
return this._cached<Element[]>(this._cacheQueryCSS, css, [context.scope, context.pierceShadow], () => {
let result: Element[] = [];
function query(root: Element | ShadowRoot | Document) {
result = result.concat([...root.querySelectorAll(css)]);
Expand Down Expand Up @@ -428,7 +428,7 @@ const textEngine: SelectorEngine = {
if (args.length !== 1 || typeof args[0] !== 'string')
throw new Error(`"text" engine expects a single string`);
const matcher = textMatcher(args[0], true);
return elementMatchesText(evaluator as SelectorEvaluatorImpl, element, matcher);
return elementMatchesText(evaluator as SelectorEvaluatorImpl, element, matcher) === 'self';
},
};

Expand All @@ -437,7 +437,7 @@ const textIsEngine: SelectorEngine = {
if (args.length !== 1 || typeof args[0] !== 'string')
throw new Error(`"text-is" engine expects a single string`);
const matcher = textMatcher(args[0], false);
return elementMatchesText(evaluator as SelectorEvaluatorImpl, element, matcher);
return elementMatchesText(evaluator as SelectorEvaluatorImpl, element, matcher) === 'self';
},
};

Expand All @@ -447,7 +447,7 @@ const textMatchesEngine: SelectorEngine = {
throw new Error(`"text-matches" engine expects a regexp body and optional regexp flags`);
const re = new RegExp(args[0], args.length === 2 ? args[1] : undefined);
const matcher = (s: string) => re.test(s);
return elementMatchesText(evaluator as SelectorEvaluatorImpl, element, matcher);
return elementMatchesText(evaluator as SelectorEvaluatorImpl, element, matcher) === 'self';
},
};

Expand Down Expand Up @@ -499,18 +499,18 @@ export function elementText(evaluator: SelectorEvaluatorImpl, root: Element | Sh
return value;
}

export function elementMatchesText(evaluator: SelectorEvaluatorImpl, element: Element, matcher: (s: string) => boolean): boolean {
export function elementMatchesText(evaluator: SelectorEvaluatorImpl, element: Element, matcher: (s: string) => boolean): 'none' | 'self' | 'selfAndChildren' {
if (shouldSkipForTextMatching(element))
return false;
return 'none';
if (!matcher(elementText(evaluator, element)))
return false;
return 'none';
for (let child = element.firstChild; child; child = child.nextSibling) {
if (child.nodeType === Node.ELEMENT_NODE && matcher(elementText(evaluator, child as Element)))
return false;
return 'selfAndChildren';
}
if (element.shadowRoot && matcher(elementText(evaluator, element.shadowRoot)))
return false;
return true;
return 'selfAndChildren';
return 'self';
}

function boxRightOf(box1: DOMRect, box2: DOMRect): number | undefined {
Expand Down

0 comments on commit 6a98241

Please sign in to comment.