diff --git a/src/lib/css-tag.ts b/src/lib/css-tag.ts index 471939f5..47e41685 100644 --- a/src/lib/css-tag.ts +++ b/src/lib/css-tag.ts @@ -12,7 +12,8 @@ found at http://polymer.github.io/PATENTS.txt /** * Whether the current browser supports `adoptedStyleSheets`. */ -export const supportsAdoptingStyleSheets = +export const supportsAdoptingStyleSheets = (window.ShadowRoot) && + (window.ShadyCSS === undefined || window.ShadyCSS.nativeShadow) && ('adoptedStyleSheets' in Document.prototype) && ('replace' in CSSStyleSheet.prototype); @@ -28,6 +29,7 @@ export class CSSResult { throw new Error( 'CSSResult is not constructable. Use `unsafeCSS` or `css` instead.'); } + this.cssText = cssText; } @@ -35,8 +37,8 @@ export class CSSResult { // stylesheets are not created until the first element instance is made. get styleSheet(): CSSStyleSheet|null { if (this._styleSheet === undefined) { - // Note, if `adoptedStyleSheets` is supported then we assume CSSStyleSheet - // is constructable. + // Note, if `supportsAdoptingStyleSheets` is true then we assume + // CSSStyleSheet is constructable. if (supportsAdoptingStyleSheets) { this._styleSheet = new CSSStyleSheet(); this._styleSheet.replaceSync(this.cssText); diff --git a/src/lit-element.ts b/src/lit-element.ts index 41817d94..f56965dd 100644 --- a/src/lit-element.ts +++ b/src/lit-element.ts @@ -18,7 +18,7 @@ import {PropertyValues, UpdatingElement} from './lib/updating-element.js'; export * from './lib/updating-element.js'; export * from './lib/decorators.js'; export {html, svg, TemplateResult, SVGTemplateResult} from 'lit-html/lit-html.js'; -import {supportsAdoptingStyleSheets, CSSResult} from './lib/css-tag.js'; +import {supportsAdoptingStyleSheets, CSSResult, unsafeCSS} from './lib/css-tag.js'; export * from './lib/css-tag.js'; declare global { @@ -33,7 +33,10 @@ declare global { (window['litElementVersions'] || (window['litElementVersions'] = [])) .push('2.3.1'); -export interface CSSResultArray extends Array {} +export type CSSResultOrNative = CSSResult|CSSStyleSheet; + +export interface CSSResultArray extends + Array {} /** * Sentinal value used to avoid calling lit-html's render function when @@ -80,11 +83,11 @@ export class LitElement extends UpdatingElement { /** * Array of styles to apply to the element. The styles should be defined - * using the [[`css`]] tag function. + * using the [[`css`]] tag function or via constructible stylesheets. */ - static styles?: CSSResult|CSSResultArray; + static styles?: CSSResultOrNative|CSSResultArray; - private static _styles: CSSResult[]|undefined; + private static _styles: Array|undefined; /** * Return the array of styles to apply to the element. @@ -92,7 +95,7 @@ export class LitElement extends UpdatingElement { * * @nocollapse */ - static getStyles(): CSSResult|CSSResultArray|undefined { + static getStyles(): CSSResultOrNative|CSSResultArray|undefined { return this.styles; } @@ -109,31 +112,48 @@ export class LitElement extends UpdatingElement { // This should be addressed when a browser ships constructable // stylesheets. const userStyles = this.getStyles(); - if (userStyles === undefined) { - this._styles = []; - } else if (Array.isArray(userStyles)) { + + if (Array.isArray(userStyles)) { // De-duplicate styles preserving the _last_ instance in the set. // This is a performance optimization to avoid duplicated styles that can // occur especially when composing via subclassing. // The last item is kept to try to preserve the cascade order with the // assumption that it's most important that last added styles override // previous styles. - const addStyles = - (styles: CSSResultArray, set: Set): Set => - styles.reduceRight( - (set: Set, s) => - // Note: On IE set.add() does not return the set - Array.isArray(s) ? addStyles(s, set) : (set.add(s), set), - set); + const addStyles = (styles: CSSResultArray, set: Set): + Set => styles.reduceRight( + (set: Set, s) => + // Note: On IE set.add() does not return the set + Array.isArray(s) ? addStyles(s, set) : (set.add(s), set), + set); // Array.from does not work on Set in IE, otherwise return // Array.from(addStyles(userStyles, new Set())).reverse() - const set = addStyles(userStyles, new Set()); - const styles: CSSResult[] = []; + const set = addStyles(userStyles, new Set()); + const styles: CSSResultOrNative[] = []; set.forEach((v) => styles.unshift(v)); this._styles = styles; } else { - this._styles = [userStyles]; + this._styles = userStyles === undefined ? [] : [userStyles]; } + + // Ensure that there are no invalid CSSStyleSheet instances here. They are + // invalid in two conditions. + // (1) the sheet is non-constructible (`sheet` of a HTMLStyleElement), but + // this is impossible to check except via .replaceSync or use + // (2) the ShadyCSS polyfill is enabled (:. supportsAdoptingStyleSheets is + // false) + this._styles = this._styles.map((s) => { + if (s instanceof CSSStyleSheet && !supportsAdoptingStyleSheets) { + // Flatten the cssText from the passed constructible stylesheet (or + // undetectable non-constructible stylesheet). The user might have + // expected to update their stylesheets over time, but the alternative + // is a crash. + const cssText = Array.prototype.slice.call(s.cssRules) + .reduce((css, rule) => css + rule.cssText, ''); + return unsafeCSS(cssText); + } + return s; + }); } private _needsShimAdoptedStyleSheets?: boolean; @@ -189,7 +209,7 @@ export class LitElement extends UpdatingElement { } // There are three separate cases here based on Shadow DOM support. // (1) shadowRoot polyfilled: use ShadyCSS - // (2) shadowRoot.adoptedStyleSheets available: use it. + // (2) shadowRoot.adoptedStyleSheets available: use it // (3) shadowRoot.adoptedStyleSheets polyfilled: append styles after // rendering if (window.ShadyCSS !== undefined && !window.ShadyCSS.nativeShadow) { @@ -197,7 +217,7 @@ export class LitElement extends UpdatingElement { styles.map((s) => s.cssText), this.localName); } else if (supportsAdoptingStyleSheets) { (this.renderRoot as ShadowRoot).adoptedStyleSheets = - styles.map((s) => s.styleSheet!); + styles.map((s) => s instanceof CSSStyleSheet ? s : s.styleSheet!); } else { // This must be done after rendering so the actual style insertion is done // in `update`. diff --git a/src/test/lit-element_styling_test.ts b/src/test/lit-element_styling_test.ts index 686803a5..02b1bb88 100644 --- a/src/test/lit-element_styling_test.ts +++ b/src/test/lit-element_styling_test.ts @@ -792,10 +792,16 @@ suite('Static get styles', () => { test('element class only gathers styles once', async () => { const base = generateElementName(); - let styleCounter = 0; + let getStylesCounter = 0; + let stylesCounter = 0; customElements.define(base, class extends LitElement { + static getStyles() { + getStylesCounter++; + return super.getStyles(); + } + static get styles() { - styleCounter++; + stylesCounter++; return css`:host { border: 10px solid black; }`; @@ -821,7 +827,8 @@ suite('Static get styles', () => { '10px', 'el2 styled correctly'); assert.equal( - styleCounter, 1, 'styles property should only be accessed once'); + stylesCounter, 1, 'styles property should only be accessed once'); + assert.equal(getStylesCounter, 1, 'getStyles() should be called once'); }); test( @@ -890,6 +897,88 @@ suite('Static get styles', () => { document.body.removeChild(element); }); + + const testAdoptedStyleSheets = + (window.ShadowRoot) && + ('replace' in CSSStyleSheet.prototype); + (testAdoptedStyleSheets ? test : test.skip)( + 'Can return CSSStyleSheet where adoptedStyleSheets are natively supported', + async () => { + const sheet = new CSSStyleSheet(); + sheet.replaceSync('div { border: 4px solid red; }'); + const normal = css`span { border: 4px solid blue; }`; + + const base = generateElementName(); + customElements.define(base, class extends LitElement { + static styles = [sheet, normal]; + + render() { + return htmlWithStyles`
`; + } + }); + + const el = document.createElement(base); + container.appendChild(el); + await (el as LitElement).updateComplete; + const div = el.shadowRoot!.querySelector('div')!; + assert.equal( + getComputedStyle(div).getPropertyValue('border-top-width').trim(), + '4px'); + + const span = el.shadowRoot!.querySelector('span')!; + assert.equal( + getComputedStyle(span).getPropertyValue('border-top-width').trim(), + '4px'); + + // When the WC polyfills are included, calling .replaceSync is a noop to + // our styles as they're already flattened (so expect 4px). Otherwise, + // look for the updated value. + const usesAdoptedStyleSheet = (window.ShadyCSS === undefined || window.ShadyCSS.nativeShadow); + const expectedValue = usesAdoptedStyleSheet ? '2px' : '4px'; + sheet.replaceSync('div { border: 2px solid red; }'); + + assert.equal( + getComputedStyle(div).getPropertyValue('border-top-width').trim(), + expectedValue); + }); + + // Test that when ShadyCSS is enabled (while still having native support for + // adoptedStyleSheets), we can return a CSSStyleSheet that will be flattened + // and play nice with others. + const testShadyCSSWithAdoptedStyleSheetSupport = + (window.ShadowRoot) && + ('replace' in CSSStyleSheet.prototype) && + (window.ShadyCSS !== undefined && !window.ShadyCSS.nativeShadow); + (testShadyCSSWithAdoptedStyleSheetSupport ? test : test.skip)( + 'CSSStyleSheet is flattened where ShadyCSS is enabled yet adoptedStyleSheets are supported', + async () => { + const sheet = new CSSStyleSheet(); + sheet.replaceSync('div { border: 4px solid red; }'); + + const base = generateElementName(); + customElements.define(base, class extends LitElement { + static styles = sheet; + + render() { + return htmlWithStyles`
`; + } + }); + + const el = document.createElement(base); + container.appendChild(el); + await (el as LitElement).updateComplete; + + const div = el.shadowRoot!.querySelector('div')!; + assert.equal( + getComputedStyle(div).getPropertyValue('border-top-width').trim(), + '4px'); + + // CSSStyleSheet update should fail, as the styles will be flattened. + sheet.replaceSync('div { border: 2px solid red; }'); + assert.equal( + getComputedStyle(div).getPropertyValue('border-top-width').trim(), + '4px', 'CSS should not reflect CSSStyleSheet as it was flattened'); + }); }); suite('ShadyDOM', () => {