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}>${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}>${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}>${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}>${tagName}>`);
+
+ registerStyles(
+ tagName,
+ css`
+ :host {
+ --foo: foo;
+ }
+ `,
+ );
+
+ const instance = fixtureSync(`<${tagName}>${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}>${parentTagName}>`);
+ await nextFrame();
+ registerStyles(
+ parentTagName,
+ css`
+ :host {
+ --foo: foo;
+ }
+ `,
+ );
+
+ const childTagName = uniqueTagName();
+ defineComponent(childTagName, parentTagName);
+ const child = fixtureSync(`<${childTagName}>${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}>${parentTagName}>`);
+ await nextFrame();
+ registerStyles(
+ parentTagName,
+ css`
+ :host {
+ --foo: foo;
+ }
+ `,
+ );
+
+ const childTagName = uniqueTagName();
+ defineComponent(childTagName, parentTagName);
+ const child = fixtureSync(`<${childTagName}>${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}>${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}>${childTagName}>`);
+ await nextFrame();
+
+ registerStyles(
+ parentTagName,
+ css`
+ :host {
+ --foo: foo;
+ }
+ `,
+ );
+
+ await nextFrame();
+
+ const child = fixtureSync(`<${childTagName}>${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}>${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}>${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}>${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}>${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}>${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));