Skip to content

Commit

Permalink
fix(perf): improve select performance fixes #702
Browse files Browse the repository at this point in the history
  • Loading branch information
dylanb committed Feb 3, 2018
1 parent 0fe74d8 commit 3274919
Show file tree
Hide file tree
Showing 4 changed files with 137 additions and 44 deletions.
133 changes: 91 additions & 42 deletions lib/core/utils/qsa.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ function matchesPseudos (target, exp) {

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

function matchSelector (targets, exp, recurse) {
var result = [];

targets = Array.isArray(targets) ? targets : [targets];
targets.forEach((target) => {
if (matchesTag(target.actualNode, exp) &&
matchesClasses(target.actualNode, exp) &&
matchesAttributes(target.actualNode, exp) &&
matchesId(target.actualNode, exp) &&
matchesPseudos(target, exp)) {
result.push(target);
}
if (recurse) {
result = result.concat(matchSelector(target.children.filter((child) => {
return !exp.id || child.shadowId === target.shadowId;
}), exp, recurse));
}
});
return result;
}

var escapeRegExp = (function(){
/*! Credit: XRegExp 0.6.1 (c) 2007-2008 Steven Levithan <http://stevenlevithan.com/regex/xregexp/> MIT License */
var from = /(?=[\-\[\]{}()*+?.\\\^$|,#\s])/g;
Expand Down Expand Up @@ -194,27 +173,80 @@ convertExpressions = function (expressions) {
});
};

matchExpressions = function (domTree, expressions, recurse) {
return expressions.reduce((collected, exprArr) => {
var candidates = domTree;
exprArr.forEach((exp, index) => {
recurse = exp.combinator === '>' ? false : recurse;
if ([' ', '>'].includes(exp.combinator) === false) {
throw new Error('axe.utils.querySelectorAll does not support the combinator: ' + exp.combinator);
function createLocalVariables (nodes, anyLevel, thisLevel, parentShadowId) {
let retVal = {
nodes: nodes.slice(),
anyLevel: anyLevel,
thisLevel: thisLevel,
parentShadowId: parentShadowId
};
retVal.nodes.reverse();
return retVal;
}

function matchesSelector (node, exp) {
return (matchesTag(node.actualNode, exp[0]) &&
matchesClasses(node.actualNode, exp[0]) &&
matchesAttributes(node.actualNode, exp[0]) &&
matchesId(node.actualNode, exp[0]) &&
matchesPseudos(node, exp[0])
);
}

matchExpressions = function (domTree, expressions, recurse, parentShadowId, filter) {
//jshint maxstatements:34
//jshint maxcomplexity:15
let stack = [];
let nodes = Array.isArray(domTree) ? domTree : [domTree];
let currentLevel = createLocalVariables(nodes, expressions, [], parentShadowId);
let result = [];

while (currentLevel.nodes.length) {
let node = currentLevel.nodes.pop();
let childOnly = []; // we will add hierarchical '>' selectors here
let childAny = [];
let combined = currentLevel.anyLevel.slice().concat(currentLevel.thisLevel);
let added = false;
// see if node matches
for ( let i = 0; i < combined.length; i++) {
let exp = combined[i];
if (matchesSelector(node, exp) &&
(!exp[0].id || node.shadowId === currentLevel.parentShadowId)) {
if (exp.length === 1) {
if (!added && (!filter || filter(node))) {
result.push(node);
added = true;
}
} else {
let rest = exp.slice(1);
if ([' ', '>'].includes(rest[0].combinator) === false) {
throw new Error('axe.utils.querySelectorAll does not support the combinator: ' + exp[1].combinator);
}
if (rest[0].combinator === '>') {
// add the rest to the childOnly array
childOnly.push(rest);
} else {
// add the rest to the childAny array
childAny.push(rest);
}
}
}
candidates = candidates.reduce((result, node) => {
return result.concat(matchSelector(index ? node.children : node, exp, recurse));
}, []);
});

// Ensure elements aren't added multiple times
return candidates.reduce((collected, candidate) => {
if (collected.includes(candidate) === false) {
collected.push(candidate);
if (currentLevel.anyLevel.includes(exp) &&
(!exp[0].id || node.shadowId === currentLevel.parentShadowId)) {
childAny.push(exp);
}
return collected;
}, collected);
}, []);
}
// "recurse"
if (node.children && node.children.length && recurse) {
stack.push(currentLevel);
currentLevel = createLocalVariables(node.children, childAny, childOnly, node.shadowId);
}
// check for "return"
while (!currentLevel.nodes.length && stack.length) {
currentLevel = stack.pop();
}
}
return result;
};

/**
Expand All @@ -227,9 +259,26 @@ matchExpressions = function (domTree, expressions, recurse) {
* @return {NodeList} Elements matched by any of the selectors
*/
axe.utils.querySelectorAll = function (domTree, selector) {
return axe.utils.querySelectorAllFilter(domTree, selector);
};

/**
* querySelectorAllFilter implements querySelectorAll on the virtual DOM with
* ability to filter the returned nodes using an optional supplied filter function
*
* @method querySelectorAll
* @memberof axe.utils
* @instance
* @param {NodeList} domTree flattened tree collection to search
* @param {String} selector String containing one or more CSS selectors separated by commas
* @param {Function} filter function (optional)
* @return {NodeList} Elements matched by any of the selectors and filtered by the filter function
*/

axe.utils.querySelectorAllFilter = function (domTree, selector, filter) {
domTree = Array.isArray(domTree) ? domTree : [domTree];
var expressions = axe.utils.cssParser.parse(selector);
expressions = expressions.selectors ? expressions.selectors : [expressions];
expressions = convertExpressions(expressions);
return matchExpressions(domTree, expressions, true);
return matchExpressions(domTree, expressions, true, domTree[0].shadowId, filter);
};
26 changes: 24 additions & 2 deletions lib/core/utils/select.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,22 @@ function pushNode(result, nodes, context) {
return result;
}

/**
* returns true if any of the nodes in the list is a parent of another node in the list
* @param {Array} the array of include nodes
* @return {Boolean}
*/
function hasOverlappingIncludes(includes) {
let list = includes.slice();
while (list.length > 1) {
let last = list.pop();
if (list[list.length - 1].actualNode.contains(last.actualNode)) {
return true;
}
}
return false;
}

/**
* Selects elements which match `selector` that are included and excluded via the `Context` object
* @param {String} selector CSS selector of the HTMLElements to select
Expand All @@ -83,6 +99,10 @@ axe.utils.select = function select(selector, context) {
'use strict';

var result = [], candidate;
if (!Array.isArray(context.include)) {
context.include = Array.from(context.include);
}
context.include.sort(axe.utils.nodeSorter); // ensure that the order of the include nodes is document order
for (var i = 0, l = context.include.length; i < l; i++) {
candidate = context.include[i];
if (candidate.actualNode.nodeType === candidate.actualNode.ELEMENT_NODE &&
Expand All @@ -91,6 +111,8 @@ axe.utils.select = function select(selector, context) {
}
result = pushNode(result, axe.utils.querySelectorAll(candidate, selector), context);
}

return result.sort(axe.utils.nodeSorter);
if (context.include.length > 1 && hasOverlappingIncludes(context.include)) {
result.sort(axe.utils.nodeSorter);
}
return result;
};
10 changes: 10 additions & 0 deletions test/core/utils/qsa.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@ describe('axe.utils.querySelectorAll', function () {
var result = axe.utils.querySelectorAll(dom, '#one');
assert.equal(result.length, 1);
});
it('should find nodes using id, but not in shadow DOM', function () {
var result = axe.utils.querySelectorAll(dom[0].children[0], '#one');
assert.equal(result.length, 1);
});
it('should find nodes using id, within a shadow DOM', function () {
var result = axe.utils.querySelectorAll(dom[0].children[0].children[2], '#one');
assert.equal(result.length, 1);
Expand Down Expand Up @@ -182,4 +186,10 @@ describe('axe.utils.querySelectorAll', function () {
assert.isBelow(divOnes.length, divs.length + ones.length,
'Elements matching both parts of a selector should not be included twice');
});
it('should return nodes sorted by document position', function () {
var result = axe.utils.querySelectorAll(dom, 'ul, #one');
assert.equal(result[0].actualNode.nodeName, 'UL');
assert.equal(result[1].actualNode.nodeName, 'DIV');
assert.equal(result[2].actualNode.nodeName, 'UL');
});
});
12 changes: 12 additions & 0 deletions test/core/utils/select.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,18 @@ describe('axe.utils.select', function () {

});

it('should sort by DOM order on overlapping elements', function () {
fixture.innerHTML = '<div id="zero"><div id="one"><div id="target1" class="bananas"></div></div>' +
'<div id="two"><div id="target2" class="bananas"></div></div></div>';

var result = axe.utils.select('.bananas', { include: [axe.utils.getFlattenedTree($id('one'))[0],
axe.utils.getFlattenedTree($id('zero'))[0]] });

assert.deepEqual(result.map(function (n) { return n.actualNode; }),
[$id('target1'), $id('target1'), $id('target2')]);
assert.equal(result.length, 3);

});


});

0 comments on commit 3274919

Please sign in to comment.