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

feat(context): allow selecting shadow DOM nodes #3798

Merged
merged 15 commits into from
Dec 1, 2022
1 change: 1 addition & 0 deletions lib/core/base/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export default function Context(spec, flatTree) {
if (!Array.isArray(this.include)) {
this.include = Array.from(this.include);
}
// TODO: Should I be sorting frames and exclude?
this.include.sort(nodeSorter); // ensure that the order of the include nodes is document order
}

Expand Down
69 changes: 29 additions & 40 deletions lib/core/base/context/normalize-context.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,48 +5,37 @@
* @return {Object} Normalized context spec to include both `include` and `exclude` arrays
*/
export function normalizeContext(context) {
// typeof NodeList.length in PhantomJS === function
if (
(context && typeof context === 'object') ||
context instanceof window.NodeList
) {
if (context instanceof window.Node) {
return {
include: [context],
exclude: []
};
}

if (
context.hasOwnProperty('include') ||
context.hasOwnProperty('exclude')
) {
return {
include:
context.include && +context.include.length
? context.include
: [document],
exclude: context.exclude || []
};
}

if (context.length === +context.length) {
return {
include: context,
exclude: []
};
}
if (!context) {
return defaultContext();
}

if (typeof context === 'string') {
return {
include: [[context]],
exclude: []
};
return defaultContext({ include: [[context]] });
}
if (context instanceof window.Node) {
return defaultContext({ include: [context] });
}
if (context instanceof window.NodeList) {
return defaultContext({ include: context });
}
if (typeof context === 'object' && context.length === +context.length) {
// TODO: validate the content
return defaultContext({ include: context });
}

if (
typeof context === 'object' &&
(context.hasOwnProperty('include') || context.hasOwnProperty('exclude'))
) {
const exclude =
context.exclude && +context.exclude.length ? context.exclude : [];
const include =
context.include && +context.include.length ? context.include : [document];
return { include, exclude };
}

return {
include: [document],
exclude: []
};
return defaultContext();
}

const defaultContext = ({ include = [document], exclude = [] } = {}) => {
return { include, exclude };
};
14 changes: 8 additions & 6 deletions lib/core/base/context/parse-selector-array.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createFrameContext } from './create-frame-context';
import { getNodeFromTree } from '../../utils';
import { getNodeFromTree, shadowSelectAll } from '../../utils';

