-
Notifications
You must be signed in to change notification settings - Fork 779
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
Changes from all commits
4929809
d29ff17
e14fd3e
aa73cd3
d33527a
63e1b53
018af6e
f79d038
fce6eef
74ee4a4
8987a4a
b60839b
ae5e59b
e945b7f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)) { | ||
|
@@ -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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: |
||
} | ||
|
||
// 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
} | ||
|
||
|
@@ -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; | ||
|
@@ -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); | ||
}); | ||
|
There was a problem hiding this comment.
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:
There was a problem hiding this comment.
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.