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

Conversation

jeeyyy
Copy link
Contributor

@jeeyyy jeeyyy commented Aug 29, 2018

This PR handles the edge case where for a given shadowRoot, the root.styleSheets lookup is returned as undefined | null.

This meant evaluation of styles with in shadowDOM failed.

This fix, tackles the edge case by parsing the content of the shadowRoot, to reverse engineer styleSheets from style tag if any specified with in the shadowRoot.

Upon reverse engineering the sheets, it is lead to flow into the generic loadCssom function to do the necessary generation of CSSOM as intended.

The concept of priority in stylesheets loaded is also introduced. The priority computation will be enhanced when handling multiple nested @import stylesheets in a separate PR after this lands.

Closes issue:

Reviewer checks

Required fields, to be filled out by PR reviewer(s)

  • Follows the commit message policy, appropriate for next version
  • Has documentation updated, a DU ticket, or requires no documentation change
  • Includes new tests, or was unnecessary
  • Code is reviewed for security by: @WilcoFiers

@jeeyyy jeeyyy requested a review from a team as a code owner August 29, 2018 14:18

if (!root.styleSheets) {
/**
* In Safari: (Issue: https://github.com/dequelabs/axe-core/issues/1082)
Copy link
Contributor

Choose a reason for hiding this comment

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

Please report this as a bug to Apple.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Reported, but do not have an issue/ bug reported link from them yet.

})
.map(node => {
const sheet = convertTextToStylesheetFn({
data: node.textContent
Copy link
Contributor

Choose a reason for hiding this comment

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

How does this solution work with link elements?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Works, just fine as shown here: https://github.com/dequelabs/axe-core/pull/1113/files#diff-b195c7e1a05c62e60347f579d8f7dddeR339

It was the style tag that needed special parsing as above.

);

(shadowSupported ? it : xit)(
'should return styles from shadow dom',
Copy link
Contributor

Choose a reason for hiding this comment

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

Make sure to test link element stylesheets as well.

@jeeyyy
Copy link
Contributor Author

jeeyyy commented Aug 31, 2018

@WilcoFiers
Updated with handling link as requested as well.
Have also introduced the concept of priority, but needs more work to tackle other scenarios and enhancing css-orientation-lock tests.

Please review this again.

@jeeyyy jeeyyy requested review from stephenmathieson, isner and a team August 31, 2018 04:26
@jeeyyy
Copy link
Contributor Author

jeeyyy commented Sep 3, 2018

@dylanb - review appreciated.

/* eslint max-statements: ["error", 20] */

// basic priority
const sheetLevelPriority = `${rootIndex}${sheetIndex}`;
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 think I understand what's happening here. It looks like we're concatenating (not adding) numbers together in a string to be used as a numerical value later on. Can you explain how this works?

I'm stuck on the following:

A "priority" string is created with a rootIndex of 1 and a sheetIndex of 3000. It is then coerced to the integer 13000.

Later on, another string is created with rootIndex of 2 and sheetIndex of 1. It's then coerced to 21.

13000 is larger than 21, but its "root" was higher. Is this OK?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

So there is multiple levels of complexity for calculating priority.

Before going into the details, let me just say the above can and is solved by introducing decimals rootIndex.sheetIndex resulting in 1.1, 1.200, 2.1 and so on, hence the priority remains ordered as expected. Good spot. I have now done that.

image

Also, note that this is just the first pass at computing priority.
There are few more issues to tackle like below:

Which I believe will introduce further complexity to the priority calculation.
This PR mainly handles shadowDOM stylesheets resolution, and introduces the concept of priority.

Copy link
Contributor

@WilcoFiers WilcoFiers left a comment

Choose a reason for hiding this comment

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

Couple minor points. I'd also like @dylanb to take a look.

shadowId
) {
// retrieve shadowRoot style sheets as []
styleSheets = Array.from(rootDocument.children).reduce((out, node) => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Why are you only looking at child nodes, instead of doing getElementsByTagName for style and link elements? It might make sense to break up the function into different load methods depending on the elements. Reduces the complexity of this function.

/* eslint max-statements: ["error", 20] */

// padding sheet index with root index to create priority
const sheetLevelPriority = Number(`${rootIndex}.${sheetIndex}`);
Copy link
Contributor

Choose a reason for hiding this comment

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

Doesn't this break if we've got more than 10 sheets? Is this priority something you came up with or is it part of a spec some place? This seems fragile.

@@ -158,7 +269,7 @@ function getAllRootsInTree(tree) {
.map(node => {
return {
shadowId: node.shadowId,
root: axe.utils.getRootNode(node.actualNode)
rootDocument: axe.utils.getRootNode(node.actualNode)
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe stick with rootNode? That's what we're calling it everywhere else.

@jeeyyy jeeyyy changed the title fix: CSSOM generation for shadowRoot in Safari [WIP] fix: CSSOM generation for shadowRoot in Safari Sep 19, 2018
@jeeyyy jeeyyy self-assigned this Sep 21, 2018
@jeeyyy jeeyyy changed the title [WIP] fix: CSSOM generation for shadowRoot in Safari fix: CSSOM generation for shadowRoot in Safari Sep 26, 2018
@jeeyyy jeeyyy dismissed WilcoFiers’s stale review September 26, 2018 09:14

Updated. Please review again

*/
function loadCssom({ root, shadowId }, timeout, convertTextToStylesheetFn) {
function loadCssom({ rootNode, rootIndex, shadowId, timeout, getStyleSheet }) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Can you please break this function up into more readable chunks? It's a 200 line function that does half a dozen different things.

Copy link
Contributor

@WilcoFiers WilcoFiers Oct 3, 2018

Choose a reason for hiding this comment

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

I've taken a stab at simplifying this. Here's a suggestion. Also notice this is readable without a comment on every line.

function loadCssom(options) {
  const { rootIndex, rootNode, shadowId } = options;
  const q = axe.utils.queue();
  const styleSheets = (rootNode.nodeType === 7 && shadowId
    ? getSheetsFromShadowDom(options)
    : getSheetsFromLightDom(options)
  );
  
  const filteredSheets = styleSheets
    .filter(isFirstHrefSheet)
    .filter(notPrintSheet);
  
  filteredSheets.forEach((sheet, sheetIndex) => {
    let sheetLoader;
    try {
      // The following line throws an error on cross-origin style sheets:
      const cssRules = sheet.cssRules;
      sheetLoader = getSameOriginSheet;
    } catch () {
      sheetLoader = getCrossOriginSheet;
    }

    q.defer((resolve, reject) => {
      sheetLoader({
        priority: [rootIndex, sheetIndex]
        url: rule.href,
        resolve,
        reject,
        ...options
      });
    });
  });

  return q;
})

getStyleSheet,
shadowId,
priority
});
});
}
}, []);
Copy link
Contributor