/**
* Finds frames in context, converts selectors to Element references and pushes unique frames
Expand All @@ -9,12 +9,14 @@ import { getNodeFromTree } from '../../utils';
* @return {Array} Parsed array of matching elements
*/
export function parseSelectorArray(context, type) {
// TODO: instead of modifying context, this should return things context can used to modify itself
const result = [];
for (let i = 0, l = context[type].length; i < l; i++) {
const item = context[type][i];
// selector
// TODO: This is unnecessary if we properly normalize
if (typeof item === 'string') {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This cannot happen. After normalising, this is always either a node, or an array..

const nodeList = Array.from(document.querySelectorAll(item));
const nodeList = shadowSelectAll(item);
result.push(...nodeList.map(node => getNodeFromTree(node)));
break;
}
Expand All @@ -34,7 +36,7 @@ export function parseSelectorArray(context, type) {
if (item.length > 1) {
pushUniqueFrameSelector(context, type, item);
} else {
const nodeList = Array.from(document.querySelectorAll(item[0]));
const nodeList = shadowSelectAll(item[0]);
result.push(...nodeList.map(node => getNodeFromTree(node)));
}
}
Expand All @@ -56,9 +58,9 @@ function pushUniqueFrameSelector(context, type, selectorArray) {
context.frames = context.frames || [];

const frameSelector = selectorArray.shift();
const frames = document.querySelectorAll(frameSelector);

Array.from(frames).forEach(frame => {
const frames = shadowSelectAll(frameSelector);
frames.forEach(frame => {
// TODO: This does not need to loop twice. This can work with just the .find
context.frames.forEach(contextFrame => {
if (contextFrame.node === frame) {
contextFrame[type].push(selectorArray);
Expand Down
2 changes: 2 additions & 0 deletions lib/core/public/load.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ function runCommand(data, keepalive, callback) {
};

var context = (data && data.context) || {};
// TODO: Could maybe replace this by using { include: [[":root"]] }
// in createFrameContext
if (context.hasOwnProperty('include') && !context.include.length) {
context.include = [document];
}
Expand Down
1 change: 1 addition & 0 deletions lib/core/public/run-virtual-rule.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ function runVirtualRule(ruleId, vNode, options = {}) {
// elements
rule = Object.create(rule, { excludeHidden: { value: false } });

// TODO: This is missing props. Might be a problem
const context = {
initiator: true,
include: [vNode]
Expand Down
3 changes: 3 additions & 0 deletions lib/core/public/run/normalize-run-params.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,11 @@ export default function normalizeRunParams([context, options, callback]) {
}

export function isContext(potential) {
// TODO: Need to allow labelled contexts here
// TODO: Should this function live somewhere else?
switch (true) {
case typeof potential === 'string':
// TODO: Should this allow other iterables?
case Array.isArray(potential):
case window.Node && potential instanceof window.Node:
case window.NodeList && potential instanceof window.NodeList:
Expand Down
44 changes: 18 additions & 26 deletions lib/core/utils/contains.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,33 +7,25 @@
* @return {Boolean} Whether `vNode` contains `otherVNode`
*/
export default function contains(vNode, otherVNode) {
/*eslint no-bitwise: 0*/
if (vNode.shadowId || otherVNode.shadowId) {
do {
if (vNode.shadowId === otherVNode.shadowId) {
return true;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This was wrong. We never caught it because contains is never called with vNode as a node in the shadow DOM tree.

}
otherVNode = otherVNode.parent;
} while (otherVNode);
return false;
// Native light DOM method
if (
!vNode.shadowId &&
!otherVNode.shadowId &&
vNode.actualNode &&
typeof vNode.actualNode.contains === 'function'
) {
return vNode.actualNode.contains(otherVNode.actualNode);
}

if (!vNode.actualNode) {
// fallback for virtualNode only contexts
// @see https://github.com/Financial-Times/polyfill-service/pull/183/files
do {
if (otherVNode === vNode) {
return true;
}
otherVNode = otherVNode.parent;
} while (otherVNode);
}
// Alternative method for shadow DOM / virtual tree tests
do {
if (vNode === otherVNode) {
return true;
} else if (otherVNode.nodeIndex < vNode.nodeIndex) {
WilcoFiers marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this just a short circuit so you don't have to navigate up the entire parent tree?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

jup

return false;
}
otherVNode = otherVNode.parent;
} while (otherVNode);

if (typeof vNode.actualNode.contains !== 'function') {
const position = vNode.actualNode.compareDocumentPosition(
otherVNode.actualNode
);
return !!(position & 16);
}
return vNode.actualNode.contains(otherVNode.actualNode);
return false;
}
1 change: 1 addition & 0 deletions lib/core/utils/is-node-in-context.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ function getDeepest(collection) {
* @return {Boolean} [description]
*/
function isNodeInContext(node, context) {
// TODO: Tests that prove this works with shadow DOM
const include =
context.include &&
getDeepest(
Expand Down
85 changes: 40 additions & 45 deletions lib/core/utils/select.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,55 +2,13 @@ import contains from './contains';
import querySelectorAllFilter from './query-selector-all-filter';
import isNodeInContext from './is-node-in-context';

/**
* Pushes unique nodes that are in context to an array
* @private
* @param {Array} result The array to push to
* @param {Array} nodes The list of nodes to push
* @param {Object} context The "resolved" context object, @see resolveContext
*/
function pushNode(result, nodes) {
let temp;

if (result.length === 0) {
return nodes;
}
if (result.length < nodes.length) {
// switch so the comparison is shortest
temp = result;
result = nodes;
nodes = temp;
}
for (let i = 0, l = nodes.length; i < l; i++) {
if (!result.includes(nodes[i])) {
result.push(nodes[i]);
}
}
return result;
}

/**
* reduces the includes list to only the outermost includes
* @private
* @param {Array} the array of include nodes
* @return {Array} the modified array of nodes
*/
function getOuterIncludes(includes) {
return includes.reduce((res, el) => {
if (!res.length || !contains(res[res.length - 1], el)) {
res.push(el);
}
return res;
}, []);
}

/**
* Selects elements which match `selector` that are included and excluded via the `Context` object
* @param {String} selector CSS selector of the HTMLElements to select
* @param {Context} context The "resolved" context object, @see Context
* @return {Array} Matching virtual DOM nodes sorted by DOM order
*/
function select(selector, context) {
export default function select(selector, context) {
let result = [];
let candidate;
if (axe._selectCache) {
Expand All @@ -69,7 +27,7 @@ function select(selector, context) {
for (let i = 0; i < outerIncludes.length; i++) {
candidate = outerIncludes[i];
const nodes = querySelectorAllFilter(candidate, selector, isInContext);
result = pushNode(result, nodes);
result = mergeArrayUniques(result, nodes);
}
if (axe._selectCache) {
axe._selectCache.push({
Expand All @@ -80,7 +38,20 @@ function select(selector, context) {
return result;
}

export default select;
/**
* reduces the includes list to only the outermost includes
* @private
* @param {Array} the array of include nodes
* @return {Array} the modified array of nodes
*/
function getOuterIncludes(includes) {
return includes.reduce((res, el) => {
if (!res.length || !contains(res[res.length - 1], el)) {
res.push(el);
}
return res;
}, []);
}

/**
* Return a filter method to test if a node is in context; or
Expand All @@ -97,3 +68,27 @@ function getContextFilter(context) {
}
return node => isNodeInContext(node, context);
}

/**
* Merge the unique items from Array 1 into 2, or from 2 into 1 (whichever is longest)
* @private
* @param {Array} Arr1
* @param {Array} Arr2
*/
function mergeArrayUniques(arr1, arr2) {
if (arr1.length === 0) {
return arr2;
}
if (arr1.length < arr2.length) {
// switch so the comparison is shortest
const temp = arr1;
arr1 = arr2;
arr2 = temp;
}
for (let i = 0, l = arr2.length; i < l; i++) {
if (!arr1.includes(arr2[i])) {
arr1.push(arr2[i]);
}
}
return arr1;
}
Loading