feat(selectors): correctly work in large DOM (#4628)

This adds a test, fixes a bunch of call stack issues and
improves performance in some places.
This commit is contained in:
Dmitry Gozman 2020-12-07 15:51:44 -08:00 committed by GitHub
parent 73982834e7
commit 18b565a969
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 98 additions and 30 deletions

View File

@ -31,9 +31,17 @@ export interface SelectorEngine {
query?(context: QueryContext, args: (string | number | Selector)[], evaluator: SelectorEvaluator): Element[];
}
type QueryCache = Map<any, { rest: any[], result: any }[]>;
export class SelectorEvaluatorImpl implements SelectorEvaluator {
private _engines = new Map<string, SelectorEngine>();
private _cache = new Map<any, { rest: any[], result: any }[]>();
private _cacheQueryCSS: QueryCache = new Map();
private _cacheMatches: QueryCache = new Map();
private _cacheQuery: QueryCache = new Map();
private _cacheMatchesSimple: QueryCache = new Map();
private _cacheMatchesParents: QueryCache = new Map();
private _cacheCallMatches: QueryCache = new Map();
private _cacheCallQuery: QueryCache = new Map();
private _cacheQuerySimple: QueryCache = new Map();
constructor(extraEngines: Map<string, SelectorEngine>) {
// Note: keep predefined names in sync with Selectors class.
@ -55,21 +63,25 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
}
// This is the only function we should use for querying, because it does
// the right thing with caching
// the right thing with caching.
evaluate(context: QueryContext, s: CSSComplexSelectorList): Element[] {
const result = this.query(context, s);
this._cache.clear();
this._cacheQueryCSS.clear();
this._cacheMatches.clear();
this._cacheQuery.clear();
this._cacheMatchesSimple.clear();
this._cacheMatchesParents.clear();
this._cacheCallMatches.clear();
this._cacheCallQuery.clear();
this._cacheQuerySimple.clear();
return result;
}
private _cached<T>(main: any, rest: any[], cb: () => T): T {
if (!this._cache.has(main))
this._cache.set(main, []);
const entries = this._cache.get(main)!;
const entry = entries.find(e => {
return e.rest.length === rest.length &&
rest.findIndex((value, index) => e.rest[index] !== value) === -1;
});
private _cached<T>(cache: QueryCache, main: any, rest: any[], cb: () => T): T {
if (!cache.has(main))
cache.set(main, []);
const entries = cache.get(main)!;
const entry = entries.find(e => rest.every((value, index) => e.rest[index] === value));
if (entry)
return entry.result as T;
const result = cb();
@ -87,7 +99,7 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
matches(element: Element, s: Selector, context: QueryContext): boolean {
const selector = this._checkSelector(s);
return this._cached<boolean>(element, ['matches', selector, context], () => {
return this._cached<boolean>(this._cacheMatches, element, [selector, context], () => {
if (Array.isArray(selector))
return this._matchesEngine(isEngine, element, selector, context);
if (!this._matchesSimple(element, selector.simples[selector.simples.length - 1].selector, context))
@ -98,7 +110,7 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
query(context: QueryContext, s: any): Element[] {
const selector = this._checkSelector(s);
return this._cached<Element[]>(selector, ['query', context], () => {
return this._cached<Element[]>(this._cacheQuery, selector, [context], () => {
if (Array.isArray(selector))
return this._queryEngine(isEngine, context, selector);
const elements = this._querySimple(context, selector.simples[selector.simples.length - 1].selector);
@ -107,7 +119,7 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
}
private _matchesSimple(element: Element, simple: CSSSimpleSelector, context: QueryContext): boolean {
return this._cached<boolean>(element, ['_matchesSimple', simple, context], () => {
return this._cached<boolean>(this._cacheMatchesSimple, element, [simple, context], () => {
const isScopeClause = simple.functions.some(f => f.name === 'scope');
if (!isScopeClause && element === context.scope)
return false;
@ -122,7 +134,10 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
}
private _querySimple(context: QueryContext, simple: CSSSimpleSelector): Element[] {
return this._cached<Element[]>(simple, ['_querySimple', context], () => {
if (!simple.functions.length)
return this._queryCSS(context, simple.css || '*');
return this._cached<Element[]>(this._cacheQuerySimple, simple, [context], () => {
let css = simple.css;
const funcs = simple.functions;
if (css === '*' && funcs.length)
@ -157,9 +172,9 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
}
private _matchesParents(element: Element, complex: CSSComplexSelector, index: number, context: QueryContext): boolean {
return this._cached<boolean>(element, ['_matchesParents', complex, index, context], () => {
if (index < 0)
return true;
if (index < 0)
return true;
return this._cached<boolean>(this._cacheMatchesParents, element, [complex, index, context], () => {
const { selector: simple, combinator } = complex.simples[index];
if (combinator === '>') {
const parent = parentElementOrShadowHostInContext(element, context);
@ -220,28 +235,26 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
}
private _callMatches(engine: SelectorEngine, element: Element, args: CSSFunctionArgument[], context: QueryContext): boolean {
return this._cached<boolean>(element, ['_callMatches', engine, args, context.scope, context.pierceShadow], () => {
return this._cached<boolean>(this._cacheCallMatches, element, [engine, args, context.scope, context.pierceShadow], () => {
return engine.matches!(element, args, context, this);
});
}
private _callQuery(engine: SelectorEngine, args: CSSFunctionArgument[], context: QueryContext): Element[] {
return this._cached<Element[]>(args, ['_callQuery', engine, context.scope, context.pierceShadow], () => {
return this._cached<Element[]>(this._cacheCallQuery, args, [engine, context.scope, context.pierceShadow], () => {
return engine.query!(context, args, this);
});
}
private _matchesCSS(element: Element, css: string): boolean {
return this._cached<boolean>(element, ['_matchesCSS', css], () => {
return element.matches(css);
});
return element.matches(css);
}
_queryCSS(context: QueryContext, css: string): Element[] {
return this._cached<Element[]>(css, ['_queryCSS', context], () => {
const result: Element[] = [];
return this._cached<Element[]>(this._cacheQueryCSS, css, [context], () => {
let result: Element[] = [];
function query(root: Element | ShadowRoot | Document) {
result.push(...root.querySelectorAll(css));
result = result.concat([...root.querySelectorAll(css)]);
if (!context.pierceShadow)
return;
if ((root as Element).shadowRoot)
@ -274,11 +287,10 @@ const isEngine: SelectorEngine = {
query(context: QueryContext, args: (string | number | Selector)[], evaluator: SelectorEvaluator): Element[] {
if (args.length === 0)
throw new Error(`"is" engine expects non-empty selector list`);
const elements: Element[] = [];
let elements: Element[] = [];
for (const arg of args)
elements.push(...evaluator.query(context, arg));
const result = Array.from(new Set(elements));
return args.length > 1 ? sortInDOMOrder(result) : result;
elements = elements.concat(evaluator.query(context, arg));
return args.length === 1 ? elements : sortInDOMOrder(elements);
},
};

View File

@ -20,6 +20,62 @@ import * as path from 'path';
const { selectorsV2Enabled } = require(path.join(__dirname, '..', 'lib', 'server', 'common', 'selectorParser'));
it('should work with large DOM', async ({page, server}) => {
await page.evaluate(() => {
let id = 0;
const next = (tag: string) => {
const e = document.createElement(tag);
const eid = ++id;
e.textContent = 'id' + eid;
e.id = '' + eid;
return e;
};
const generate = (depth: number) => {
const div = next('div');
const span1 = next('span');
const span2 = next('span');
div.appendChild(span1);
div.appendChild(span2);
if (depth > 0) {
div.appendChild(generate(depth - 1));
div.appendChild(generate(depth - 1));
}
return div;
};
document.body.appendChild(generate(12));
});
const selectors = [
'div div div span',
'div > div div > span',
'div + div div div span + span',
'div ~ div div > span ~ span',
'div > div > div + div > div + div > span ~ span',
'div div div div div div div div div div span',
'div > div > div > div > div > div > div > div > div > div > span',
'div ~ div div ~ div div ~ div div ~ div div ~ div span',
'span',
];
const measure = false;
for (const selector of selectors) {
const counts1 = [];
const time1 = Date.now();
for (let i = 0; i < (measure ? 10 : 1); i++)
counts1.push(await page.$$eval(selector, els => els.length));
if (measure)
console.log('pw: ' + (Date.now() - time1));
const time2 = Date.now();
const counts2 = [];
for (let i = 0; i < (measure ? 10 : 1); i++)
counts2.push(await page.evaluate(selector => document.querySelectorAll(selector).length, selector));
if (measure)
console.log('qs: ' + (Date.now() - time2));
expect(counts1).toEqual(counts2);
}
});
it('should work for open shadow roots', async ({page, server}) => {
await page.goto(server.PREFIX + '/deep-shadow.html');
expect(await page.$eval(`css=span`, e => e.textContent)).toBe('Hello from root1');