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

core(page-functions): don't try to clone a ShadowRoot #9079

Merged
merged 9 commits into from
Jun 7, 2019
12 changes: 12 additions & 0 deletions lighthouse-cli/test/fixtures/dobetterweb/dbw_tester.html
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,18 @@
<object id="5934a"></object>
<object id="5934b"></object>

<div id="shadow-root-container"></div>
<script>
// DOM Size has had trouble with very specific shadow root traversal issues.
// Make sure it can handle reporting of the widest element being a shadow root.
const shadowRootContainer = document.getElementById('shadow-root-container');
const shadowRoot = shadowRootContainer.attachShadow({mode: 'open'});
for (let i = 0; i < 100; i++) {
const span = document.createElement('span');
shadowRoot.appendChild(span);
}
</script>

<script>
// Ensure gatherers still work when individual elements override '.matches'
document.getElementById("5934a").matches = "blahblah";
Expand Down
10 changes: 7 additions & 3 deletions lighthouse-cli/test/smokehouse/dobetterweb/dbw-expectations.js
Original file line number Diff line number Diff line change
Expand Up @@ -155,12 +155,16 @@ module.exports = [
},
'dom-size': {
score: 1,
numericValue: 35,
numericValue: 137,
details: {
items: [
{statistic: 'Total DOM Elements', value: '35'},
{statistic: 'Total DOM Elements', value: '137'},
{statistic: 'Maximum DOM Depth', value: '3'},
{statistic: 'Maximum Child Elements', value: '33'},
{
statistic: 'Maximum Child Elements',
value: '100',
element: {value: '<div id="shadow-root-container">'},
},
],
},
},
Expand Down
26 changes: 16 additions & 10 deletions lighthouse-core/lib/page-functions.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
// @ts-nocheck
'use strict';

/* global window document Node */
/* global window document Node ShadowRoot */

/**
* Helper functions that are passed by `toString()` by Driver to be evaluated in target page.
Expand Down Expand Up @@ -113,18 +113,24 @@ function getElementsInDocument(selector) {
*/
/* istanbul ignore next */
function getOuterHTMLSnippet(element, ignoreAttrs = []) {
const clone = element.cloneNode();

ignoreAttrs.forEach(attribute =>{
clone.removeAttribute(attribute);
});

const reOpeningTag = /^[\s\S]*?>/;
const match = clone.outerHTML.match(reOpeningTag);
try {
if (ShadowRoot.prototype.isPrototypeOf(element) && element.host && element.localName !== 'a') {
Copy link
Member

Choose a reason for hiding this comment

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

nit:

Suggested change
if (ShadowRoot.prototype.isPrototypeOf(element) && element.host && element.localName !== 'a') {
if (element instanceof ShadowRoot && element.host && element.localName !== 'a') {

reads a little simpler than the prototype check, but

non-nit: can we also add a comment to this line? Both to explain the shadow root case, but also to explain the localName check. I don't really understand it...ShadowRoot doesn't have a localName, so by that point in the conditional won't the comparison always be true?

Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Member

Choose a reason for hiding this comment

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

@patrickhulce since you added this line, what's the deal with the element.localName !== 'a' check?

Copy link
Collaborator

@patrickhulce patrickhulce Jun 5, 2019

Choose a reason for hiding this comment

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

This entire piece is straight up copied from the dom-size audit that filters this way, so you'd have to ask Eric as to why it was written this way :)

It indeed seemed like overkill and I thought about trying to extract this to a isShadowRoot function with a canonical version to do this or something but bringing in all the baggage of page-functions with dependencies into this PR seemed unfair.

I don't see why we can't just use element instanceof ShadowRoot personally, but I assumed Eric thought of something I didn't which is a pretty safe bet when it comes to our comparative web component/shadow dom knowledge 😆

Copy link
Contributor Author

Choose a reason for hiding this comment

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

:P I commited the suggested changes,
is there any other issue that we need to tackle?

Copy link
Member

Choose a reason for hiding this comment

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

This entire piece is straight up copied from the dom-size audit that filters this way, so you'd have to ask Eric as to why it was written this way :)

OK! After consulting the old texts (#2131), I think this was from adapting earlier code (which identified shadow roots just by looking for an element.host but had to rule out anchors because they also have a host property). That code was wrapped without modification in the ShadowRoot.prototype check in that PR, which I believe was the error (but since it's always true, it doesn't cause any bad behavior).

I'm going to remove based on the spec and my own testing saying it's fine. I'll take responsibility for any failures :)

element = element.host;
}

return (match && match[0]) || '';
const clone = element.cloneNode();
ignoreAttrs.forEach(attribute =>{
clone.removeAttribute(attribute);
Copy link
Member

Choose a reason for hiding this comment

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

the types on this whole function get kind of broken if we aren't really accepting just Element.

removeAttribute and outerHTML (and more or less localName) exist only on Element.

Maybe we should type it as @param {Element|ShadowRoot} element and (if we don't need that localName check) have the conditional be just if (element instanceof ShadowRoot) element = element.host; (with a comment somewhere explaining that this function handles the ShadowRoot case).

It's still playing a little fast and loose with types (it doesn't help that the tsc built in types don't recognize ShadowRoot as a global value), but it simplifies things a bit.

});
const reOpeningTag = /^[\s\S]*?>/;
const match = clone.outerHTML.match(reOpeningTag);
return (match && match[0]) || '';
} catch (_) {
return `<${element.localName}>`;
Copy link
Collaborator

@connorjclark connorjclark Jun 3, 2019

Choose a reason for hiding this comment

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

can this entire range fail? if it's just the clone, could you reduce the try/catch to just that part?

a comment in the catch block would be useful.

}
}


/**
* Computes a memory/CPU performance benchmark index to determine rough device class.
* @see https://docs.google.com/spreadsheets/d/1E0gZwKsxegudkjJl8Fki_sOwHKpqgXwt8aBAfuUaB8A/edit?usp=sharing
Expand Down
14 changes: 13 additions & 1 deletion lighthouse-core/test/lib/page-functions-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,15 @@ describe('Page Functions', () => {
let dom;

beforeAll(() => {
const {document} = new jsdom.JSDOM().window;
const {document, ShadowRoot} = new jsdom.JSDOM().window;
global.ShadowRoot = ShadowRoot;
dom = new DOM(document);
});

afterAll(() => {
global.ShadowRoot = undefined;
});

describe('get outer HTML snippets', () => {
it('gets full HTML snippet', () => {
assert.equal(pageFunctions.getOuterHTMLSnippet(
Expand All @@ -38,6 +43,13 @@ describe('Page Functions', () => {
), '<div id="1">');
});

it('should handle dom nodes that cannot be inspected', () => {
const element = dom.createElement('div');
element.cloneNode = () => {
throw new Error('oops!');
};
assert.equal(pageFunctions.getOuterHTMLSnippet(element), '<div>');
});
it('ignores when attribute not found', () => {
assert.equal(pageFunctions.getOuterHTMLSnippet(
dom.createElement('div', '', {'id': '1', 'style': 'style', 'aria-label': 'label'}),
Expand Down