Skip to content

Commit

Permalink
fix: CSSOM generation for shadowRoot in Safari (dequelabs#1113)
Browse files Browse the repository at this point in the history
  • Loading branch information
jeeyyy authored and Daniel Aasen committed Nov 1, 2018
1 parent a979f21 commit 93d959b
Show file tree
Hide file tree
Showing 5 changed files with 323 additions and 122 deletions.
254 changes: 164 additions & 90 deletions lib/core/utils/preload-cssom.js
Original file line number Diff line number Diff line change
@@ -1,58 +1,82 @@
/**
* Returns a then(able) queue of CSSStyleSheet(s)
* @param {Object} ownerDocument document object to be inspected for stylesheets
* @param {number} timeout on network request for stylesheet that need to be externally fetched
* @param {Function} convertTextToStylesheetFn a utility function to generate a style sheet from text
* @return {Object} queue
* Make an axios get request to fetch a given resource and resolve
* @method getExternalStylesheet
* @param {Object} options an object with properties to configure the external XHR
* @property {Object} options.resolve resolve callback on queue
* @property {Object} options.reject reject callback on queue
* @property {String} options.url string representing the url of the resource to load
* @property {Object} options.rootNode document or shadowDOM root document for which to process CSSOM
* @property {Number} options.timeout timeout to about network call
* @property {Function} options.getStyleSheet a utility function to generate a style sheet for a given text content
* @property {String} options.shadowId an id if undefined denotes that given root is a shadowRoot
* @property {Number} options.priority css applied priority
* @returns resolve with stylesheet object
* @private
*/
function loadCssom({ root, shadowId }, timeout, convertTextToStylesheetFn) {
/**
* Make an axios get request to fetch a given resource and resolve
* @method getExternalStylesheet
* @private
* @param {Object} param an object with properties to configure the external XHR
* @property {Object} param.resolve resolve callback on queue
* @property {Object} param.reject reject callback on queue
* @property {String} param.url string representing the url of the resource to load
* @property {Number} param.timeout timeout to about network call
*/
function getExternalStylesheet({ resolve, reject, url }) {
axe.imports
.axios({
method: 'get',
url,
timeout
})
.then(({ data }) => {
const sheet = convertTextToStylesheetFn({
data,
isExternal: true,
shadowId,
root
});
resolve(sheet);
})
.catch(reject);
}

const q = axe.utils.queue();
function getExternalStylesheet(options) {
const {
resolve,
reject,
url,
rootNode,
timeout,
getStyleSheet,
shadowId,
priority
} = options;
axe.imports
.axios({
method: 'get',
url,
timeout
})
.then(({ data }) => {
const sheet = getStyleSheet({
data,
isExternal: true,
shadowId,
root: rootNode,
priority
});
resolve(sheet);
})
.catch(reject);
}

// handle .styleSheets non existent on certain shadowDOM root
const rootStyleSheets = root.styleSheets
? Array.from(root.styleSheets)
: null;
if (!rootStyleSheets) {
return q;
}
/**
* Get stylesheet(s) from shadowDOM
* @param {Object} documentFragment document fragment node
* @param {Function} getStyleSheet helper function to get stylesheet object
* @returns an array of stylesheet objects
*/
function getSheetsFromShadowDom(documentFragment, getStyleSheet) {
return Array.from(documentFragment.children).reduce((out, node) => {
const nodeName = node.nodeName.toUpperCase();
if (nodeName !== 'STYLE' && nodeName !== 'LINK') {
return out;
}
if (nodeName === 'STYLE') {
const dynamicSheet = getStyleSheet({ data: node.textContent });
out.push(dynamicSheet.sheet);
}
if (nodeName === 'LINK' && !node.media.includes('print')) {
const dynamicSheet = getStyleSheet({ data: node, isLink: true });
out.push(dynamicSheet.sheet);
}
return out;
}, []);
}

// convenience array fot help unique sheets if duplicated by same `href`
// both external and internal sheets
/**
* Filter a given array of stylesheet objects
* @param {Array<Object>} styleSheets array of stylesheets
* @returns an filtered array of stylesheets
*/
function filterStyleSheets(styleSheets) {
let sheetHrefs = [];

// filter out sheets, that should not be accounted for...
const sheets = rootStyleSheets.filter(sheet => {
// FILTER > sheets with the same href (if exists)
return styleSheets.filter(sheet => {
// 1) FILTER > sheets with the same href
let sheetAlreadyExists = false;
if (sheet.href) {
if (!sheetHrefs.includes(sheet.href)) {
Expand All @@ -61,81 +85,112 @@ function loadCssom({ root, shadowId }, timeout, convertTextToStylesheetFn) {
sheetAlreadyExists = true;
}
}
// FILTER > media='print'
// Note:
// Chrome does this automagically, Firefox returns every sheet
// hence the need to filter
// 2) FILTER > media='print'
const isPrintMedia = Array.from(sheet.media).includes('print');
// FILTER > disabled
// Firefox does not respect `disabled` attribute on stylesheet
// Hence decided not to filter out disabled for the time being

// return
return !isPrintMedia && !sheetAlreadyExists;
});
}

/**
* Returns a then(able) queue of CSSStyleSheet(s)
* @method loadCssom
* @private
* @param {Object} options an object with attributes essential to load CSSOM
* @property {Object} options.rootNode document or shadowDOM root document for which to process CSSOM
* @property {Number} options.rootIndex a number representing the index of the document or shadowDOM, used for priority
* @property {String} options.shadowId an id if undefined denotes that given root is a shadowRoot
* @property {Number} options.timeout abort duration for network request
* @param {Function} options.getStyleSheet a utility function to generate a style sheet for a given text content
* @return {Object} queue
*/
function loadCssom(options) {
const { rootNode, rootIndex, shadowId, getStyleSheet } = options;
const q = axe.utils.queue();
const styleSheets =
rootNode.nodeType === 11 && shadowId
? getSheetsFromShadowDom(rootNode, getStyleSheet)
: Array.from(rootNode.styleSheets);
const sheets = filterStyleSheets(styleSheets);

sheets.forEach((sheet, sheetIndex) => {
/* eslint max-statements: ["error", 20] */
const priority = [rootIndex, sheetIndex];

// iterate to decipher multi-level nested sheets if any (this is essential to retrieve styles from shadowDOM)
sheets.forEach(sheet => {
// attempt to retrieve cssRules, or for external sheets make a XMLHttpRequest
try {
// accessing .cssRules throws for external (cross-domain) sheets, which is handled in the catch
// The following line throws an error on cross-origin style sheets:
const cssRules = sheet.cssRules;
// read all css rules in the sheet
const rules = Array.from(cssRules);
if (!rules.length) {
return;
}

// filter rules that are included by way of @import or nested link
const importRules = rules.filter(r => r.href);

// if no import or nested link rules, with in these cssRules
// return current sheet
if (!importRules.length) {
q.defer(resolve =>
resolve({
sheet,
isExternal: false,
shadowId,
root
root: rootNode,
priority
})
);
return;
}

// if any import rules exists, fetch via `href` which eventually constructs a sheet with results from resource
// for import rules, fetch via `href`
importRules.forEach(rule => {
q.defer((resolve, reject) => {
getExternalStylesheet({ resolve, reject, url: rule.href });
getExternalStylesheet({
resolve,
reject,
url: rule.href,
priority,
...options
});
});
});

// in the same sheet - get inline rules in <style> tag or in a CSSStyleSheet excluding @import or nested link
const inlineRules = rules.filter(rule => !rule.href);
if (!inlineRules.length) {
return;
}

// concat all cssText into a string for inline rules
// concat all cssText into a string for inline rules & create sheet
const inlineRulesCssText = inlineRules
.reduce((out, rule) => {
out.push(rule.cssText);
return out;
}, [])
.join();
// create and return a sheet with inline rules
q.defer(resolve =>
resolve(
convertTextToStylesheetFn({
getStyleSheet({
data: inlineRulesCssText,
shadowId,
root,
isExternal: false
root: rootNode,
isExternal: false,
priority
})
)
);
} catch (e) {
// external sheet -> make an xhr and q the response
q.defer((resolve, reject) => {
getExternalStylesheet({ resolve, reject, url: sheet.href });
getExternalStylesheet({
resolve,
reject,
url: sheet.href,
priority,
...options
});
});
}
}, []);
// return
});

return q;
}

Expand All @@ -158,7 +213,7 @@ function getAllRootsInTree(tree) {
.map(node => {
return {
shadowId: node.shadowId,
root: axe.utils.getRootNode(node.actualNode)
rootNode: axe.utils.getRootNode(node.actualNode)
};
});
return documents;
Expand Down Expand Up @@ -188,34 +243,53 @@ axe.utils.preloadCssom = function preloadCssom({

/**
* Convert text content to CSSStyleSheet
* @method convertTextToStylesheet
* @method getStyleSheet
* @private
* @param {Object} param an object with properties to construct stylesheet
* @property {String} param.data text content of the stylesheet
* @property {Boolean} param.isExternal flag to notify if the resource was fetched from the network
* @property {Object} param.doc implementation document to create style elements
* @property {String} param.shadowId (Optional) shadowId if shadowDOM
* @param {Object} arg an object with properties to construct stylesheet
* @property {String} arg.data text content of the stylesheet
* @property {Boolean} arg.isExternal flag to notify if the resource was fetched from the network
* @property {String} arg.shadowId (Optional) shadowId if shadowDOM
* @property {Object} arg.root implementation document to create style elements
* @property {String} arg.priority a number indicating the loaded priority of CSS, to denote specificity of styles contained in the sheet.
*/
function convertTextToStylesheet({ data, isExternal, shadowId, root }) {
function getStyleSheet({
data,
isExternal,
shadowId,
root,
priority,
isLink = false
}) {
const style = dynamicDoc.createElement('style');
style.type = 'text/css';
style.appendChild(dynamicDoc.createTextNode(data));
if (isLink) {
// as creating a stylesheet as link will need to be awaited
// till `onload`, it is wise to convert link href to @import statement
const text = dynamicDoc.createTextNode(`@import "${data.href}"`);
style.appendChild(text);
} else {
style.appendChild(dynamicDoc.createTextNode(data));
}
dynamicDoc.head.appendChild(style);
return {
sheet: style.sheet,
isExternal,
shadowId,
root
root,
priority
};
}

q.defer((resolve, reject) => {
// as there can be multiple documents (root document, shadow document fragments, and frame documents)
// reduce these into a queue
roots
.reduce((out, root) => {
.reduce((out, root, index) => {
out.defer((resolve, reject) => {
loadCssom(root, timeout, convertTextToStylesheet)
loadCssom({
rootNode: root.rootNode,
rootIndex: index + 1, // we want index to start with 1 for priority calculation
shadowId: root.shadowId,
timeout,
getStyleSheet
})
.then(resolve)
.catch(reject);
});
Expand Down
8 changes: 7 additions & 1 deletion test/core/utils/preload-cssom.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,13 @@ describe('axe.utils.preloadCssom unit tests', function() {
var cssom = results[0];
assert.lengthOf(cssom, 2);
cssom.forEach(function(o) {
assert.hasAllKeys(o, ['root', 'shadowId', 'sheet', 'isExternal']);
assert.hasAllKeys(o, [
'root',
'shadowId',
'sheet',
'isExternal',
'priority'
]);
});
done();
})
Expand Down
Loading

0 comments on commit 93d959b

Please sign in to comment.