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

fix: CSSOM generation for shadowRoot in Safari #1113

Merged
merged 14 commits into from
Oct 9, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 }) => {
Copy link
Member

Choose a reason for hiding this comment

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

I don't recall off the top of my head. Does Axios treat non-2xx response codes as errors? If not, we might want to add something like:

.then(({ data, status }) => {
  if ((status / 100 | 0) !== 2) {
    throw new Error('non-2xx response')
  }
  // ...
})

Copy link
Contributor

Choose a reason for hiding this comment

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

Docs say that it does.

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) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Filter it based on what? This needs a better name.

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;
Copy link
Contributor

Choose a reason for hiding this comment

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

!!! OK It took me 10 minutes before I understood what this is doing. Can you add a comment here, something along the lines of: // Stylesheet only has @import rules in it

}

// 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
Copy link
Contributor

Choose a reason for hiding this comment

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

That's a bit misleading. This is a cross-origin stylesheet.

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