Skip to content

Commit a1cdbf3

Browse files
authored
fix(chat): Run adoptPageStyles on theme swap (#1950)
This commit addresses several scenarios where the old implementation would fail: 1. A runtime change of the global theme. Since the theme controller overwrites the adopted stylesheets collection this would nuke previous root styles. 2. SPA router navigation where the chat is created before a certain framework passes down the 'options' object.
1 parent 62275cd commit a1cdbf3

File tree

4 files changed

+165
-76
lines changed

4 files changed

+165
-76
lines changed

src/components/chat/chat-input.ts

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { consume } from '@lit/context';
1+
import { ContextConsumer, consume } from '@lit/context';
22
import { html, LitElement, nothing } from 'lit';
33
import { query, state } from 'lit/decorators.js';
44
import { cache } from 'lit/directives/cache.js';
@@ -24,7 +24,12 @@ import type {
2424
ChatTemplateRenderer,
2525
IgcChatMessageAttachment,
2626
} from './types.js';
27-
import { adoptPageStyles, getChatAcceptedFiles, getIconName } from './utils.js';
27+
import {
28+
addAdoptedStylesController,
29+
type ChatAcceptedFileTypes,
30+
getChatAcceptedFiles,
31+
getIconName,
32+
} from './utils.js';
2833

2934
type DefaultInputRenderers = {
3035
input: ChatTemplateRenderer<ChatInputRenderContext>;
@@ -95,39 +100,53 @@ export default class IgcChatInputComponent extends LitElement {
95100
private _userLastTypeTime = Date.now();
96101
private _typingTimeout = 0;
97102

98-
@consume({ context: chatContext, subscribe: true })
99-
private readonly _state!: ChatState;
103+
private readonly _adoptedStyles = addAdoptedStylesController(this);
104+
105+
private readonly _stateChanged = () => {
106+
this._adoptedStyles.shouldAdoptStyles(
107+
!!this._state.options?.adoptRootStyles &&
108+
!this._adoptedStyles.hasAdoptedStyles
109+
);
110+
};
111+
112+
private readonly _stateConsumer = new ContextConsumer(this, {
113+
context: chatContext,
114+
callback: this._stateChanged,
115+
subscribe: true,
116+
});
100117

101118
@consume({ context: chatUserInputContext, subscribe: true })
102119
private readonly _userInputState!: ChatState;
103120

104121
@query(IgcTextareaComponent.tagName)
105-
private readonly _textInputElement!: IgcTextareaComponent;
122+
private readonly _textInputElement?: IgcTextareaComponent;
106123

107124
@query('#input_attachments')
108-
protected readonly _fileInput!: HTMLInputElement;
125+
protected readonly _fileInput?: HTMLInputElement;
109126

110127
@state()
111128
private _parts = { 'input-container': true, dragging: false };
112129

113-
private get _acceptedTypes() {
130+
private get _state(): ChatState {
131+
return this._stateConsumer.value!;
132+
}
133+
134+
private get _acceptedTypes(): ChatAcceptedFileTypes | null {
114135
return this._state.acceptedFileTypes;
115136
}
116137

117138
constructor() {
118139
super();
119-
addThemingController(this, all);
120-
}
121-
122-
protected override firstUpdated(): void {
123-
if (this._state.options?.adoptRootStyles) {
124-
adoptPageStyles(this);
125-
}
140+
addThemingController(this, all, { themeChange: this._adoptPageStyles });
126141
}
127142

128143
/** @internal */
129144
public focusInput(): void {
130-
this._textInputElement.focus();
145+
this._textInputElement?.focus();
146+
}
147+
148+
private _adoptPageStyles(): void {
149+
this._adoptedStyles.shouldAdoptStyles(this._adoptedStyles.hasAdoptedStyles);
131150
}
132151

133152
private _getRenderer<U extends keyof DefaultInputRenderers>(
@@ -210,7 +229,7 @@ export default class IgcChatInputComponent extends LitElement {
210229
}
211230

212231
private _handleFileInputClick(): void {
213-
this._fileInput.showPicker();
232+
this._fileInput?.showPicker();
214233
}
215234

216235
private _handleFocusState(event: FocusEvent): void {

src/components/chat/chat-message.ts

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { consume } from '@lit/context';
1+
import { ContextConsumer } from '@lit/context';
22
import { html, LitElement, nothing } from 'lit';
33
import { property } from 'lit/decorators.js';
44
import { cache } from 'lit/directives/cache.js';
@@ -19,7 +19,7 @@ import type {
1919
ChatTemplateRenderer,
2020
IgcChatMessage,
2121
} from './types.js';
22-
import { adoptPageStyles } from './utils.js';
22+
import { addAdoptedStylesController } from './utils.js';
2323

2424
const LIKE_INACTIVE = 'thumb_up_inactive';
2525
const LIKE_ACTIVE = 'thumb_up_active';
@@ -66,15 +66,31 @@ export default class IgcChatMessageComponent extends LitElement {
6666
);
6767
}
6868

69+
private readonly _adoptedStyles = addAdoptedStylesController(this);
70+
6971
private readonly _defaults = Object.freeze<DefaultMessageRenderers>({
7072
messageHeader: () => this._renderHeader(),
7173
messageContent: () => this._renderContent(),
7274
messageAttachments: () => this._renderAttachments(),
7375
messageActions: () => this._renderActions(),
7476
});
7577

76-
@consume({ context: chatContext, subscribe: true })
77-
private readonly _state!: ChatState;
78+
private readonly _stateChanged = () => {
79+
this._adoptedStyles.shouldAdoptStyles(
80+
!!this._state.options?.adoptRootStyles &&
81+
!this._adoptedStyles.hasAdoptedStyles
82+
);
83+
};
84+
85+
private readonly _stateConsumer = new ContextConsumer(this, {
86+
context: chatContext,
87+
callback: this._stateChanged,
88+
subscribe: true,
89+
});
90+
91+
private get _state(): ChatState {
92+
return this._stateConsumer.value!;
93+
}
7894

7995
/**
8096
* The chat message to render.
@@ -84,13 +100,11 @@ export default class IgcChatMessageComponent extends LitElement {
84100

85101
constructor() {
86102
super();
87-
addThemingController(this, all);
103+
addThemingController(this, all, { themeChange: this._adoptPageStyles });
88104
}
89105

90-
protected override firstUpdated(): void {
91-
if (this._state.options?.adoptRootStyles) {
92-
adoptPageStyles(this);
93-
}
106+
private _adoptPageStyles(): void {
107+
this._adoptedStyles.shouldAdoptStyles(this._adoptedStyles.hasAdoptedStyles);
94108
}
95109

96110
private _getRenderer(name: keyof DefaultMessageRenderers) {

src/components/chat/chat.spec.ts

Lines changed: 52 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { elementUpdated, expect, fixture } from '@open-wc/testing';
22
import { html, nothing } from 'lit';
33
import { spy, stub, useFakeTimers } from 'sinon';
4+
import { configureTheme } from '../../theming/config.js';
45
import type IgcIconButtonComponent from '../button/icon-button.js';
56
import IgcChipComponent from '../chip/chip.js';
67
import { enterKey, tabKey } from '../common/controllers/key-bindings.js';
@@ -22,7 +23,12 @@ import IgcChatComponent from './chat.js';
2223
import IgcChatInputComponent from './chat-input.js';
2324
import IgcChatMessageComponent from './chat-message.js';
2425
import IgcMessageAttachmentsComponent from './message-attachments.js';
25-
import type { IgcChatMessage, IgcChatMessageAttachment } from './types.js';
26+
import type {
27+
ChatMessageRenderContext,
28+
IgcChatMessage,
29+
IgcChatMessageAttachment,
30+
IgcChatOptions,
31+
} from './types.js';
2632

2733
describe('Chat', () => {
2834
before(() => {
@@ -1026,7 +1032,30 @@ describe('Chat', () => {
10261032
});
10271033

10281034
describe('adoptRootStyles behavior', () => {
1029-
const customStyles = 'custom-background';
1035+
let chat: IgcChatComponent;
1036+
1037+
const renderer = ({ message }: ChatMessageRenderContext) =>
1038+
html`<div class="custom-background">${message.text}</div>`;
1039+
1040+
async function createAdoptedStylesChat(options: IgcChatOptions) {
1041+
chat = await fixture(html`
1042+
<igc-chat
1043+
.messages=${[{ id: 'id', sender: 'bot', text: 'Hello' }]}
1044+
.options=${{ renderers: { messageContent: renderer }, ...options }}
1045+
></igc-chat>
1046+
`);
1047+
}
1048+
1049+
function verifyCustomStyles(state: boolean) {
1050+
const { messages } = getChatDOM(chat);
1051+
const { backgroundColor } = getComputedStyle(
1052+
getChatMessageDOM(first(messages)).content.querySelector(
1053+
'.custom-background'
1054+
)!
1055+
);
1056+
1057+
expect(backgroundColor === 'rgb(255, 0, 0)').to.equal(state);
1058+
}
10301059

10311060
beforeEach(async () => {
10321061
const styles = document.createElement('style');
@@ -1039,48 +1068,36 @@ describe('Chat', () => {
10391068
document.head.append(styles);
10401069
});
10411070

1042-
it('correctly applies `adoptRootStyles` when set', async () => {
1043-
chat.options = {
1044-
adoptRootStyles: true,
1045-
renderers: {
1046-
messageContent: ({ message }) =>
1047-
html`<div class=${customStyles}>${message.text}</div>`,
1048-
},
1049-
};
1050-
chat.messages = [{ id: 'id', sender: 'bot', text: 'Hello' }];
1071+
afterEach(() => {
1072+
document.head.querySelector('#adopt-styles-test')?.remove();
1073+
});
10511074

1075+
it('correctly applies `adoptRootStyles` when set', async () => {
1076+
await createAdoptedStylesChat({ adoptRootStyles: true });
10521077
await elementUpdated(chat);
1053-
1054-
const { messages } = getChatDOM(chat);
1055-
expect(
1056-
getComputedStyle(
1057-
getChatMessageDOM(first(messages)).content.querySelector(
1058-
`.${customStyles}`
1059-
)!
1060-
).backgroundColor
1061-
).equal('rgb(255, 0, 0)');
1078+
verifyCustomStyles(true);
10621079
});
10631080

10641081
it('skips `adoptRootStyles` when not set', async () => {
1065-
chat.options = {
1066-
renderers: {
1067-
messageContent: ({ message }) =>
1068-
html`<div class=${customStyles}>${message.text}</div>`,
1069-
},
1070-
};
1082+
await createAdoptedStylesChat({ adoptRootStyles: false });
1083+
await elementUpdated(chat);
1084+
verifyCustomStyles(false);
1085+
});
1086+
1087+
it('correctly reapplies `adoptRootStyles` when set and the theme is changed', async () => {
1088+
await createAdoptedStylesChat({ adoptRootStyles: true });
1089+
await elementUpdated(chat);
1090+
verifyCustomStyles(true);
10711091

1072-
chat.messages = [{ id: 'id', sender: 'bot', text: 'Hello' }];
1092+
// Change the theme
1093+
configureTheme('material');
10731094

10741095
await elementUpdated(chat);
1096+
verifyCustomStyles(true);
10751097

1076-
const { messages } = getChatDOM(chat);
1077-
expect(
1078-
getComputedStyle(
1079-
getChatMessageDOM(first(messages)).content.querySelector(
1080-
`.${customStyles}`
1081-
)!
1082-
).backgroundColor
1083-
).not.equal('rgb(255, 0, 0)');
1098+
configureTheme('bootstrap');
1099+
await elementUpdated(chat);
1100+
verifyCustomStyles(true);
10841101
});
10851102
});
10861103
});

src/components/chat/utils.ts

Lines changed: 55 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { adoptStyles, type LitElement } from 'lit';
1+
import {
2+
adoptStyles,
3+
type LitElement,
4+
type ReactiveController,
5+
type ReactiveControllerHost,
6+
} from 'lit';
27
import { last } from '../common/util.js';
38
import type { IgcChatMessageAttachment } from './types.js';
49

@@ -109,23 +114,57 @@ export function isImageAttachment(
109114
);
110115
}
111116

112-
export function adoptPageStyles(element: LitElement): void {
113-
const sheets: CSSStyleSheet[] = [];
117+
class AdoptedStylesController implements ReactiveController {
118+
private readonly _host: ReactiveControllerHost & LitElement;
119+
private _hasAdoptedStyles = false;
114120

115-
for (const sheet of document.styleSheets) {
116-
try {
117-
const constructed = new CSSStyleSheet();
118-
for (const rule of sheet.cssRules) {
119-
// https://drafts.csswg.org/cssom/#dom-cssstylesheet-insertrule:~:text=If%20parsed%20rule%20is%20an%20%40import%20rule
120-
if (rule.cssText.startsWith('@import')) {
121-
continue;
121+
public get hasAdoptedStyles(): boolean {
122+
return this._hasAdoptedStyles;
123+
}
124+
125+
private _adoptRootStyles(): void {
126+
const sheets: CSSStyleSheet[] = [];
127+
128+
for (const sheet of document.styleSheets) {
129+
try {
130+
const constructed = new CSSStyleSheet();
131+
for (const rule of sheet.cssRules) {
132+
// https://drafts.csswg.org/cssom/#dom-cssstylesheet-insertrule:~:text=If%20parsed%20rule%20is%20an%20%40import%20rule
133+
if (rule.cssText.startsWith('@import')) {
134+
continue;
135+
}
136+
constructed.insertRule(rule.cssText);
122137
}
123-
constructed.insertRule(rule.cssText);
124-
}
125-
sheets.push(constructed);
126-
} catch {}
138+
sheets.push(constructed);
139+
} catch {}
140+
}
141+
142+
const ctor = this._host.constructor as typeof LitElement;
143+
adoptStyles(this._host.shadowRoot!, [...ctor.elementStyles, ...sheets]);
127144
}
128145

129-
const ctor = element.constructor as typeof LitElement;
130-
adoptStyles(element.shadowRoot!, [...ctor.elementStyles, ...sheets]);
146+
constructor(host: ReactiveControllerHost & LitElement) {
147+
this._host = host;
148+
host.addController(this);
149+
}
150+
151+
public shouldAdoptStyles(condition: boolean): void {
152+
if (condition) {
153+
this._adoptRootStyles();
154+
this._hasAdoptedStyles = true;
155+
}
156+
}
157+
158+
/** @internal */
159+
public hostDisconnected(): void {
160+
this._hasAdoptedStyles = false;
161+
}
131162
}
163+
164+
export function addAdoptedStylesController(
165+
host: ReactiveControllerHost & LitElement
166+
): AdoptedStylesController {
167+
return new AdoptedStylesController(host);
168+
}
169+
170+
export type { AdoptedStylesController };

0 commit comments

Comments
 (0)