Choose a reason for hiding this comment

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

This empty array is passed into sheets.forEach((sheet, sheetIndex) => {...}, []).

*/
function loadCssom({ root, shadowId }, timeout, convertTextToStylesheetFn) {
function loadCssom({ rootNode, rootIndex, shadowId, timeout, getStyleSheet }) {
Copy link
Contributor

@WilcoFiers WilcoFiers Oct 3, 2018

Choose a reason for hiding this comment

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

I've taken a stab at simplifying this. Here's a suggestion. Also notice this is readable without a comment on every line.

function loadCssom(options) {
  const { rootIndex, rootNode, shadowId } = options;
  const q = axe.utils.queue();
  const styleSheets = (rootNode.nodeType === 7 && shadowId
    ? getSheetsFromShadowDom(options)
    : getSheetsFromLightDom(options)
  );
  
  const filteredSheets = styleSheets
    .filter(isFirstHrefSheet)
    .filter(notPrintSheet);
  
  filteredSheets.forEach((sheet, sheetIndex) => {
    let sheetLoader;
    try {
      // The following line throws an error on cross-origin style sheets:
      const cssRules = sheet.cssRules;
      sheetLoader = getSameOriginSheet;
    } catch () {
      sheetLoader = getCrossOriginSheet;
    }

    q.defer((resolve, reject) => {
      sheetLoader({
        priority: [rootIndex, sheetIndex]
        url: rule.href,
        resolve,
        reject,
        ...options
      });
    });
  });

  return q;
})

@jeeyyy
Copy link
Contributor Author

jeeyyy commented Oct 4, 2018

@WilcoFiers
Thanks for the refactor suggestions, embraced most of it.

The suggestion as per this snippet #1113 (comment), to use sheetLoader which then is deferred into the queue is not as simple, so left that section after minimal refactor.

Reviews welcome.

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.

Copy link
Contributor

@WilcoFiers WilcoFiers left a comment

Choose a reason for hiding this comment

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

Editorials. You're welcome to merge and raise a new PR with the editorial changes.

* @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.

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.

});
});

// 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

@jeeyyy jeeyyy merged commit a51ae03 into develop Oct 9, 2018
@jeeyyy jeeyyy deleted the fix-safari-shadow-cssom branch October 9, 2018 09:19
Danidude pushed a commit to tingtun/axe-core that referenced this pull request Nov 1, 2018
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants