Skip to content

Commit a51ae03

Browse files
authored
fix: CSSOM generation for shadowRoot in Safari (#1113)
1 parent 49dff2b commit a51ae03

File tree

5 files changed

+323
-122
lines changed

5 files changed

+323
-122
lines changed

lib/core/utils/preload-cssom.js

Lines changed: 164 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,82 @@
11
/**
2-
* Returns a then(able) queue of CSSStyleSheet(s)
3-
* @param {Object} ownerDocument document object to be inspected for stylesheets
4-
* @param {number} timeout on network request for stylesheet that need to be externally fetched
5-
* @param {Function} convertTextToStylesheetFn a utility function to generate a style sheet from text
6-
* @return {Object} queue
2+
* Make an axios get request to fetch a given resource and resolve
3+
* @method getExternalStylesheet
4+
* @param {Object} options an object with properties to configure the external XHR
5+
* @property {Object} options.resolve resolve callback on queue
6+
* @property {Object} options.reject reject callback on queue
7+
* @property {String} options.url string representing the url of the resource to load
8+
* @property {Object} options.rootNode document or shadowDOM root document for which to process CSSOM
9+
* @property {Number} options.timeout timeout to about network call
10+
* @property {Function} options.getStyleSheet a utility function to generate a style sheet for a given text content
11+
* @property {String} options.shadowId an id if undefined denotes that given root is a shadowRoot
12+
* @property {Number} options.priority css applied priority
13+
* @returns resolve with stylesheet object
714
* @private
815
*/
9-
function loadCssom({ root, shadowId }, timeout, convertTextToStylesheetFn) {
10-
/**
11-
* Make an axios get request to fetch a given resource and resolve
12-
* @method getExternalStylesheet
13-
* @private
14-
* @param {Object} param an object with properties to configure the external XHR
15-
* @property {Object} param.resolve resolve callback on queue
16-
* @property {Object} param.reject reject callback on queue
17-
* @property {String} param.url string representing the url of the resource to load
18-
* @property {Number} param.timeout timeout to about network call
19-
*/
20-
function getExternalStylesheet({ resolve, reject, url }) {
21-
axe.imports
22-
.axios({
23-
method: 'get',
24-
url,
25-
timeout
26-
})
27-
.then(({ data }) => {
28-
const sheet = convertTextToStylesheetFn({
29-
data,
30-
isExternal: true,
31-
shadowId,
32-
root
33-
});
34-
resolve(sheet);
35-
})
36-
.catch(reject);
37-
}
38-
39-
const q = axe.utils.queue();
16+
function getExternalStylesheet(options) {
17+
const {
18+
resolve,
19+
reject,
20+
url,
21+
rootNode,
22+
timeout,
23+
getStyleSheet,
24+
shadowId,
25+
priority
26+
} = options;
27+
axe.imports
28+
.axios({
29+
method: 'get',
30+
url,
31+
timeout
32+
})
33+
.then(({ data }) => {
34+
const sheet = getStyleSheet({
35+
data,
36+
isExternal: true,
37+
shadowId,
38+
root: rootNode,
39+
priority
40+
});
41+
resolve(sheet);
42+
})
43+
.catch(reject);
44+
}
4045

41-
// handle .styleSheets non existent on certain shadowDOM root
42-
const rootStyleSheets = root.styleSheets
43-
? Array.from(root.styleSheets)
44-
: null;
45-
if (!rootStyleSheets) {
46-
return q;
47-
}
46+
/**
47+
* Get stylesheet(s) from shadowDOM
48+
* @param {Object} documentFragment document fragment node
49+
* @param {Function} getStyleSheet helper function to get stylesheet object
50+
* @returns an array of stylesheet objects
51+
*/
52+
function getSheetsFromShadowDom(documentFragment, getStyleSheet) {
53+
return Array.from(documentFragment.children).reduce((out, node) => {
54+
const nodeName = node.nodeName.toUpperCase();
55+
if (nodeName !== 'STYLE' && nodeName !== 'LINK') {
56+
return out;
57+
}
58+
if (nodeName === 'STYLE') {
59+
const dynamicSheet = getStyleSheet({ data: node.textContent });
60+
out.push(dynamicSheet.sheet);
61+
}
62+
if (nodeName === 'LINK' && !node.media.includes('print')) {
63+
const dynamicSheet = getStyleSheet({ data: node, isLink: true });
64+
out.push(dynamicSheet.sheet);
65+
}
66+
return out;
67+
}, []);
68+
}
4869

49-
// convenience array fot help unique sheets if duplicated by same `href`
50-
// both external and internal sheets
70+
/**
71+
* Filter a given array of stylesheet objects
72+
* @param {Array<Object>} styleSheets array of stylesheets
73+
* @returns an filtered array of stylesheets
74+
*/
75+
function filterStyleSheets(styleSheets) {
5176
let sheetHrefs = [];
5277

53-
// filter out sheets, that should not be accounted for...
54-
const sheets = rootStyleSheets.filter(sheet => {
55-
// FILTER > sheets with the same href (if exists)
78+
return styleSheets.filter(sheet => {
79+
// 1) FILTER > sheets with the same href
5680
let sheetAlreadyExists = false;
5781
if (sheet.href) {
5882
if (!sheetHrefs.includes(sheet.href)) {
@@ -61,81 +85,112 @@ function loadCssom({ root, shadowId }, timeout, convertTextToStylesheetFn) {
6185
sheetAlreadyExists = true;
6286
}
6387
}
64-
// FILTER > media='print'
65-
// Note:
66-
// Chrome does this automagically, Firefox returns every sheet
67-
// hence the need to filter
88+
// 2) FILTER > media='print'
6889
const isPrintMedia = Array.from(sheet.media).includes('print');
69-
// FILTER > disabled
70-
// Firefox does not respect `disabled` attribute on stylesheet
71-
// Hence decided not to filter out disabled for the time being
7290

73-
// return
7491
return !isPrintMedia && !sheetAlreadyExists;
7592
});
93+
}
94+
95+
/**
96+
* Returns a then(able) queue of CSSStyleSheet(s)
97+
* @method loadCssom
98+
* @private
99+
* @param {Object} options an object with attributes essential to load CSSOM
100+
* @property {Object} options.rootNode document or shadowDOM root document for which to process CSSOM
101+
* @property {Number} options.rootIndex a number representing the index of the document or shadowDOM, used for priority
102+
* @property {String} options.shadowId an id if undefined denotes that given root is a shadowRoot
103+
* @property {Number} options.timeout abort duration for network request
104+
* @param {Function} options.getStyleSheet a utility function to generate a style sheet for a given text content
105+
* @return {Object} queue
106+
*/
107+
function loadCssom(options) {
108+
const { rootNode, rootIndex, shadowId, getStyleSheet } = options;
109+
const q = axe.utils.queue();
110+
const styleSheets =
111+
rootNode.nodeType === 11 && shadowId
112+
? getSheetsFromShadowDom(rootNode, getStyleSheet)
113+
: Array.from(rootNode.styleSheets);
114+
const sheets = filterStyleSheets(styleSheets);
115+
116+
sheets.forEach((sheet, sheetIndex) => {
117+
/* eslint max-statements: ["error", 20] */
118+
const priority = [rootIndex, sheetIndex];
76119

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

86128
// filter rules that are included by way of @import or nested link
87129
const importRules = rules.filter(r => r.href);
88-
89-
// if no import or nested link rules, with in these cssRules
90-
// return current sheet
91130
if (!importRules.length) {
92131
q.defer(resolve =>
93132
resolve({
94133
sheet,
95134
isExternal: false,
96135
shadowId,
97-
root
136+
root: rootNode,
137+
priority
98138
})
99139
);
100140
return;
101141
}
102142

103-
// if any import rules exists, fetch via `href` which eventually constructs a sheet with results from resource
143+
// for import rules, fetch via `href`
104144
importRules.forEach(rule => {
105145
q.defer((resolve, reject) => {
106-
getExternalStylesheet({ resolve, reject, url: rule.href });
146+
getExternalStylesheet({
147+
resolve,
148+
reject,
149+
url: rule.href,
150+
priority,
151+
...options
152+
});
107153
});
108154
});
109155

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

113-
// concat all cssText into a string for inline rules
162+
// concat all cssText into a string for inline rules & create sheet
114163
const inlineRulesCssText = inlineRules
115164
.reduce((out, rule) => {
116165
out.push(rule.cssText);
117166
return out;
118167
}, [])
119168
.join();
120-
// create and return a sheet with inline rules
121169
q.defer(resolve =>
122170
resolve(
123-
convertTextToStylesheetFn({
171+
getStyleSheet({
124172
data: inlineRulesCssText,
125173
shadowId,
126-
root,
127-
isExternal: false
174+
root: rootNode,
175+
isExternal: false,
176+
priority
128177
})
129178
)
130179
);
131180
} catch (e) {
132181
// external sheet -> make an xhr and q the response
133182
q.defer((resolve, reject) => {
134-
getExternalStylesheet({ resolve, reject, url: sheet.href });
183+
getExternalStylesheet({
184+
resolve,
185+
reject,
186+
url: sheet.href,
187+
priority,
188+
...options
189+
});
135190
});
136191
}
137-
}, []);
138-
// return
192+
});
193+
139194
return q;
140195
}
141196

@@ -158,7 +213,7 @@ function getAllRootsInTree(tree) {
158213
.map(node => {
159214
return {
160215
shadowId: node.shadowId,
161-
root: axe.utils.getRootNode(node.actualNode)
216+
rootNode: axe.utils.getRootNode(node.actualNode)
162217
};
163218
});
164219
return documents;
@@ -188,34 +243,53 @@ axe.utils.preloadCssom = function preloadCssom({
188243

189244
/**
190245
* Convert text content to CSSStyleSheet
191-
* @method convertTextToStylesheet
246+
* @method getStyleSheet
192247
* @private
193-
* @param {Object} param an object with properties to construct stylesheet
194-
* @property {String} param.data text content of the stylesheet
195-
* @property {Boolean} param.isExternal flag to notify if the resource was fetched from the network
196-
* @property {Object} param.doc implementation document to create style elements
197-
* @property {String} param.shadowId (Optional) shadowId if shadowDOM
248+
* @param {Object} arg an object with properties to construct stylesheet
249+
* @property {String} arg.data text content of the stylesheet
250+
* @property {Boolean} arg.isExternal flag to notify if the resource was fetched from the network
251+
* @property {String} arg.shadowId (Optional) shadowId if shadowDOM
252+
* @property {Object} arg.root implementation document to create style elements
253+
* @property {String} arg.priority a number indicating the loaded priority of CSS, to denote specificity of styles contained in the sheet.
198254
*/
199-
function convertTextToStylesheet({ data, isExternal, shadowId, root }) {
255+
function getStyleSheet({
256+
data,
257+
isExternal,
258+
shadowId,
259+
root,
260+
priority,
261+
isLink = false
262+
}) {
200263
const style = dynamicDoc.createElement('style');
201-
style.type = 'text/css';
202-
style.appendChild(dynamicDoc.createTextNode(data));
264+
if (isLink) {
265+
// as creating a stylesheet as link will need to be awaited
266+
// till `onload`, it is wise to convert link href to @import statement
267+
const text = dynamicDoc.createTextNode(`@import "${data.href}"`);
268+
style.appendChild(text);
269+
} else {
270+
style.appendChild(dynamicDoc.createTextNode(data));
271+
}
203272
dynamicDoc.head.appendChild(style);
204273
return {
205274
sheet: style.sheet,
206275
isExternal,
207276
shadowId,
208-
root
277+
root,
278+
priority
209279
};
210280
}
211281

212282
q.defer((resolve, reject) => {
213-
// as there can be multiple documents (root document, shadow document fragments, and frame documents)
214-
// reduce these into a queue
215283
roots
216-
.reduce((out, root) => {
284+
.reduce((out, root, index) => {
217285
out.defer((resolve, reject) => {
218-
loadCssom(root, timeout, convertTextToStylesheet)
286+
loadCssom({
287+
rootNode: root.rootNode,
288+
rootIndex: index + 1, // we want index to start with 1 for priority calculation
289+
shadowId: root.shadowId,
290+
timeout,
291+
getStyleSheet
292+
})
219293
.then(resolve)
220294
.catch(reject);
221295
});

test/core/utils/preload-cssom.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,13 @@ describe('axe.utils.preloadCssom unit tests', function() {
6363
var cssom = results[0];
6464
assert.lengthOf(cssom, 2);
6565
cssom.forEach(function(o) {
66-
assert.hasAllKeys(o, ['root', 'shadowId', 'sheet', 'isExternal']);
66+
assert.hasAllKeys(o, [
67+
'root',
68+
'shadowId',
69+
'sheet',
70+
'isExternal',
71+
'priority'
72+
]);
6773
});
6874
done();
6975
})

0 commit comments

Comments
 (0)