From 4aeb89f7ff6907d8aba44f8d9252b1a2b2f967c9 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Wed, 11 Feb 2026 12:20:35 +0200 Subject: [PATCH] feat(chat): Implemented reactive adoptRootStyles with dedicated controller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added a new AdoptedStylesController that enables chat components to dynamically adopt document-level styles into Shadow DOM with proper lifecycle management. Changes: - Created src/components/common/controllers/adopt-styles.ts * Reactive controller for managing document style adoption * Implements WeakMap-based caching per document for performance * Provides cache invalidation for theme changes * Handles cross-origin stylesheet restrictions gracefully * Automatic cleanup on component disconnection - Updated chat-input.ts and chat-message.ts * Migrated from inline controller to shared adopt-styles controller * Added reactive _shouldAdoptRootStyles state property * Implemented proper update() lifecycle to respond to option changes * Added cache invalidation on theme changes via _adoptPageStyles() - Enhanced chat.spec.ts * Added ResizeObserver loop error suppression for test stability * Added tests for dynamic adoptRootStyles toggling (false→true, true→false) * Added test for multiple toggle cycles * Added test for theme change interaction with adoptRootStyles - Updated types.ts * Removed outdated warning about runtime changes not working - Refactored chat/utils.ts * Removed duplicate AdoptedStylesController implementation * Controller moved to shared controllers directory Benefits: - adoptRootStyles now works reactively at runtime (no longer one-time shot) - Consistent controller implementation across all components - Improved performance through stylesheet caching - Better memory management with automatic cleanup - Proper theme change handling with cache invalidation - Comprehensive test coverage for all scenarios --- src/components/chat/chat-input.ts | 22 +- src/components/chat/chat-message.ts | 24 +- src/components/chat/chat.spec.ts | 53 +++++ src/components/chat/types.ts | 2 - src/components/chat/utils.ts | 61 ----- .../common/controllers/adopt-styles.ts | 218 ++++++++++++++++++ src/index.ts | 1 + 7 files changed, 303 insertions(+), 78 deletions(-) create mode 100644 src/components/common/controllers/adopt-styles.ts diff --git a/src/components/chat/chat-input.ts b/src/components/chat/chat-input.ts index baecb42d1..73653d840 100644 --- a/src/components/chat/chat-input.ts +++ b/src/components/chat/chat-input.ts @@ -1,5 +1,5 @@ import { ContextConsumer, consume } from '@lit/context'; -import { html, LitElement, nothing } from 'lit'; +import { html, LitElement, nothing, type PropertyValues } from 'lit'; import { query, state } from 'lit/decorators.js'; import { cache } from 'lit/directives/cache.js'; import { ifDefined } from 'lit/directives/if-defined.js'; @@ -8,6 +8,7 @@ import { addThemingController } from '../../theming/theming-controller.js'; import IgcIconButtonComponent from '../button/icon-button.js'; import IgcChipComponent from '../chip/chip.js'; import { chatContext, chatUserInputContext } from '../common/context.js'; +import { addAdoptedStylesController } from '../common/controllers/adopt-styles.js'; import { enterKey, tabKey } from '../common/controllers/key-bindings.js'; import { registerComponent } from '../common/definitions/register.js'; import { partMap } from '../common/part-map.js'; @@ -25,7 +26,6 @@ import type { IgcChatMessageAttachment, } from './types.js'; import { - addAdoptedStylesController, type ChatAcceptedFileTypes, getChatAcceptedFiles, getIconName, @@ -103,10 +103,7 @@ export default class IgcChatInputComponent extends LitElement { private readonly _adoptedStyles = addAdoptedStylesController(this); private readonly _stateChanged = () => { - this._adoptedStyles.shouldAdoptStyles( - !!this._state.options?.adoptRootStyles && - !this._adoptedStyles.hasAdoptedStyles - ); + this._shouldAdoptRootStyles = Boolean(this._state.options?.adoptRootStyles); }; private readonly _stateConsumer = new ContextConsumer(this, { @@ -127,6 +124,9 @@ export default class IgcChatInputComponent extends LitElement { @state() private _parts = { 'input-container': true, dragging: false }; + @state() + private _shouldAdoptRootStyles = false; + private get _state(): ChatState { return this._stateConsumer.value!; } @@ -140,13 +140,21 @@ export default class IgcChatInputComponent extends LitElement { addThemingController(this, all, { themeChange: this._adoptPageStyles }); } + protected override update(props: PropertyValues): void { + if (props.has('_shouldAdoptRootStyles')) { + this._adoptedStyles.shouldAdoptStyles(this._shouldAdoptRootStyles); + } + super.update(props); + } + /** @internal */ public focusInput(): void { this._textInputElement?.focus(); } private _adoptPageStyles(): void { - this._adoptedStyles.shouldAdoptStyles(this._adoptedStyles.hasAdoptedStyles); + this._adoptedStyles.invalidateCache(this.ownerDocument); + this._adoptedStyles.shouldAdoptStyles(this._shouldAdoptRootStyles); } private _getRenderer( diff --git a/src/components/chat/chat-message.ts b/src/components/chat/chat-message.ts index a9d6ba88a..9ed92df62 100644 --- a/src/components/chat/chat-message.ts +++ b/src/components/chat/chat-message.ts @@ -1,11 +1,12 @@ import { ContextConsumer } from '@lit/context'; -import { html, LitElement, nothing } from 'lit'; -import { property } from 'lit/decorators.js'; +import { html, LitElement, nothing, type PropertyValues } from 'lit'; +import { property, state } from 'lit/decorators.js'; import { cache } from 'lit/directives/cache.js'; import { until } from 'lit/directives/until.js'; import { addThemingController } from '../../theming/theming-controller.js'; import IgcIconButtonComponent from '../button/icon-button.js'; import { chatContext } from '../common/context.js'; +import { addAdoptedStylesController } from '../common/controllers/adopt-styles.js'; import { registerComponent } from '../common/definitions/register.js'; import { partMap } from '../common/part-map.js'; import { isEmpty, trimmedHtml } from '../common/util.js'; @@ -19,7 +20,6 @@ import type { ChatTemplateRenderer, IgcChatMessage, } from './types.js'; -import { addAdoptedStylesController } from './utils.js'; const LIKE_INACTIVE = 'thumb_up_inactive'; const LIKE_ACTIVE = 'thumb_up_active'; @@ -76,10 +76,7 @@ export default class IgcChatMessageComponent extends LitElement { }); private readonly _stateChanged = () => { - this._adoptedStyles.shouldAdoptStyles( - !!this._state.options?.adoptRootStyles && - !this._adoptedStyles.hasAdoptedStyles - ); + this._shouldAdoptRootStyles = Boolean(this._state.options?.adoptRootStyles); }; private readonly _stateConsumer = new ContextConsumer(this, { @@ -88,6 +85,9 @@ export default class IgcChatMessageComponent extends LitElement { subscribe: true, }); + @state() + private _shouldAdoptRootStyles = false; + private get _state(): ChatState { return this._stateConsumer.value!; } @@ -103,8 +103,16 @@ export default class IgcChatMessageComponent extends LitElement { addThemingController(this, all, { themeChange: this._adoptPageStyles }); } + protected override update(props: PropertyValues): void { + if (props.has('_shouldAdoptRootStyles')) { + this._adoptedStyles.shouldAdoptStyles(this._shouldAdoptRootStyles); + } + super.update(props); + } + private _adoptPageStyles(): void { - this._adoptedStyles.shouldAdoptStyles(this._adoptedStyles.hasAdoptedStyles); + this._adoptedStyles.invalidateCache(this.ownerDocument); + this._adoptedStyles.shouldAdoptStyles(this._shouldAdoptRootStyles); } private _getRenderer(name: keyof DefaultMessageRenderers) { diff --git a/src/components/chat/chat.spec.ts b/src/components/chat/chat.spec.ts index e4d073ab5..5c9d3b033 100644 --- a/src/components/chat/chat.spec.ts +++ b/src/components/chat/chat.spec.ts @@ -33,6 +33,16 @@ import type { describe('Chat', () => { before(() => { defineComponents(IgcChatComponent, IgcInputComponent); + + // Suppress ResizeObserver loop errors that can occur during tests from + // the underlying igc-textarea component. These errors do not affect the tests and are not actionable. + const errorHandler = window.onerror; + window.onerror = (message, ...args) => { + if (typeof message === 'string' && message.match(/ResizeObserver loop/)) { + return true; + } + return errorHandler ? errorHandler(message, ...args) : false; + }; }); const textInputTemplate = (text: string) => html` @@ -1099,6 +1109,49 @@ describe('Chat', () => { await elementUpdated(chat); verifyCustomStyles(true); }); + + it('correctly adopts styles when toggling from false to true', async () => { + await createAdoptedStylesChat({ adoptRootStyles: false }); + await elementUpdated(chat); + verifyCustomStyles(false); + + // Toggle to true + chat.options = { ...chat.options, adoptRootStyles: true }; + await elementUpdated(chat); + verifyCustomStyles(true); + }); + + it('correctly removes adopted styles when toggling from true to false', async () => { + await createAdoptedStylesChat({ adoptRootStyles: true }); + await elementUpdated(chat); + verifyCustomStyles(true); + + // Toggle to false + chat.options = { ...chat.options, adoptRootStyles: false }; + await elementUpdated(chat); + verifyCustomStyles(false); + }); + + it('correctly handles multiple toggles of adoptRootStyles', async () => { + await createAdoptedStylesChat({ adoptRootStyles: false }); + await elementUpdated(chat); + verifyCustomStyles(false); + + // Toggle to true + chat.options = { ...chat.options, adoptRootStyles: true }; + await elementUpdated(chat); + verifyCustomStyles(true); + + // Toggle back to false + chat.options = { ...chat.options, adoptRootStyles: false }; + await elementUpdated(chat); + verifyCustomStyles(false); + + // Toggle to true again + chat.options = { ...chat.options, adoptRootStyles: true }; + await elementUpdated(chat); + verifyCustomStyles(true); + }); }); }); diff --git a/src/components/chat/types.ts b/src/components/chat/types.ts index bab1db065..5cfd6ebe7 100644 --- a/src/components/chat/types.ts +++ b/src/components/chat/types.ts @@ -146,8 +146,6 @@ export type IgcChatOptions = { * global styles unexpectedly bleed into the component, breaking encapsulation and causing * unpredictable visual issues. * - * **WARNING**: This is a once time shot. Changing this property in runtime won't reflect - * its value. */ adoptRootStyles?: boolean; diff --git a/src/components/chat/utils.ts b/src/components/chat/utils.ts index fa62f5938..3d8f65394 100644 --- a/src/components/chat/utils.ts +++ b/src/components/chat/utils.ts @@ -1,9 +1,3 @@ -import { - adoptStyles, - type LitElement, - type ReactiveController, - type ReactiveControllerHost, -} from 'lit'; import { last } from '../common/util.js'; import type { IgcChatMessageAttachment } from './types.js'; @@ -113,58 +107,3 @@ export function isImageAttachment( attachment.type === 'image' || attachment.file?.type.startsWith('image/') ); } - -class AdoptedStylesController implements ReactiveController { - private readonly _host: ReactiveControllerHost & LitElement; - private _hasAdoptedStyles = false; - - public get hasAdoptedStyles(): boolean { - return this._hasAdoptedStyles; - } - - private _adoptRootStyles(): void { - const sheets: CSSStyleSheet[] = []; - - for (const sheet of document.styleSheets) { - try { - const constructed = new CSSStyleSheet(); - for (const rule of sheet.cssRules) { - // https://drafts.csswg.org/cssom/#dom-cssstylesheet-insertrule:~:text=If%20parsed%20rule%20is%20an%20%40import%20rule - if (rule.cssText.startsWith('@import')) { - continue; - } - constructed.insertRule(rule.cssText); - } - sheets.push(constructed); - } catch {} - } - - const ctor = this._host.constructor as typeof LitElement; - adoptStyles(this._host.shadowRoot!, [...ctor.elementStyles, ...sheets]); - } - - constructor(host: ReactiveControllerHost & LitElement) { - this._host = host; - host.addController(this); - } - - public shouldAdoptStyles(condition: boolean): void { - if (condition) { - this._adoptRootStyles(); - this._hasAdoptedStyles = true; - } - } - - /** @internal */ - public hostDisconnected(): void { - this._hasAdoptedStyles = false; - } -} - -export function addAdoptedStylesController( - host: ReactiveControllerHost & LitElement -): AdoptedStylesController { - return new AdoptedStylesController(host); -} - -export type { AdoptedStylesController }; diff --git a/src/components/common/controllers/adopt-styles.ts b/src/components/common/controllers/adopt-styles.ts new file mode 100644 index 000000000..5d52a0321 --- /dev/null +++ b/src/components/common/controllers/adopt-styles.ts @@ -0,0 +1,218 @@ +/** + * Reactive controller for adopting document-level styles into Shadow DOM. + * + * This controller enables web components to access and apply styles from the + * document's stylesheets within their Shadow DOM, effectively bridging the + * style encapsulation boundary when needed. + * + * @example + * ```typescript + * class MyComponent extends LitElement { + * private readonly _adoptedStyles = addAdoptedStylesController(this); + * + * protected override update(props: PropertyValues): void { + * if (props.has('shouldAdopt')) { + * this._adoptedStyles.shouldAdoptStyles(this.shouldAdopt); + * } + * super.update(props); + * } + * } + * ``` + */ + +import { + adoptStyles, + type LitElement, + type ReactiveController, + type ReactiveControllerHost, +} from 'lit'; + +/** + * Controller that manages the adoption of document-level styles into a + * component's Shadow DOM. + * + * This controller provides: + * - Automatic caching of cloned stylesheets per document + * - Efficient adoption and removal of document styles + * - Cache invalidation for theme changes + * - Automatic cleanup on component disconnection + */ +class AdoptedStylesController implements ReactiveController { + private static _cachedSheets = new WeakMap(); + + private static _invalidateCache(doc: Document): void { + AdoptedStylesController._cachedSheets.delete(doc); + } + + private readonly _host: ReactiveControllerHost & LitElement; + private _hasAdoptedStyles = false; + + /** + * Indicates whether document styles have been adopted into the host's Shadow DOM. + */ + public get hasAdoptedStyles(): boolean { + return this._hasAdoptedStyles; + } + + constructor(host: ReactiveControllerHost & LitElement) { + this._host = host; + host.addController(this); + } + + /** + * Conditionally adopts or clears document styles based on the provided condition. + * + * @param condition - If true, adopts document styles; if false, clears adopted styles + * + * @example + * ```typescript + * this._adoptedStyles.shouldAdoptStyles(this.options?.adoptRootStyles); + * ``` + */ + public shouldAdoptStyles(condition: boolean): void { + condition ? this._adoptRootStyles() : this._clearAdoptedStyles(); + } + + /** + * Invalidates the stylesheet cache for the specified document. + * + * This should be called when the document's stylesheets change (e.g., theme changes) + * to ensure the next adoption uses the updated styles. + * + * @param doc - The document whose cache to invalidate. Defaults to the global document. + * + * @example + * ```typescript + * // Invalidate cache on theme change + * this._adoptedStyles.invalidateCache(this.ownerDocument); + * ``` + */ + public invalidateCache(doc?: Document): void { + AdoptedStylesController._invalidateCache(doc ?? document); + } + + /** + * Lifecycle callback invoked when the host component is disconnected. + * Automatically clears adopted styles to prevent memory leaks. + * @internal + */ + public hostDisconnected(): void { + this._clearAdoptedStyles(); + } + + /** + * Adopts document-level styles into the host's Shadow DOM. + * + * This method: + * 1. Checks the cache for previously cloned stylesheets + * 2. Clones document stylesheets if not cached + * 3. Applies both component styles and cloned document styles to the Shadow Root + */ + private _adoptRootStyles(): void { + const ownerDocument = this._host.ownerDocument; + + if (!AdoptedStylesController._cachedSheets.has(ownerDocument)) { + AdoptedStylesController._cachedSheets.set( + ownerDocument, + this._cloneDocumentStyleSheets(ownerDocument) + ); + } + + const ctor = this._host.constructor as typeof LitElement; + adoptStyles(this._host.shadowRoot!, [ + ...ctor.elementStyles, + ...AdoptedStylesController._cachedSheets.get(ownerDocument)!, + ]); + this._hasAdoptedStyles = true; + } + + /** + * Removes previously adopted document styles from the Shadow DOM. + * + * Only removes stylesheets that were added by this controller, preserving + * the component's original styles. + */ + private _clearAdoptedStyles(): void { + const shadowRoot = this._host.shadowRoot; + if (shadowRoot) { + shadowRoot.adoptedStyleSheets = shadowRoot.adoptedStyleSheets.filter( + (sheet) => + !AdoptedStylesController._cachedSheets + .get(this._host.ownerDocument) + ?.includes(sheet) + ); + } + this._hasAdoptedStyles = false; + } + + /** + * Clones all accessible stylesheets from the document into constructable stylesheets. + * + * This method: + * - Iterates through all document stylesheets + * - Skips cross-origin stylesheets (CORS restrictions) + * - Skips @import rules (cannot be cloned into constructable stylesheets) + * - Creates new CSSStyleSheet instances with cloned rules + * + * @param ownerDocument - The document whose stylesheets should be cloned + * @returns An array of cloned CSSStyleSheet objects + */ + private _cloneDocumentStyleSheets(ownerDocument: Document): CSSStyleSheet[] { + const sheets: CSSStyleSheet[] = []; + + for (const sheet of ownerDocument.styleSheets) { + try { + const constructed = new CSSStyleSheet(); + let hasRules = false; + + for (const rule of sheet.cssRules) { + // Skip @import rules as they cannot be inserted into constructable stylesheets + if (rule instanceof CSSImportRule) { + continue; + } + + try { + constructed.insertRule(rule.cssText); + hasRules = true; + } catch { + // Skip rules that cannot be cloned (e.g., invalid syntax) + } + } + + if (hasRules) { + sheets.push(constructed); + } + } catch { + // Skip stylesheets that cannot be accessed (e.g., cross-origin) + } + } + + return sheets; + } +} + +/** + * Creates and attaches an AdoptedStylesController to a Lit component. + * + * @param host - The Lit component that will host the controller + * @returns The created AdoptedStylesController instance + * + * @example + * ```typescript + * class MyComponent extends LitElement { + * private readonly _adoptedStyles = addAdoptedStylesController(this); + * + * connectedCallback() { + * super.connectedCallback(); + * this._adoptedStyles.shouldAdoptStyles(true); + * } + * } + * ``` + */ +export function addAdoptedStylesController( + host: ReactiveControllerHost & LitElement +): AdoptedStylesController { + return new AdoptedStylesController(host); +} + +export type { AdoptedStylesController }; diff --git a/src/index.ts b/src/index.ts index e79d5309b..0c2c953dd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -86,6 +86,7 @@ export { export { configureTheme } from './theming/config.js'; export type { Theme, ThemeVariant } from './theming/types.js'; export { addThemingController as θaddThemingController } from './theming/theming-controller.js'; +export { addAdoptedStylesController as θaddAdoptedStylesController } from './components/common/controllers/adopt-styles.js'; // localization objects export {