From 32749195f43b2b625e275887fd6ba620658a1ca3 Mon Sep 17 00:00:00 2001 From: Dylan Barrell Date: Sat, 3 Feb 2018 15:32:43 -0500 Subject: [PATCH] fix(perf): improve select performance fixes #702 --- lib/core/utils/qsa.js | 133 ++++++++++++++++++++++++++------------ lib/core/utils/select.js | 26 +++++++- test/core/utils/qsa.js | 10 +++ test/core/utils/select.js | 12 ++++ 4 files changed, 137 insertions(+), 44 deletions(-) diff --git a/lib/core/utils/qsa.js b/lib/core/utils/qsa.js index 1a6db08905..ac32c21ad7 100644 --- a/lib/core/utils/qsa.js +++ b/lib/core/utils/qsa.js @@ -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)) { @@ -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 MIT License */ var from = /(?=[\-\[\]{}()*+?.\\\^$|,#\s])/g; @@ -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; }; /** @@ -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); }; diff --git a/lib/core/utils/select.js b/lib/core/utils/select.js index 029db974cf..a751df14ab 100644 --- a/lib/core/utils/select.js +++ b/lib/core/utils/select.js @@ -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 @@ -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 && @@ -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; }; diff --git a/test/core/utils/qsa.js b/test/core/utils/qsa.js index d9483cc267..18109b25b9 100644 --- a/test/core/utils/qsa.js +++ b/test/core/utils/qsa.js @@ -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); @@ -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'); + }); }); diff --git a/test/core/utils/select.js b/test/core/utils/select.js index 48bff29677..2ac60392a6 100644 --- a/test/core/utils/select.js +++ b/test/core/utils/select.js @@ -136,6 +136,18 @@ describe('axe.utils.select', function () { }); + it('should sort by DOM order on overlapping elements', function () { + fixture.innerHTML = '
' + + '
'; + + 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); + + }); }); \ No newline at end of file