Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] fix(qsa): greatly improve qsa performance #2679

Closed
wants to merge 14 commits into from
8 changes: 8 additions & 0 deletions lib/core/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ import v2Reporter from './reporters/v2';

import * as commons from '../commons';
import * as utils from './utils';
import {
cacheNodeSelectors,
getNodesMatchingSelector
} from './utils/selector-cache';

axe.constants = constants;
axe.log = log;
Expand All @@ -59,6 +63,10 @@ axe._thisWillBeDeletedDoNotUse.base = {
Rule,
metadataFunctionMap
};
axe._thisWillBeDeletedDoNotUse.utils = {
cacheNodeSelectors,
getNodesMatchingSelector
};

axe.imports = imports;

Expand Down
36 changes: 28 additions & 8 deletions lib/core/utils/get-flattened-tree.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import isShadowRoot from './is-shadow-root';
import VirtualNode from '../base/virtual-node/virtual-node';
import cache from '../base/cache';
import { cacheNodeSelectors } from './selector-cache';

/**
* This implemnts the flatten-tree algorithm specified:
Expand Down Expand Up @@ -38,6 +39,22 @@ function getSlotChildren(node) {
return retVal;
}

/**
* Create a virtual node
* @param {Node} node the current node
* @param {VirtualNode} parent the parent VirtualNode
* @param {String} shadowId, optional ID of the shadow DOM that is the closest shadow ancestor of the node
* @return {VirtualNode}
*/
let nodeIndex = 0;
function createNode(node, parent, shadowId) {
const retVal = new VirtualNode(node, parent, shadowId);
cacheNodeSelectors(retVal, nodeIndex);
nodeIndex++;

return retVal;
}

/**
* Recursvely returns an array of the virtual DOM nodes at this level
* excluding comment nodes and the shadow DOM nodes <content> and <slot>
Expand Down Expand Up @@ -67,7 +84,7 @@ function flattenTree(node, shadowId, parent) {
if (isShadowRoot(node)) {
// generate an ID for this shadow root and overwrite the current
// closure shadowId with this value so that it cascades down the tree
retVal = new VirtualNode(node, parent, shadowId);
retVal = createNode(node, parent, shadowId);
shadowId =
'a' +
Math.random()
Expand Down Expand Up @@ -102,7 +119,7 @@ function flattenTree(node, shadowId, parent) {
if (false && styl.display !== 'contents') {
// intentionally commented out
// has a box
retVal = new VirtualNode(node, parent, shadowId);
retVal = createNode(node, parent, shadowId);
retVal.children = realArray.reduce((res, child) => {
return reduceShadowDOM(res, child, retVal);
}, []);
Expand All @@ -115,7 +132,7 @@ function flattenTree(node, shadowId, parent) {
}
} else {
if (node.nodeType === 1) {
retVal = new VirtualNode(node, parent, shadowId);
retVal = createNode(node, parent, shadowId);
realArray = Array.from(node.childNodes);
retVal.children = realArray.reduce((res, child) => {
return reduceShadowDOM(res, child, retVal);
Expand All @@ -124,28 +141,31 @@ function flattenTree(node, shadowId, parent) {
return [retVal];
} else if (node.nodeType === 3) {
// text
return [new VirtualNode(node, parent)];
return [createNode(node, parent)];
}
return undefined;
}
}
}

/**
* Recursvely returns an array of the virtual DOM nodes at this level
* Recursively returns an array of the virtual DOM nodes at this level
* excluding comment nodes and the shadow DOM nodes <content> and <slot>
*
* @param {Node} [node=document.documentElement] optional node. NOTE: passing in anything other than body or the documentElement may result in incomplete results.
* @param {String} [shadowId] optional ID of the shadow DOM that is the closest shadow
* ancestor of the node
* @param {String} [shadowId] optional ID of the shadow DOM that is the closest shadow ancestor of the node
*/
function getFlattenedTree(node = document.documentElement, shadowId) {
cache.set('nodeMap', new WeakMap());

// reset nodeIndex for every flatten tree call
nodeIndex = 0;

// specifically pass `null` to the parent to designate the top
// node of the tree. if parent === undefined then we know
// we are in a disconnected tree
return flattenTree(node, shadowId, null);
const tree = flattenTree(node, shadowId, null);
return tree;
}

