Skip to content

Commit 3274919

Browse files
committed
fix(perf): improve select performance fixes #702
1 parent 0fe74d8 commit 3274919

File tree

4 files changed

+137
-44
lines changed

4 files changed

+137
-44
lines changed

lib/core/utils/qsa.js

Lines changed: 91 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ function matchesPseudos (target, exp) {
2929

3030
if (!exp.pseudos || exp.pseudos.reduce((result, pseudo) => {
3131
if (pseudo.name === 'not') {
32-
return result && !matchExpressions([target], pseudo.expressions, false).length;
32+
return result && !matchExpressions([target], pseudo.expressions, false, target.shadowId).length;
3333
}
3434
throw new Error('the pseudo selector ' + pseudo.name + ' has not yet been implemented');
3535
}, true)) {
@@ -38,27 +38,6 @@ function matchesPseudos (target, exp) {
3838
return false;
3939
}
4040

41-
function matchSelector (targets, exp, recurse) {
42-
var result = [];
43-
44-
targets = Array.isArray(targets) ? targets : [targets];
45-
targets.forEach((target) => {
46-
if (matchesTag(target.actualNode, exp) &&
47-
matchesClasses(target.actualNode, exp) &&
48-
matchesAttributes(target.actualNode, exp) &&
49-
matchesId(target.actualNode, exp) &&
50-
matchesPseudos(target, exp)) {
51-
result.push(target);
52-
}
53-
if (recurse) {
54-
result = result.concat(matchSelector(target.children.filter((child) => {
55-
return !exp.id || child.shadowId === target.shadowId;
56-
}), exp, recurse));
57-
}
58-
});
59-
return result;
60-
}
61-
6241
var escapeRegExp = (function(){
6342
/*! Credit: XRegExp 0.6.1 (c) 2007-2008 Steven Levithan <http://stevenlevithan.com/regex/xregexp/> MIT License */
6443
var from = /(?=[\-\[\]{}()*+?.\\\^$|,#\s])/g;
@@ -194,27 +173,80 @@ convertExpressions = function (expressions) {
194173
});
195174
};
196175

197-
matchExpressions = function (domTree, expressions, recurse) {
198-
return expressions.reduce((collected, exprArr) => {
199-
var candidates = domTree;
200-
exprArr.forEach((exp, index) => {
201-
recurse = exp.combinator === '>' ? false : recurse;
202-
if ([' ', '>'].includes(exp.combinator) === false) {
203-
throw new Error('axe.utils.querySelectorAll does not support the combinator: ' + exp.combinator);
176+
function createLocalVariables (nodes, anyLevel, thisLevel, parentShadowId) {
177+
let retVal = {
178+
nodes: nodes.slice(),
179+
anyLevel: anyLevel,
180+
thisLevel: thisLevel,
181+
parentShadowId: parentShadowId
182+
};
183+
retVal.nodes.reverse();
184+
return retVal;
185+
}
186+
187+
function matchesSelector (node, exp) {
188+
return (matchesTag(node.actualNode, exp[0]) &&
189+
matchesClasses(node.actualNode, exp[0]) &&
190+
matchesAttributes(node.actualNode, exp[0]) &&
191+
matchesId(node.actualNode, exp[0]) &&
192+
matchesPseudos(node, exp[0])
193+
);
194+
}
195+
196+
matchExpressions = function (domTree, expressions, recurse, parentShadowId, filter) {
197+
//jshint maxstatements:34
198+
//jshint maxcomplexity:15
199+
let stack = [];
200+
let nodes = Array.isArray(domTree) ? domTree : [domTree];
201+
let currentLevel = createLocalVariables(nodes, expressions, [], parentShadowId);
202+
let result = [];
203+
204+
while (currentLevel.nodes.length) {
205+
let node = currentLevel.nodes.pop();
206+
let childOnly = []; // we will add hierarchical '>' selectors here
207+
let childAny = [];
208+
let combined = currentLevel.anyLevel.slice().concat(currentLevel.thisLevel);
209+
let added = false;
210+
// see if node matches
211+
for ( let i = 0; i < combined.length; i++) {
212+
let exp = combined[i];
213+
if (matchesSelector(node, exp) &&
214+
(!exp[0].id || node.shadowId === currentLevel.parentShadowId)) {
215+
if (exp.length === 1) {
216+
if (!added && (!filter || filter(node))) {
217+
result.push(node);
218+
added = true;
219+
}
220+
} else {
221+
let rest = exp.slice(1);
222+
if ([' ', '>'].includes(rest[0].combinator) === false) {
223+
throw new Error('axe.utils.querySelectorAll does not support the combinator: ' + exp[1].combinator);
224+
}
225+
if (rest[0].combinator === '>') {
226+
// add the rest to the childOnly array
227+
childOnly.push(rest);
228+
} else {
229+
// add the rest to the childAny array
230+
childAny.push(rest);
231+
}
232+
}
204233
}
205-
candidates = candidates.reduce((result, node) => {
206-
return result.concat(matchSelector(index ? node.children : node, exp, recurse));
207-
}, []);
208-
});
209-
210-
// Ensure elements aren't added multiple times
211-
return candidates.reduce((collected, candidate) => {
212-
if (collected.includes(candidate) === false) {
213-
collected.push(candidate);
234+
if (currentLevel.anyLevel.includes(exp) &&
235+
(!exp[0].id || node.shadowId === currentLevel.parentShadowId)) {
236+
childAny.push(exp);
214237
}
215-
return collected;
216-
}, collected);
217-
}, []);
238+
}
239+
// "recurse"
240+
if (node.children && node.children.length && recurse) {
241+
stack.push(currentLevel);
242+
currentLevel = createLocalVariables(node.children, childAny, childOnly, node.shadowId);
243+
}
244+
// check for "return"
245+
while (!currentLevel.nodes.length && stack.length) {
246+
currentLevel = stack.pop();
247+
}
248+
}
249+
return result;
218250
};
219251

220252
/**
@@ -227,9 +259,26 @@ matchExpressions = function (domTree, expressions, recurse) {
227259
* @return {NodeList} Elements matched by any of the selectors
228260
*/
229261
axe.utils.querySelectorAll = function (domTree, selector) {
262+
return axe.utils.querySelectorAllFilter(domTree, selector);
263+
};
264+
265+
/**
266+
* querySelectorAllFilter implements querySelectorAll on the virtual DOM with
267+
* ability to filter the returned nodes using an optional supplied filter function
268+
*
269+
* @method querySelectorAll
270+
* @memberof axe.utils
271+
* @instance
272+
* @param {NodeList} domTree flattened tree collection to search
273+
* @param {String} selector String containing one or more CSS selectors separated by commas
274+
* @param {Function} filter function (optional)
275+
* @return {NodeList} Elements matched by any of the selectors and filtered by the filter function
276+
*/
277+
278+
axe.utils.querySelectorAllFilter = function (domTree, selector, filter) {
230279
domTree = Array.isArray(domTree) ? domTree : [domTree];
231280
var expressions = axe.utils.cssParser.parse(selector);
232281
expressions = expressions.selectors ? expressions.selectors : [expressions];
233282
expressions = convertExpressions(expressions);
234-
return matchExpressions(domTree, expressions, true);
283+
return matchExpressions(domTree, expressions, true, domTree[0].shadowId, filter);
235284
};

lib/core/utils/select.js

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,22 @@ function pushNode(result, nodes, context) {
7373
return result;
7474
}
7575

76+
/**
77+
* returns true if any of the nodes in the list is a parent of another node in the list
78+
* @param {Array} the array of include nodes
79+
* @return {Boolean}
80+
*/
81+
function hasOverlappingIncludes(includes) {
82+
let list = includes.slice();
83+
while (list.length > 1) {
84+
let last = list.pop();
85+
if (list[list.length - 1].actualNode.contains(last.actualNode)) {
86+
return true;
87+
}
88+
}
89+
return false;
90+
}
91+
7692
/**
7793
* Selects elements which match `selector` that are included and excluded via the `Context` object
7894
* @param {String} selector CSS selector of the HTMLElements to select
@@ -83,6 +99,10 @@ axe.utils.select = function select(selector, context) {
8399
'use strict';
84100

85101
var result = [], candidate;
102+
if (!Array.isArray(context.include)) {
103+
context.include = Array.from(context.include);
104+
}
105+
context.include.sort(axe.utils.nodeSorter); // ensure that the order of the include nodes is document order
86106
for (var i = 0, l = context.include.length; i < l; i++) {
87107
candidate = context.include[i];
88108
if (candidate.actualNode.nodeType === candidate.actualNode.ELEMENT_NODE &&
@@ -91,6 +111,8 @@ axe.utils.select = function select(selector, context) {
91111
}
92112
result = pushNode(result, axe.utils.querySelectorAll(candidate, selector), context);
93113
}
94-
95-
return result.sort(axe.utils.nodeSorter);
114+
if (context.include.length > 1 && hasOverlappingIncludes(context.include)) {
115+
result.sort(axe.utils.nodeSorter);
116+
}
117+
return result;
96118
};

test/core/utils/qsa.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,10 @@ describe('axe.utils.querySelectorAll', function () {
109109
var result = axe.utils.querySelectorAll(dom, '#one');
110110
assert.equal(result.length, 1);
111111
});
112+
it('should find nodes using id, but not in shadow DOM', function () {
113+
var result = axe.utils.querySelectorAll(dom[0].children[0], '#one');
114+
assert.equal(result.length, 1);
115+
});
112116
it('should find nodes using id, within a shadow DOM', function () {
113117
var result = axe.utils.querySelectorAll(dom[0].children[0].children[2], '#one');
114118
assert.equal(result.length, 1);
@@ -182,4 +186,10 @@ describe('axe.utils.querySelectorAll', function () {
182186
assert.isBelow(divOnes.length, divs.length + ones.length,
183187
'Elements matching both parts of a selector should not be included twice');
184188
});
189+
it('should return nodes sorted by document position', function () {
190+
var result = axe.utils.querySelectorAll(dom, 'ul, #one');
191+
assert.equal(result[0].actualNode.nodeName, 'UL');
192+
assert.equal(result[1].actualNode.nodeName, 'DIV');
193+
assert.equal(result[2].actualNode.nodeName, 'UL');
194+
});
185195
});

test/core/utils/select.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,18 @@ describe('axe.utils.select', function () {
136136

137137
});
138138

139+
it('should sort by DOM order on overlapping elements', function () {
140+
fixture.innerHTML = '<div id="zero"><div id="one"><div id="target1" class="bananas"></div></div>' +
141+
'<div id="two"><div id="target2" class="bananas"></div></div></div>';
142+
143+
var result = axe.utils.select('.bananas', { include: [axe.utils.getFlattenedTree($id('one'))[0],
144+
axe.utils.getFlattenedTree($id('zero'))[0]] });
145+
146+
assert.deepEqual(result.map(function (n) { return n.actualNode; }),
147+
[$id('target1'), $id('target1'), $id('target2')]);
148+
assert.equal(result.length, 3);
149+
150+
});
139151

140152

141153
});

0 commit comments

Comments
 (0)