diff --git a/packages/vaadin-themable-mixin/test/post-finalize-styles-lit.test.ts b/packages/vaadin-themable-mixin/test/post-finalize-styles-lit.test.ts new file mode 100644 index 0000000000..ebe8320b7d --- /dev/null +++ b/packages/vaadin-themable-mixin/test/post-finalize-styles-lit.test.ts @@ -0,0 +1,19 @@ +import './post-finalize-styles.common.ts'; +import { css, LitElement } from 'lit'; + +customElements.define( + 'test-element', + class extends LitElement { + static get styles() { + return css` + :host { + display: block; + } + `; + } + + render() { + return ''; + } + }, +); diff --git a/packages/vaadin-themable-mixin/test/post-finalize-styles-polymer.test.ts b/packages/vaadin-themable-mixin/test/post-finalize-styles-polymer.test.ts new file mode 100644 index 0000000000..aacdc87d8d --- /dev/null +++ b/packages/vaadin-themable-mixin/test/post-finalize-styles-polymer.test.ts @@ -0,0 +1,17 @@ +import './post-finalize-styles.common.ts'; +import { html, PolymerElement } from '@polymer/polymer/polymer-element.js'; + +customElements.define( + 'test-element', + class extends PolymerElement { + static get template() { + return html` + + `; + } + }, +); diff --git a/packages/vaadin-themable-mixin/test/post-finalize-styles.common.ts b/packages/vaadin-themable-mixin/test/post-finalize-styles.common.ts new file mode 100644 index 0000000000..106377009a --- /dev/null +++ b/packages/vaadin-themable-mixin/test/post-finalize-styles.common.ts @@ -0,0 +1,391 @@ +import { expect } from '@esm-bundle/chai'; +import { fixtureSync, nextFrame } from '@vaadin/testing-helpers'; +import sinon from 'sinon'; +import { css, registerStyles, ThemableMixin } from '../vaadin-themable-mixin.js'; + +function defineComponent(tagName, parentTagName = 'test-element') { + customElements.define( + tagName, + class CustomElement extends ThemableMixin(customElements.get(parentTagName)!) { + static is = tagName; + }, + ); +} + +function getCssText(instance) { + if (instance.shadowRoot.adoptedStyleSheets?.length) { + // Uses adopted stylesheets + return [...instance.shadowRoot.adoptedStyleSheets].reduce((acc, sheet) => { + return sheet.rules ? acc + [...sheet.rules].reduce((acc, rule) => acc + rule.cssText, '') : acc; + }); + } + // Uses style elements + return [...instance.shadowRoot.querySelectorAll('style')].reduce((acc, style) => acc + style.textContent, ''); +} + +describe('ThemableMixin - post-finalize styles', () => { + let warn; + + let tagId = 0; + function uniqueTagName() { + tagId += 1; + return `custom-element-${tagId}`; + } + + before(() => customElements.whenDefined('test-element')); + + beforeEach(() => { + warn = sinon.stub(console, 'warn'); + }); + + afterEach(() => { + warn.restore(); + }); + + it('should have pre-finalize styles', () => { + const tagName = uniqueTagName(); + + registerStyles( + tagName, + css` + :host { + --foo: foo; + } + `, + ); + + defineComponent(tagName); + const instance = fixtureSync(`<${tagName}>`); + + const styles = getComputedStyle(instance); + expect(styles.getPropertyValue('--foo')).to.equal('foo'); + }); + + it('should have post-finalize styles', async () => { + const tagName = uniqueTagName(); + + defineComponent(tagName); + const instance = fixtureSync(`<${tagName}>`); + + registerStyles( + tagName, + css` + :host { + --foo: foo; + } + `, + ); + + await nextFrame(); + + const styles = getComputedStyle(instance); + expect(styles.getPropertyValue('--foo')).to.equal('foo'); + }); + + it('should have post-finalize styles for a type matching a wildcard', async () => { + const tagName = uniqueTagName(); + + defineComponent(tagName); + const instance = fixtureSync(`<${tagName}>`); + + registerStyles( + `${tagName}*`, + css` + :host { + --foo: foo; + } + `, + ); + + await nextFrame(); + + const styles = getComputedStyle(instance); + expect(styles.getPropertyValue('--foo')).to.equal('foo'); + }); + + it('should have post-finalize styles on a new instance', async () => { + const tagName = uniqueTagName(); + defineComponent(tagName); + // Create an instance to trigger finalize in case of a Polymer component + fixtureSync(`<${tagName}>`); + + registerStyles( + tagName, + css` + :host { + --foo: foo; + } + `, + ); + + const instance = fixtureSync(`<${tagName}>`); + await nextFrame(); + + const styles = getComputedStyle(instance); + expect(styles.getPropertyValue('--foo')).to.equal('foo'); + }); + + it('should inherit post-finalize styles from parent', async () => { + const parentTagName = uniqueTagName(); + defineComponent(parentTagName); + const parent = fixtureSync(`<${parentTagName}>`); + await nextFrame(); + registerStyles( + parentTagName, + css` + :host { + --foo: foo; + } + `, + ); + + const childTagName = uniqueTagName(); + defineComponent(childTagName, parentTagName); + const child = fixtureSync(`<${childTagName}>`); + await nextFrame(); + registerStyles( + childTagName, + css` + :host { + --bar: bar; + } + `, + ); + + await nextFrame(); + + const parentStyles = getComputedStyle(parent); + expect(parentStyles.getPropertyValue('--foo')).to.equal('foo'); + expect(parentStyles.display).to.equal('block'); + + const childStyles = getComputedStyle(child); + expect(childStyles.getPropertyValue('--foo')).to.equal('foo'); + expect(childStyles.getPropertyValue('--bar')).to.equal('bar'); + expect(childStyles.display).to.equal('block'); + }); + + it('should not include duplicate styles', async () => { + const parentTagName = uniqueTagName(); + defineComponent(parentTagName); + const parent = fixtureSync(`<${parentTagName}>`); + await nextFrame(); + registerStyles( + parentTagName, + css` + :host { + --foo: foo; + } + `, + ); + + const childTagName = uniqueTagName(); + defineComponent(childTagName, parentTagName); + const child = fixtureSync(`<${childTagName}>`); + await nextFrame(); + + // Expect the cssText to contain "--foo: foo;" only once + const count = (getCssText(child).match(/--foo: foo;/gu) || []).length; + expect(count).to.equal(1); + }); + + it('should inherit post-finalize styles to already defined child after instantiating', async () => { + const parentTagName = uniqueTagName(); + defineComponent(parentTagName); + await nextFrame(); + + const childTagName = uniqueTagName(); + defineComponent(childTagName, parentTagName); + const child = fixtureSync(`<${childTagName}>`); + await nextFrame(); + + registerStyles( + parentTagName, + css` + :host { + --foo: foo; + } + `, + ); + + await nextFrame(); + + const childStyles = getComputedStyle(child); + expect(childStyles.getPropertyValue('--foo')).to.equal('foo'); + }); + + it('should inherit post-finalize styles to already defined child before instantiating', async () => { + const parentTagName = uniqueTagName(); + defineComponent(parentTagName); + await nextFrame(); + + const childTagName = uniqueTagName(); + defineComponent(childTagName, parentTagName); + // Create an instance to trigger finalize in case of a Polymer component + fixtureSync(`<${childTagName}>`); + await nextFrame(); + + registerStyles( + parentTagName, + css` + :host { + --foo: foo; + } + `, + ); + + await nextFrame(); + + const child = fixtureSync(`<${childTagName}>`); + await nextFrame(); + + const childStyles = getComputedStyle(child); + expect(childStyles.getPropertyValue('--foo')).to.equal('foo'); + }); + + it('should inherit ThemableMixin from parent', async () => { + const parentTagName = uniqueTagName(); + defineComponent(parentTagName); + registerStyles( + parentTagName, + css` + :host { + --foo: foo; + } + `, + ); + + const childTagName = uniqueTagName(); + class Child extends (customElements.get(parentTagName)!) { + static is = childTagName; + } + customElements.define(childTagName, Child); + + const child = fixtureSync(`<${childTagName}>`); + await nextFrame(); + + const childStyles = getComputedStyle(child); + expect(childStyles.getPropertyValue('--foo')).to.equal('foo'); + }); + + it('should not throw for components without shadow root', async () => { + class Component extends ThemableMixin(customElements.get('test-element')!) { + static is = 'rootless-component'; + + // LitElement + createRenderRoot() { + return this; + } + + // PolymerElement + _attachDom(dom) { + this.appendChild(dom); + } + } + + customElements.define('rootless-component', Component); + fixtureSync(''); + await nextFrame(); + + const doRegister = () => + registerStyles( + 'rootless-component', + css` + :host { + --foo: foo; + } + `, + ); + + expect(doRegister).to.not.throw(); + }); + + it('should warn when using post-finalize styles', async () => { + const tagName = uniqueTagName(); + defineComponent(tagName); + fixtureSync(`<${tagName}>`); + registerStyles( + tagName, + css` + :host { + --foo: foo; + } + `, + ); + + await nextFrame(); + + expect(warn.calledOnce).to.be.true; + expect(warn.args[0][0]).to.include('The custom element definition for'); + }); + + it('should suppress the warning for post-finalize styles', async () => { + Object.assign(window, { Vaadin: { suppressPostFinalizeStylesWarning: true } }); + + const tagName = uniqueTagName(); + defineComponent(tagName); + fixtureSync(`<${tagName}>`); + registerStyles( + tagName, + css` + :host { + --foo: foo; + } + `, + ); + + await nextFrame(); + + expect(warn.called).to.be.false; + }); + + it('should warn when the same style rules get added again', async () => { + const tagName = uniqueTagName(); + defineComponent(tagName); + const instance = fixtureSync(`<${tagName}>`); + + registerStyles( + tagName, + css` + :host { + --foo: foo; + } + `, + ); + + registerStyles( + tagName, + css` + :host { + --foo: foo; + } + `, + ); + + await nextFrame(); + + expect(warn.calledOnce).to.be.true; + expect(warn.args[0][0]).to.include('Registering styles that already exist for'); + expect(getComputedStyle(instance).getPropertyValue('--foo')).to.equal('foo'); + }); + + it('should warn when the same style instance gets added again', async () => { + const tagName = uniqueTagName(); + defineComponent(tagName); + const instance = fixtureSync(`<${tagName}>`); + + const style = css` + :host { + --foo: foo; + } + `; + + registerStyles(tagName, style); + registerStyles(tagName, style); + + await nextFrame(); + + expect(warn.calledOnce).to.be.true; + expect(warn.args[0][0]).to.include('Registering styles that already exist for'); + expect(getComputedStyle(instance).getPropertyValue('--foo')).to.equal('foo'); + }); +}); diff --git a/packages/vaadin-themable-mixin/vaadin-themable-mixin.js b/packages/vaadin-themable-mixin/vaadin-themable-mixin.js index 39a066b1d4..e23ee3e2dc 100644 --- a/packages/vaadin-themable-mixin/vaadin-themable-mixin.js +++ b/packages/vaadin-themable-mixin/vaadin-themable-mixin.js @@ -3,7 +3,7 @@ * Copyright (c) 2017 - 2024 Vaadin Ltd. * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ */ -import { css, CSSResult, unsafeCSS } from 'lit'; +import { adoptStyles, css, CSSResult, LitElement, unsafeCSS } from 'lit'; import { ThemePropertyMixin } from './vaadin-theme-property-mixin.js'; export { css, unsafeCSS }; @@ -23,6 +23,16 @@ export { css, unsafeCSS }; */ const themeRegistry = []; +/** + * @type {WeakRef[]} + */ +const themableInstances = new Set(); + +/** + * @type {string[]} + */ +const themableTagNames = new Set(); + /** * Check if the custom element type has themes applied. * @param {Function} elementClass @@ -57,6 +67,129 @@ function flattenStyles(styles = []) { }); } +/** + * Returns true if the themeFor string matches the tag name + * @param {string} themeFor + * @param {string} tagName + * @returns {boolean} + */ +function matchesThemeFor(themeFor, tagName) { + return (themeFor || '').split(' ').some((themeForToken) => { + return new RegExp(`^${themeForToken.split('*').join('.*')}$`, 'u').test(tagName); + }); +} + +/** + * Returns the CSS text content from an array of CSSResults + * @param {CSSResult[]} styles + * @returns {string} + */ +function getCssText(styles) { + return styles.map((style) => style.cssText).join('\n'); +} + +const STYLE_ID = 'vaadin-themable-mixin-style'; + +/** + * Includes the styles to the template. + * @param {CSSResult[]} styles + * @param {HTMLTemplateElement} template + */ +function addStylesToTemplate(styles, template) { + const styleEl = document.createElement('style'); + styleEl.id = STYLE_ID; + styleEl.textContent = getCssText(styles); + template.content.appendChild(styleEl); +} + +/** + * Dynamically updates the styles of the given component instance. + * @param {HTMLElement} instance + */ +function updateInstanceStyles(instance) { + if (!instance.shadowRoot) { + return; + } + + const componentClass = instance.constructor; + + if (instance instanceof LitElement) { + // LitElement + + // The adoptStyles function may fall back to appending style elements to shadow root. + // Remove them first to avoid duplicates. + [...instance.shadowRoot.querySelectorAll('style')].forEach((style) => style.remove()); + + // Adopt the updated styles + adoptStyles(instance.shadowRoot, componentClass.elementStyles); + } else { + // PolymerElement + + // Update style element content in the shadow root + const style = instance.shadowRoot.getElementById(STYLE_ID); + const template = componentClass.prototype._template; + style.textContent = template.content.getElementById(STYLE_ID).textContent; + } +} + +/** + * Dynamically updates the styles of the instances matching the given component type. + * @param {Function} componentClass + */ +function updateInstanceStylesOfType(componentClass) { + // Iterate over component instances and update their styles if needed + themableInstances.forEach((ref) => { + const instance = ref.deref(); + if (instance instanceof componentClass) { + updateInstanceStyles(instance); + } else if (!instance) { + // Clean up the weak reference to a GC'd instance + themableInstances.delete(ref); + } + }); +} + +/** + * Dynamically updates the styles of the given component type. + * @param {Function} componentClass + */ +function updateComponentStyles(componentClass) { + if (componentClass.prototype instanceof LitElement) { + // Update LitElement-based component's elementStyles + componentClass.elementStyles = componentClass.finalizeStyles(componentClass.styles); + } else { + // Update Polymer-based component's template + const template = componentClass.prototype._template; + template.content.getElementById(STYLE_ID).textContent = getCssText(componentClass.getStylesForThis()); + } + + // Update the styles of inheriting types + themableTagNames.forEach((inheritingTagName) => { + const inheritingClass = customElements.get(inheritingTagName); + if (inheritingClass !== componentClass && inheritingClass.prototype instanceof componentClass) { + updateComponentStyles(inheritingClass); + } + }); +} + +/** + * Check if the component type already has a style matching the given styles. + * + * @param {Function} componentClass + * @param {CSSResultGroup} styles + * @returns {boolean} + */ +function hasMatchingStyle(componentClass, styles) { + const themes = componentClass.__themes; + if (!themes || !styles) { + return false; + } + + return themes.some((theme) => + theme.styles.some((themeStyle) => styles.some((style) => style.cssText === themeStyle.cssText)), + ); +} + /** * Registers CSS styles for a component type. Make sure to register the styles before * the first instance of a component of the type is attached to DOM. @@ -68,15 +201,6 @@ function flattenStyles(styles = []) { * @return {void} */ export function registerStyles(themeFor, styles, options = {}) { - if (themeFor) { - if (hasThemes(themeFor)) { - console.warn(`The custom element definition for "${themeFor}" - was finalized before a style module was registered. - Make sure to add component specific style modules before - importing the corresponding custom element.`); - } - } - styles = flattenStyles(styles); if (window.Vaadin && window.Vaadin.styleModules) { @@ -89,6 +213,34 @@ export function registerStyles(themeFor, styles, options = {}) { moduleId: options.moduleId, }); } + + if (themeFor) { + // Update styles of the component types that match themeFor and have already been finalized + themableTagNames.forEach((tagName) => { + if (matchesThemeFor(themeFor, tagName) && hasThemes(tagName)) { + const componentClass = customElements.get(tagName); + + if (hasMatchingStyle(componentClass, styles)) { + // Show a warning if the component type already has some of the given styles + console.warn(`Registering styles that already exist for ${tagName}`); + } else if (!window.Vaadin || !window.Vaadin.suppressPostFinalizeStylesWarning) { + // Show a warning if the component type has already been finalized + console.warn( + `The custom element definition for "${tagName}" ` + + `was finalized before a style module was registered. ` + + `Ideally, import component specific style modules before ` + + `importing the corresponding custom element. ` + + `This warning can be suppressed by setting "window.Vaadin.suppressPostFinalizeStylesWarning = true".`, + ); + } + + // Update the styles of the component type + updateComponentStyles(componentClass); + // Update the styles of the component instances matching the component type + updateInstanceStylesOfType(componentClass); + } + }); + } } /** @@ -103,18 +255,6 @@ function getAllThemes() { return themeRegistry; } -/** - * Returns true if the themeFor string matches the tag name - * @param {string} themeFor - * @param {string} tagName - * @returns {boolean} - */ -function matchesThemeFor(themeFor, tagName) { - return (themeFor || '').split(' ').some((themeForToken) => { - return new RegExp(`^${themeForToken.split('*').join('.*')}$`, 'u').test(tagName); - }); -} - /** * Maps the moduleName to an include priority number which is used for * determining the order in which styles are applied. @@ -151,17 +291,6 @@ function getIncludedStyles(theme) { return includedStyles; } -/** - * Includes the styles to the template. - * @param {CSSResult[]} styles - * @param {HTMLTemplateElement} template - */ -function addStylesToTemplate(styles, template) { - const styleEl = document.createElement('style'); - styleEl.innerHTML = styles.map((style) => style.cssText).join('\n'); - template.content.appendChild(styleEl); -} - /** * Returns an array of themes that should be used for styling a component matching * the tag name. The array is sorted by the include order. @@ -197,6 +326,12 @@ function getThemes(tagName) { */ export const ThemableMixin = (superClass) => class VaadinThemableMixin extends ThemePropertyMixin(superClass) { + constructor() { + super(); + // Store a weak reference to the instance + themableInstances.add(new WeakRef(this)); + } + /** * Covers PolymerElement based component styling * @protected @@ -204,6 +339,10 @@ export const ThemableMixin = (superClass) => static finalize() { super.finalize(); + if (this.is) { + themableTagNames.add(this.is); + } + // Make sure not to run the logic intended for PolymerElement when LitElement is used. if (this.elementStyles) { return; @@ -227,7 +366,7 @@ export const ThemableMixin = (superClass) => // a LitElement based component. The theme styles are added after it // so that they can override the component styles. const themeStyles = this.getStylesForThis(); - return styles ? [...super.finalizeStyles(styles), ...themeStyles] : themeStyles; + return styles ? [...[styles].flat(Infinity), ...themeStyles] : themeStyles; } /** @@ -236,9 +375,10 @@ export const ThemableMixin = (superClass) => * @private */ static getStylesForThis() { + const superClassThemes = superClass.__themes || []; const parent = Object.getPrototypeOf(this.prototype); const inheritedThemes = (parent ? parent.constructor.__themes : []) || []; - this.__themes = [...inheritedThemes, ...getThemes(this.is)]; + this.__themes = [...superClassThemes, ...inheritedThemes, ...getThemes(this.is)]; const themeStyles = this.__themes.flatMap((theme) => theme.styles); // Remove duplicates return themeStyles.filter((style, index) => index === themeStyles.lastIndexOf(style));