export default getFlattenedTree;
4 changes: 4 additions & 0 deletions lib/core/utils/matches.js
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,10 @@ export function convertSelector(selector) {
* @returns {Boolean}
*/
export function matchesExpression(vNode, expressions, matchAnyParent) {
if (!vNode) {
return false;
}

const exps = [].concat(expressions);
const expression = exps.pop();
let matches = matchExpression(vNode, expression);
Expand Down
12 changes: 12 additions & 0 deletions lib/core/utils/query-selector-all-filter.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { matchesExpression, convertSelector } from './matches';
import { getNodesMatchingSelector } from './selector-cache';

function createLocalVariables(vNodes, anyLevel, thisLevel, parentShadowId) {
const retVal = {
Expand Down Expand Up @@ -97,6 +98,17 @@ function matchExpressions(domTree, expressions, filter) {
*/
function querySelectorAllFilter(domTree, selector, filter) {
domTree = Array.isArray(domTree) ? domTree : [domTree];

// see if the passed in node is the root node of the tree and can
// find nodes using the cache rather than looping through the
// the entire tree
const nodes = getNodesMatchingSelector(domTree, selector, filter);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From the look of it, you might as well pass in the expressions variable instead of the selector. That way you're not converting it twice, and it makes for a more consistent function signature.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passing an expression makes testing harder as instead of a simple string I need to parse the selector into the expression first then pass it to the function. I don't feel that passing an expression makes it any more consistent either.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const nodes = getNodesMatchingSelector(domTree, selector, filter);
if (axe._tree === domTree) {
return matchSelectorOnTreeRoot(domTree, selector, filter);
}

if (nodes) {
return nodes;
}

// if the selector cache is not set up or if not passed the
// top level node we default back to parsing the whole tree
const expressions = convertSelector(selector);
return matchExpressions(domTree, expressions, filter);
}
Expand Down
107 changes: 107 additions & 0 deletions lib/core/utils/selector-cache.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { convertSelector, matchesExpression } from './matches';

let selectorMap = {};

function cacheSelector(key, vNode) {
selectorMap[key] = selectorMap[key] || [];
selectorMap[key].push(vNode);
}

/**
* Cache selector information about a VirtalNode
* @param {VirtualNode} vNode
*/
export function cacheNodeSelectors(vNode, nodeIndex = 0) {
if (vNode.props.nodeType !== 1) {
return;
}

// node index is used for sorting nodes by their DOM order
// since multiple expressions can find DOM nodes out of order
vNode._nodeIndex = nodeIndex;

// cache the selector map to the root node of the tree
if (nodeIndex === 0) {
selectorMap = {};
vNode._selectorMap = selectorMap;
}

cacheSelector(vNode.props.nodeName, vNode);
cacheSelector('*', vNode);

vNode.attrNames.forEach(attrName => {
cacheSelector(`[${attrName}]`, vNode);
});
}

/**
* Get nodes from the selector cache that match the selector
* @param {VirtualTree[]} domTree flattened tree collection to search
* @param {String} selector
* @param {Function} filter function (optional)
* @return {Mixed} Array of nodes that match the selector or undefined if the selector map is not setup
*/
export function getNodesMatchingSelector(domTree, selector, filter) {
// check to see if the domTree is the root and has the selector
// map. if not we just return and let our QSA code do the finding
const selectorMap = domTree[0]._selectorMap;
if (!selectorMap) {
return;
}
Comment on lines +47 to +50
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const selectorMap = domTree[0]._selectorMap;
if (!selectorMap) {
return;
}
let selectorMap = cache.get('selectorMap')
if (!selectorMap || cache.get('selectorMapRoot') !== domTree) {
selectorMap = getSelectorMap(domTree)
cache.set('selectorMap', selectorMap)
cache.set('selectorMapRoot', domTree)
}


const shadowId = domTree[0].shadowId;
const expressions = convertSelector(selector);
let matchedNodes = [];

// find nodes that match just a part of the selector in order
// to speed up traversing the entire tree
expressions.forEach(expression => {
// use the last part of the expression to find nodes as it's more
// specific. e.g. for `body h1` use `h1` and not `body`
const exp = expression[expression.length - 1];

// the expression `[id]` will use `*` as the tag name
const isGlobalSelector =
exp.tag === '*' && !exp.attributes && !exp.id && !exp.classes;
let nodes = [];

if (isGlobalSelector && selectorMap['*']) {
nodes = selectorMap['*'];
}
// for `h1[role]` we want to use the tag name as it is more
// specific than using all nodes with the role attribute
else if (exp.tag && exp.tag !== '*' && selectorMap[exp.tag]) {
nodes = selectorMap[exp.tag];
} else if (exp.id && selectorMap['[id]']) {
// when using id selector (#one) we should only select nodes
// that match the shadowId of the root
nodes = selectorMap['[id]'].filter(node => node.shadowId === shadowId);
} else if (exp.classes && selectorMap['[class]']) {
nodes = selectorMap['[class]'];
} else if (exp.attributes) {
// break once we find nodes that match any of the attributes
for (let i = 0; i < exp.attributes.length; i++) {
const attrName = exp.attributes[i].key;
if (selectorMap['['.concat(attrName, ']')]) {
nodes = selectorMap['['.concat(attrName, ']')];
break;
}
}
}

// now that we have a list of all nodes that match a part of
// the expression we need to check if the node actually matches
// the entire expression
nodes.forEach(node => {
if (matchesExpression(node, expression) && !matchedNodes.includes(node)) {
matchedNodes.push(node);
}
});
});

if (filter) {
matchedNodes = matchedNodes.filter(filter);
}

return matchedNodes.sort((a, b) => a._nodeIndex - b._nodeIndex);
}
7 changes: 7 additions & 0 deletions test/core/utils/flattened-tree.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,13 @@ describe('axe.utils.getFlattenedTree', function() {
assert(tree[0].parent === null);
});

it('should cache selectors', function() {
fixture.innerHTML = '<div></div><span></span><main></main>';
var tree = axe.utils.getFlattenedTree(fixture);
assert.exists(tree[0]._selectorMap);
assert.lengthOf(tree[0]._selectorMap['*'], 4);
});

if (shadowSupport.v0) {
describe('shadow DOM v0', function() {
afterEach(function() {
Expand Down
5 changes: 5 additions & 0 deletions test/core/utils/matches.js
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,11 @@ describe('utils.matches', function() {
assert.isFalse(matches(virtualNode, 'div span > td h1'));
});

it('returns false if node does not have parent to match', function() {
queryFixture('<span id="target" class="foo bar baz"></span>');
assert.isFalse(matches(axe._tree[0], 'html *'));
});

it('throws error if combinator is not implemented', function() {
var virtualNode = queryFixture('<div></div><h1 id="target">foo</h1>');
assert.throws(function() {
Expand Down
Loading